1. 요약

작업앱원문
달력 시트 높이·목록 스크롤 이미 있음. 코드 유지 20260923_1050_바텀시트_안드로이드웹이식.html
날짜+건수: 올라온 시트는 접기 이미 있음. 코드 유지 20260923_1213_날짜건수_올라온시트접기.html
최대 높이에서 아래 화살표 이번에 넣음 20260923_1313_바텀시트_아래화살표_안드로이드이식.html
예시 데이터 지우기(공휴일만 남김) 이번에 넣음 20260923_1350_예시데이터_지우기_안드로이드이식.html
반복 할일 = 생성일 기준, 기한은 간격 이번에 넣음. 매일·매주·매월·매년 20260923_2342_반복할일_생성일기준_앱이식.html
웹 날짜 규칙·시트 명세 HTML 문서만 작성. 웹 코드는 안 고침 20260923_1552_웹_날짜규칙_화면시트.html
날짜칸 시트 높이(1050)와 날짜+건수 토글(1213)은 대화 시점에 이미 lib/screens/calendar_tab.dart 에 있었다. 나중에 온 문서가 앞 문서를 덮는다. 날짜+건수는 1213, 화살표 패딩은 1313.

2. 반복 할일 — 생성일 기준

웹이 먼저 이 규칙이다. 앱 repeatAnchor 가 할일을 기한으로 봐서 칸이 기한 날에 붙고, 회차도 기한만 밀렸다.

최종 규칙: 칸과 회차 일자는 생성일. 기한은 생성일과의 간격만 유지한다. 매주 표는 예시일 뿐이다.

주기생성 9/24 · 기한 9/25 다음 회차
매일생성 9/25, 기한 9/26
매주생성 10/1, 기한 10/2
매월생성 10/24, 기한 10/25
매년생성 2027-09-24, 기한 2027-09-25

주기는 생성일만 민다. 기한 = 새 생성일 + (원래 기한 − 원래 생성일)일.

2.1 기준일 — lib/models/todo.dart

/// 반복 기준일. 순수 일정과 반복 할일은 생성일. 기록은 기한.
String? get repeatAnchor {
  final created = createdAt;
  final createdDay = (created != null && created.length >= 10)
      ? created.substring(0, 10)
      : null;
  final pureEvent = onCalendar && !isTodo && !isRecord;
  if (pureEvent) return eventDay;
  if (isTodo && !isRecord && isRepeating && createdDay != null) {
    return createdDay;
  }
  if (dueDate != null && dueDate!.isNotEmpty) return dueDate;
  return createdDay;
}

occurrencesIn 은 이미 repeatAnchor 를 쓴다. 여기를 바꾸면 달력·반복 보기 칸이 생성일로 따라간다.

pendingOccurrence 도 기한 대신 기준일을 쓴다.

DateTime? pendingOccurrence(Todo t, DateTime today) {
  final anchor = t.repeatAnchor;
  if (!t.isRepeating || anchor == null) return null;
  DateTime base;
  try {
    base = DateTime.parse(anchor);
  } catch (_) {
    return null;
  }
  ...
}

2.2 다음 회차 식 — lib/logic/recurrence.dart

nextOccurrenceAfter(매일 +1, 매주 +7, 매월 같은 날, 매년 같은 날)로 생성일을 밀고, 기한에 간격을 더한다.

int repeatDueGapDays(String createdDay, String? due) {
  final d = _day10(due);
  if (d == null) return 0;
  final a = DateTime.tryParse(createdDay);
  final b = DateTime.tryParse(d);
  if (a == null || b == null) return 0;
  return DateTime(b.year, b.month, b.day)
      .difference(DateTime(a.year, a.month, a.day))
      .inDays;
}

({String createdDay, String dueDay}) repeatDatesOn(DateTime occ, int gap) {
  final created = dOnly(occ);
  return (
    createdDay: _iso(created),
    dueDay: _iso(created.add(Duration(days: gap))),
  );
}

({String createdDay, String dueDay})? nextRepeatCreatedDue({
  required String createdDay,
  String? dueDate,
  required String? unit,
  int interval = 1,
  DateTime? until,
}) {
  final base = DateTime.tryParse(createdDay);
  if (base == null) return null;
  final next = nextOccurrenceAfter(base, unit, interval, until: until);
  if (next == null) return null;
  return repeatDatesOn(next, repeatDueGapDays(createdDay, dueDate));
}

