1. 요약
| 작업 | 내용 |
|---|---|
| 반복 회차 메모 | 처음 메모는 모든 회차. 이후 수정은 그 회차만 occ_notes |
| 다른 회차 편집 화면 | 생성일·기한을 그 회차 날로 보여 주고, 저장 시 원본 생성일은 유지.
명세 20260924_1028_회차날짜_상단오늘_앱이식.html |
| 날짜에 연도 | 2026년 9월 24일 (목) |
| 편집·탭 상단 오늘 | 할일·일정·기록 이름 옆에 오늘 아이콘과 오늘 날짜. 달력 탭에는 없음 |
| 날짜칸 공휴일 | 날짜 숫자 오른쪽. 칸을 넘기면 … |
| 날짜칸 높이 | 기록·일정·할일이 겹쳐 아래 칸을 침범하면 그 달 행을 키움 |
2. 반복 회차 메모는 개별 저장
반복 할일·일정은 DB에 지금 회차 행 하나다. 다른 칸은 그 행을 펼친 복사본이라, 메모를 고치면 모든 회차가 같이 바뀌었다.
규칙:
- 반복을 처음 만들 때 넣은 메모는 공통
note. 모든 회차에 보인다. - 그 뒤 어느 회차 메모를 고쳐도 그 날짜만
occ_notesJSON에 남긴다. - 한 회차 메모를 비우면 그 회차만 비고, 공통 메모로 되돌아가지 않는다.
2.1 모델 — lib/models/todo.dart
/// 회차별 메모. JSON {"yyyy-MM-dd":"메모"}. 키가 있으면 그 회차만 이 값.
/// 빈 문자열은 그 회차 메모 없음(공통 메모로 되돌리지 않음).
String? occNotes;
/// 이 객체가 가리키는 반복 회차 날짜. 전개 복사본에만 채운다.
String? occIso;
/// 그 회차에 보이는 메모. 개별 값이 있으면 그것, 없으면 만들 때 넣은 공통 메모.
String? noteFor(String day) { ... }
/// 이 회차만 메모를 덮어쓴다. 공통 [note] 는 바꾸지 않는다.
void setOccNote(String day, String text) { ... }
DB 컬럼 occ_notes TEXT (스키마 버전 12). 달력 전개 복사본에 occIso 를 붙인다.
2.2 저장 — lib/widgets/todo_edit_sheet.dart
void _writeNote() {
final text = _note.text.trim();
final wasRepeating = widget.todo?.isRepeating ?? false;
if (isEdit && wasRepeating && t.isRepeating) {
final day = t.memoDay;
if (day != null && day.isNotEmpty) {
t.setOccNote(day, text);
return;
}
}
t.note = text.isEmpty ? null : text;
if (!t.isRepeating) t.occNotes = null;
}
3. 다른 회차를 열면 그 날로 계산
10월 1일 칸은 원본 9월 24일 행을 펼친 것이다.
편집 화면이 원본 날짜를 그대로 읽으면 생성일·기한이 9월 24일로 남고,
연도가 다른 두 날이 9월 24일 로만 보여 D-365 처럼 보인다.
| 연 회차 | 화면 생성일 | 화면 기한 | 저장되는 원본 |
|---|---|---|---|
| 이번 회차 9월 24일 | 2026-09-24 | 2027-09-24 | 그대로 |
| 다음 회차 10월 1일 | 2026-10-01 | 2027-10-01 | 생성일 9월 24일 유지. 기한을 고치면 간격만 반영 |
일정은 기한이 없다. 다른 회차의 생성일만 그 날로 보여 주고 저장 시 기한은 비운다. 기록은 이 계산에 넣지 않는다.
3.1 계산 — lib/logic/recurrence.dart
({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);
}
3.2 편집기 — 화면만 바꾸고 저장 때 되돌림
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;
}
initState 수정 분기에서 원본을 기억한 뒤 화면 날짜를 바꾼다. _save 에서는 메모를 쓴 직후 원본으로 되돌린다.
} else {
_seriesCreatedAt = t.createdAt;
_seriesDueDate = t.dueDate;
_showOccurrenceDates();
final created = _dateOnly(t.createdAt);
_anchorCreatedIso = created.isNotEmpty ? created : t.dueDate;
}
_writeNote();
_foldOccurrenceDatesBack();
3.3 일자에 연도
String _fmtDue(String iso) {
final d = DateTime.tryParse(iso);
if (d == null) return iso;
return '${d.year}년 ${d.month}월 ${d.day}일 (${_wd[d.weekday]})';
}
생성일, 기한, 반복 기한, 완료일, 기록 날짜가 이 함수를 쓴다.
4. 편집 상단 · 하단 탭 오늘 표시
입력칸 라벨 할 일 은 빼고, 종류 이름을 본문 16의 120%인 19.2로 키운다.
오른쪽에 오늘 아이콘, 그 오른쪽에 오늘 날짜.
| 종류 | 글자 | 강조색 |
|---|---|---|
| 할일 | 할일 | #12448A |
| 일정 | 일정 | #0B7A3B |
| 기록 | 기록 | #7A4FBB |
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),
/* 오늘 아이콘 + '오늘' 흰 글자, 배경은 color */
Flexible(child: Text(_fmtDue(isoOf(DateTime.now())), ...)),
],
),
);
}
하단 탭 제목은 달력(모담 하루계획)에는 날짜가 없다. 할일·일정·기록만 오늘 아이콘과
2026년 9월 24일 (목) 을 붙인다. 상단 막대가 #3F6FB0 이라 아이콘은 흰 배경에 파란 글자다.
String _todayLabel() {
final n = DateTime.now();
return '${n.year}년 ${n.month}월 ${n.day}일 (${_wd[n.weekday]})';
}
Widget _barTitle() {
final name = _titles[_index];
if (_index == 0) return Text(name);
return Row(children: [
Text(name),
/* 오늘 아이콘(흰 배경) + _todayLabel() */
]);
}
5. 날짜칸 — 공휴일은 숫자 오른쪽
스샷의 24일 칸은 날짜 아래 줄에 추석 전날 이 있고,
그 아래 기록·일정·할일이 겹쳐 다음 주 1일 칸까지 글자가 내려갔다.
공휴일 이름은 날짜 숫자 오른쪽에 둔다.
칸 폭(완료 점 여백 포함)을 넘기면 TextOverflow.ellipsis 로
추석 전… 처럼 자른다. 아래 칸으로 넘기지 않는다.
return Container(
width: double.infinity,
clipBehavior: Clip.hardEdge,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Column(children: [
Padding(
padding: EdgeInsets.fromLTRB(2, 3, showDone ? 12 : 2, 0),
child: Row(children: [
Container( /* 날짜 숫자 24×24 */ ),
if (showHoli) ...[
const SizedBox(width: 2),
Expanded(
child: Text(
holi!,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12, color: Color(0xFFD94C4C)),
),
),
],
]),
),
// 기록 → 일정 → 할일 → 구글 점
]),
if (showDone)
Positioned(top: 4, right: 4, child: _dot(Colors.black)),
],
),
);
clipBehavior: Clip.hardEdge 는 행 높이 추정이 1px 짧아도 아래 칸으로 그려지지 않게 하는 안전장치다.
6. 내용이 많으면 행 높이를 키운다
공휴일을 숫자 옆으로 옮겨도, 기록·일정·할일이 한 칸에 겹치면
고정 fitRow 보다 내용이 길다. 잘라 숨기지 않고
TableCalendar.rowHeight 를 필요한 높이로 올린다.
표는 행마다 같은 높이라, 그 달에서 가장 내용이 많은 칸을 기준으로 모든 행이 커진다.
double _titleLineH(List<String>? items) {
if (items == null || items.isEmpty) return 0;
return items.length <= 1 ? 16 : 18;
}
double _neededCellHeight(DateTime day, {required bool outside}) {
const dateRow = 27.0; // 위 패딩 3 + 숫자 24
const bottom = 4.0;
if (outside) return dateRow + bottom;
final iso = isoOf(day);
var h = dateRow;
// 기록 / 일정 / 할일 각 한 줄 + 구글 점 6
return h + bottom;
}
double _maxNeededRowHeight() {
var maxH = 31.0;
final last = DateTime(_focused.year, _focused.month + 1, 0).day;
for (var d = 1; d <= last; d++) {
final need = _neededCellHeight(
DateTime(_focused.year, _focused.month, d),
outside: false,
);
if (need > maxH) maxH = need;
}
return maxH;
}
var row = fitRow;
if (!row.isFinite || row <= 0) row = 80.0;
final need = _maxNeededRowHeight();
if (need > row) row = need;
// TableCalendar(rowHeight: row)
return row > fitRow
? SingleChildScrollView(child: cal)
: cal;
row > fitRow 이면 달력 영역만 세로 스크롤한다.
예전 주석 「fitRow 로 채우되 내용 많으면 그 이상」이 이제 실제로 동작한다.
7. 손본 파일
| 파일 | 내용 |
|---|---|
lib/models/todo.dart |
occNotes, occIso, noteFor, setOccNote |
lib/db/app_db.dart |
occ_notes 컬럼(버전 12), 완료 회차에 그 날 메모 |
lib/logic/recurrence.dart |
occurrenceCreatedDue, 전개 복사본 occIso |
lib/widgets/todo_edit_sheet.dart |
회차 메모, 회차 날짜 접기, 연도, _kindHead |
lib/screens/home_screen.dart |
탭 제목 옆 오늘 아이콘·연도 |
lib/screens/calendar_tab.dart |
공휴일 위치, 말줄임, 행 높이 |
test/occ_note_test.dart |
공통 메모 vs 회차 메모 |
test/repeat_anchor_test.dart |
다른 회차 생성일·기한 간격 |
확인
- 매주 할일을 만들고 한 회차만 메모를 바꾼다. 다른 주는 처음 메모가 남는다.
- 생성 2026-09-24, 기한 2027-09-24, 매주. 10월 1일 회차를 열면 생성 2026년 10월 1일, 기한 2027년 10월 1일. 저장해도 원본 생성일은 9월 24일이다.
- 할일·일정·기록 편집 상단에 오늘 아이콘과 오늘 날짜가 있다.
- 하단 할일·일정·기록 탭에만 오늘 아이콘이 있다. 달력 탭에는 없다.
- 9월 24일 칸:
24옆에추석 전날. 길면…. 기록·일정·할일이 있어도 1일 칸으로 글자가 내려가지 않고, 그 주 행이 높아진다.
핫 리로드 대신 앱을 완전히 다시 실행한다.