1. 원인
반복 할일은 회차마다 행을 미리 만들지 않는다. 데이터베이스에는 지금 회차 행 하나만 있고, 생성일·기한도 그 행의 값이다. 10월 1일 칸은 그 행을 펼쳐 보여 준 것이라, 편집 화면도 원본의 9월 24일을 그대로 읽었다.
예: 생성일 2026-09-24(목), 기한 2027-09-24(금). 간격은 365일이다.
화면이 9월 24일 만 찍으면 해가 다른 두 날짜가 같아 보인다.
D-365 가 그 차이다.
2. 규칙
다른 회차를 열면 화면의 생성일은 그 회차 날이다. 기한은 원본 간격만 유지한다. 저장할 때 원본 행의 생성일은 그대로 둔다.
| 연 회차 | 화면에 보이는 생성일 | 화면에 보이는 기한 | 저장되는 원본 행 |
|---|---|---|---|
| 이번 회차 9월 24일 | 2026-09-24 | 2027-09-24 | 그대로 |
| 다음 회차 10월 1일 | 2026-10-01 | 2027-10-01 | 생성일은 9월 24일 유지. 기한을 고치면 간격만 원본에 반영 |
| 간격 0 (생성=기한 9월 24일) | 2026-10-01 | 2026-10-01 | 원본 생성일 유지 |
3. 계산 — lib/logic/recurrence.dart
nextRepeatCreatedDue 바로 아래에 둔다. repeatDueGapDays, repeatDatesOn 이 이미 있어야 한다.
/// 다른 회차를 열 때 보이는 날짜. 생성일은 그 회차, 기한은 원래 간격.
({String createdDay, String dueDay}) occurrenceCreatedDue({
required String seriesCreated,
required String? seriesDue,
required String occDay,
}) {
final gap = repeatDueGapDays(seriesCreated, seriesDue);
final occ = DateTime.tryParse(occDay);
if (occ == null) {
return (createdDay: occDay, dueDay: seriesDue ?? occDay);
}
return repeatDatesOn(occ, gap);
}
달력에서 회차를 열 때 Todo.occIso 에 그 칸의 날짜가 들어 있어야 한다. 전개 복사본에 ..occIso = iso 를 이미 넣고 있으면 추가하지 않는다.
4. 편집기 — lib/widgets/todo_edit_sheet.dart
상태 필드. 원본 행 날짜를 기억해 두었다가, 화면만 회차 날짜로 바꾼다.
/// 반복 원본 행의 날짜. 다른 회차를 열면 화면만 그 회차 날짜로 바꾸고,
/// 저장할 때 원본 행은 이 날짜를 유지한다.
String? _seriesCreatedAt;
String? _seriesDueDate;
/// 지금 연 회차가 원본 행의 생성일과 다르면, 그 회차의 생성일·기한을 보여 준다.
void _showOccurrenceDates() {
final shifted = _occurrenceDates();
if (shifted == null) return;
t.createdAt = _createdStamp(shifted.createdDay);
if (kind == 'event') {
t.dueDate = null;
return;
}
t.dueDate = shifted.dueDay;
}
/// 다른 회차에서 기한을 고치면 간격만 원본에 반영하고, 원본 생성일은 그대로 둔다.
void _foldOccurrenceDatesBack() {
final series = _dateOnly(_seriesCreatedAt);
final shifted = _occurrenceDates();
if (shifted == null || series.isEmpty) return;
final shownCreated = _dateOnly(t.createdAt);
final gap = shownCreated.isEmpty
? repeatDueGapDays(series, _seriesDueDate)
: repeatDueGapDays(shownCreated, t.dueDate);
t.createdAt = _seriesCreatedAt;
if (kind == 'event') {
t.dueDate = null;
return;
}
final base = DateTime.tryParse(series);
if (base == null || t.dueDate == null || t.dueDate!.isEmpty) {
t.dueDate = _seriesDueDate;
return;
}
t.dueDate = repeatDatesOn(base, gap).dueDay;
}
({String createdDay, String dueDay})? _occurrenceDates() {
if (!isEdit || t.isRecord || !t.isRepeating) return null;
final occ = t.occIso;
final series = _dateOnly(_seriesCreatedAt);
if (occ == null || occ.isEmpty || series.isEmpty || occ == series) {
return null;
}
return occurrenceCreatedDue(
seriesCreated: series,
seriesDue: _seriesDueDate,
occDay: occ,
);
}
initState 의 수정 분기. 메모 컨트롤러를 만든 뒤, 원본을 저장하고 나서 화면 날짜를 바꾼다.
} else {
_seriesCreatedAt = t.createdAt;
_seriesDueDate = t.dueDate;
_showOccurrenceDates();
final created = _dateOnly(t.createdAt);
_anchorCreatedIso = created.isNotEmpty ? created : t.dueDate;
}
_save 에서 메모를 쓴 직후, 기한 검사보다 앞에 원본 날짜로 되돌린다.
_writeNote();
_foldOccurrenceDatesBack();
5. 일자에 연도
같은 월·일이라도 해가 다르면 구분되어야 한다. 형식은 2026년 9월 24일 (목) 이다.
5.1 편집기 — _fmtDue
생성일, 기한, 반복 기한, 완료일, 기록 날짜가 이 함수를 쓴다.
String _fmtDue(String iso) {
final d = DateTime.tryParse(iso);
if (d == null) return iso;
return '${d.year}년 ${d.month}월 ${d.day}일 (${_wd[d.weekday]})';
}
5.2 하단 탭 제목 — lib/screens/home_screen.dart
String _todayLabel() {
final n = DateTime.now();
return '${n.year}년 ${n.month}월 ${n.day}일 (${_wd[n.weekday]})';
}
6. 편집 상단 — 할일 · 일정 · 기록
입력칸 라벨 할 일 은 본문보다 작다.
종류 이름을 본문 16의 120%인 19.2로 키우고 강조색을 쓴다.
그 오른쪽에 오늘 아이콘, 그 오른쪽에 오늘 날짜.
입력칸의 labelText 는 빼서 이름이 두 번 나오지 않게 한다. 힌트만 남긴다.
| 종류 | 글자 | 강조색 |
|---|---|---|
| 할일 | 할일 | #12448A |
| 일정 | 일정 | #0B7A3B |
| 기록 | 기록 | #7A4FBB |
/// 편집 상단. 본문(16)보다 20% 큰 강조 글자, 그 옆 오늘 표시와 오늘 날짜.
Widget _kindHead(String label, Color color) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Text(
label,
style: TextStyle(
fontSize: 19.2,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.all(Radius.circular(6)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.today, size: 14, color: Colors.white),
SizedBox(width: 3),
Text(
'오늘',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
_fmtDue(isoOf(DateTime.now())),
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: color,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
할일 폼 제목 칸 위:
_kindHead('할일', const Color(0xFF12448A)),
TextField(
controller: _title,
autofocus: !isEdit,
maxLines: null,
keyboardType: TextInputType.multiline,
decoration: const InputDecoration(hintText: '할 일을 입력…'),
),
일정·기록 폼. 예전 13px 일정 라벨과 labelText: '$label 제목' 을 이 헤더로 바꾼다.
final label = kind == 'record' ? '기록' : '일정';
final accent =
kind == 'record' ? const Color(0xFF7A4FBB) : const Color(0xFF0B7A3B);
// ...
_kindHead(label, accent),
TextField(
controller: _title,
autofocus: !isEdit,
maxLines: null,
decoration: InputDecoration(hintText: '$label을 입력…'),
),
7. 하단 탭 상단 — lib/screens/home_screen.dart
할일·일정·기록 탭 제목과 오늘 날짜 사이에 오늘 아이콘을 둔다.
달력 탭 제목 모담 하루계획 에는 날짜가 없으므로 아이콘도 없다.
상단 막대 배경이 #3F6FB0 이라 아이콘은 흰 배경에 파란 글자로 둔다.
Widget _barTitle() {
final name = _titles[_index];
if (_index == 0) return Text(name);
return Row(
children: [
Text(name),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.today, size: 14, color: Color(0xFF3F6FB0)),
SizedBox(width: 3),
Text(
'오늘',
style: TextStyle(
color: Color(0xFF3F6FB0),
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
_todayLabel(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400),
),
),
],
);
}
보이는 순서: 할 일 · 오늘 · 2026년 9월 24일 (목). 일정·기록도 같다.
8. 테스트 — test/repeat_anchor_test.dart
아이폰 앱이면 import 패키지 이름만 pubspec.yaml 의 name 으로 바꾼다.
test('다른 회차의 생성일은 그 날, 기한은 같은 간격', () {
expect(
occurrenceCreatedDue(
seriesCreated: '2026-09-24',
seriesDue: '2026-09-24',
occDay: '2026-10-01',
),
(createdDay: '2026-10-01', dueDay: '2026-10-01'),
);
expect(
occurrenceCreatedDue(
seriesCreated: '2026-09-24',
seriesDue: '2027-09-24',
occDay: '2026-10-01',
),
(createdDay: '2026-10-01', dueDay: '2027-10-01'),
);
});
9. 확인
- 생성 2026-09-24, 기한 2027-09-24, 매주. 9월 24일 회차는 그 날짜 그대로다.
- 10월 1일 회차를 열면 생성일 2026년 10월 1일, 기한 2027년 10월 1일이다.
- 그 화면에서 저장만 하면 9월 24일 원본 생성일은 바뀌지 않는다.
- 생성일과 기한이 같은 해의 9월 24일이면, 10월 1일 회차의 둘 다 2026년 10월 1일이다.
- 할일·일정·기록 편집 상단 이름이 본문보다 크고, 옆에 오늘 아이콘과 오늘 날짜가 있다.
- 하단 할일·일정·기록 탭 제목과 날짜 사이에 오늘 아이콘이 있다. 달력 탭에는 없다.
핫 리로드 대신 앱을 완전히 다시 실행한다.