1. 요약
| 작업 | 앱 | 원문 |
|---|---|---|
| 달력 시트 높이·목록 스크롤 | 이미 있음. 코드 유지 | 20260923_1050_바텀시트_안드로이드웹이식.html |
| 날짜+건수: 올라온 시트는 접기 | 이미 있음. 코드 유지 | 20260923_1213_날짜건수_올라온시트접기.html |
| 최대 높이에서 아래 화살표 | 이번에 넣음 | 20260923_1313_바텀시트_아래화살표_안드로이드이식.html |
| 예시 데이터 지우기(공휴일만 남김) | 이번에 넣음 | 20260923_1350_예시데이터_지우기_안드로이드이식.html |
| 반복 할일 = 생성일 기준, 기한은 간격 | 이번에 넣음. 매일·매주·매월·매년 | 20260923_2342_반복할일_생성일기준_앱이식.html |
| 웹 날짜 규칙·시트 명세 HTML | 문서만 작성. 웹 코드는 안 고침 | 20260923_1552_웹_날짜규칙_화면시트.html |
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 그대로 둔 것
- 일정 편집기 기한 칸 없음.
dueDate = null. - 기록 칸·반복 기준은 기한.
- 달력 헤더 반복 보기 버튼.
- 「오늘 할일」이 생성일을 덮지 않는 규칙.
anchor_created컬럼은 안 넣음. - 웹 목록 반복 아이콘 위치. 앱 작업 아님.
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;
}
- 접힘 → 0.9
- 0.6 / 0.63 / 0.9 → 바로 접힘
- 날짜칸 높이 식은 1050 그대로 (최소 0.6, 잘릴 때만 0.9)
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. 하지 않은 것
- 웹
modam_doc/app_schedule소스 수정. 사용자가 수정하지 말라고 했다. 동기화는 이미 반영됐다고 했다. - 웹에 남은 화면·조회는 명세만 씀:
docs/2026-09-23/20260923_1552_웹_날짜규칙_화면시트.html - 이 대화에서 git commit 하지 않음.
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 |
확인
- 생성 9/24 할일, 기한 비움 → 기한 9/24, 칸 24일.
- 매주 + 기한 9/25 → 칸은 24일. 25일 칸 없음.
- 매일·매월·매년도 생성일만 주기만큼, 기한은 간격.
- 그 회차 완료 → 그 생성일에 완료. 다음 행은 새 생성일 + 간격 기한.
- 일정은 기한 없음. 칸은 생성일.
- 올라온 시트에서 날짜+건수 → 바로 접힘.
- 0.9 이고 아래가 남으면 목록 밑에 화살표.
- 예시 데이터 지우기 → 공휴일만. 재실행해도 예시 자동 삽입 없음.
핫 리로드 대신 앱을 완전히 다시 실행한다.