2.3 저장 — 빈 기한은 생성일 — lib/widgets/todo_edit_sheet.dart _save

「기한은 생성일보다 빠를 수 없습니다」 검사보다 앞이다. 일정은 기한을 비운다. 일정 편집기에 기한 칸을 넣지 않는다.

final createdDay = _dateOnly(t.createdAt);
if (kind == 'todo' &&
    (t.dueDate == null || t.dueDate!.isEmpty) &&
    createdDay.isNotEmpty) {
  t.dueDate = createdDay;
}
if (kind == 'event') t.dueDate = null;
if (createdDay.isNotEmpty &&
    t.dueDate != null &&
    t.dueDate!.isNotEmpty &&
    t.dueDate!.compareTo(createdDay) < 0) {
  await showAppAlert(context, '기한은 생성일보다 빠를 수 없습니다.');
  return;
}

2.4 회차 이동 — lib/db/app_db.dart _rollForwardImpl

조회에서 due_date IS NOT NULL 을 뺐다. 생성일이 있는 반복 할일이 대상이다. 일정 반복은 가상 표시라 넣지 않는다.

final rows = await d.query('tb_todo',
    where:
        "user_id = ? AND is_todo = 1 AND repeat_unit IS NOT NULL AND repeat_unit != '' "
        "AND is_deleted = 0 AND (created_at IS NOT NULL OR due_date IS NOT NULL)",
    whereArgs: [u]);
for (final m in rows) {
  final t = Todo.fromMap(m);
  final createdDay = AppDb._day10(t.createdAt) ?? t.dueDate;
  if (createdDay == null || createdDay.isEmpty) continue;
  final base = DateTime.tryParse(createdDay);
  if (base == null) continue;
  final gap = repeatDueGapDays(createdDay, t.dueDate);
  final until =
      t.repeatUntil == null ? null : DateTime.tryParse(t.repeatUntil!);
  if (t.isDone) {
    await d.update('tb_todo',
        {
          'repeat_unit': null,
          'cal_visible': 1,
          'done_date': createdDay,
          'updated_at': nowUtcStamp(),
        },
        where: 'id = ? AND user_id = ?',
        whereArgs: [t.id, u]);
    final nxt = nextRepeatCreatedDue(
      createdDay: createdDay,
      dueDate: t.dueDate,
      unit: t.repeatUnit,
      interval: t.repeatInterval,
      until: until,
    );
    if (nxt != null) {
      final map = t.toMap()
        ..remove('id')
        ..['created_at'] = '${nxt.createdDay} 00:00:00'
        ..['due_date'] = nxt.dueDay
        ..['is_done'] = 0
        ..['done_date'] = null
        ..['last_done_date'] = null
        ..['user_id'] = u
        ..['sync_uid'] = AppDb.childSyncUid(t.syncUid)
        ..['updated_at'] = nowUtcStamp()
        ..['is_deleted'] = 0;
      await d.insert('tb_todo', map);
    }
    continue;
  }
  final target = latestOccurrenceOnOrBefore(
      base, t.repeatUnit, t.repeatInterval, today,
      until: until);
  if (target == null) continue;
  final shifted = repeatDatesOn(target, gap);
  if (shifted.createdDay == createdDay) continue;
  await d.update(
    'tb_todo',
    {
      'created_at': '${shifted.createdDay} 00:00:00',
      'due_date': shifted.dueDay,
      'updated_at': nowUtcStamp(),
    },
    where: 'id = ? AND user_id = ?',
    whereArgs: [t.id, u],
  );
}

예전에는 완료 다음 행이 예전 created_at 을 복사하고 기한만 다음 회차로 바꿨다. 지금은 생성일을 복사하지 않는다.

2.5 그 날짜만 완료

createdAt 은 원본 복사가 아니라 '$iso 00:00:00'. doneDate 는 회차 생성일. dueDate 는 그 날 + 간격.

