회차 날짜는 그 날로 계산, 상단에 오늘 표시

2026-09-24 10:28 (KST). 안드로이드 day_plan_app 에 이미 들어간 코드다. 아이폰 day_plan_appl_app 은 같은 Dart 경로를 쓴다. 아래 함수를 같은 자리에 넣으면 된다. 파일 전체를 덮어쓰지 않는다.

1. 원인

반복 할일은 회차마다 행을 미리 만들지 않는다. 데이터베이스에는 지금 회차 행 하나만 있고, 생성일·기한도 그 행의 값이다. 10월 1일 칸은 그 행을 펼쳐 보여 준 것이라, 편집 화면도 원본의 9월 24일을 그대로 읽었다.

예: 생성일 2026-09-24(목), 기한 2027-09-24(금). 간격은 365일이다. 화면이 9월 24일 만 찍으면 해가 다른 두 날짜가 같아 보인다. D-365 가 그 차이다.

10월 1일 회차를 열어도 생성일·기한이 9월 24일로 남는다. 그 회차용 날짜를 화면에서 다시 계산하지 않았기 때문이다. 원본 행을 10월 1일로 바꿔 저장하면 9월 24일 회차가 사라진다.

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]})';
}

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. 확인

  1. 생성 2026-09-24, 기한 2027-09-24, 매주. 9월 24일 회차는 그 날짜 그대로다.
  2. 10월 1일 회차를 열면 생성일 2026년 10월 1일, 기한 2027년 10월 1일이다.
  3. 그 화면에서 저장만 하면 9월 24일 원본 생성일은 바뀌지 않는다.
  4. 생성일과 기한이 같은 해의 9월 24일이면, 10월 1일 회차의 둘 다 2026년 10월 1일이다.
  5. 할일·일정·기록 편집 상단 이름이 본문보다 크고, 옆에 오늘 아이콘과 오늘 날짜가 있다.
  6. 하단 할일·일정·기록 탭 제목과 날짜 사이에 오늘 아이콘이 있다. 달력 탭에는 없다.

핫 리로드 대신 앱을 완전히 다시 실행한다.