Future<void> completeRepeatOccurrence(
    Todo base, String iso, String todayIso) async {
  final createdDay = AppDb._day10(base.createdAt) ?? base.dueDate ?? iso;
  final gap = repeatDueGapDays(createdDay, base.dueDate);
  final occ = DateTime.tryParse(iso);
  final due = occ == null ? iso : repeatDatesOn(occ, gap).dueDay;
  final rec = Todo(
    content: base.content,
    isDone: true,
    isStar: base.isStar,
    isMyDay: false,
    dueDate: due,
    repeatInterval: 1,
    note: base.note,
    onCalendar: base.onCalendar,
    calVisible: true,
    isRecord: base.isRecord,
    isTodo: base.isTodo,
    doneDate: iso,
    eventTime: base.eventTime,
    createdAt: '$iso 00:00:00',
    syncUid: AppDb.childSyncUid(base.syncUid),
  );
  await insert(rec);
}

가상 회차 복사에서 dueDate = 선택일 을 지웠다. 간격을 잃지 않기 위해서다. 토글은 선택일을 넘긴다.

onToggleDone: t.isRecurring
    ? () => AppDb.instance.completeRepeatOccurrence(
        t, isoOf(_selected), isoOf(DateTime.now()))
    : null,

시트에 넣을 가상 회차:

base.add(t.copy()..isRecurring = true);

2.6 조회

listRepeating — 기한이 빈 반복 할일·일정이 빠지지 않게.

where:
  "user_id = ? AND repeat_unit IS NOT NULL AND repeat_unit != '' "
  "AND is_deleted = 0 AND ("
  "(NOT (on_calendar = 1 AND is_todo = 0 AND is_record = 0) "
  " AND (created_at IS NOT NULL OR due_date IS NOT NULL)) "
  "OR (on_calendar = 1 AND is_todo = 0 AND is_record = 0 "
  " AND created_at IS NOT NULL))"

listByDate 반복 줄도 생성일 칸.

"repeat_unit IS NOT NULL AND repeat_unit != '' AND ("
"substr(created_at, 1, 10) = ? OR ("
"(created_at IS NULL OR created_at = '') AND due_date = ?))"

완료 범위 조회는 완료일·생성일·기한 순.

AND COALESCE(NULLIF(done_date, ''), substr(created_at, 1, 10), due_date) >= ?
AND COALESCE(NULLIF(done_date, ''), substr(created_at, 1, 10), due_date) <= ?

달력에서 이미 있는 회차를 건너뛸 때도 생성일 칸을 쓴다(markOcc).

2.7 그대로 둔 것

2.8 테스트 — test/repeat_anchor_test.dart

test('다음 회차: 생성일은 주기만큼, 기한은 간격 유지', () {
  const created = '2026-09-24';
  const due = '2026-09-25';
  expect(
    nextRepeatCreatedDue(createdDay: created, dueDate: due, unit: 'day'),
    (createdDay: '2026-09-25', dueDay: '2026-09-26'),
  );
  expect(
    nextRepeatCreatedDue(createdDay: created, dueDate: due, unit: 'week'),
    (createdDay: '2026-10-01', dueDay: '2026-10-02'),
  );
  expect(
    nextRepeatCreatedDue(createdDay: created, dueDate: due, unit: 'month'),
    (createdDay: '2026-10-24', dueDay: '2026-10-25'),
  );
  expect(
    nextRepeatCreatedDue(createdDay: created, dueDate: due, unit: 'year'),
    (createdDay: '2027-09-24', dueDay: '2027-09-25'),
  );
});

3. 달력 시트

3.1 날짜+건수 (1213, 이미 있음)

1050 의 atMax 는 0.6 시트를 0.9 로 한 단 더 올렸다. 판정은 「이미 올라왔는가」다.

double calendarSheetSizeOnCountTap({
  required double current,
  required double collapsed,
}) {
  final raised = current > collapsed + 0.12;
  return raised ? collapsed : kCalendarSheetMax;
}

3.2 아래 화살표 (1313, 이번에 넣음)

bool calendarSheetShowsMoreBelow({
  required double sheetSize,
  required double scrollOffset,
  required double maxScrollExtent,
}) {
  final atMax = sheetSize >= kCalendarSheetMax - 0.02;
  if (!atMax) return false;
  return maxScrollExtent - scrollOffset > 8;
}

목록 Expanded 다음 자식. 글자 위에 겹치지 않는다. 목록 아래 패딩 28 → 8.

Expanded(
  child: ListView(
    controller: _listCtl,
    primary: false,
    padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
    children: [ /* 기존 목록 */ ],
  ),
),
_SheetMoreHint(sheet: _sheetCtl, list: _listCtl),
@override
Widget build(BuildContext context) {
  if (!_visible) return const SizedBox.shrink();
  return const ColoredBox(
    color: Color(0xFFF7F9FC),
    child: SizedBox(
      width: double.infinity,
      child: Padding(
        padding: EdgeInsets.only(bottom: 4),
        child: Icon(
          Icons.keyboard_arrow_down,
          size: 22,
          color: Color(0xFF3F6FB0),
        ),
      ),
    ),
  );
}

4. 예시 데이터 지우기 (1350)

예전 버튼은 demo- 만 지웠다. 갈라진 회차와 직접 만든 항목이 남고, 행이 0이면 재실행 때 예시가 다시 들어갔다.

4.1 갈라진 회차도 demo-

static const demoUidPrefix = 'demo-';

static String childSyncUid(String? parentUid) {
  if (parentUid != null && parentUid.startsWith(demoUidPrefix)) {
    return '$demoUidPrefix${genUuid()}';
  }
  return genUuid();
}

사용처: detachPastRepeatEvent, completeRepeatOccurrence, _rollForwardImpl 의 새 줄. insert/update 의 빈 sync_uid 채우기는 그대로 genUuid().

4.2 공휴일만 남기고 삭제

Future<int> deleteAllExceptHolidays() async {
  final d = await db;
  final u = await currentUser();
  var n = 0;
  n += await d.delete('tb_todo', where: 'user_id = ?', whereArgs: [u]);
  n += await d.delete('tb_record_template');
  return n;
}
static Future<void> clearAllExceptHolidays() async {
  final db = AppDb.instance;
  await db.deleteAllExceptHolidays();
  await db.setSetting(_skipAutoloadKey, '1');
  await NotifyService.instance.syncFromDb();
  bumpData();
}

installIfNoTodos 는 demo_skip_autoload=1 이면 삽입하지 않는다. 「예시 데이터 넣기」 끝에서 그 값을 0 으로 푼다.

설정 문구: 버튼 「예시 데이터 지우기」. 확인 「공휴일은 남고, 할일·일정·기록은 모두 지워집니다.」

await DemoSeed.clearAllExceptHolidays();
await showAppAlert(context, '예시 데이터를 지웠습니다. 공휴일만 남습니다.');
로컬 하드 삭제다. 로그인 기기에서 서버에 같은 항목이 있으면 다음 동기화 때 다시 내려올 수 있다.

5. 하지 않은 것

6. 손본 파일 (앱)

파일내용
lib/models/todo.dart repeatAnchor
lib/logic/recurrence.dart 간격·다음 회차, pendingOccurrence
lib/db/app_db.dart 회차 이동, 완료 행, 조회, childSyncUid, 예시 통삭제
lib/widgets/todo_edit_sheet.dart 빈 기한 → 생성일, 일정 기한 비움
lib/screens/calendar_tab.dart 시트 토글·화살표, 가상 회차, 완료 토글 인자
lib/demo/demo_seed.dart 자동 삽입 금지, 통삭제
lib/screens/settings_screen.dart 지우기 문구·호출
test/repeat_anchor_test.dart 매일·매주·매월·매년 다음 회차
test/calendar_sheet_test.dart 날짜+건수, 화살표 판정
test/demo_uid_test.dart childSyncUid

확인

  1. 생성 9/24 할일, 기한 비움 → 기한 9/24, 칸 24일.
  2. 매주 + 기한 9/25 → 칸은 24일. 25일 칸 없음.
  3. 매일·매월·매년도 생성일만 주기만큼, 기한은 간격.
  4. 그 회차 완료 → 그 생성일에 완료. 다음 행은 새 생성일 + 간격 기한.
  5. 일정은 기한 없음. 칸은 생성일.
  6. 올라온 시트에서 날짜+건수 → 바로 접힘.
  7. 0.9 이고 아래가 남으면 목록 밑에 화살표.
  8. 예시 데이터 지우기 → 공휴일만. 재실행해도 예시 자동 삽입 없음.

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