수정한 Dart 전체
아이폰 앱 day_plan_appl_app 은 이 앱과 같은 Dart 소스다.
아래는 이번 작업으로 바뀐 파일의 현재 전체 코드다.
같은 경로의 파일을 이 내용으로 바꾸면 된다.
파일로 이동: lib/main.dart lib/models/todo.dart lib/logic/recurrence.dart lib/db/app_db.dart lib/services/notify.dart lib/demo/demo_seed.dart lib/screens/home_screen.dart lib/screens/list_tab.dart lib/screens/month_events_tab.dart lib/screens/calendar_tab.dart lib/widgets/todo_tile.dart lib/widgets/todo_edit_sheet.dart
lib/main.dart
import 'package:flutter/material.dart';
import 'db/app_db.dart';
import 'demo/demo_seed.dart';
import 'logic/holidays.dart';
import 'services/notify.dart';
import 'screens/home_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Holidays.load();
await NotifyService.instance.init();
await DemoSeed.installIfNoTodos();
await AppDb.instance.dropEventDueDates();
await NotifyService.instance.syncFromDb(); // 저장된 미리알림 재예약(오프라인 포함)
runApp(const ScheduleApp());
}
class ScheduleApp extends StatelessWidget {
const ScheduleApp({super.key});
@override
Widget build(BuildContext context) {
const seed = Color(0xFF3F6FB0); // 모담테크 블루 계열
return MaterialApp(
title: '하루계획',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: seed),
scaffoldBackgroundColor: const Color(0xFFF4F6F9),
),
// 전체 화면 글자 12% 키움(달력 셀은 자체 크기 유지 — 별도 처리).
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: const TextScaler.linear(1.06)),
child: child!,
),
home: const HomeScreen(),
);
}
}
lib/models/todo.dart
/// 할 일 / 일정 모델 (modam_doc app_schedule 의 tb_todo 재구현).
/// 한 항목은 할일(isTodo) · 일정(onCalendar) · 둘 다가 될 수 있다.
class Todo {
final int? id;
String content;
bool isDone;
bool isStar;
bool isMyDay;
String? dueDate; // 'yyyy-MM-dd'
String? remindTime; // 'HH:mm'
String? repeatUnit; // day | week | month | year | null
int repeatInterval;
String? repeatUntil; // 반복 종료일 'yyyy-MM-dd' (null = 무기한)
String? note;
bool onCalendar; // 달력 칸에 제목 표시(일정)
bool calVisible; // 달력(셀·시트)에 표시 여부. false=달력에서 숨김(목록 탭에는 유지)
bool isRecord; // 기록(일정과 동일 로직, 이름만 '기록')
bool isTodo; // 할 일 목록 노출
String? lastDoneDate; // 반복 할 일의 현재 회차 완료일 'yyyy-MM-dd'
String? doneDate; // 완료된 일자 'yyyy-MM-dd' (비반복 할일)
String? remindDay; // 미리 알림 기준일: 'prev'(전일) | 'same'(당일) | null
String? eventTime; // 일정/기록의 시각 'HH:mm' (미리알림과 별개)
String? createdAt;
// 동기화 메타
String? syncUid; // 서버·앱 공통 식별자(UUID)
String? updatedAt; // 마지막 수정(UTC 'yyyy-MM-dd HH:mm:ss')
bool isDeleted; // 소프트 삭제(묘비)
// 표시용(비영속)
bool isRecurring;
bool isOverdue; // 기한 지남(지연) 표시용
bool isTodayTask; // 오늘 할 일(당일) 표시용(✔)
Todo({
this.id,
this.content = '',
this.isDone = false,
this.isStar = false,
this.isMyDay = false,
this.dueDate,
this.remindTime,
this.repeatUnit,
this.repeatInterval = 1,
this.repeatUntil,
this.note,
this.onCalendar = false,
this.calVisible = true,
this.isRecord = false,
this.isTodo = true,
this.lastDoneDate,
this.doneDate,
this.remindDay,
this.eventTime,
this.createdAt,
this.syncUid,
this.updatedAt,
this.isDeleted = false,
this.isRecurring = false,
this.isOverdue = false,
this.isTodayTask = false,
});
bool get isRepeating => repeatUnit != null && repeatUnit!.isNotEmpty;
/// 순수 일정의 날. 기한은 없고, 생성일이 그 날이다.
String? get eventDay {
final created = createdAt;
if (created != null && created.length >= 10) {
return created.substring(0, 10);
}
if (onCalendar && !isTodo && dueDate != null && dueDate!.isNotEmpty) {
return dueDate;
}
return null;
}
/// 반복 기준일. 일정은 생성일, 할일은 기한.
String? get repeatAnchor {
if (onCalendar && !isTodo) return eventDay;
if (dueDate != null && dueDate!.isNotEmpty) return dueDate;
return null;
}
factory Todo.fromMap(Map<String, dynamic> m) => Todo(
id: m['id'] as int?,
content: (m['content'] ?? '') as String,
isDone: (m['is_done'] ?? 0) == 1,
isStar: (m['is_star'] ?? 0) == 1,
isMyDay: (m['is_my_day'] ?? 0) == 1,
dueDate: m['due_date'] as String?,
remindTime: m['remind_time'] as String?,
repeatUnit: m['repeat_unit'] as String?,
repeatInterval: (m['repeat_interval'] ?? 1) as int,
repeatUntil: m['repeat_until'] as String?,
note: m['note'] as String?,
onCalendar: (m['on_calendar'] ?? 0) == 1,
calVisible: (m['cal_visible'] ?? 1) == 1,
isRecord: (m['is_record'] ?? 0) == 1,
isTodo: (m['is_todo'] ?? 1) == 1,
lastDoneDate: m['last_done_date'] as String?,
doneDate: m['done_date'] as String?,
remindDay: m['remind_day'] as String?,
eventTime: m['event_time'] as String?,
createdAt: m['created_at'] as String?,
syncUid: m['sync_uid'] as String?,
updatedAt: m['updated_at'] as String?,
isDeleted: (m['is_deleted'] ?? 0) == 1,
);
Map<String, dynamic> toMap() => {
if (id != null) 'id': id,
'content': content,
'is_done': isDone ? 1 : 0,
'is_star': isStar ? 1 : 0,
'is_my_day': isMyDay ? 1 : 0,
'due_date': dueDate,
'remind_time': remindTime,
'repeat_unit': repeatUnit,
'repeat_interval': repeatInterval,
'repeat_until': repeatUntil,
'note': note,
'on_calendar': onCalendar ? 1 : 0,
'cal_visible': calVisible ? 1 : 0,
'is_record': isRecord ? 1 : 0,
'is_todo': isTodo ? 1 : 0,
'last_done_date': lastDoneDate,
'done_date': doneDate,
'remind_day': remindDay,
'event_time': eventTime,
// 신규 insert 시엔 생략 → DB DEFAULT(now). 복제·roll-forward 시엔
// 최초 생성일을 넘긴다.
if (createdAt != null && createdAt!.isNotEmpty) 'created_at': createdAt,
'sync_uid': syncUid,
'updated_at': updatedAt,
'is_deleted': isDeleted ? 1 : 0,
};
Todo copy() => Todo(
id: id,
content: content,
isDone: isDone,
isStar: isStar,
isMyDay: isMyDay,
dueDate: dueDate,
remindTime: remindTime,
repeatUnit: repeatUnit,
repeatInterval: repeatInterval,
repeatUntil: repeatUntil,
note: note,
onCalendar: onCalendar,
calVisible: calVisible,
isRecord: isRecord,
isTodo: isTodo,
lastDoneDate: lastDoneDate,
doneDate: doneDate,
remindDay: remindDay,
eventTime: eventTime,
createdAt: createdAt,
syncUid: syncUid,
updatedAt: updatedAt,
isDeleted: isDeleted,
isRecurring: isRecurring,
isOverdue: isOverdue,
isTodayTask: isTodayTask,
);
}
const Map<String, String> kRepeatLabels = {
'day': '매일',
'week': '매주',
'month': '매달',
'year': '매년',
};
lib/logic/recurrence.dart
import '../models/todo.dart';
/// 반복 발생일 계산 (modam_doc mdl_recur.py 포팅).
/// 날짜는 시각 없는 DateTime(연/월/일)로 다룬다.
DateTime dOnly(DateTime d) => DateTime(d.year, d.month, d.day);
int _diffMonths(DateTime a, DateTime b) =>
(a.year - b.year) * 12 + (a.month - b.month);
/// base(기준일)에서 시작하는 반복이 target 에 발생하는가.
/// until(반복 기한)이 주어지면 그 이후 날짜는 발생하지 않는다.
bool occursOn(DateTime base, String? unit, int interval, DateTime target,
{DateTime? until}) {
if (unit == null || unit.isEmpty) return false;
base = dOnly(base);
target = dOnly(target);
if (target.isBefore(base)) return false;
if (until != null && target.isAfter(dOnly(until))) return false;
final iv = interval < 1 ? 1 : interval;
switch (unit) {
case 'day':
return target.difference(base).inDays % iv == 0;
case 'week':
return target.difference(base).inDays % (iv * 7) == 0;
case 'month':
if (target.day != base.day) return false;
final dm = _diffMonths(target, base);
return dm >= 0 && dm % iv == 0;
case 'year':
return target.month == base.month &&
target.day == base.day &&
(target.year - base.year) % iv == 0;
default:
return false;
}
}
DateTime? _addMonths(DateTime base, int n) {
final m0 = base.month - 1 + n;
final y = base.year + (m0 ~/ 12);
final m = m0 % 12 + 1;
final lastDay = DateTime(y, m + 1, 0).day;
if (base.day > lastDay) return null; // 그 달에 해당 일 없음(occursOn 과 동일 규칙)
return DateTime(y, m, base.day);
}
/// base 에서 시작하는 반복의, target(포함) 이하 가장 최근 발생일. 없으면 null.
/// until(반복 기한)이 주어지면 target 을 until 이하로 클램프한다(기한 이후 회차 없음).
DateTime? latestOccurrenceOnOrBefore(
DateTime base, String? unit, int interval, DateTime target,
{DateTime? until}) {
if (unit == null || unit.isEmpty) return null;
base = dOnly(base);
target = dOnly(target);
if (until != null && dOnly(until).isBefore(target)) target = dOnly(until);
if (target.isBefore(base)) return null;
final iv = interval < 1 ? 1 : interval;
switch (unit) {
case 'day':
final k = target.difference(base).inDays ~/ iv;
return base.add(Duration(days: k * iv));
case 'week':
final step = iv * 7;
final k = target.difference(base).inDays ~/ step;
return base.add(Duration(days: k * step));
case 'month':
var k = _diffMonths(target, base) ~/ iv;
while (k >= 0) {
final occ = _addMonths(base, k * iv);
if (occ != null && !occ.isAfter(target)) return occ;
k--;
}
return null;
case 'year':
var k = (target.year - base.year) ~/ iv;
while (k >= 0) {
final y = base.year + k * iv;
final lastDay = DateTime(y, base.month + 1, 0).day;
if (base.day <= lastDay) {
final occ = DateTime(y, base.month, base.day);
if (!occ.isAfter(target)) return occ;
}
k--;
}
return null;
}
return null;
}
/// base 에서 시작하는 반복의, base 바로 다음 발생일. until 초과면 null.
DateTime? nextOccurrenceAfter(DateTime base, String? unit, int interval,
{DateTime? until}) {
if (unit == null || unit.isEmpty) return null;
base = dOnly(base);
final iv = interval < 1 ? 1 : interval;
DateTime? occ;
switch (unit) {
case 'day':
occ = base.add(Duration(days: iv));
break;
case 'week':
occ = base.add(Duration(days: iv * 7));
break;
case 'month':
for (var k = 1; k < 1200 && occ == null; k++) {
occ = _addMonths(base, k * iv); // 그 달에 해당 일 없으면 null → 다음 배수로
}
break;
case 'year':
for (var k = 1; k < 400 && occ == null; k++) {
final y = base.year + k * iv;
final lastDay = DateTime(y, base.month + 1, 0).day;
if (base.day <= lastDay) occ = DateTime(y, base.month, base.day);
}
break;
}
if (occ == null) return null;
if (until != null && dOnly(occ).isAfter(dOnly(until))) return null;
return occ;
}
/// 반복 할 일이 today(포함) 기준 '현재 회차 미완료' 인가.
/// 미완료면 그 회차 발생일(DateTime) 반환, 완료/해당없음이면 null.
DateTime? pendingOccurrence(Todo t, DateTime today) {
if (!t.isRepeating || t.dueDate == null) return null;
DateTime base;
try {
base = DateTime.parse(t.dueDate!);
} catch (_) {
return null;
}
final until = t.repeatUntil == null ? null : DateTime.tryParse(t.repeatUntil!);
final occ = latestOccurrenceOnOrBefore(
base, t.repeatUnit, t.repeatInterval, today,
until: until);
if (occ == null) return null;
final ldd = t.lastDoneDate;
if (ldd != null && ldd.compareTo(_iso(occ)) >= 0) return null; // 그 회차 완료됨
return occ;
}
/// 반복 todo 목록을 [start, end] 기간의 날짜별로 전개.
/// day/week 는 기간 전체에 매일 채우면 달력이 매 칸마다 표시되므로,
/// today 가 기간 안의 실제 발생일일 때만 그 날짜 하나에 채운다.
/// month/year 는 특정 날짜에만 매칭되므로 기간 전체를 전개한다.
/// expandAll: true 면 매일/매주 '할일'도 기간 전체에 전개(반복보기 전용).
/// 기본(false)은 매일/매주 순수 할일을 today 한 칸에만 표시(일반 달력 칸 도배 방지).
Map<String, List<Todo>> occurrencesIn(
List<Todo> todos, DateTime start, DateTime end,
{DateTime? today, bool expandAll = false}) {
final out = <String, List<Todo>>{};
start = dOnly(start);
end = dOnly(end);
final t0 = today == null ? null : dOnly(today);
for (final t in todos) {
final du = t.repeatAnchor;
final unit = t.repeatUnit;
if (du == null || unit == null || unit.isEmpty) continue;
DateTime base;
try {
base = DateTime.parse(du);
} catch (_) {
continue;
}
final iv = t.repeatInterval;
final until = t.repeatUntil == null ? null : DateTime.tryParse(t.repeatUntil!);
// 순수 할일의 매일/매주 반복은 오늘 한 칸에만(달력 매 칸 채움 방지).
// 단, 일정(onCalendar)은 실제 발생일 전부에 표시 — 안 그러면 반복 일정이
// 오늘이 발생일이 아닐 때 아무 날짜에도 안 보여 '삭제된 것'처럼 됨.
if ((unit == 'day' || unit == 'week') && !t.onCalendar && !expandAll) {
if (t0 != null &&
!t0.isBefore(start) &&
!t0.isAfter(end) &&
occursOn(base, unit, iv, t0, until: until)) {
out.putIfAbsent(_iso(t0), () => []).add(t);
}
continue;
}
var cur = start;
while (!cur.isAfter(end)) {
if (until != null && cur.isAfter(dOnly(until))) break; // 반복 기한 이후 중단
if (occursOn(base, unit, iv, cur, until: until)) {
out.putIfAbsent(_iso(cur), () => []).add(t);
}
cur = cur.add(const Duration(days: 1));
}
}
return out;
}
String _iso(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
lib/db/app_db.dart
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import '../models/todo.dart';
import '../logic/recurrence.dart';
import '../util/sync_meta.dart';
/// 로컬 SQLite (modam_doc tb_todo 재구현).
///
/// 개인화(멀티유저): 모든 행에 user_id, 모든 조회는 현재 로그인 사용자로 격리.
/// (SQLite AUTOINCREMENT 는 복합 PK 불가 → id 는 단독 PK 로 두고 user_id NOT NULL + 필터로 동일 효과.)
/// 동기화: sync_uid(공통 식별자) · updated_at(UTC) · is_deleted(묘비).
class AppDb {
AppDb._();
static final AppDb instance = AppDb._();
static const localUser = '(local)'; // 로그인 전 로컬 소유자
String? _cachedUser;
Database? _db;
Future<Database> get db async {
_db ??= await _open();
return _db!;
}
/// 현재 사용자(로그인 아이디). 미로그인 시 '(local)'.
Future<String> currentUser() async {
if (_cachedUser != null) return _cachedUser!;
final v = await getSetting('sync_username');
_cachedUser = (v == null || v.isEmpty) ? localUser : v;
return _cachedUser!;
}
/// 로그인 시 호출: 로컬(미소유) 행을 로그인 사용자 소유로 흡수 + 컨텍스트 전환.
Future<void> adoptLocalTo(String username) async {
final d = await db;
await d.update('tb_todo', {'user_id': username},
where: "user_id IS NULL OR user_id = '' OR user_id = ?",
whereArgs: [localUser]);
_cachedUser = username;
}
/// 로그아웃: 로컬 컨텍스트로 전환(로그인 사용자 데이터는 그대로 보관, 화면에선 숨김).
void resetToLocal() => _cachedUser = localUser;
Future<Database> _open() async {
final dir = await getDatabasesPath();
final path = p.join(dir, 'schedule.db');
return openDatabase(
path,
version: 11,
onConfigure: (d) async => d.execute('PRAGMA foreign_keys = ON'),
onCreate: (d, v) async {
await d.execute('''
CREATE TABLE tb_todo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL DEFAULT '(local)',
content TEXT NOT NULL,
is_done INTEGER NOT NULL DEFAULT 0,
is_star INTEGER NOT NULL DEFAULT 0,
is_my_day INTEGER NOT NULL DEFAULT 0,
due_date TEXT,
remind_time TEXT,
repeat_unit TEXT,
repeat_interval INTEGER NOT NULL DEFAULT 1,
repeat_until TEXT,
note TEXT,
on_calendar INTEGER NOT NULL DEFAULT 0,
cal_visible INTEGER NOT NULL DEFAULT 1,
is_record INTEGER NOT NULL DEFAULT 0,
is_todo INTEGER NOT NULL DEFAULT 1,
last_done_date TEXT,
done_date TEXT,
remind_day TEXT,
event_time TEXT,
sync_uid TEXT,
updated_at TEXT,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
)
''');
await d.execute('CREATE INDEX ix_todo_user_due ON tb_todo(user_id, due_date)');
await d.execute('CREATE UNIQUE INDEX ux_todo_uid ON tb_todo(sync_uid)');
await d.execute('CREATE TABLE tb_setting (k TEXT PRIMARY KEY, v TEXT)');
// 공휴일 메모(날짜별) — 할일/일정/기록과 분리. 서버↔앱 동기화 대상.
await d.execute(
'CREATE TABLE tb_holiday_memo (memo_date TEXT PRIMARY KEY, memo TEXT, '
'updated_at TEXT, is_deleted INTEGER NOT NULL DEFAULT 0)');
// 기록 템플릿(기록 종류) — 서버↔앱 동기화 대상(sync_uid 기준).
await d.execute(
'CREATE TABLE tb_record_template (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'name TEXT NOT NULL, options TEXT, is_fixed INTEGER NOT NULL DEFAULT 0, '
'sync_uid TEXT, updated_at TEXT, is_deleted INTEGER NOT NULL DEFAULT 0)');
await d.execute(
'CREATE UNIQUE INDEX ux_rectpl_uid ON tb_record_template(sync_uid)');
},
onUpgrade: (d, oldV, newV) async {
if (oldV < 2) {
await d.execute('ALTER TABLE tb_todo ADD COLUMN last_done_date TEXT');
await d.execute('ALTER TABLE tb_todo ADD COLUMN remind_day TEXT');
}
if (oldV < 3) {
await d.execute('ALTER TABLE tb_todo ADD COLUMN sync_uid TEXT');
await d.execute('ALTER TABLE tb_todo ADD COLUMN updated_at TEXT');
await d.execute(
'ALTER TABLE tb_todo ADD COLUMN is_deleted INTEGER NOT NULL DEFAULT 0');
final rows = await d.query('tb_todo', columns: ['id']);
for (final r in rows) {
await d.update(
'tb_todo',
{'sync_uid': genUuid(), 'updated_at': nowUtcStamp()},
where: 'id = ?',
whereArgs: [r['id']]);
}
await d.execute(
'CREATE UNIQUE INDEX IF NOT EXISTS ux_todo_uid ON tb_todo(sync_uid)');
}
if (oldV < 4) {
await d.execute(
"ALTER TABLE tb_todo ADD COLUMN user_id TEXT NOT NULL DEFAULT '(local)'");
// 이미 로그인돼 있으면 기존 행을 그 사용자 소유로 흡수.
final s = await d.query('tb_setting',
where: 'k = ?', whereArgs: ['sync_username'], limit: 1);
final u = s.isEmpty ? null : s.first['v'] as String?;
if (u != null && u.isNotEmpty) {
await d.update('tb_todo', {'user_id': u});
}
await d.execute(
'CREATE INDEX IF NOT EXISTS ix_todo_user_due ON tb_todo(user_id, due_date)');
}
if (oldV < 5) {
await d.execute(
'ALTER TABLE tb_todo ADD COLUMN is_record INTEGER NOT NULL DEFAULT 0');
}
if (oldV < 6) {
await d.execute('ALTER TABLE tb_todo ADD COLUMN event_time TEXT');
}
if (oldV < 7) {
await d.execute(
'CREATE TABLE IF NOT EXISTS tb_holiday_memo (memo_date TEXT PRIMARY KEY, memo TEXT)');
}
if (oldV < 8) {
// 공휴일 메모 동기화 컬럼 추가(기존 로컬 메모는 updated_at 채워 동기화 대상으로).
try {
await d.execute('ALTER TABLE tb_holiday_memo ADD COLUMN updated_at TEXT');
} catch (_) {}
try {
await d.execute(
'ALTER TABLE tb_holiday_memo ADD COLUMN is_deleted INTEGER NOT NULL DEFAULT 0');
} catch (_) {}
await d.update('tb_holiday_memo', {'updated_at': nowUtcStamp()},
where: 'updated_at IS NULL');
}
if (oldV < 9) {
// 기록 템플릿(기록 종류) 테이블 신규.
await d.execute(
'CREATE TABLE IF NOT EXISTS tb_record_template (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'name TEXT NOT NULL, options TEXT, is_fixed INTEGER NOT NULL DEFAULT 0, '
'sync_uid TEXT, updated_at TEXT, is_deleted INTEGER NOT NULL DEFAULT 0)');
await d.execute(
'CREATE UNIQUE INDEX IF NOT EXISTS ux_rectpl_uid ON tb_record_template(sync_uid)');
}
if (oldV < 10) {
// 완료 일자(비반복 할일이 완료된 날). 동기화 대상.
await d.execute('ALTER TABLE tb_todo ADD COLUMN done_date TEXT');
// 기존 완료 항목은 완료일이 없으므로 오늘로 일괄 보정.
final n = DateTime.now();
final today = '${n.year.toString().padLeft(4, '0')}-'
'${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
await d.rawUpdate(
"UPDATE tb_todo SET done_date = ? "
"WHERE is_done = 1 AND (done_date IS NULL OR done_date = '')",
[today]);
}
if (oldV < 11) {
// 반복 기한(종료일) + 달력 표시 여부(숨김은 달력에서만, 목록엔 유지).
await d.execute('ALTER TABLE tb_todo ADD COLUMN repeat_until TEXT');
await d.execute(
'ALTER TABLE tb_todo ADD COLUMN cal_visible INTEGER NOT NULL DEFAULT 1');
}
},
);
}
// ---------- 설정(key-value) ----------
Future<String?> getSetting(String key) async {
final d = await db;
final r = await d.query('tb_setting',
where: 'k = ?', whereArgs: [key], limit: 1);
return r.isEmpty ? null : r.first['v'] as String?;
}
Future<void> setSetting(String key, String value) async {
final d = await db;
await d.insert('tb_setting', {'k': key, 'v': value},
conflictAlgorithm: ConflictAlgorithm.replace);
}
// ---------- 공휴일 메모(날짜별) — 서버↔앱 동기화 ----------
Future<String?> getHolidayMemo(String iso) async {
final d = await db;
final r = await d.query('tb_holiday_memo',
where: 'memo_date = ? AND is_deleted = 0', whereArgs: [iso], limit: 1);
return r.isEmpty ? null : r.first['memo'] as String?;
}
Future<void> setHolidayMemo(String iso, String memo) async {
final d = await db;
final m = memo.trim();
// 빈 메모면 툼스톤(is_deleted=1)으로 표시해 삭제를 서버로 전파.
await d.insert(
'tb_holiday_memo',
{
'memo_date': iso,
'memo': m,
'updated_at': nowUtcStamp(),
'is_deleted': m.isEmpty ? 1 : 0,
},
conflictAlgorithm: ConflictAlgorithm.replace);
}
/// 동기화 push용: 전체 공휴일 메모(툼스톤 포함).
Future<List<Map<String, dynamic>>> allHolidayMemosForSync() async {
final d = await db;
final r = await d.query('tb_holiday_memo', orderBy: 'memo_date');
return r
.map((m) => {
'memo_date': m['memo_date'],
'memo': m['memo'] ?? '',
'updated_at': m['updated_at'] ?? '',
'is_deleted': (m['is_deleted'] ?? 0),
})
.toList();
}
/// 서버 스냅샷 적용: memo_date 기준 upsert, 더 최근 updated_at 이 이김.
Future<int> applyHolidayMemos(List<dynamic> rows) async {
final d = await db;
return d.transaction<int>((txn) async {
var n = 0;
for (final raw in rows) {
final m = raw as Map;
final date = m['memo_date'] as String?;
if (date == null || date.isEmpty) continue;
final au = (m['updated_at'] ?? '').toString();
final ex = await txn.query('tb_holiday_memo',
columns: ['updated_at'],
where: 'memo_date = ?',
whereArgs: [date],
limit: 1);
final localUpd =
ex.isEmpty ? null : (ex.first['updated_at'] ?? '').toString();
if (ex.isEmpty || (localUpd != null && au.compareTo(localUpd) > 0)) {
await txn.insert(
'tb_holiday_memo',
{
'memo_date': date,
'memo': (m['memo'] ?? '').toString(),
'updated_at': au,
'is_deleted': (m['is_deleted'] ?? 0) is int
? (m['is_deleted'] ?? 0)
: int.tryParse('${m['is_deleted']}') ?? 0,
},
conflictAlgorithm: ConflictAlgorithm.replace);
n++;
}
}
return n;
});
}
// ---------- 기록 템플릿(기록 종류) ----------
List<String> _splitOptions(String? s) =>
(s ?? '').split('\n').map((e) => e.trim()).where((e) => e.isNotEmpty).toList();
/// 활성 템플릿 목록(고정 먼저, 이름순). 각 항목에 option_list(문자열 리스트)·is_fixed(bool) 포함.
Future<List<Map<String, dynamic>>> listRecordTemplates() async {
final d = await db;
final r = await d.query('tb_record_template',
where: 'is_deleted = 0', orderBy: 'is_fixed DESC, name');
return r.map((m) {
final map = Map<String, dynamic>.from(m);
map['option_list'] = _splitOptions(m['options'] as String?);
map['is_fixed'] = (m['is_fixed'] ?? 0) == 1;
return map;
}).toList();
}
/// 로컬 수정용 updated_at: 기존값보다 항상 크게(단조 증가).
/// 웹에서 만든 행은 updated_at 이 서버 실시각이라, 폰 시계가 뒤처지면
/// 폰 수정이 LWW 에서 져 되돌려짐 → 기존값+1초로 보장해 폰 수정이 이기게 함.
String _laterStamp(String? existing) {
final now = nowUtcStamp();
if (existing == null || existing.isEmpty || now.compareTo(existing) > 0) {
return now;
}
final base = existing.length >= 19 ? existing.substring(0, 19) : existing;
final dt = DateTime.tryParse(base.replaceFirst(' ', 'T'));
if (dt == null) return now;
final b = dt.add(const Duration(seconds: 1));
String p(int n, [int w = 2]) => n.toString().padLeft(w, '0');
return '${p(b.year, 4)}-${p(b.month)}-${p(b.day)} '
'${p(b.hour)}:${p(b.minute)}:${p(b.second)}';
}
Future<String?> _rectplUpdatedAt(dynamic d, int id) async {
final r = await d.query('tb_record_template',
columns: ['updated_at'], where: 'id = ?', whereArgs: [id], limit: 1);
return r.isEmpty ? null : r.first['updated_at'] as String?;
}
/// 신규(id==null)/수정. options=옵션 리스트. updated_at 갱신 → 동기화 전파.
Future<void> saveRecordTemplate(
{int? id,
required String name,
required List<String> options,
required bool isFixed}) async {
final d = await db;
final opts =
options.map((e) => e.trim()).where((e) => e.isNotEmpty).join('\n');
if (id == null) {
await d.insert('tb_record_template', {
'name': name.trim(),
'options': opts,
'is_fixed': isFixed ? 1 : 0,
'sync_uid': genUuid(),
'updated_at': nowUtcStamp(),
'is_deleted': 0,
});
} else {
final stamp = _laterStamp(await _rectplUpdatedAt(d, id));
await d.update(
'tb_record_template',
{
'name': name.trim(),
'options': opts,
'is_fixed': isFixed ? 1 : 0,
'updated_at': stamp,
},
where: 'id = ?',
whereArgs: [id]);
}
}
/// 소프트 삭제(툼스톤) — 삭제 전파.
Future<void> deleteRecordTemplate(int id) async {
final d = await db;
final stamp = _laterStamp(await _rectplUpdatedAt(d, id));
await d.update('tb_record_template',
{'is_deleted': 1, 'updated_at': stamp},
where: 'id = ?', whereArgs: [id]);
}
Future<List<Map<String, dynamic>>> allRecordTemplatesForSync() async {
final d = await db;
final r = await d.query('tb_record_template');
return r
.map((m) => {
'sync_uid': m['sync_uid'] ?? '',
'name': m['name'] ?? '',
'options': m['options'] ?? '',
'is_fixed': (m['is_fixed'] ?? 0),
'updated_at': m['updated_at'] ?? '',
'is_deleted': (m['is_deleted'] ?? 0),
})
.toList();
}
/// 서버 스냅샷 적용: sync_uid 기준 upsert, 더 최근 updated_at 이 이김.
Future<int> applyRecordTemplates(List<dynamic> rows) async {
final d = await db;
return d.transaction<int>((txn) async {
var n = 0;
for (final raw in rows) {
final m = raw as Map;
final uid = (m['sync_uid'] ?? '').toString();
if (uid.isEmpty) continue;
final au = (m['updated_at'] ?? '').toString();
final ex = await txn.query('tb_record_template',
columns: ['updated_at'],
where: 'sync_uid = ?',
whereArgs: [uid],
limit: 1);
final localUpd =
ex.isEmpty ? null : (ex.first['updated_at'] ?? '').toString();
if (ex.isEmpty || (localUpd != null && au.compareTo(localUpd) > 0)) {
final fixed = (m['is_fixed'] ?? 0) is int
? (m['is_fixed'] ?? 0)
: int.tryParse('${m['is_fixed']}') ?? 0;
final del = (m['is_deleted'] ?? 0) is int
? (m['is_deleted'] ?? 0)
: int.tryParse('${m['is_deleted']}') ?? 0;
final vals = {
'name': (m['name'] ?? '').toString(),
'options': (m['options'] ?? '').toString(),
'is_fixed': fixed,
'updated_at': au,
'is_deleted': del,
};
if (ex.isEmpty) {
await txn.insert('tb_record_template', {...vals, 'sync_uid': uid});
} else {
await txn.update('tb_record_template', vals,
where: 'sync_uid = ?', whereArgs: [uid]);
}
n++;
}
}
return n;
});
}
// ---------- CRUD ----------
Future<int> insert(Todo t) async {
final d = await db;
final u = await currentUser();
final map = t.toMap();
final uid = t.syncUid;
map['sync_uid'] = (uid == null || uid.isEmpty) ? genUuid() : uid;
map['updated_at'] = nowUtcStamp();
map['is_deleted'] = 0;
map['user_id'] = u;
return d.insert('tb_todo', map);
}
Future<void> update(Todo t) async {
final d = await db;
final u = await currentUser();
final map = t.toMap();
map['updated_at'] = nowUtcStamp();
if (map['sync_uid'] == null || (map['sync_uid'] as String).isEmpty) {
map['sync_uid'] = genUuid();
}
await d.update('tb_todo', map,
where: 'id = ? AND user_id = ?', whereArgs: [t.id, u]);
}
/// 소프트 삭제(묘비). 동기화로 서버까지 삭제를 전파.
/// 완료된 할일(is_done=1)은 삭제하지 않는다.
Future<void> delete(int id) async {
await deleteIncomplete(id);
}
/// 미완료 행만 소프트 삭제. 완료 기록은 그대로 둔다.
Future<bool> deleteIncomplete(int id) async {
final d = await db;
final u = await currentUser();
final n = await d.update(
'tb_todo',
{'is_deleted': 1, 'updated_at': nowUtcStamp()},
where: 'id = ? AND user_id = ? AND is_done = 0',
whereArgs: [id, u]);
return n > 0;
}
/// 완료 행을 명시적으로 소프트 삭제(완료 상세에서 사용자가 삭제할 때만).
/// is_done 조건 없이 해당 id 1건만 삭제 — 완료 기록 자체를 지운다.
Future<void> deleteDone(int id) async {
final d = await db;
final u = await currentUser();
await d.update(
'tb_todo',
{'is_deleted': 1, 'updated_at': nowUtcStamp()},
where: 'id = ? AND user_id = ?',
whereArgs: [id, u],
);
}
/// 반복 할일 중단: 같은 계열 미완료는 전부 삭제, 완료만 보존.
/// 계열 = content + is_todo + (id 일치 또는 같은 repeat_unit/interval).
Future<void> stopRepeatTodo(Todo t) async {
if (t.id == null) return;
final d = await db;
final u = await currentUser();
final stamp = nowUtcStamp();
final unit = t.repeatUnit ?? '';
if (unit.isEmpty) {
if (t.isDone) {
await d.update(
'tb_todo',
{
'repeat_unit': null,
'repeat_until': null,
'cal_visible': 1,
'updated_at': stamp,
},
where: 'id = ? AND user_id = ?',
whereArgs: [t.id, u]);
} else {
await deleteIncomplete(t.id!);
}
return;
}
final args = [
stamp,
u,
t.content,
t.isTodo ? 1 : 0,
t.id,
unit,
t.repeatInterval,
];
await d.rawUpdate(
'UPDATE tb_todo SET is_deleted = 1, updated_at = ? '
'WHERE user_id = ? AND is_done = 0 AND is_deleted = 0 '
'AND content = ? AND is_todo = ? '
'AND (id = ? OR (repeat_unit = ? AND repeat_interval = ?))',
args);
await d.rawUpdate(
'UPDATE tb_todo SET repeat_unit = NULL, repeat_until = NULL, '
'cal_visible = 1, updated_at = ? '
'WHERE user_id = ? AND is_done = 1 AND is_deleted = 0 '
'AND content = ? AND is_todo = ? '
'AND (id = ? OR (repeat_unit = ? AND repeat_interval = ?))',
args);
}
/// 반복 기한 설정(반복 일정 '이후 중단'용). null이면 무기한.
Future<void> setRepeatUntil(int id, String? until) async {
final d = await db;
final u = await currentUser();
await d.update('tb_todo',
{'repeat_until': until, 'updated_at': nowUtcStamp()},
where: 'id = ? AND user_id = ?', whereArgs: [id, u]);
}
Future<Todo?> getById(int id) async {
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo',
where: 'id = ? AND user_id = ? AND is_deleted = 0',
whereArgs: [id, u], limit: 1);
return r.isEmpty ? null : Todo.fromMap(r.first);
}
/// 반복 일정 삭제 시: 시작일~어제(또는 repeat_until) 회차를 '반복 없는 개별 일정'으로
/// 실체화한 뒤 반복 기준행을 소프트 삭제한다. → 지난 일정이 개별 항목으로 남아
/// 각각 개별 수정·삭제가 가능(오늘·이후 반복 중단, 가상 과거 회차 제거). 알림은 제외.
Future<int> detachPastRepeatEvent(Todo base, DateTime yesterday) async {
if (base.id == null) return 0;
final start = DateTime.tryParse(base.repeatAnchor ?? '');
if (start == null) return 0;
final d = await db;
final u = await currentUser();
final until =
base.repeatUntil == null ? null : DateTime.tryParse(base.repeatUntil!);
var cap = DateTime(yesterday.year, yesterday.month, yesterday.day);
if (until != null && until.isBefore(cap)) cap = until;
// 시작일~cap 회차 열거(과거 회차만).
final occs = <DateTime>[];
var cur = DateTime(start.year, start.month, start.day);
if (!cur.isAfter(cap)) occs.add(cur);
var guard = 0;
while (guard++ < 5000) {
final nx = nextOccurrenceAfter(cur, base.repeatUnit, base.repeatInterval,
until: cap);
if (nx == null) break;
occs.add(nx);
cur = nx;
}
final stamp = nowUtcStamp();
return d.transaction<int>((txn) async {
for (final o in occs) {
final map = base.toMap()
..remove('id')
..['due_date'] = null
..['created_at'] = '${_isoDate(o)} 00:00:00'
..['repeat_unit'] = null
..['repeat_until'] = null
..['repeat_interval'] = 1
..['is_todo'] = 0
..['on_calendar'] = 1
..['is_done'] = 0
..['done_date'] = null
..['last_done_date'] = null
..['cal_visible'] = 1
..['remind_time'] = null
..['remind_day'] = null
..['sync_uid'] = genUuid()
..['updated_at'] = stamp
..['is_deleted'] = 0
..['user_id'] = u;
await txn.insert('tb_todo', map);
}
// 반복 기준행 소프트 삭제(오늘·이후 중단 + 가상 과거 회차 제거).
await txn.update('tb_todo', {'is_deleted': 1, 'updated_at': stamp},
where: 'id = ? AND user_id = ?', whereArgs: [base.id, u]);
return occs.length;
});
}
Future<void> setPresence(int id, bool onCalendar, bool isTodo) async {
final d = await db;
final u = await currentUser();
await d.update(
'tb_todo',
{
'on_calendar': onCalendar ? 1 : 0,
'is_todo': isTodo ? 1 : 0,
'updated_at': nowUtcStamp(),
},
where: 'id = ? AND user_id = ?',
whereArgs: [id, u],
);
}
Future<void> toggleDone(int id) async {
final d = await db;
final u = await currentUser();
final n = DateTime.now();
final today = '${n.year.toString().padLeft(4, '0')}-'
'${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
// 완료로 바뀌면 완료일=오늘, 해제되면 NULL (CASE 는 변경 전 is_done 기준).
await d.rawUpdate(
'UPDATE tb_todo SET is_done = CASE is_done WHEN 1 THEN 0 ELSE 1 END, '
'done_date = CASE is_done WHEN 1 THEN NULL ELSE ? END, '
'updated_at = ? WHERE id = ? AND user_id = ?',
[today, nowUtcStamp(), id, u]);
}
Future<void> toggleStar(int id) async {
final d = await db;
final u = await currentUser();
await d.rawUpdate(
'UPDATE tb_todo SET is_star = CASE is_star WHEN 1 THEN 0 ELSE 1 END, updated_at = ? WHERE id = ? AND user_id = ?',
[nowUtcStamp(), id, u]);
}
Future<void> setLastDoneDate(int id, String? value) async {
final d = await db;
final u = await currentUser();
await d.update('tb_todo',
{'last_done_date': value, 'updated_at': nowUtcStamp()},
where: 'id = ? AND user_id = ?', whereArgs: [id, u]);
}
/// 완료 토글. 반복 할 일도 이제 회차마다 실제 행이므로 일반 is_done 토글과 동일.
/// (완료된 회차 행은 roll-forward 시 완료 기록으로 보존되고 새 회차 행이 생성된다.)
Future<void> toggleDoneSmart(int id, String todayIso) async {
await toggleDone(id);
}
/// 전개(달력에 표시=on) 반복 회차를 '그 날짜만' 완료 처리.
/// - 반복 원본 행은 건드리지 않아 다른 회차는 그대로 유지된다.
/// - 그 날짜에 '완료 기록'(비반복·완료) 독립 행을 만든다 → 삭제 시에도 보존.
/// - 달력 전개는 같은 내용의 비반복 할일이 있는 날짜를 건너뛰므로 중복 표시되지 않는다.
Future<void> completeRepeatOccurrence(
Todo base, String iso, String todayIso) async {
final rec = Todo(
content: base.content,
isDone: true,
isStar: base.isStar,
isMyDay: false,
dueDate: iso,
repeatInterval: 1,
note: base.note,
onCalendar: base.onCalendar,
calVisible: true, // 완료 기록은 숨김 반복이라도 항상 표시(그 날짜만 완료 확인).
isRecord: base.isRecord,
isTodo: base.isTodo,
// 완료 기록은 '그 회차 날짜'에만 표시되도록 done_date도 회차 날짜(iso)로.
// (오늘로 두면 미래/다른 회차를 완료할 때 오늘 칸에도 완료 기록이 잘못 뜸)
doneDate: iso,
eventTime: base.eventTime,
createdAt: base.createdAt,
);
await insert(rec);
}
// roll-forward 직렬화 락: 달력/목록 탭이 동시에 호출해도 순차 실행(중복 생성 방지).
Future<void> _rollChain = Future<void>.value();
/// 반복 할 일을 오늘까지 정리(실체화). 동시 호출은 직렬화된다.
/// - 미완료 회차: 기한을 현재 회차 날짜로 이동(이전 지연은 삭제 = 행 재사용, 최신 1개만).
/// - 완료 회차: 그 행을 완료 기록으로 보존(반복 해제) + 새 회차 행 생성.
/// 일정(on_calendar) 반복은 가상 표시라 대상 아님(is_todo=1 만).
Future<void> rollForwardRepeatTodos(String todayIso) {
final next =
_rollChain.then((_) => _rollForwardImpl(todayIso)).catchError((_) {});
_rollChain = next;
return next;
}
Future<void> _rollForwardImpl(String todayIso) async {
final d = await db;
final u = await currentUser();
final today = DateTime.tryParse(todayIso);
if (today == null) return;
final rows = await d.query('tb_todo',
where:
"user_id = ? AND is_todo = 1 AND repeat_unit IS NOT NULL AND repeat_unit != '' "
"AND due_date IS NOT NULL AND is_deleted = 0",
whereArgs: [u]);
for (final m in rows) {
final t = Todo.fromMap(m);
final base = DateTime.tryParse(t.dueDate!);
if (base == null) continue;
// 반복 기한 이후로는 회차를 진행하지 않음(마지막 회차에서 멈춤).
final until =
t.repeatUntil == null ? null : DateTime.tryParse(t.repeatUntil!);
if (t.isDone) {
// 완료된 회차는 '같은 날 완료'라도 즉시 완료 기록으로 보존(반복 해제) +
// 다음 회차 행 생성. → 완료 상태가 다음 회차 가상 표시로 새지 않게(회차 독립).
// 완료 기록은 숨김 반복이라도 항상 표시(cal_visible=1).
// done_date를 '그 회차 날짜(due)'로 고정 → due≠오늘인 회차를 완료해도
// 오늘 칸에 잘못 뜨지 않고 회차 날짜에만 표시(회차 독립).
await d.update('tb_todo',
{'repeat_unit': null, 'cal_visible': 1, 'done_date': t.dueDate, 'updated_at': nowUtcStamp()},
where: 'id = ? AND user_id = ?', whereArgs: [t.id, u]);
final next = nextOccurrenceAfter(
base, t.repeatUnit, t.repeatInterval,
until: until);
if (next != null) {
final map = t.toMap()
..remove('id')
..['due_date'] = _isoDate(next)
..['is_done'] = 0
..['done_date'] = null
..['last_done_date'] = null
..['user_id'] = u
..['sync_uid'] = genUuid()
..['updated_at'] = nowUtcStamp()
..['is_deleted'] = 0;
final ca = t.createdAt;
if (ca != null && ca.isNotEmpty) map['created_at'] = ca;
await d.insert('tb_todo', map);
}
continue;
}
// 미완료 회차 → 현재 회차 날짜로 이동(이전 지연 삭제). 새 회차 없으면 유지.
final target = latestOccurrenceOnOrBefore(
base, t.repeatUnit, t.repeatInterval, today,
until: until);
if (target == null) continue;
final targetIso = _isoDate(target);
if (targetIso == t.dueDate) continue;
await d.update('tb_todo',
{'due_date': targetIso, 'updated_at': nowUtcStamp()},
where: 'id = ? AND user_id = ?', whereArgs: [t.id, u]);
}
}
static String _isoDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
/// 일정만 등록 (달력 표시 on_calendar=1, 할일 목록 제외 is_todo=0).
Future<int> insertEventOnly(Todo t) async {
t.onCalendar = true;
t.isTodo = false;
return insert(t);
}
// ---------- 조회 (현재 사용자 + 미삭제) ----------
Future<List<Todo>> listByDate(String iso) async {
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo',
where:
"user_id = ? AND is_todo = 1 AND is_deleted = 0 AND ("
"((repeat_unit IS NULL OR repeat_unit = '') AND ("
"substr(created_at, 1, 10) = ? OR ("
"(created_at IS NULL OR created_at = '') AND due_date = ?))) OR ("
"repeat_unit IS NOT NULL AND repeat_unit != '' AND due_date = ?))",
whereArgs: [u, iso, iso, iso],
orderBy: 'is_done, is_star DESC, id');
return r.map(Todo.fromMap).toList();
}
Future<List<Todo>> listRepeating() async {
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo',
where:
"user_id = ? AND repeat_unit IS NOT NULL AND repeat_unit != '' AND due_date IS NOT NULL AND is_deleted = 0",
whereArgs: [u]);
return r.map(Todo.fromMap).toList();
}
Future<List<Todo>> listByView(String view, String todayIso) async {
final d = await db;
final u = await currentUser();
late List<Map<String, Object?>> r;
if (view == 'today') {
// 오늘: 기한이 오늘 이전(지연) · 오늘 할일 · 생성일이 오늘.
// 별표가 있어도 그것만으로 오늘에 넣지 않는다.
r = await d.query('tb_todo',
where:
"user_id = ? AND is_todo = 1 AND is_done = 0 AND is_deleted = 0 AND ("
"due_date <= ? OR is_my_day = 1 OR "
"((repeat_unit IS NULL OR repeat_unit = '') AND substr(created_at, 1, 10) = ?))",
whereArgs: [u, todayIso, todayIso],
orderBy: 'is_my_day DESC, is_star DESC, id DESC');
} else if (view == 'incomplete') {
r = await d.query('tb_todo',
where: 'user_id = ? AND is_todo = 1 AND is_done = 0 AND is_deleted = 0',
whereArgs: [u],
orderBy: 'is_star DESC, (due_date IS NULL), due_date, id');
} else if (view == 'overdue') {
r = await d.query('tb_todo',
where:
"user_id = ? AND is_todo = 1 AND is_done = 0 AND is_deleted = 0 AND due_date IS NOT NULL AND due_date < ?",
whereArgs: [u, todayIso],
orderBy: 'due_date, is_star DESC, id');
} else if (view == 'important') {
// 중요: 별표(즐겨찾기) 미완료 할일.
r = await d.query('tb_todo',
where:
'user_id = ? AND is_todo = 1 AND is_star = 1 AND is_done = 0 AND is_deleted = 0',
whereArgs: [u],
orderBy: '(due_date IS NULL), due_date, id');
} else if (view == 'done') {
r = await d.query('tb_todo',
where: 'user_id = ? AND is_todo = 1 AND is_done = 1 AND is_deleted = 0',
whereArgs: [u],
orderBy: 'due_date DESC, id DESC');
} else {
r = await d.query('tb_todo',
where: 'user_id = ? AND is_todo = 1 AND is_deleted = 0',
whereArgs: [u],
orderBy: 'is_done, (due_date IS NULL), due_date, id');
}
return r.map(Todo.fromMap).toList();
}
Future<int> countIncomplete() async {
final d = await db;
final u = await currentUser();
final r = await d.rawQuery(
'SELECT COUNT(*) c FROM tb_todo WHERE user_id = ? AND is_todo = 1 AND is_done = 0 AND is_deleted = 0',
[u]);
return (r.first['c'] as int?) ?? 0;
}
Future<int> countStar() async {
final d = await db;
final u = await currentUser();
final r = await d.rawQuery(
'SELECT COUNT(*) c FROM tb_todo WHERE user_id = ? AND is_todo = 1 AND is_star = 1 AND is_done = 0 AND is_deleted = 0',
[u]);
return (r.first['c'] as int?) ?? 0;
}
Future<int> countToday(String todayIso) async {
final d = await db;
final u = await currentUser();
final r = await d.rawQuery(
'SELECT COUNT(*) c FROM tb_todo WHERE user_id = ? AND is_todo = 1 AND is_done = 0 AND is_deleted = 0 AND (due_date <= ? OR is_my_day = 1 OR ((repeat_unit IS NULL OR repeat_unit = \'\') AND substr(created_at, 1, 10) = ?))',
[u, todayIso, todayIso]);
return (r.first['c'] as int?) ?? 0;
}
Future<Map<String, int>> countsByMonth(int year, int month) async {
final d = await db;
final u = await currentUser();
final like = '${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-%';
final r = await d.rawQuery(
"SELECT due_date, COUNT(*) c FROM tb_todo WHERE user_id = ? AND due_date LIKE ? AND is_done = 0 AND is_todo = 1 AND is_deleted = 0 GROUP BY due_date",
[u, like]);
return {for (final row in r) row['due_date'] as String: row['c'] as int};
}
/// 월별 날짜별 '완료된 할일' 카운트(검정 점) — 완료일(done_date) 기준.
/// 완료일이 없으면 기한(due_date)으로 폴백. → 완료 회차가 그 날짜에만 표시.
Future<Map<String, int>> doneCountsByMonth(int year, int month) async {
final d = await db;
final u = await currentUser();
final like =
'${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-%';
// 완료 할일은 완료일(done_date)에만 카운트. 완료일이 없으면 기한(due_date)으로 폴백.
final r = await d.rawQuery(
'SELECT iso, COUNT(*) AS c FROM ('
' SELECT COALESCE(NULLIF(done_date, \'\'), due_date) AS iso FROM tb_todo'
' WHERE user_id = ? AND is_done = 1 AND is_todo = 1 AND is_deleted = 0'
') WHERE iso LIKE ? GROUP BY iso',
[u, like],
);
return {
for (final row in r)
if (row['iso'] != null) row['iso'] as String: row['c'] as int,
};
}
/// 일정 기한을 지운다. 생성일이 없거나 기한보다 늦으면 기한일을 생성일로 당긴다.
Future<void> dropEventDueDates() async {
final d = await db;
final u = await currentUser();
await d.rawUpdate(
"UPDATE tb_todo SET created_at = due_date || ' 00:00:00' "
"WHERE user_id = ? AND on_calendar = 1 AND is_todo = 0 "
"AND is_record = 0 AND is_deleted = 0 "
"AND due_date IS NOT NULL AND due_date != '' "
"AND (created_at IS NULL OR created_at = '' "
"OR substr(created_at, 1, 10) > due_date)",
[u],
);
await d.rawUpdate(
"UPDATE tb_todo SET due_date = NULL "
"WHERE user_id = ? AND on_calendar = 1 AND is_todo = 0 "
"AND is_record = 0 AND is_deleted = 0 "
"AND due_date IS NOT NULL",
[u],
);
}
/// 특정 날짜의 (비반복) 달력 일정(on_calendar) 항목 — 전체 필드(탭 편집/삭제용).
/// 일정은 생성일 칸에만 둔다.
Future<List<Todo>> eventsOnDate(String iso) async {
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo',
where:
"user_id = ? AND on_calendar = 1 AND (repeat_unit IS NULL OR repeat_unit = '') "
"AND is_deleted = 0 AND substr(created_at, 1, 10) = ?",
whereArgs: [u, iso],
orderBy: 'id');
return r.map(Todo.fromMap).toList();
}
Future<List<Todo>> calEventsInRange(String startIso, String endIso) async {
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo',
where:
"user_id = ? AND on_calendar = 1 AND (repeat_unit IS NULL OR repeat_unit = '') "
"AND is_deleted = 0 AND substr(created_at, 1, 10) >= ? "
"AND substr(created_at, 1, 10) <= ?",
whereArgs: [u, startIso, endIso],
orderBy: 'id');
return r.map(Todo.fromMap).toList();
}
/// 달력 칸 제목 표시용 — 기간 내 미완료(비반복) 할 일. 배지 카운트와 동일 필터.
Future<List<Todo>> calTodosInRange(String startIso, String endIso) async {
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo',
where:
"user_id = ? AND is_done = 0 AND is_todo = 1 AND is_deleted = 0 AND ("
"(repeat_unit IS NULL OR repeat_unit = '') AND ("
"(substr(created_at, 1, 10) >= ? AND substr(created_at, 1, 10) <= ?) OR ("
"(created_at IS NULL OR created_at = '') AND "
"due_date >= ? AND due_date <= ?)))",
whereArgs: [u, startIso, endIso, startIso, endIso],
orderBy: 'is_star DESC, id');
return r.map(Todo.fromMap).toList();
}
/// 그 날짜의 완료된 할일 — 완료일(done_date) 기준(없으면 기한 폴백).
Future<List<Todo>> doneTodosOnDate(String iso) async {
final d = await db;
final u = await currentUser();
final r = await d.rawQuery(
'SELECT * FROM tb_todo '
'WHERE user_id = ? '
' AND is_todo = 1 '
' AND is_done = 1 '
' AND is_deleted = 0 '
// 완료 할일은 완료일(done_date)에만 표시. 완료일이 없으면 기한(due_date)으로 폴백.
' AND COALESCE(NULLIF(done_date, \'\'), due_date) = ? '
'ORDER BY id',
[u, iso],
);
return r.map(Todo.fromMap).toList();
}
/// 완료된 비반복 할일 — 기간 내(전개 반복 회차의 '그 날짜만 완료' 표시/건너뛰기용).
Future<List<Todo>> doneTodosInRange(String startIso, String endIso) async {
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo',
where:
"user_id = ? AND due_date >= ? AND due_date <= ? AND is_done = 1 AND is_todo = 1 "
"AND (repeat_unit IS NULL OR repeat_unit = '') AND is_deleted = 0",
whereArgs: [u, startIso, endIso],
orderBy: 'id');
return r.map(Todo.fromMap).toList();
}
/// 달력 표시용 기록(is_record=1, 비반복) — 기간 내.
/// 특정 날짜의 (비반복) 기록(is_record) 항목 — 전체 필드(탭 편집/삭제용).
Future<List<Todo>> recordsOnDate(String iso) async {
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo',
where:
"user_id = ? AND is_record = 1 AND (repeat_unit IS NULL OR repeat_unit = '') AND due_date = ? AND is_deleted = 0",
whereArgs: [u, iso],
orderBy: 'id');
return r.map(Todo.fromMap).toList();
}
Future<List<Todo>> calRecordsInRange(String startIso, String endIso) async {
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo',
where:
"user_id = ? AND is_record = 1 AND (repeat_unit IS NULL OR repeat_unit = '') AND due_date >= ? AND due_date <= ? AND is_deleted = 0",
whereArgs: [u, startIso, endIso],
orderBy: 'id');
return r.map(Todo.fromMap).toList();
}
/// 전체 기록 — [start, end] 기간(due_date 기준), 최근순.
Future<List<Todo>> recordsByPeriod(String startIso, String endIso) async {
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo',
where:
"user_id = ? AND is_record = 1 AND (repeat_unit IS NULL OR repeat_unit = '') AND due_date IS NOT NULL AND due_date >= ? AND due_date <= ? AND is_deleted = 0",
whereArgs: [u, startIso, endIso],
orderBy: 'due_date DESC, id DESC');
return r.map(Todo.fromMap).toList();
}
/// 미리알림 대상(기한+알림시각 보유, 미삭제) — 알림 재예약용.
Future<List<Todo>> reminderTodos() async {
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo',
where:
"user_id = ? AND is_deleted = 0 AND remind_time IS NOT NULL AND remind_time != '' AND ("
"(due_date IS NOT NULL AND due_date != '') OR "
"(on_calendar = 1 AND is_todo = 0 AND created_at IS NOT NULL AND created_at != ''))",
whereArgs: [u]);
return r.map(Todo.fromMap).toList();
}
// ---------- 서버 동기화 (현재 사용자 범위) ----------
Future<void> ensureSyncMeta() async {
final d = await db;
final u = await currentUser();
final rows = await d.query('tb_todo',
columns: ['id', 'sync_uid', 'updated_at'],
where: 'user_id = ?', whereArgs: [u]);
for (final r in rows) {
final uid = r['sync_uid'] as String?;
final upd = r['updated_at'] as String?;
if (uid != null && uid.isNotEmpty && upd != null && upd.isNotEmpty) {
continue;
}
await d.update(
'tb_todo',
{
'sync_uid': (uid == null || uid.isEmpty) ? genUuid() : uid,
'updated_at': (upd == null || upd.isEmpty) ? nowUtcStamp() : upd,
},
where: 'id = ?',
whereArgs: [r['id']]);
}
}
/// 병합 동기화용 — 현재 사용자 전체 행(묘비 포함)을 서버 전송용 Map 으로.
Future<List<Map<String, dynamic>>> allForSync() async {
await ensureSyncMeta();
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo', where: 'user_id = ?', whereArgs: [u], orderBy: 'id');
return r.map((m) => Todo.fromMap(m).toMap()..remove('id')).toList();
}
/// 병합 스냅샷 적용 — sync_uid 기준 upsert(로컬 id 보존), 묘비는 삭제. 현재 사용자 소유로.
Future<int> applyMergedSnapshot(List<Map<String, dynamic>> rows) async {
final d = await db;
final u = await currentUser();
return d.transaction<int>((txn) async {
var n = 0;
for (final m in rows) {
final uid = m['sync_uid'] as String?;
if (uid == null || uid.isEmpty) continue;
final deleted = (m['is_deleted'] ?? 0) == 1;
if (deleted) {
await txn.delete('tb_todo', where: 'sync_uid = ?', whereArgs: [uid]);
n++;
continue;
}
final map = Todo.fromMap(m).toMap()
..remove('id')
..['is_deleted'] = 0
..['user_id'] = u;
final ex = await txn.query('tb_todo',
columns: ['id'], where: 'sync_uid = ?', whereArgs: [uid], limit: 1);
if (ex.isNotEmpty) {
await txn
.update('tb_todo', map, where: 'sync_uid = ?', whereArgs: [uid]);
} else {
await txn.insert('tb_todo', map);
}
n++;
}
return n;
});
}
// ---------- 강제 동기화(전체 덮어쓰기, 현재 사용자 범위) ----------
Future<List<Map<String, dynamic>>> allTodoMaps() async {
await ensureSyncMeta();
final d = await db;
final u = await currentUser();
final r = await d.query('tb_todo',
where: 'user_id = ? AND is_deleted = 0', whereArgs: [u], orderBy: 'id');
return r.map((m) => Todo.fromMap(m).toMap()..remove('id')).toList();
}
Future<int> replaceAllTodos(List<Map<String, dynamic>> rows) async {
final d = await db;
final u = await currentUser();
return d.transaction<int>((txn) async {
await txn.delete('tb_todo', where: 'user_id = ?', whereArgs: [u]);
for (final m in rows) {
final map = Todo.fromMap(m).toMap()
..remove('id')
..['is_deleted'] = 0
..['user_id'] = u;
await txn.insert('tb_todo', map);
}
return rows.length;
});
}
}
lib/services/notify.dart
import 'dart:typed_data';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:timezone/timezone.dart' as tz;
import '../db/app_db.dart';
import '../models/todo.dart';
/// 로컬 푸시 알림 (미리 알림). 미지원 플랫폼/오류는 조용히 무시.
class NotifyService {
NotifyService._();
static final NotifyService instance = NotifyService._();
final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
bool _ready = false;
static const _channelId = 'schedule_reminders_v3'; // 소리/진동 확실히 적용된 새 채널
final Int64List _vibration = Int64List.fromList([0, 500, 250, 500]);
Future<void> init() async {
try {
tzdata.initializeTimeZones();
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
await _plugin.initialize(
settings: const InitializationSettings(android: android),
);
final androidImpl = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
await androidImpl?.requestNotificationsPermission(); // 알림 권한(안드13+)
try {
await androidImpl?.requestExactAlarmsPermission(); // 정확 알람 권한(안드12+)
} catch (_) {}
// 채널을 소리·진동·중요도 최대로 명시 생성(첫 알림 전에 확정).
await androidImpl?.createNotificationChannel(
AndroidNotificationChannel(
_channelId,
'일정 알림',
description: '할 일 미리 알림',
importance: Importance.max,
playSound: true,
enableVibration: true,
vibrationPattern: _vibration,
),
);
_ready = true;
} catch (_) {
_ready = false;
}
}
NotificationDetails get _details => NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
'일정 알림',
channelDescription: '할 일 미리 알림',
importance: Importance.max,
priority: Priority.high,
playSound: true,
enableVibration: true,
vibrationPattern: _vibration,
),
);
Future<void> scheduleReminder({
required int id,
required String title,
required DateTime when,
}) async {
if (!_ready) return;
if (when.isBefore(DateTime.now())) return;
final tzTime = tz.TZDateTime.from(when, tz.local);
try {
// 정확 시각 알림(정확 알람). 권한 없으면 예외 → 아래에서 부정확 모드로 재시도.
await _plugin.zonedSchedule(
id: id,
title: '일정 알림',
body: title,
scheduledDate: tzTime,
notificationDetails: _details,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
} catch (_) {
try {
await _plugin.zonedSchedule(
id: id,
title: '일정 알림',
body: title,
scheduledDate: tzTime,
notificationDetails: _details,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
);
} catch (_) {
/* ignore */
}
}
}
Future<void> cancel(int id) async {
try {
await _plugin.cancel(id: id);
} catch (_) {
/* ignore */
}
}
// ---- 미리알림 일괄 재예약 (웹 등 다른 기기에서 설정한 알림도 폰에서 울리게) ----
String? _lastHash;
/// 기준일+알림시각으로 알림 발생 시각 계산. remind_day='prev'면 전일.
/// 할일 기준일은 기한, 일정 기준일은 생성일.
/// 지연(과거 기한·미완료·비반복) 할일은 '오늘 할 일'이므로 알림을 오늘로 이월
/// (오늘 그 시각이 이미 지났으면 현재+1시간). 지난 일정은 완료라 이월하지 않는다.
DateTime? _whenFor(Todo t) {
final dayIso = (t.onCalendar && !t.isTodo) ? t.eventDay : t.dueDate;
if (dayIso == null || dayIso.isEmpty || t.remindTime == null) return null;
final d = DateTime.tryParse(dayIso);
if (d == null) return null;
final p = t.remindTime!.split(':');
if (p.length < 2) return null;
final rh = int.tryParse(p[0]) ?? 0;
final rm = int.tryParse(p[1]) ?? 0;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final dueDay = DateTime(d.year, d.month, d.day);
if (t.isTodo &&
!t.isDone &&
(t.repeatUnit ?? '').isEmpty &&
dueDay.isBefore(today)) {
var when = DateTime(today.year, today.month, today.day, rh, rm);
if (when.isBefore(now)) when = now.add(const Duration(hours: 1));
return when;
}
var when = DateTime(d.year, d.month, d.day, rh, rm);
if (t.remindDay == 'prev') when = when.subtract(const Duration(days: 1));
return when;
}
/// 로컬 DB의 모든 미리알림을 재예약(변경 없으면 스킵). 지난 시각은 자동 제외.
Future<void> syncFromDb() async {
if (!_ready) return;
final todos = await AppDb.instance.reminderTodos();
final rel = todos
.where((t) =>
t.id != null &&
t.remindTime != null &&
((t.onCalendar && !t.isTodo)
? t.eventDay != null
: t.dueDate != null))
.toList();
// 오늘 날짜를 포함 → 자정 넘어가면(지연 이월 대상 변화) 재예약되도록.
final todayIso = DateTime.now().toIso8601String().substring(0, 10);
final hash =
'$todayIso|${rel.map((t) => '${t.id}|${t.dueDate}|${t.eventDay}|${t.remindTime}|${t.remindDay}').join(';')}';
if (hash == _lastHash) return; // 미리알림 구성이 그대로면 재예약 생략
_lastHash = hash;
try {
await _plugin.cancelAll();
} catch (_) {}
for (final t in rel) {
final when = _whenFor(t);
if (when != null) {
await scheduleReminder(id: t.id!, title: t.content, when: when);
}
}
}
}
lib/demo/demo_seed.dart
import '../db/app_db.dart';
import '../models/todo.dart';
import '../state.dart';
import '../util/sync_meta.dart';
/// 기능 소개 영상용 예시 데이터.
/// 날짜는 넣는 날 기준이라 촬영일이 바뀌어도 오늘/어제/내일이 맞는다.
/// `sync_uid` 가 `demo-` 로 시작해 사용자 데이터와 따로 지울 수 있다.
class DemoSeed {
DemoSeed._();
static const uidPrefix = 'demo-';
static const _holidayKey = 'demo_holiday_iso';
static String _stamp(String day) => '$day 00:00:00';
static String _iso(DateTime d) => isoOf(d);
static DateTime _today() {
final n = DateTime.now();
return DateTime(n.year, n.month, n.day);
}
static String _d(int offset) =>
_iso(_today().add(Duration(days: offset)));
static DateTime _thisMonday() =>
_today().subtract(Duration(days: _today().weekday - 1));
static String _untilMonths(int months) {
final t = _today();
return _iso(DateTime(t.year, t.month + months, t.day));
}
static Todo _todo({
required String uid,
required String content,
String? due,
bool done = false,
String? doneDate,
bool star = false,
bool myDay = false,
String? note,
String? repeat,
int interval = 1,
String? until,
bool calVisible = true,
String? remindTime,
String? remindDay,
bool onCalendar = false,
}) =>
Todo(
content: content,
dueDate: due,
isDone: done,
doneDate: done ? (doneDate ?? due) : null,
isStar: star,
isMyDay: myDay,
note: note,
repeatUnit: repeat,
repeatInterval: interval,
repeatUntil: until,
calVisible: calVisible,
remindTime: remindTime,
remindDay: remindDay,
onCalendar: onCalendar,
isTodo: true,
isRecord: false,
createdAt: _stamp(due ?? _d(0)),
syncUid: uid,
);
static Todo _event({
required String uid,
required String content,
required String due,
String? time,
String? note,
String? repeat,
String? until,
String? remindTime,
String? remindDay,
}) =>
Todo(
content: content,
eventTime: time,
note: note,
repeatUnit: repeat,
repeatInterval: 1,
repeatUntil: until,
remindTime: remindTime,
remindDay: remindDay,
onCalendar: true,
isTodo: false,
isRecord: false,
calVisible: true,
createdAt: _stamp(due),
syncUid: uid,
);
static Todo _record({
required String uid,
required String content,
required String due,
String? time,
String? note,
}) =>
Todo(
content: content,
dueDate: due,
eventTime: time,
note: note,
onCalendar: false,
isTodo: false,
isRecord: true,
calVisible: true,
createdAt: _stamp(due),
syncUid: uid,
);
static Future<void> clear() async {
final db = AppDb.instance;
final d = await db.db;
final u = await db.currentUser();
await d.delete('tb_todo',
where: 'user_id = ? AND sync_uid LIKE ?',
whereArgs: [u, '$uidPrefix%']);
await d.delete('tb_record_template',
where: 'sync_uid LIKE ?', whereArgs: ['$uidPrefix%']);
final holiIso = await db.getSetting(_holidayKey);
if (holiIso != null && holiIso.isNotEmpty) {
await db.setHolidayMemo(holiIso, '');
await db.setSetting(_holidayKey, '');
}
}
/// 할일이 하나도 없으면(신규 설치·데이터 삭제 후) 시연 데이터를 넣는다.
static Future<void> installIfNoTodos() async {
final db = AppDb.instance;
final d = await db.db;
final u = await db.currentUser();
final r = await d.rawQuery(
'SELECT COUNT(*) AS c FROM tb_todo '
'WHERE user_id = ? AND is_deleted = 0',
[u]);
final n = (r.first['c'] as int?) ?? 0;
if (n == 0) await install();
await _fixCreatedAfterDue();
}
/// 이미 들어간 시연 행: 생성일이 기한보다 늦으면 생성일을 기한일로 당긴다.
static Future<void> _fixCreatedAfterDue() async {
final db = AppDb.instance;
final d = await db.db;
final u = await db.currentUser();
await d.rawUpdate(
"UPDATE tb_todo SET created_at = due_date || ' 00:00:00' "
"WHERE user_id = ? AND sync_uid LIKE ? "
"AND due_date IS NOT NULL AND due_date != '' "
"AND substr(created_at, 1, 10) > due_date",
[u, '$uidPrefix%'],
);
}
static Future<int> install() async {
await clear();
final db = AppDb.instance;
final items = _items();
for (final t in items) {
await db.insert(t);
}
await _templates();
const chuseok = '2026-09-25';
await db.setHolidayMemo(chuseok, '부모님 댁 방문. 오전 출발, 송편·과일 챙기기');
await db.setSetting(_holidayKey, chuseok);
bumpData();
return items.length;
}
static Future<void> _templates() async {
final d = await AppDb.instance.db;
final stamp = nowUtcStamp();
Future<void> add(String uid, String name, List<String> opts, bool fixed) =>
d.insert('tb_record_template', {
'name': name,
'options': opts.join('\n'),
'is_fixed': fixed ? 1 : 0,
'sync_uid': uid,
'updated_at': stamp,
'is_deleted': 0,
});
await add('$uidPrefix-tpl-sleep', '수면관리', ['기상', '취침'], true);
await add('$uidPrefix-tpl-ex', '운동', ['걷기', '근력', '휴식'], false);
}
static List<Todo> _items() {
final mon = _iso(_thisMonday());
final sunday = _iso(_thisMonday().add(const Duration(days: 6)));
final until = _untilMonths(3);
return [
// ── 할 일 ──
_todo(
uid: '$uidPrefix-todo-today',
content: '주간 업무 보고서 작성',
due: _d(0),
myDay: true,
remindTime: '09:00',
remindDay: 'same',
note: '공유 폴더에 올리고 팀에 링크 전달',
),
_todo(
uid: '$uidPrefix-todo-star',
content: '자동차 보험 갱신 비교',
due: _d(4),
star: true,
note: '다이렉트 3곳 견적 후 결정',
),
_todo(
uid: '$uidPrefix-todo-overdue',
content: '도서관 책 반납',
due: _d(-2),
note: '대출 2권. 연체료 확인',
),
_todo(
uid: '$uidPrefix-todo-done-today',
content: '아침 스트레칭 10분',
due: _d(0),
done: true,
doneDate: _d(0),
),
// 완료만 있는 날 → 검정 점 + 칸 탭 시 시트
_todo(
uid: '$uidPrefix-todo-done-only',
content: '아파트 관리비 이체',
due: _d(-3),
done: true,
doneDate: _d(-3),
note: '우리은행 자동이체 확인함',
),
_todo(
uid: '$uidPrefix-todo-done-late',
content: '건강검진 예약',
due: _d(-6),
done: true,
doneDate: _d(0),
note: '다음 달 첫째 주 오전',
),
_todo(
uid: '$uidPrefix-todo-water-d2',
content: '물 2리터 마시기',
due: _d(-2),
done: true,
doneDate: _d(-2),
),
_todo(
uid: '$uidPrefix-todo-water-d1',
content: '물 2리터 마시기',
due: _d(-1),
done: true,
doneDate: _d(-1),
),
_todo(
uid: '$uidPrefix-todo-water',
content: '물 2리터 마시기',
due: _d(0),
repeat: 'day',
until: until,
calVisible: false,
note: '텀블러 채워서 책상 옆에',
),
_todo(
uid: '$uidPrefix-todo-week',
content: '분리수거 내놓기',
due: _d(0),
repeat: 'week',
until: until,
note: '플라스틱·캔. 22시 이전',
),
_todo(
uid: '$uidPrefix-todo-month',
content: '카드 명세서 확인',
due: _d(5),
repeat: 'month',
until: until,
note: '이상 결제 있는지 확인',
),
_todo(
uid: '$uidPrefix-todo-future',
content: '여행 짐 리스트 정리',
due: _d(8),
note: '여권·충전기·상비약',
),
_todo(
uid: '$uidPrefix-both-deadline',
content: '분기 발표 자료 마감',
due: _d(1),
onCalendar: true,
remindTime: '10:00',
remindDay: 'prev',
note: '슬라이드 12장, 데모 영상 1분',
),
// ── 일정 ──
_event(
uid: '$uidPrefix-ev-standup',
content: '팀 스탠드업',
due: _d(0),
time: '09:30',
remindTime: '09:00',
remindDay: 'same',
note: '화상. 진행 중 이슈만',
),
_event(
uid: '$uidPrefix-ev-allday',
content: '재택근무',
due: _d(0),
time: '종일',
),
_event(
uid: '$uidPrefix-ev-dentist',
content: '치과 스케일링',
due: _d(1),
time: '14:00',
remindTime: '10:00',
remindDay: 'prev',
note: '예약번호 4821',
),
_event(
uid: '$uidPrefix-ev-past',
content: '학부모 상담',
due: _d(-1),
time: '19:00',
note: '2학년 담임. 학습 태도',
),
_event(
uid: '$uidPrefix-ev-weekly',
content: '주간 팀 회의',
due: mon,
time: '10:00',
repeat: 'week',
until: until,
remindTime: '10:00',
remindDay: 'prev',
note: '주간 목표·블로커 공유',
),
_event(
uid: '$uidPrefix-ev-picnic',
content: '가족 나들이',
due: sunday,
time: '11:00',
note: '서울숲. 도시락',
),
// ── 기록 ──
_record(
uid: '$uidPrefix-rec-wake',
content: '기상',
due: _d(0),
time: '06:40',
),
_record(
uid: '$uidPrefix-rec-sleep',
content: '취침',
due: _d(-1),
time: '23:20',
),
_record(
uid: '$uidPrefix-rec-walk',
content: '걷기',
due: _d(-1),
time: '19:30',
note: '한강 4km',
),
_record(
uid: '$uidPrefix-rec-med',
content: '혈압약',
due: _d(-4),
time: '08:00',
),
];
}
}
lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import '../state.dart';
import 'calendar_tab.dart';
import 'list_tab.dart';
import 'month_events_tab.dart';
import 'records_tab.dart';
import 'settings_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _index = 0;
// 하단 탭별 상단 제목(달력/할일/일정/기록).
static const _titles = ['모담 하루계획', '할 일', '일정', '기록'];
static const _wd = ['', '월', '화', '수', '목', '금', '토', '일'];
/// 할일·일정·기록 제목 옆 오늘 날짜. 달력 제목에는 붙이지 않는다.
String _todayLabel() {
final n = DateTime.now();
return '${n.month}월 ${n.day}일 (${_wd[n.weekday]})';
}
Widget _barTitle() {
final name = _titles[_index];
if (_index == 0) return Text(name);
return Row(
children: [
Text(name),
const SizedBox(width: 10),
Text(
_todayLabel(),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400),
),
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: _barTitle(),
backgroundColor: const Color(0xFF3F6FB0),
foregroundColor: Colors.white,
actions: [
// 설정 — 구글 캘린더 iCal URL(서버 동기화는 없음).
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
),
],
),
body: Column(
children: [
Expanded(
child: IndexedStack(
index: _index,
children: const [
CalendarTab(),
ListTab(initialView: 'today'),
MonthEventsTab(),
RecordsTab(),
],
),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (i) {
setState(() => _index = i);
// 하단 탭(할일/일정/기록) 선택 → 달력 '보기' 버튼도 그 유형으로 연동.
const kinds = {1: 'todo', 2: 'event', 3: 'record'};
final k = kinds[i];
if (k != null) calendarViewKind.value = k;
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.calendar_month_outlined),
selectedIcon: Icon(Icons.calendar_month),
label: '달력',
),
NavigationDestination(
icon: Icon(Icons.checklist_outlined),
selectedIcon: Icon(Icons.checklist),
label: '할 일',
),
NavigationDestination(
icon: Icon(Icons.event_note_outlined),
selectedIcon: Icon(Icons.event_note),
label: '일정',
),
NavigationDestination(
icon: Icon(Icons.description_outlined),
selectedIcon: Icon(Icons.description),
label: '기록',
),
],
),
);
}
}
lib/screens/list_tab.dart
import 'package:flutter/material.dart';
import '../db/app_db.dart';
import '../models/todo.dart';
import '../state.dart';
import '../widgets/todo_edit_sheet.dart';
import '../widgets/todo_tile.dart';
/// 목록 탭 — 오늘 / 미완료 / 지연 / 완료됨 / 전체 필터.
class ListTab extends StatefulWidget {
final String initialView; // today | incomplete | overdue | done | all
const ListTab({super.key, this.initialView = 'incomplete'});
@override
State<ListTab> createState() => _ListTabState();
}
class _ListTabState extends State<ListTab> {
static const _views = {
'today': '오늘 할 일',
'incomplete': '예정',
'overdue': '지연',
'done': '완료됨',
'all': '전체 할 일',
};
late String _view;
List<Todo> _items = [];
int _starCount = 0; // 별표(즐겨찾기) 개수 — 0이면 '별표' 칩 숨김
bool _desc = true; // 날짜 정렬: true=내림차순(기본), false=오름차순
@override
void initState() {
super.initState();
_view = widget.initialView;
dataVersion.addListener(_load);
_load();
}
@override
void dispose() {
dataVersion.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final todayIso = isoOf(DateTime.now());
// 반복 할 일을 오늘까지 실체화(미완료=최신 1개로 이동, 완료=기록 보존+새 회차).
await AppDb.instance.rollForwardRepeatTodos(todayIso);
final starCount = await AppDb.instance.countStar();
// 별표가 하나도 없는데 '별표' 뷰면 오늘 할 일로 되돌림.
if (_view == 'important' && starCount == 0) _view = 'today';
final items = await AppDb.instance.listByView(_view, todayIso);
for (final t in items) {
// 반복도 이제 회차별 실제 행 → 기한이 과거면 지연.
t.isOverdue =
t.dueDate != null && t.dueDate!.compareTo(todayIso) < 0;
}
if (_view == 'overdue') {
for (final t in items) {
t.isOverdue = true;
}
}
if (_view == 'incomplete' || _view == 'all' || _view == 'important') {
// 항목 앞 마커: 지연이면 지연만, 아니면 오늘 할 일(당일/나의 하루)이면 '오늘'.
for (final t in items) {
t.isTodayTask = !t.isOverdue &&
(t.dueDate == todayIso ||
t.isMyDay ||
_createdOnDay(t, todayIso));
}
}
if (_view == 'today') {
// 오늘 뷰의 모든 항목: 지연이면 '지연', 아니면 '오늘' 태그.
for (final t in items) {
t.isTodayTask = !t.isOverdue;
}
}
if (!mounted) return;
var result = items;
if (_view == 'incomplete') {
// '예정'만: 지연·오늘은 제외(각각 지연/오늘 할 일 탭에서 확인).
result = items.where((t) => !t.isOverdue && !t.isTodayTask).toList();
}
setState(() {
_items = result;
_starCount = starCount;
});
}
/// 기한이 없고 생성일이 [iso]인 할일.
bool _createdOnDay(Todo t, String iso) {
final due = t.dueDate;
final created = t.createdAt;
final noDue = due == null || due.isEmpty;
return noDue &&
created != null &&
created.length >= 10 &&
created.substring(0, 10) == iso;
}
/// 칩 목록. 별표가 있을 때만 '오늘 할 일' 뒤에 '별표' 칩을 끼움.
List<MapEntry<String, String>> _visibleEntries() {
final out = <MapEntry<String, String>>[];
_views.forEach((k, v) {
out.add(MapEntry(k, v));
if (k == 'today' && _starCount > 0) {
out.add(MapEntry('important', '별표 $_starCount'));
}
});
return out;
}
// 날짜 오름/내림 콤보(상단 오른쪽).
Widget _orderCombo() => Padding(
padding: const EdgeInsets.only(right: 10, left: 4),
child: DropdownButton<bool>(
value: _desc,
isDense: true,
underline: const SizedBox(),
items: const [
DropdownMenuItem(value: true, child: Text('내림차순')),
DropdownMenuItem(value: false, child: Text('오름차순')),
],
onChanged: (v) => setState(() => _desc = v ?? true),
),
);
/// 오늘 → 예정 → 지연 → 완료. 오름/내림과 상관없이 이 순서가 우선한다.
int _statusRank(Todo t) {
if (t.isDone) return 3;
if (t.isOverdue) return 2;
if (t.isTodayTask) return 0;
return 1;
}
String _day(String? raw) {
if (raw == null || raw.length < 10) return '';
return raw.substring(0, 10);
}
/// 빈 날짜는 방향과 상관없이 뒤로. 있는 날짜만 오름/내림.
int _cmpDay(String a, String b) {
if (a.isEmpty && b.isEmpty) return 0;
if (a.isEmpty) return 1;
if (b.isEmpty) return -1;
return _desc ? b.compareTo(a) : a.compareTo(b);
}
/// 상태 → 생성일 → 기한 → 완료일.
int _cmpTodo(Todo a, Todo b) {
final byStatus = _statusRank(a).compareTo(_statusRank(b));
if (byStatus != 0) return byStatus;
final byCreated = _cmpDay(_day(a.createdAt), _day(b.createdAt));
if (byCreated != 0) return byCreated;
final byDue = _cmpDay(_day(a.dueDate), _day(b.dueDate));
if (byDue != 0) return byDue;
final byDone = _cmpDay(_day(a.doneDate), _day(b.doneDate));
if (byDone != 0) return byDone;
return (a.id ?? 0).compareTo(b.id ?? 0);
}
List<Widget> _sortedTiles(BuildContext context) {
final list = [..._items]..sort(_cmpTodo);
final out = <Widget>[];
for (var i = 0; i < list.length; i++) {
if (i > 0) {
out.add(const Divider(
height: 1, thickness: 1, color: Color(0xFFCFD3D8)));
}
final item = list[i];
out.add(TodoTile(
todo: item,
showDday: _view == 'today' || _view == 'all',
onTap: () => showTodoEditor(context, todo: item),
onDelete: () => deleteTodoFlow(context, item)));
}
out.add(const SizedBox(height: 20));
return out;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
const SizedBox(height: 8),
Row(children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: _visibleEntries().map((e) {
final sel = e.key == _view;
final isImportant = e.key == 'important';
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
avatar: isImportant
? const Icon(Icons.star,
size: 18, color: Color(0xFFF5A623))
: null,
label: Text(e.value),
selected: sel,
onSelected: (_) {
setState(() => _view = e.key);
_load();
},
),
);
}).toList(),
),
),
),
_orderCombo(),
]),
const Divider(height: 12),
Expanded(
child: _items.isEmpty
? const Center(
child: Text('할 일이 없습니다.',
style: TextStyle(color: Colors.grey)))
: ListView(children: _sortedTiles(context)),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => showTodoEditor(context,
kind: 'todo',
defaultMyDay: _view == 'today',
defaultDate: _view == 'today' ? isoOf(DateTime.now()) : null),
child: const Icon(Icons.add),
),
);
}
}
lib/screens/month_events_tab.dart
import 'package:flutter/material.dart';
import '../db/app_db.dart';
import '../logic/recurrence.dart';
import '../models/todo.dart';
import '../services/gcal.dart';
import '../state.dart';
import '../widgets/todo_edit_sheet.dart';
/// 이달의 일정 — 이번 달 + 다음 2개월의 on_calendar 일정을 날짜순으로.
/// 지난 일정=취소선, 오늘 일정=체크 아이콘. (웹 app_schedule month_events 포팅)
class MonthEventsTab extends StatefulWidget {
const MonthEventsTab({super.key});
@override
State<MonthEventsTab> createState() => _MonthEventsTabState();
}
class _Ev {
final String content;
final String date;
final bool isRepeat; // 반복 일정 발생분 여부
final Todo? todo; // 탭하면 편집할 원본
bool isToday = false;
bool isPast = false;
_Ev(this.content, this.date, {this.isRepeat = false, this.todo});
}
class _MonthGroup {
final int year;
final int month;
final List<_Ev> events;
_MonthGroup(this.year, this.month, this.events);
}
class _MonthEventsTabState extends State<MonthEventsTab> {
List<_MonthGroup> _groups = [];
List<_MonthGroup> _gGroups = []; // 구글 일정 모드용
bool _desc = false; // 날짜 정렬: true=내림차순, false=오름차순(기본)
bool _googleMode = false; // true면 아래 리스트를 구글 일정으로 구성
bool _hasGcalUrl = false; // 구글 URL 설정 시에만 '구글 일정' 버튼 노출
// 날짜 오름/내림 콤보(상단 오른쪽).
Widget _orderCombo() => DropdownButton<bool>(
value: _desc,
isDense: true,
underline: const SizedBox(),
items: const [
DropdownMenuItem(value: true, child: Text('내림차순')),
DropdownMenuItem(value: false, child: Text('오름차순')),
],
onChanged: (v) => setState(() => _desc = v ?? true),
);
// 정렬 박스 앞의 '구글 일정' 토글 버튼(구글 URL 설정 시에만 표시).
Widget _googleToggleButton() => OutlinedButton.icon(
onPressed: () {
setState(() => _googleMode = !_googleMode);
if (_googleMode) _loadGoogle();
},
icon: Icon(Icons.event,
size: 16,
color: _googleMode ? Colors.white : const Color(0xFF1A73E8)),
label: Text('구글 일정',
style: TextStyle(
fontSize: 12,
color: _googleMode ? Colors.white : const Color(0xFF1A73E8))),
style: OutlinedButton.styleFrom(
backgroundColor: _googleMode ? const Color(0xFF1A73E8) : null,
side: const BorderSide(color: Color(0xFF1A73E8)),
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: 10),
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
);
@override
void initState() {
super.initState();
dataVersion.addListener(_load);
_load();
}
@override
void dispose() {
dataVersion.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final today = DateTime.now();
final repeating = await AppDb.instance.listRepeating();
final hasUrl = ((await GcalService.getUrl()) ?? '').trim().isNotEmpty;
final groups = <_MonthGroup>[];
var y = today.year, m = today.month;
for (var i = 0; i < 3; i++) {
groups.add(await _monthGroup(y, m, repeating, today));
m++;
if (m > 12) {
m = 1;
y++;
}
}
if (!mounted) return;
setState(() {
_groups = groups;
_hasGcalUrl = hasUrl;
if (!hasUrl) _googleMode = false; // URL 없으면 구글 모드 강제 해제
});
if (_googleMode && hasUrl) _loadGoogle();
}
// 구글 일정을 이번 달+다음 2개월 그룹으로 구성(.ics 피드, 읽기전용).
Future<void> _loadGoogle() async {
final today = DateTime.now();
final todayIso = isoOf(today);
final gmap = await GcalService.events();
final groups = <_MonthGroup>[];
var y = today.year, m = today.month;
for (var i = 0; i < 3; i++) {
final prefix =
'${y.toString().padLeft(4, '0')}-${m.toString().padLeft(2, '0')}-';
final events = <_Ev>[];
gmap.forEach((iso, list) {
if (iso.startsWith(prefix)) {
for (final g in list) {
final content =
g.time.isEmpty ? g.title : '${g.time} ${g.title}';
events.add(_Ev(content, iso));
}
}
});
events.sort((a, b) => a.date.compareTo(b.date));
for (final e in events) {
e.isToday = e.date == todayIso;
e.isPast = e.date.compareTo(todayIso) < 0;
}
groups.add(_MonthGroup(y, m, events));
m++;
if (m > 12) {
m = 1;
y++;
}
}
if (!mounted) return;
setState(() => _gGroups = groups);
}
Future<_MonthGroup> _monthGroup(
int y, int m, List<Todo> repeating, DateTime today) async {
final start = DateTime(y, m, 1);
final end = DateTime(y, m + 1, 0); // 그 달 말일
final events = <_Ev>[];
final ev =
await AppDb.instance.calEventsInRange(isoOf(start), isoOf(end));
for (final e in ev) {
final home = e.eventDay;
if (home == null) continue;
events.add(_Ev(e.content, home, todo: e));
}
final occ = occurrencesIn(repeating, start, end, today: today);
occ.forEach((iso, list) {
for (final t in list) {
if (t.onCalendar) {
events.add(_Ev(t.content, iso, isRepeat: true, todo: t));
}
}
});
events.sort((a, b) => a.date.compareTo(b.date));
final todayIso = isoOf(today);
for (final e in events) {
e.isToday = e.date == todayIso;
e.isPast = e.date.compareTo(todayIso) < 0;
}
return _MonthGroup(y, m, events);
}
// 앞 태그: 오늘=초록 '오늘', 지난 일정=회색 '완료'.
Widget _tag(String text, Color bg, Color border, Color fg) => Container(
margin: const EdgeInsets.only(right: 6),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: border),
),
child: Text(text,
style:
TextStyle(fontSize: 11, color: fg, fontWeight: FontWeight.bold)),
);
Widget _evTile(BuildContext context, _Ev e) {
return InkWell(
onTap: e.todo == null
? null
: () => showTodoEditor(context, todo: e.todo, simple: true),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
if (e.isToday)
_tag('오늘', const Color(0xFFE5F5EA), const Color(0xFFAEDCBF),
const Color(0xFF0B9D4A))
else if (e.isPast)
_tag('완료', const Color(0xFFEDEDED), const Color(0xFFD5D5D5),
const Color(0xFF7A7F85))
else if (e.isRepeat) // 오늘 이후의 반복 일정(예정보다 우선)
_tag('반복', const Color(0xFFE7EEF8), const Color(0xFFB8CCE8),
const Color(0xFF3F6FB0))
else // 오늘 이후 단발 일정
_tag('예정', const Color(0xFFEAF1FA), const Color(0xFFC3D5EC),
const Color(0xFF3F6FB0)),
Expanded(
child: Text.rich(
TextSpan(
style: TextStyle(
fontSize: 16,
height: 1.25,
decoration: e.isPast ? TextDecoration.lineThrough : null,
decorationColor: const Color(0xFFCFD3D8),
decorationThickness: 1.0,
color: e.isPast ? const Color(0xFF4D4D4D) : null,
),
children: [
TextSpan(text: e.content),
if (e.todo?.isStar == true)
const WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: EdgeInsets.only(left: 4),
child: Icon(Icons.star,
size: 16, color: Color(0xFFF5A623)),
),
),
],
),
overflow: TextOverflow.ellipsis,
),
),
Text(
e.date,
style: TextStyle(
fontSize: 13,
color: e.isPast ? const Color(0xFF888888) : Colors.grey,
decoration: e.isPast ? TextDecoration.lineThrough : null,
decorationColor: const Color(0xFFCFD3D8),
),
),
],
),
),
);
}
/// 오늘 → 예정 → 완료. 일정은 기한이 없어 지연이 없다.
/// 오늘보다 전날이면 완료, 오늘보다 뒤면 예정.
int _statusRank(_Ev e) {
final done = e.todo?.isDone ?? false;
if (done || e.isPast) return 3;
if (e.isToday) return 0;
return 1;
}
String _day(String? raw) {
if (raw == null || raw.length < 10) return '';
return raw.substring(0, 10);
}
int _cmpDay(String a, String b) {
if (a.isEmpty && b.isEmpty) return 0;
if (a.isEmpty) return 1;
if (b.isEmpty) return -1;
return _desc ? b.compareTo(a) : a.compareTo(b);
}
/// 상태 → 생성일 → 기한 → 완료일. 구글 일정은 표시 날짜로 비교.
int _cmpEv(_Ev a, _Ev b) {
final byStatus = _statusRank(a).compareTo(_statusRank(b));
if (byStatus != 0) return byStatus;
final aCreated = a.todo == null ? a.date : _day(a.todo!.createdAt);
final bCreated = b.todo == null ? b.date : _day(b.todo!.createdAt);
final byCreated = _cmpDay(aCreated, bCreated);
if (byCreated != 0) return byCreated;
final aDue = a.isRepeat ? a.date : _day(a.todo?.dueDate);
final bDue = b.isRepeat ? b.date : _day(b.todo?.dueDate);
final byDue = _cmpDay(aDue, bDue);
if (byDue != 0) return byDue;
final byDone = _cmpDay(_day(a.todo?.doneDate), _day(b.todo?.doneDate));
if (byDone != 0) return byDone;
return _cmpDay(a.date, b.date);
}
Widget _groupCard(BuildContext context, _MonthGroup g) {
final evs = List.of(g.events)..sort(_cmpEv);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: evs.isEmpty
? const Padding(
padding: EdgeInsets.all(16),
child: Text('일정이 없습니다.',
style: TextStyle(color: Colors.grey)))
: Column(
children: [
for (var i = 0; i < evs.length; i++) ...[
if (i > 0)
const Divider(
height: 1, thickness: 1, color: Color(0xFFCFD3D8)),
_evTile(context, evs[i]),
],
],
),
);
}
@override
Widget build(BuildContext context) {
final groups = _googleMode ? _gGroups : _groups;
return Column(
children: [
// 헤더: (구글 URL 설정 시) '구글 일정' 토글 버튼 + 정렬 콤보.
Padding(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 0),
child: Row(
children: [
const Spacer(),
if (_hasGcalUrl) ...[
_googleToggleButton(),
const SizedBox(width: 8),
],
_orderCombo(),
],
),
),
Expanded(
child: groups.isEmpty
? const Center(child: CircularProgressIndicator())
: _buildList(context, groups),
),
],
);
}
Widget _buildList(BuildContext context, List<_MonthGroup> groups) {
final first = groups.first;
final rest = groups.skip(1).toList();
final kind = _googleMode ? '구글 일정' : '일정';
return ListView(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Text('${first.year}년 ${first.month}월 $kind',
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold)),
),
_groupCard(context, first),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text('다음 달 $kind',
style:
const TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
),
for (final g in rest) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 2),
child: Text('${g.year}년 ${g.month}월',
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600)),
),
_groupCard(context, g),
],
const SizedBox(height: 20),
],
);
}
}
lib/screens/calendar_tab.dart
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:table_calendar/table_calendar.dart';
import '../db/app_db.dart';
import '../logic/holidays.dart';
import '../logic/recurrence.dart';
import '../models/todo.dart';
import '../services/gcal.dart';
import '../state.dart';
import '../widgets/todo_edit_sheet.dart';
import '../widgets/todo_tile.dart';
class CalendarTab extends StatefulWidget {
const CalendarTab({super.key});
@override
State<CalendarTab> createState() => _CalendarTabState();
}
class _CalendarTabState extends State<CalendarTab> {
DateTime _focused = DateTime.now();
DateTime _selected = DateTime.now();
bool _eventsOnly = false; // 일정보기: 켜면 달력에 일정만
bool _recordsOnly = false; // 기록보기: 켜면 달력에 기록만 (일정보기와 배타)
bool _todosOnly = false; // 할일보기: 모두에서 일정·기록만 숨김(공휴일·할일 배지 유지)
bool _holidaysOnly = false; // 공휴일보기: 달력에 공휴일만 표시
bool _repeatOnly = false; // 반복보기: 그 달 반복 항목만 표시(공휴일과 동일한 조회 모드). cal_visible 무관.
final TextEditingController _holidayMemoCtl =
TextEditingController(); // 공휴일 메모(공휴일보기 상세)
bool _loadBusy = false; // _load 동시실행 방지(코얼레싱)
bool _loadAgain = false; // 로딩 중 재요청 → 완료 후 1회 재실행
Timer? _navDebounce; // 월 이동 연속 클릭 디바운스
final GlobalKey _yearKey = GlobalKey(); // 년/월 팝오버 앵커
final GlobalKey _monthKey = GlobalKey();
OverlayEntry? _ymOverlay; // 년/월 선택 팝오버
static const int _yearSpan = 10; // 년도 선택/이동 범위: 오늘 년도 ±_yearSpan
Map<String, int> _doneCounts = {}; // 날짜별 완료 할일 수(검정 점)
Map<String, List<String>> _eventsByDate = {};
Map<String, List<String>> _recordsByDate = {};
Map<String, List<String>> _todosByDate = {}; // 날짜별 할일 제목(외 N 표시)
Map<String, List<GEvent>> _gcal = {};
List<Todo> _selItems = [];
List<Todo> _selEvents = [];
List<Todo> _selRecords = [];
// 하단 상세 패널 제어 — 날짜 선택 시 위로 확장.
final DraggableScrollableController _sheetCtl =
DraggableScrollableController();
// 날짜 탭 후 로드가 끝나면, 완료만 있어도 시트를 올리기 위한 플래그.
bool _raiseAfterLoad = false;
// 접힌 시트 위치(collapsedFrac) 변화 감지용. 월 이동으로 주 수(5↔6)가 바뀌면
// 접힘 상태의 시트를 새 달력 높이에 맞춰 자동 재배치(6주차 가림 방지).
double _lastCollapsedFrac = -1;
@override
void initState() {
super.initState();
dataVersion.addListener(_load);
calendarViewKind.addListener(_applyBottomTabView);
_load();
}
// 하단 탭(할일/일정/기록) 선택 → 달력 '보기' 버튼 자동 선택 + 리로드.
void _applyBottomTabView() {
if (!mounted) return;
final k = calendarViewKind.value;
setState(() {
_todosOnly = k == 'todo';
_eventsOnly = k == 'event';
_recordsOnly = k == 'record';
_holidaysOnly = false;
_repeatOnly = false;
});
_load();
}
@override
void dispose() {
dataVersion.removeListener(_load);
calendarViewKind.removeListener(_applyBottomTabView);
_navDebounce?.cancel();
_removeYmOverlay();
_sheetCtl.dispose();
_holidayMemoCtl.dispose();
super.dispose();
}
/// 선택일 시트에 넣을 항목이 있으면 true(완료 할일만 있어도 true).
bool _hasSheetContentForSelected() {
final iso = isoOf(_selected);
if (Holidays.nameOf(iso) != null) return true;
if (_selItems.isNotEmpty) return true;
if (_selEvents.isNotEmpty) return true;
if (_selRecords.isNotEmpty) return true;
if (_gcal[iso]?.isNotEmpty ?? false) return true;
return false;
}
void _maybeRaiseSheetAfterLoad() {
if (!_raiseAfterLoad) return;
_raiseAfterLoad = false;
if (_hasSheetContentForSelected()) _raiseSheet();
}
/// 날짜 선택 시 상세 패널을 위로 올림(현재보다 낮을 때만).
void _raiseSheet() {
const target = 0.6;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_sheetCtl.isAttached && _sheetCtl.size < target) {
_sheetCtl.animateTo(target,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut);
}
});
}
/// 시트 날짜 헤더 탭 → 토글: 내려가 있으면 올리고(내용칸 클릭처럼=0.6), 올라와 있으면 내림(접힘 위치로).
/// 접힘 위치(collapsedFrac)는 화면/주 수에 따라 동적이지만 항상 0.5 미만이므로,
/// 펼침(0.6) 기준 절대 임계값 0.5로 현재 상태를 판정한다.
void _toggleSheet() {
if (!_sheetCtl.isAttached) return;
final collapsed = _lastCollapsedFrac > 0 ? _lastCollapsedFrac : 0.06;
final target = _sheetCtl.size < 0.5 ? 0.6 : collapsed;
// 탭 후 시트 스크롤의 ballistic 정착이 끝난 뒤 실행(즉시/다음프레임은 정착에 덮여 무시됨).
Future.delayed(const Duration(milliseconds: 150), () {
if (!mounted || !_sheetCtl.isAttached) return;
_sheetCtl.animateTo(target,
duration: const Duration(milliseconds: 250), curve: Curves.easeOut);
});
}
/// 편집기(FAB·상세 항목 탭)를 열 때 상세 시트를 접어 입력 폼을 가리지 않게 한다.
/// 이미 접혀 있으면 아무것도 하지 않는다.
void _collapseSheet() {
if (!_sheetCtl.isAttached) return;
final collapsed = _lastCollapsedFrac > 0 ? _lastCollapsedFrac : 0.06;
if (_sheetCtl.size > collapsed + 0.002) {
_sheetCtl.animateTo(collapsed,
duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
}
}
// 동시 다발 호출을 직렬화(빠른 월 이동/주기 동기화가 겹쳐도 화면이 흔들리지 않게).
Future<void> _load() async {
if (_loadBusy) {
_loadAgain = true;
return;
}
_loadBusy = true;
try {
await _loadInner();
} finally {
_loadBusy = false;
if (_loadAgain) {
_loadAgain = false;
_load();
}
}
}
Future<void> _loadInner() async {
final db = AppDb.instance;
final first = DateTime(_focused.year, _focused.month, 1);
final gridStart = first.subtract(Duration(days: first.weekday % 7));
final gridEnd = gridStart.add(const Duration(days: 41));
final now = DateTime.now();
final todayIso = isoOf(now);
// 반복 할 일을 오늘까지 실체화(회차별 실제 행). 이후 조회는 일반 날짜 조회로.
await db.rollForwardRepeatTodos(todayIso);
// 반복보기: 그 달 반복 항목만(공휴일 모드와 동일). cal_visible 무관, 미래 회차는 반복 기한까지 전개.
if (_repeatOnly) {
await _loadRepeatMode(db, gridStart, gridEnd, now);
return;
}
// 완료 할일 카운트(지난 날짜 포함, 검정 점).
final doneCounts = await db.doneCountsByMonth(_focused.year, _focused.month);
// 오늘 셀 할일 제목 계산용('오늘 할 일' = 지연 이월 포함).
final todayBase = await db.listByView('today', todayIso);
final repeating = await db.listRepeating();
final repeatEvents = repeating.where((t) => t.onCalendar).toList();
final events = <String, List<String>>{};
final records = <String, List<String>>{};
final todos = <String, List<String>>{};
// 전개 반복 회차 '그 날짜만 완료' 지원: 그 날짜에 같은 내용의 '비반복 할일'(완료/미완료
// 무관)이 있으면 그 날짜의 반복 전개는 건너뛴다(완료 기록/개별 회차가 대신 표시, 중복 방지).
final rangeTodos = await db.calTodosInRange(isoOf(gridStart), isoOf(gridEnd));
final occTodo = <String, Set<String>>{};
void markOcc(Todo t) {
final due = t.dueDate;
if (due == null || due.isEmpty) return;
occTodo.putIfAbsent(t.content, () => <String>{}).add(due);
}
for (final t
in await db.doneTodosInRange(isoOf(gridStart), isoOf(gridEnd))) {
markOcc(t);
}
for (final t in rangeTodos) {
if (!t.isRepeating) markOcc(t);
}
// ── 반복 항목(할일·일정) '달력에 표시' 규칙 ──
// · 체크(cal_visible=true) → 일정처럼 모든 회차(각 항목 반복 기한까지) 달력 셀에 전개.
// · 해제(cal_visible=false) → 셀에는 숨김(제목 안 뜸). 단, 해당 날짜를 '선택'하면
// 하단 시트에는 표시됨(_selectedItems/_selectedEvents 에서 전개).
void addRep(Todo t, String iso) {
if (t.onCalendar) {
events.putIfAbsent(iso, () => []).add(t.content);
} else if (t.isTodo) {
if (occTodo[t.content]?.contains(iso) ?? false) return; // 그 날짜는 개별 회차로 표시됨
todos.putIfAbsent(iso, () => []).add(t.content);
}
}
// 표시(cal_visible=true) 반복: 모든 회차를 셀에 전개.
occurrencesIn(repeating.where((t) => t.calVisible).toList(),
gridStart, gridEnd, today: now, expandAll: true)
.forEach((iso, list) {
for (final t in list) {
addRep(t, iso);
}
});
// 숨김(cal_visible=false) 반복: 셀엔 '현재 회차(생성/최근 발생) 한 곳'만.
// 이후 반복 날짜는 셀에서 숨김(단, 그 날짜를 선택하면 하단 시트에는 표시됨).
// 시작일(기준일)이 미래여서 오늘 이하 회차가 없으면 '만든 날짜(기준일)' 칸에 표시
// → 달력 표시를 해제해도 만든 날짜칸에는 항상 보이게(생성 직후 사라지지 않도록).
for (final t in repeating.where((t) => !t.calVisible)) {
final base = DateTime.tryParse(t.repeatAnchor ?? '');
if (base == null) continue;
final until =
t.repeatUntil == null ? null : DateTime.tryParse(t.repeatUntil!);
final cur = latestOccurrenceOnOrBefore(
base, t.repeatUnit, t.repeatInterval, now,
until: until) ??
dOnly(base);
if (cur.isBefore(gridStart) || cur.isAfter(gridEnd)) {
continue;
}
addRep(t, isoOf(cur));
}
// ── 비반복 일정/기록/할일: cal_visible=true 인 것만 그 날짜에 ──
for (final e in await db.calEventsInRange(isoOf(gridStart), isoOf(gridEnd))) {
if (e.isRepeating || !e.calVisible) continue;
final home = e.eventDay;
if (home == null || home.isEmpty) continue;
events.putIfAbsent(home, () => []).add(e.content);
}
for (final r
in await db.calRecordsInRange(isoOf(gridStart), isoOf(gridEnd))) {
if (r.isRepeating || !r.calVisible) continue;
records.putIfAbsent(r.dueDate!, () => []).add(r.content);
}
for (final t in rangeTodos) {
if (t.isRepeating || !t.calVisible) continue;
// 할일 칸은 생성일. 기한이 있어도 기한 날짜에는 만들지 않는다.
final created = t.createdAt;
final home = (created != null && created.length >= 10)
? created.substring(0, 10)
: t.dueDate;
if (home == null || home.isEmpty) continue;
todos.putIfAbsent(home, () => []).add(t.content);
}
// ── 오늘 셀: 비반복 '오늘 할 일'(지연 이월 포함, 표시)을 합침(반복은 위 규칙) ──
final todayNonRep = todayBase
.where((t) => !t.isRepeating && t.calVisible)
.map((t) => t.content);
final mergedToday = <String>{...?todos[todayIso], ...todayNonRep}.toList();
if (mergedToday.isEmpty) {
todos.remove(todayIso);
} else {
todos[todayIso] = mergedToday;
}
final sel = await _selectedItems(db);
final selEv = await _selectedEvents(db, repeatEvents);
final selRec = await db.recordsOnDate(isoOf(_selected));
final selMemo = await db.getHolidayMemo(isoOf(_selected));
if (!mounted) return;
_holidayMemoCtl.text = selMemo ?? ''; // 공휴일 메모 프리필
// 로컬(DB) 데이터로 달력을 먼저 표시.
setState(() {
_doneCounts = doneCounts;
_eventsByDate = events;
_recordsByDate = records;
_todosByDate = todos;
_selItems = _starFirst(sel);
_selEvents = selEv;
_selRecords = selRec.where((t) => t.calVisible).toList();
});
// 구글 일정은 네트워크(최대 8초)라 블로킹하지 않고 도착하면 갱신 → 진입 지연 방지.
GcalService.events().then((gcal) {
if (!mounted) return;
setState(() => _gcal = gcal);
});
_maybeRaiseSheetAfterLoad();
}
/// 반복보기 데이터: 그 달 반복 항목(할일+일정)만 날짜별 전개. cal_visible 무관.
/// 매일/매주 할일도 전체 전개(expandAll), 미래 회차는 각 항목의 반복 기한까지.
Future<void> _loadRepeatMode(
AppDb db, DateTime gridStart, DateTime gridEnd, DateTime now) async {
final repeating = await db.listRepeating();
final repTodos = repeating.where((t) => t.isTodo).toList();
final repEvents = repeating.where((t) => t.onCalendar).toList();
final occT =
occurrencesIn(repTodos, gridStart, gridEnd, today: now, expandAll: true);
final occE = occurrencesIn(repEvents, gridStart, gridEnd, today: now);
// 완료 기록(그 날짜만 완료된 회차)을 반복보기에도 반영: 완료 회차는 제목 대신 완료 점,
// 시트에는 실제 완료 기록(완료 상태, 탭하면 완료 해제)으로 표시.
final repContents = repTodos.map((t) => t.content).toSet();
final doneRec = <String, Set<String>>{}; // content -> 완료 날짜들
final doneCounts = <String, int>{};
for (final t
in await db.doneTodosInRange(isoOf(gridStart), isoOf(gridEnd))) {
if (!repContents.contains(t.content)) continue; // 반복과 무관한 완료 제외
doneRec.putIfAbsent(t.content, () => <String>{}).add(t.dueDate!);
doneCounts[t.dueDate!] = (doneCounts[t.dueDate!] ?? 0) + 1;
}
final todos = <String, List<String>>{};
occT.forEach((iso, list) {
for (final t in list) {
if (doneRec[t.content]?.contains(iso) ?? false) continue; // 완료 회차는 점으로
todos.putIfAbsent(iso, () => []).add(t.content);
}
});
final events = <String, List<String>>{};
occE.forEach((iso, list) {
for (final t in list) {
events.putIfAbsent(iso, () => []).add(t.content);
}
});
final selIso = isoOf(_selected);
final selT = occurrencesIn(repTodos, _selected, _selected,
today: now, expandAll: true)[selIso] ??
[];
final selE =
occurrencesIn(repEvents, _selected, _selected, today: now)[selIso] ?? [];
// 선택 날짜의 완료 기록(실제 완료 상태로 표시). 중복 방지 위해 그 내용의 가상 회차는 제외.
final selDone = (await db.doneTodosInRange(selIso, selIso))
.where((t) => repContents.contains(t.content))
.toList();
final selDoneContents = selDone.map((t) => t.content).toSet();
if (!mounted) return;
setState(() {
_doneCounts = doneCounts;
_eventsByDate = events;
_recordsByDate = {};
_todosByDate = todos;
_selItems = [
...selDone, // 그 날짜 완료 기록(완료 상태)
...selT
.where((t) => !selDoneContents.contains(t.content))
.map((t) => t.copy()
..isRecurring = true
..dueDate = selIso),
];
_selEvents = selE
.map((t) => t.copy()..isRecurring = t.repeatAnchor != selIso)
.toList();
_selRecords = [];
_gcal = {};
});
_maybeRaiseSheetAfterLoad();
}
/// 선택 날짜의 달력 일정(on_calendar) 객체 — 비반복 + 반복 발생. 탭하면 편집/삭제.
/// 반복 일정은 '달력에 표시' 여부와 무관하게, 선택 날짜가 회차면 시트에 표시(숨김도 선택 시 보임).
Future<List<Todo>> _selectedEvents(AppDb db, List<Todo> repeatEvents) async {
final iso = isoOf(_selected);
final now = DateTime.now();
// 비반복 일정: cal_visible=true 인 실제 행만(반복은 아래에서 전개).
final list = (await db.eventsOnDate(iso))
.where((e) => !e.isRepeating && e.calVisible)
.toList();
final exp = occurrencesIn(repeatEvents, _selected, _selected,
today: now, expandAll: true)[iso] ??
[];
for (final t in exp) {
if (!t.onCalendar) continue;
// 기준일(첫 회차)은 원본 행 → 할일처럼 반복 옵션 수정 가능.
// 이후 가상 회차만 isRecurring(반복 속성 잠금).
list.add(t.copy()..isRecurring = t.repeatAnchor != iso);
}
return list;
}
// 별표 → 상태(오늘→지연→예정→완료) → 상태 내 일자 오름차순.
// 미완료는 생성일, 완료는 완료일(없으면 기한·생성일 폴백).
int _statusRank(Todo t) {
if (t.isDone) return 3;
if (t.isOverdue) return 1;
if (t.isTodayTask) return 0;
return 2; // 예정
}
String _statusSortDate(Todo t) {
if (t.isDone) {
final d = t.doneDate;
if (d != null && d.isNotEmpty) return d;
return t.dueDate ?? t.createdAt ?? '';
}
final c = t.createdAt;
if (c != null && c.isNotEmpty) {
return c.length >= 10 ? c.substring(0, 10) : c;
}
return t.dueDate ?? '';
}
List<Todo> _starFirst(List<Todo> items) {
final out = [...items];
out.sort((a, b) {
final sa = a.isStar ? 1 : 0, sb = b.isStar ? 1 : 0;
if (sa != sb) return sb - sa; // 별표 먼저
final ra = _statusRank(a), rb = _statusRank(b);
if (ra != rb) return ra - rb;
final byDate = _statusSortDate(a).compareTo(_statusSortDate(b));
if (byDate != 0) return byDate;
return (a.id ?? 0).compareTo(b.id ?? 0);
});
return out;
}
Future<List<Todo>> _selectedItems(AppDb db) async {
final iso = isoOf(_selected);
final todayIso = isoOf(DateTime.now());
final now = DateTime.now();
List<Todo> base;
if (iso == todayIso) {
// 오늘 선택 시 '오늘 할 일'(지연 이월 포함). 반복도 실제 행이라 함께 조회됨.
// listByView('today')는 미완료만 가져오므로, 완료 항목은 아래에서 합친다.
base = await db.listByView('today', todayIso);
for (final t in base) {
t.isOverdue = t.dueDate != null && t.dueDate!.compareTo(todayIso) < 0;
t.isTodayTask = !t.isOverdue; // 기한이 오늘(지연 아님) → '오늘'.
}
} else {
// 그 외 날짜: 그 날짜의 실제 할 일(반복 회차 포함).
base = await db.listByDate(iso);
if (iso.compareTo(todayIso) < 0) {
// 지난 날짜: 생성일이 그 날인 할일은 그 칸에 둔다. 기한 날짜로는 옮기지 않는다.
base = base.where((t) {
if (t.isDone) return true;
final created = t.createdAt;
return created != null &&
created.length >= 10 &&
created.substring(0, 10) == iso;
}).toList();
}
}
// 그 날짜의 완료 할일(기한 또는 완료일). 오늘 뷰는 is_done=0 만 조회하므로 여기서 합침.
final seen = base.map((t) => t.id).toSet();
for (final t in await db.doneTodosOnDate(iso)) {
if (seen.add(t.id)) base.add(t);
}
// 비반복 미완료는 cal_visible=false 면 달력에서 제외.
// 완료 기록은 날짜칸 검정 점과 같이 시트에 표시(숨김 반복의 완료 회차 포함).
base = base.where((t) => t.isDone || t.isRepeating || t.calVisible).toList();
// 반복 할일은 '달력에 표시' 여부와 무관하게, 선택한 날짜가 회차면 시트에 표시한다.
// (숨김 반복도 그 날짜를 선택하면 여기서 보이게 됨) — 현재 회차=실제 행과 id 중복이면 제외.
final repAll =
(await db.listRepeating()).where((t) => t.isTodo).toList();
final occ = occurrencesIn(repAll, _selected, _selected,
today: now, expandAll: true)[iso] ??
[];
final baseIds = base.map((t) => t.id).toSet();
// 그 날짜에 같은 내용의 비반복 항목(완료 기록/개별 회차)이 이미 있으면 반복 회차는 표시 안 함.
final occContents =
base.where((t) => !t.isRepeating).map((t) => t.content).toSet();
for (final t in occ) {
if (baseIds.contains(t.id)) continue;
if (occContents.contains(t.content)) continue;
// 가상 회차의 기한을 '그 날짜'로 → 완료 시 그 날짜만 완료 처리하도록.
base.add(t.copy()
..isRecurring = true
..dueDate = iso);
}
return base;
}
Color _dayColor(DateTime d, bool outside) {
if (outside) return Colors.grey.shade400;
if (Holidays.nameOf(isoOf(d)) != null || d.weekday == DateTime.sunday) {
return const Color(0xFFD94C4C);
}
if (d.weekday == DateTime.saturday) return const Color(0xFF3F6FD6);
return const Color(0xFF333333);
}
Widget _cell(DateTime day, {bool outside = false, bool selected = false, bool today = false}) {
final iso = isoOf(day);
final holi = Holidays.nameOf(iso);
final doneCnt = _doneCounts[iso] ?? 0;
return Container(
width: double.infinity, // 셀 전체 폭을 채워 표 격자에 맞게
decoration: BoxDecoration(
color: selected ? const Color(0xFFE9F1FA) : null,
// 표 격자선은 TableCalendar tableBorder가 그림. 선택 칸만 파란 강조 테두리.
border: selected
? Border.all(color: const Color(0xFF3F6FB0), width: 1.6)
: null,
),
child: Stack(
children: [
Column(
children: [
const SizedBox(height: 3),
// 날짜 숫자는 항상 왼쪽 상단(제목/점이 없어도 가운데로 밀리지 않게).
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 2),
child: Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: today
? const BoxDecoration(
color: Color(0xFF4A7FA8), shape: BoxShape.circle)
: null,
child: Text('${day.day}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: today ? Colors.white : _dayColor(day, outside))),
),
),
),
if (holi != null && !outside && !_eventsOnly && !_recordsOnly && !_repeatOnly)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Text(holi,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: Color(0xFFD94C4C))),
),
// 기록: 1개면 제목, 2개+면 '기록 ②' 원숫자. 칸 안에서는 기록 → 일정 → 할일.
if (!outside && !_eventsOnly && !_todosOnly && !_holidaysOnly && (_recordsByDate[iso]?.isNotEmpty ?? false))
_titleLine(_recordsByDate[iso]!, const Color(0xFF7A4FBB), '기록'),
// 일정: 1개면 제목, 2개+면 '일정 ②' 원숫자(전체·일정보기 동일).
if (!outside && !_recordsOnly && !_todosOnly && !_holidaysOnly && (_eventsByDate[iso]?.isNotEmpty ?? false))
_titleLine(_eventsByDate[iso]!, const Color(0xFF0B7A3B), '일정'),
// 할일: 제목 말줄임 + '외 N'(한 줄) — 웹 달력과 동일(빨강). 네이티브 말줄임(TextPainter 미사용).
if (!outside &&
!_eventsOnly &&
!_recordsOnly &&
!_holidaysOnly &&
(_todosByDate[iso]?.isNotEmpty ?? false))
_titleLine(_todosByDate[iso]!, const Color(0xFFE11D1D), '할일'),
// 구글 일정(파랑 점)은 하단 중앙에 그대로 점으로 표시.
if (!outside &&
(_gcal[iso]?.isNotEmpty ?? false) &&
!_recordsOnly &&
!_holidaysOnly)
Padding(
padding: const EdgeInsets.only(top: 1),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [_dot(const Color(0xFF1A73E8))],
),
),
],
),
// 완료된 할일 표시(검정 점) — 날짜칸 우측 상단.
if (!outside &&
doneCnt > 0 &&
!_eventsOnly &&
!_recordsOnly &&
!_holidaysOnly)
Positioned(top: 4, right: 4, child: _dot(Colors.black)),
],
),
);
}
/// 표시 중인 달의 주(행) 수 (일요일 시작 기준, 5 또는 6).
int _weeksInFocusedMonth() {
final firstWeekday = DateTime(_focused.year, _focused.month, 1).weekday % 7;
final daysInMonth = DateTime(_focused.year, _focused.month + 1, 0).day;
return ((firstWeekday + daysInMonth) / 7).ceil();
}
void _changeMonth(int delta) {
// 달력 그리드는 즉시 새 달로 바뀌고, 데이터 로드는 연속 클릭을 묶어 한 번만.
setState(() => _focused = DateTime(_focused.year, _focused.month + delta, 1));
_navDebounce?.cancel();
_navDebounce = Timer(const Duration(milliseconds: 250), _load);
}
void _removeYmOverlay() {
_ymOverlay?.remove();
_ymOverlay = null;
}
// 년/월 텍스트 바로 아래에 붙는 팝오버 + 휠(CupertinoPicker). 바깥 탭/완료 시 닫힘.
void _showYmPopover(GlobalKey anchor, String title, List<int> values,
int current, String Function(int) label, void Function(int) onPicked) {
_removeYmOverlay();
final ac = anchor.currentContext;
if (ac == null) return;
final box = ac.findRenderObject() as RenderBox;
final pos = box.localToGlobal(Offset.zero);
final sz = box.size;
final screenW = MediaQuery.of(context).size.width;
var idx = values.indexOf(current);
if (idx < 0) idx = 0;
var cur = idx;
const popW = 132.0;
var left = pos.dx - 10;
if (left + popW > screenW - 8) left = screenW - 8 - popW;
if (left < 8) left = 8;
_ymOverlay = OverlayEntry(
builder: (_) => Stack(children: [
// 바깥 탭 → 닫기
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _removeYmOverlay,
),
),
Positioned(
left: left,
top: pos.dy + sz.height + 6,
width: popW,
child: Material(
elevation: 12,
shadowColor: Colors.black45,
borderRadius: BorderRadius.circular(14),
color: Colors.white,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(title,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF5F6B7A))),
const SizedBox(height: 2),
SizedBox(
height: 132,
child: CupertinoPicker(
scrollController:
FixedExtentScrollController(initialItem: idx),
itemExtent: 34,
squeeze: 1.15,
selectionOverlay:
const CupertinoPickerDefaultSelectionOverlay(
background: Color(0x1A3F6FB0)),
onSelectedItemChanged: (i) => cur = i,
children: [
for (final v in values)
Center(
child: Text(label(v),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600)),
),
],
),
),
const SizedBox(height: 4),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
final v = values[cur];
_removeYmOverlay();
onPicked(v);
},
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(36),
padding: EdgeInsets.zero),
child: const Text('완료'),
),
),
],
),
),
),
),
]),
);
Overlay.of(context).insert(_ymOverlay!);
}
// 년도 선택(오늘 년도 ±10). 선택 시 해당 년도로 이동(월 유지).
void _pickYear() {
final ny = DateTime.now().year;
_showYmPopover(
_yearKey,
'년도',
[for (var y = ny - _yearSpan; y <= ny + _yearSpan; y++) y],
_focused.year,
(y) => '$y년', (y) {
setState(() => _focused = DateTime(y, _focused.month, 1));
_load();
});
}
// 월 선택(1~12). 선택 시 해당 월로 이동(년 유지).
void _pickMonth() {
_showYmPopover(_monthKey, '월', [for (var m = 1; m <= 12; m++) m],
_focused.month, (m) => '$m월', (m) {
setState(() => _focused = DateTime(_focused.year, m, 1));
_load();
});
}
void _goToday() {
final now = DateTime.now();
setState(() {
_focused = now;
_selected = now;
});
_load();
}
void _applyEditorResult(TodoEditorResult? result) {
if (result == null || !mounted) return;
final k = result.kind;
final hidden = (_eventsOnly && k != 'event') ||
(_recordsOnly && k != 'record') ||
(_todosOnly && k != 'todo');
if (hidden) {
setState(() {
_eventsOnly = k == 'event';
_recordsOnly = k == 'record';
_todosOnly = k == 'todo';
});
}
if (result.focusToday) _goToday();
}
Future<void> _editAndFocusToday({
Todo? todo,
String? kind,
String? defaultDate,
bool defaultMyDay = false,
void Function(String kind)? onKindSelected,
}) async {
_collapseSheet(); // 편집기 열기 전 상세 시트 접기(폼 가림 방지).
final result = await showTodoEditor(context,
todo: todo,
kind: kind,
defaultDate: defaultDate,
defaultMyDay: defaultMyDay,
onKindSelected: onKindSelected);
_applyEditorResult(result);
}
// 달력 뷰 토글 버튼(컴팩트). active 면 색 채움. 년월 행 오른쪽 끝에 4개 나열.
Widget _modeBtn(String label, bool active, Color activeBg, VoidCallback onTap) {
return Padding(
padding: const EdgeInsets.only(left: 5),
child: OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6)), // 네모+약간 라운드
backgroundColor: active ? activeBg : null,
foregroundColor: active ? Colors.white : null,
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
child: Text(label),
),
);
}
Widget _sectionHeader(String label, Color color, IconData icon,
{Widget? trailing}) =>
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
child: Row(children: [
Icon(icon, size: 15, color: color),
const SizedBox(width: 5),
Text(label,
style: TextStyle(
color: color, fontWeight: FontWeight.bold, fontSize: 13)),
if (trailing != null) ...[const Spacer(), trailing],
]),
);
// 구글 일정을 선택 유형(할일/일정/기록)으로 tb_todo 에 편입(복사).
// 구글 원본은 읽기 전용이라 그대로 두고, 내 데이터로 새 행을 INSERT.
Future<void> _addGEventAs(GEvent g, String iso, String kind) async {
final t = Todo(
content: g.title.trim().isEmpty ? '(제목 없음)' : g.title.trim(),
dueDate: iso,
isTodo: kind == 'todo',
onCalendar: kind == 'event',
isRecord: kind == 'record',
// 할일은 시각 개념이 없어 비움. 일정/기록만 구글 시각을 보존.
eventTime: (kind == 'todo' || g.time.isEmpty) ? null : g.time,
repeatInterval: 1,
);
final id = await AppDb.instance.insert(t);
await GcalService.hide(iso, g); // 편입한 구글 일정은 목록에서 숨김(저장)
bumpData();
if (!mounted) return;
final label = kind == 'todo' ? '할일' : (kind == 'event' ? '일정' : '기록');
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('"${t.content}"을(를) $label에 추가했어요'),
action: SnackBarAction(
label: '실행취소',
onPressed: () async {
await AppDb.instance.delete(id);
await GcalService.unhide(iso, g); // 숨김 해제 → 구글 일정 복원
bumpData();
},
),
));
}
// 구글 일정 행에 붙는 편입 버튼(할일/일정/기록).
Widget _gImportBtn(
GEvent g, String iso, String kind, String label, Color c) =>
Padding(
padding: const EdgeInsets.only(left: 2),
child: OutlinedButton(
onPressed: () => _addGEventAs(g, iso, kind),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 30),
padding: const EdgeInsets.symmetric(horizontal: 8),
foregroundColor: c,
side: BorderSide(color: c),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
child: Text(label, style: const TextStyle(fontSize: 11)),
),
);
// 구글 일정 새로고침: 캐시 무효화 후 .ics 피드 재조회 → 구글에서 삭제된(전환 안 한) 일정은
// 사라지고, 아직 구글에 있는 일정은 그대로 유지된다.
// (구글 비공개 .ics는 삭제 반영이 지연될 수 있어, 그 지연만큼 늦게 반영됨)
Future<void> _refreshGEvents() async {
GcalService.invalidateCache();
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('구글 일정을 새로고침했어요'),
duration: Duration(seconds: 2),
));
}
// 셀 한 줄: 항목 1개면 제목 말줄임(기존), 2개 이상이면 '라벨 + 개수 원'(타입 색).
Widget _titleLine(List<String> items, Color color, String label) {
final style = TextStyle(fontSize: 12, color: color);
if (items.length <= 1) {
// 1개: 기존대로 제목 말줄임(네이티브 말줄임, TextPainter 미사용).
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(items.first,
maxLines: 1,
softWrap: false,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: style),
);
}
// 2개 이상: '할일 ③'처럼 라벨 + 개수 동그라미(타입 색).
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: TextStyle(
fontSize: 12, color: color, fontWeight: FontWeight.w500)),
const SizedBox(width: 3),
Container(
width: 16,
height: 16,
alignment: Alignment.center,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
child: Text('${items.length}',
style: const TextStyle(
fontSize: 10,
height: 1.0,
color: Colors.white,
fontWeight: FontWeight.bold)),
),
],
),
);
}
Widget _dot(Color c) => Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
width: 5,
height: 5,
decoration: BoxDecoration(color: c, shape: BoxShape.circle),
);
@override
Widget build(BuildContext context) {
final selIso = isoOf(_selected);
final selHoli = Holidays.nameOf(selIso);
final gcalSel = _gcal[selIso] ?? [];
final selPast = selIso.compareTo(isoOf(DateTime.now())) < 0;
// 하단 시트 섹션 표시 범위(웹과 동일): 일정보기=일정만, 기록보기=기록만, 할일보기=할일만.
final showEvents = !_recordsOnly && !_todosOnly && !_holidaysOnly;
final showRecords = !_eventsOnly && !_todosOnly && !_holidaysOnly;
final showTodos = !_eventsOnly && !_recordsOnly && !_holidaysOnly;
final selCount = _holidaysOnly
? 0
: _repeatOnly
? _selItems.length + _selEvents.length // 반복보기: 반복 할일+일정
: _eventsOnly
? _selEvents.length
: _recordsOnly
? _selRecords.length
: _selItems.length;
// 모든 보기가 셀에서 '제목/원숫자' 한 줄로 표시되므로 행높이는 화면맞춤(fitRow) 기준 고정값.
const double calRowHeight = 80.0;
return Scaffold(
body: LayoutBuilder(builder: (context, constraints) {
final bodyH = constraints.maxHeight;
// 달력 실제 높이 ≈ 여백/헤더/요일줄 + (주 수 × 행높이). 시트는 그 아래에서 시작.
// 실제 달력 높이(측정값) 우선, 첫 프레임 전엔 추정치 사용.
// 달력 높이는 결정적 추정치(여백/헤더/요일줄 + 주 수 × 행높이)로 계산.
// (실측→setState→재빌드 루프를 제거해 월 이동/동기화와 겹칠 때 화면이 흔들리지 않게 함.)
// 달력은 자연 높이(주 수 × 행높이)로 두고, 접힌 시트를 그 '아래'에 배치.
// 짧은 화면이면 시트 최소 높이(헤더)를 확보하고 달력이 그만큼 줄어듦.
final weeks = _weeksInFocusedMonth();
const collapsedSheetH = 84.0; // 접힌 시트: '날짜·건수' 헤더만 보이는 최소 높이
// 접힌 시트는 헤더(날짜·건수)만 보이는 최소 높이로 고정 → 달력이 나머지 세로
// 공간을 최대한 차지해 행 높이를 최대화(그 달 주 수 5/6에 따라 자동으로 조절됨).
final collapsedFrac = (bodyH > 0 ? collapsedSheetH / bodyH : 0.12)
.clamp(0.06, 0.5)
.toDouble();
final sheetInit = collapsedFrac; // 접힌 시트는 달력 바로 아래(최소 높이)에서 시작
// 월 이동으로 주 수(5↔6)가 바뀌어 접힘 위치가 달라지면, 접힘 상태의 시트를
// 새 위치로 자동 이동(6주차를 덮지 않게). 상세를 펼친 상태(≈collapsedFrac+0.12↑)면 유지.
if ((collapsedFrac - _lastCollapsedFrac).abs() > 0.001) {
_lastCollapsedFrac = collapsedFrac;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_sheetCtl.isAttached) return;
final raised = _sheetCtl.size > collapsedFrac + 0.12; // 상세 펼침 상태면 건드리지 않음
if (raised) return;
if (_sheetCtl.size > collapsedFrac + 0.002) {
// 시트가 달력(6주차)을 덮음(5→6주) → 부드럽게 내림.
_sheetCtl.animateTo(collapsedFrac,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut);
} else if (_sheetCtl.size < collapsedFrac - 0.002) {
// 접힘 위치가 위로 올라감(6→5주) → 즉시 스냅(카드 흰 배경이 뒤를 채워 '띠' 안 보임).
_sheetCtl.jumpTo(collapsedFrac);
}
});
}
return Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
bottom: bodyH * collapsedFrac, // 접힌 시트 높이만큼 = 달력 아래에서 시작
child: Card(
margin: const EdgeInsets.all(8),
child: Column(
children: [
// 커스텀 헤더 1줄: ‹ 2026년 8월 › …오른쪽 끝… [전체][할일][일정][기록][반복][공휴일]
Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(6),
onPressed: () => _changeMonth(-1),
),
// 년/월 탭 → 바로 아래 팝오버로 휠 선택(앞뒤 버튼 반복 불필요).
InkWell(
key: _yearKey,
onTap: _pickYear,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 4),
child: Text('${_focused.year}년',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
),
),
InkWell(
key: _monthKey,
onTap: _pickMonth,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 4),
child: Text('${_focused.month}월',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(6),
onPressed: () => _changeMonth(1),
),
// 남는 폭에 맞춰 버튼 4개를 오른쪽 정렬 + 좁으면 축소(overflow 방지).
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 전체 = 필터 해제 + 오늘 달로 이동(이전 '모두').
_modeBtn(
'전체',
!_eventsOnly &&
!_recordsOnly &&
!_todosOnly &&
!_holidaysOnly &&
!_repeatOnly,
const Color(0xFF3F6FB0), () {
setState(() {
_eventsOnly = false;
_recordsOnly = false;
_todosOnly = false;
_holidaysOnly = false;
_repeatOnly = false;
});
_goToday();
}),
_modeBtn(
'할일', _todosOnly, const Color(0xFFE11D1D), () {
setState(() {
_todosOnly = true;
_eventsOnly = false;
_recordsOnly = false;
_holidaysOnly = false;
_repeatOnly = false;
});
_goToday(); // 클릭 시 오늘 날짜로 이동(웹과 동일)
}),
_modeBtn(
'일정', _eventsOnly, const Color(0xFF0B7A3B),
() {
setState(() {
_eventsOnly = !_eventsOnly;
if (_eventsOnly) {
_recordsOnly = false;
_todosOnly = false;
_holidaysOnly = false;
_repeatOnly = false;
}
});
_load();
}),
_modeBtn(
'기록', _recordsOnly, const Color(0xFF7A4FBB),
() {
setState(() {
_recordsOnly = !_recordsOnly;
if (_recordsOnly) {
_eventsOnly = false;
_todosOnly = false;
_holidaysOnly = false;
_repeatOnly = false;
}
});
_load();
}),
// 반복: 그 달 반복 항목만(공휴일과 동일 조회 모드). cal_visible 무관.
_modeBtn(
'반복', _repeatOnly, const Color(0xFF00838F),
() {
setState(() {
_repeatOnly = !_repeatOnly;
if (_repeatOnly) {
_eventsOnly = false;
_recordsOnly = false;
_todosOnly = false;
_holidaysOnly = false;
}
});
_load();
}),
// 공휴일: 달력에 공휴일만. 현재 달 유지.
_modeBtn(
'공휴일', _holidaysOnly, const Color(0xFFD94C4C),
() {
setState(() {
_holidaysOnly = !_holidaysOnly;
if (_holidaysOnly) {
_eventsOnly = false;
_recordsOnly = false;
_todosOnly = false;
_repeatOnly = false;
}
});
_load();
}),
],
),
),
),
],
),
),
Expanded(
child: LayoutBuilder(builder: (ctx, cc) {
const dowH = 22.0;
// 요일줄(dowH) + 표 가로 테두리(행마다 1px씩) + 여유를 빼야 그리드가
// 배정 높이를 넘지 않음(안 빼면 하단에 노랑·검정 오버플로 '띠' 발생).
final fitRow = weeks > 0
? (cc.maxHeight - dowH - weeks - 18) / weeks
: calRowHeight;
// 모든 보기가 셀 한 줄 표시 → 행을 fitRow 로 채워 항상 화면에 맞춤(6주도 안 잘림).
var row = fitRow;
if (!row.isFinite || row <= 0) row = 80.0;
final cal = MediaQuery(
data: MediaQuery.of(context)
.copyWith(textScaler: TextScaler.noScaling),
child: TableCalendar(
firstDay: DateTime(DateTime.now().year - _yearSpan),
lastDay: DateTime(DateTime.now().year + _yearSpan, 12, 31),
focusedDay: _focused,
rowHeight: row, // fitRow 로 채우되 내용 많으면 그 이상
daysOfWeekHeight: 22, // 요일 글자(15px) 아래가 날짜칸에 가리지 않게(기본 16→22)
startingDayOfWeek: StartingDayOfWeek.sunday,
selectedDayPredicate: (d) => isSameDay(d, _selected),
headerVisible: false,
// 날짜칸을 표(행·열) 격자로: 셀 여백 제거 + 표 테두리(옅은 진회색).
calendarStyle: const CalendarStyle(
cellMargin: EdgeInsets.zero,
cellPadding: EdgeInsets.zero,
tableBorder: TableBorder(
top: BorderSide(color: Color(0xFF9AA5B1)),
bottom: BorderSide(color: Color(0xFF9AA5B1)),
left: BorderSide(color: Color(0xFF9AA5B1)),
right: BorderSide(color: Color(0xFF9AA5B1)),
horizontalInside: BorderSide(color: Color(0xFF9AA5B1)),
verticalInside: BorderSide(color: Color(0xFF9AA5B1)),
),
),
availableGestures: AvailableGestures.horizontalSwipe,
onDaySelected: (sel, foc) {
setState(() {
_selected = sel;
_focused = foc;
});
_raiseAfterLoad = true;
_load();
// 내용(공휴일/일정/기록/할일/완료/구글)이 있는 날에만 시트를 올림.
final si = isoOf(sel);
final hasContent = Holidays.nameOf(si) != null ||
(_eventsByDate[si]?.isNotEmpty ?? false) ||
(_recordsByDate[si]?.isNotEmpty ?? false) ||
(_todosByDate[si]?.isNotEmpty ?? false) ||
((_doneCounts[si] ?? 0) > 0) ||
(_gcal[si]?.isNotEmpty ?? false);
if (hasContent) _raiseSheet(); // 내용 있을 때만 위로 올림
},
onPageChanged: (foc) {
_focused = foc;
_load();
},
calendarBuilders: CalendarBuilders(
defaultBuilder: (c, d, f) => _cell(d),
outsideBuilder: (c, d, f) => _cell(d, outside: true),
todayBuilder: (c, d, f) => _cell(d, today: true),
selectedBuilder: (c, d, f) =>
_cell(d, selected: true, today: isSameDay(d, DateTime.now())),
dowBuilder: (c, d) {
const names = ['일', '월', '화', '수', '목', '금', '토'];
final w = d.weekday % 7; // Sun=0
return Center(
child: Text(names[w],
style: TextStyle(
fontSize: 15,
color: w == 0
? const Color(0xFFD94C4C)
: w == 6
? const Color(0xFF3F6FD6)
: Colors.black87)),
);
},
),
),
);
return row > fitRow
? SingleChildScrollView(child: cal)
: cal;
}),
),
],
),
),
),
// 하단 시트: 선택 날짜의 공휴일·일정·할일·구글(드래그로 크기 조절).
// key는 '모드(행높이)'만 기준 → 월 이동(5↔6주)엔 재생성 안 함(깜박임 방지).
// 일정/기록보기처럼 달력 높이가 크게 달라질 때만 재배치.
DraggableScrollableSheet(
key: const ValueKey('calendar-sheet'), // 고정 → 행높이 변화에도 재생성 안 함(NaN 방지)
controller: _sheetCtl,
initialChildSize: sheetInit,
minChildSize: collapsedFrac,
maxChildSize: 0.9,
builder: (ctx, sc) => Container(
decoration: const BoxDecoration(
color: Color(0xFFF7F9FC),
borderRadius:
BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 8,
offset: Offset(0, -2)),
],
),
child: Stack(children: [
ListView(
controller: sc,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
children: [
// 드래그 핸들: 세로 드래그로 시트 높이 직접 조절(터치·마우스 모두).
// (DraggableScrollableSheet는 데스크톱 마우스 드래그로는 안 움직여 핸들에 제스처 부여)
GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: (d) {
if (!_sheetCtl.isAttached) return;
final h = bodyH <= 0 ? 1.0 : bodyH;
final s =
(_sheetCtl.size - d.delta.dy / h)
.clamp(collapsedFrac, 0.9);
_sheetCtl.jumpTo(s);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
),
),
),
const SizedBox(height: 4),
GestureDetector(
onTap: _toggleSheet,
behavior: HitTestBehavior.opaque,
child: Text('$selIso ($selCount건)',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 4),
if (selHoli != null && !_repeatOnly)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
const Icon(Icons.flag,
size: 16, color: Color(0xFFD94C4C)),
const SizedBox(width: 6),
Text(selHoli,
style: const TextStyle(
color: Color(0xFFD94C4C),
fontWeight: FontWeight.w600)),
]),
),
// 공휴일보기: 공휴일 날짜에만 '메모만' 편집(별도 저장). 아니면 안내.
if (_holidaysOnly)
if (selHoli != null) ...[
const SizedBox(height: 8),
TextField(
controller: _holidayMemoCtl,
minLines: 3,
maxLines: 8,
decoration: const InputDecoration(
labelText: '공휴일 메모',
border: OutlineInputBorder()),
),
const SizedBox(height: 10),
Row(children: [
Expanded(
child: OutlinedButton(
onPressed: _clearHolidayMemo,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48)),
child: const Text('지우기'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: _saveHolidayMemo,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48)),
child: const Text('저장'),
),
),
]),
] else
const Padding(
padding: EdgeInsets.only(top: 12),
child: Text('공휴일이 아닌 날짜입니다.',
style: TextStyle(color: Colors.grey)),
),
if (showTodos && gcalSel.isNotEmpty) ...[
_sectionHeader('구글 일정', const Color(0xFF1A73E8), Icons.event,
trailing: InkWell(
onTap: _refreshGEvents,
borderRadius: BorderRadius.circular(4),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.refresh,
size: 15, color: Color(0xFF1A73E8)),
SizedBox(width: 3),
Text('새로고침',
style: TextStyle(
fontSize: 12, color: Color(0xFF1A73E8))),
]),
),
)),
...gcalSel.map((g) => Padding(
padding:
const EdgeInsets.symmetric(vertical: 6),
child: Row(children: [
const Icon(Icons.circle,
size: 8, color: Color(0xFF1A73E8)),
const SizedBox(width: 8),
Expanded(
child: Text(g.title,
maxLines: 2,
overflow: TextOverflow.ellipsis)),
if (g.time.isNotEmpty)
Text(g.time,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF1A73E8))),
const SizedBox(width: 4),
// 선택 유형으로 tb_todo 에 편입하는 버튼.
_gImportBtn(g, selIso, 'todo', '할일',
const Color(0xFF3F6FB0)),
_gImportBtn(g, selIso, 'event', '일정',
const Color(0xFF0B7A3B)),
_gImportBtn(g, selIso, 'record', '기록',
const Color(0xFF7A4FBB)),
]),
)),
],
if (showEvents && _selEvents.isNotEmpty) ...[
_sectionHeader('일정', const Color(0xFF0B7A3B), Icons.event),
..._selEvents.map((t) => InkWell(
onTap: () => _editAndFocusToday(
todo: t, kind: 'event'),
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 6),
child: Row(children: [
const Icon(Icons.circle,
size: 8, color: Color(0xFF0B7A3B)),
const SizedBox(width: 8),
if (t.eventTime != null && t.eventTime!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 6),
child: Text(t.eventTime!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF0B7A3B))),
),
Expanded(
child: Text.rich(
TextSpan(
style: TextStyle(
decoration: selPast
? TextDecoration.lineThrough
: null,
decorationColor:
const Color(0xFFCFD3D8),
decorationThickness: 1.0,
color: selPast
? const Color(0xFF4D4D4D)
: null,
),
children: [
TextSpan(text: t.content),
if (t.isStar)
const WidgetSpan(
alignment:
PlaceholderAlignment.middle,
child: Padding(
padding: EdgeInsets.only(left: 4),
child: Icon(Icons.star,
size: 16,
color: Color(0xFFF5A623)),
),
),
],
),
overflow: TextOverflow.ellipsis,
),
),
if (t.isRepeating)
const Icon(Icons.repeat,
size: 13, color: Color(0xFF0B7A3B)),
]),
),
)),
],
if (showRecords && _selRecords.isNotEmpty) ...[
_sectionHeader('기록', const Color(0xFFE11D1D), Icons.description),
..._selRecords.map((t) => InkWell(
onTap: () => _editAndFocusToday(
todo: t, kind: 'record'),
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 6),
child: Row(children: [
const Icon(Icons.circle,
size: 8, color: Color(0xFFE11D1D)),
const SizedBox(width: 8),
if (t.eventTime != null && t.eventTime!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 6),
child: Text(t.eventTime!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF7A4FBB))),
),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(text: t.content),
if (t.isStar)
const WidgetSpan(
alignment:
PlaceholderAlignment.middle,
child: Padding(
padding: EdgeInsets.only(left: 4),
child: Icon(Icons.star,
size: 16,
color: Color(0xFFF5A623)),
),
),
],
),
overflow: TextOverflow.ellipsis,
),
),
]),
),
)),
],
if (showTodos) ...[
_sectionHeader('할 일', const Color(0xFF3F6FB0), Icons.check_circle),
if (_selItems.isEmpty)
const Padding(
padding: EdgeInsets.fromLTRB(4, 4, 4, 0),
child: Text('할 일 없음',
style: TextStyle(
color: Colors.grey, fontSize: 13)),
)
else
..._selItems.map((t) => TodoTile(
todo: t,
onTap: () => _editAndFocusToday(todo: t),
// 전개 반복 회차(가상)는 '그 날짜만' 완료 처리.
onToggleDone: t.isRecurring
? () => AppDb.instance.completeRepeatOccurrence(
t, t.dueDate!, isoOf(DateTime.now()))
: null,
// 휴지통 삭제 — 편집기 삭제 버튼과 동일 플로우.
onDelete: () => deleteTodoFlow(context, t),
)),
],
],
),
// 상세 시트 닫기(X): 접힌 위치(핸들만)로 부드럽게 내림.
Positioned(
top: 2,
right: 2,
child: IconButton(
icon: const Icon(Icons.close),
iconSize: 20,
tooltip: '닫기',
onPressed: () {
if (_sheetCtl.isAttached) {
_sheetCtl.animateTo(0.06,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut);
}
},
),
),
]),
),
),
],
);
}),
// 공휴일·반복보기는 조회 전용 → 추가 버튼 숨김.
floatingActionButton: (_holidaysOnly || _repeatOnly)
? null
: FloatingActionButton(
// 어느 보기에서든 통합 입력 폼(상단 유형 선택기 포함)으로 연다.
// 폼에서 유형을 고르거나 저장하면, 그 유형이 현재 보기에 안 보일 때
// 해당 보기로 자동 전환 → 방금 입력한 항목이 바로 보이게.
onPressed: () async {
_collapseSheet(); // 입력 폼 열기 전 상세 시트 접기(폼 가림 방지).
// 편집기에서 유형(할일/일정/기록)을 '선택'하면 달력 '보기' 버튼도 그 유형으로 즉시 전환.
void syncViewToKind(String k) {
if (!mounted) return;
setState(() {
_eventsOnly = k == 'event';
_recordsOnly = k == 'record';
_todosOnly = k == 'todo';
_holidaysOnly = false;
_repeatOnly = false;
});
}
// 저장 시엔 저장 유형이 현재 보기에 '안 보일 때만' 그 보기로 전환(전체는 유지).
void showIfHidden(String k) {
if (!mounted) return;
final hidden = (_eventsOnly && k != 'event') ||
(_recordsOnly && k != 'record') ||
(_todosOnly && k != 'todo');
if (hidden) syncViewToKind(k);
}
// 현재 보기(일정/기록/할일)의 유형이 선택된 채로 폼을 연다.
// 전체 보기도 기본 유형은 할일(defaultMyDay만 할일보기에서 true).
final addKind = _eventsOnly
? 'event'
: _recordsOnly
? 'record'
: 'todo';
final saved = await showTodoEditor(context,
defaultDate: selIso,
kind: addKind,
defaultMyDay: _todosOnly,
onKindSelected: syncViewToKind);
if (saved != null) {
showIfHidden(saved.kind);
if (saved.focusToday) _goToday();
}
},
child: const Icon(Icons.add),
),
);
}
Future<void> _saveHolidayMemo() async {
await AppDb.instance.setHolidayMemo(isoOf(_selected), _holidayMemoCtl.text);
if (!mounted) return;
FocusScope.of(context).unfocus();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('공휴일 메모를 저장했어요')));
}
// 지우기: 메모 초기화 + 즉시 저장(빈 메모 → 삭제).
Future<void> _clearHolidayMemo() async {
_holidayMemoCtl.clear();
await AppDb.instance.setHolidayMemo(isoOf(_selected), '');
if (!mounted) return;
FocusScope.of(context).unfocus();
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('공휴일 메모를 지웠어요')));
}
}
lib/widgets/todo_tile.dart
import 'package:flutter/material.dart';
import '../db/app_db.dart';
import '../models/todo.dart';
import '../state.dart';
/// 목록의 할 일 한 줄. 왼쪽 완료 토글, 제목/기한/표시, 오른쪽 별표.
class TodoTile extends StatelessWidget {
final Todo todo;
final VoidCallback onTap;
final bool showStatus; // 기록은 상태 없음 → false 로 태그 숨김
final bool showDday; // 목록(오늘/전체 뷰)에서 상태 다음에 D-DAY 배지 표시
final Future<void> Function()? onToggleDone; // 완료 토글 대체(전개 반복 회차: 그 날짜만 완료)
final Future<void> Function()? onDelete; // 리스트 휴지통 삭제(편집기 삭제와 동일 플로우)
const TodoTile(
{super.key,
required this.todo,
required this.onTap,
this.showStatus = true,
this.showDday = false,
this.onToggleDone,
this.onDelete});
@override
Widget build(BuildContext context) {
final t = todo;
final listDday = _listDdayBadge();
return ListTile(
dense: true,
// 제목이 여러 줄이어도 상태태그·완료 체크박스·휴지통을 세로 가운데 정렬.
titleAlignment: ListTileTitleAlignment.center,
// 바텀시트 좌우 여백 축소(넓히되 가장자리에 살짝 마진).
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
horizontalTitleGap: 4,
title: Row(
// 상태 태그·제목을 세로 가운데 정렬. 긴 제목은 여러 줄로 wrap(잘림 없음).
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 완료원 대신 상태 태그가 토글: 완료→예정, 예정/오늘/지연→완료. (기록은 숨김)
if (showStatus) _statusTag(),
// 오늘/전체 목록: 상태 다음에 D-DAY 배지(오늘=D-DAY·미래=D-n·지난=D+n).
?listDday,
Flexible(
child: Text.rich(
TextSpan(
style: TextStyle(
fontSize: 16,
height: 1.25,
fontWeight: FontWeight.w400,
color: t.isDone
? const Color(0xFF4D4D4D)
: const Color(0xFF242424),
decoration: t.isDone ? TextDecoration.lineThrough : null,
decorationColor: const Color(0xFFCFD3D8),
decorationThickness: 1.0,
),
children: [
TextSpan(text: t.content),
if (t.isStar)
const WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: EdgeInsets.only(left: 4),
child: Icon(Icons.star,
size: 16, color: Color(0xFFF5A623)),
),
),
],
),
softWrap: true,
),
),
],
),
subtitle: _sub(t),
// 별표는 FAB(편집기)로 이동 → 타일 오른쪽 끝엔 '완료' 라벨 + 체크박스만.
trailing: showStatus
? Row(
mainAxisSize: MainAxisSize.min,
children: [
// 상태 태그와 동일한 완료 토글: 체크→완료, 해제→원래 상태.
const Text('완료',
style: TextStyle(fontSize: 12, color: Color(0xFF7A7F85))),
Checkbox(
value: t.isDone,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: (t.id == null && onToggleDone == null)
? null
: (_) => _toggleDone(),
),
// 완료 체크박스 뒤 휴지통 — 편집기 삭제 버튼과 동일한 삭제 플로우.
if (onDelete != null)
IconButton(
icon: const Icon(Icons.delete_outline,
size: 20, color: Color(0xFF9AA0A6)),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: '삭제',
onPressed: () => onDelete!(),
),
],
)
: null,
onTap: onTap,
);
}
// 완료 토글: 상태 태그와 체크박스가 공유. 전개 반복 회차는 onToggleDone(그 날짜만) 사용.
Future<void> _toggleDone() async {
final t = todo;
if (onToggleDone != null) {
await onToggleDone!();
} else {
await AppDb.instance.toggleDoneSmart(t.id!, isoOf(DateTime.now()));
}
bumpData();
}
// 상태 태그(완료/지연/오늘/예정) — 완료원 대체. 탭하면 완료↔미완료 토글.
Widget _statusTag() {
final t = todo;
final String text;
final Color bg, border, fg;
if (t.isDone) {
text = '완료';
bg = const Color(0xFFEDEDED);
border = const Color(0xFFD5D5D5);
fg = const Color(0xFF7A7F85);
} else if (t.isOverdue) {
text = '지연';
bg = const Color(0xFFFDEAEA);
border = const Color(0xFFF2B9B9);
fg = const Color(0xFFD94C4C);
} else if (t.isTodayTask) {
text = '오늘';
bg = const Color(0xFFE5F5EA);
border = const Color(0xFFAEDCBF);
fg = const Color(0xFF0B9D4A);
} else {
text = '예정';
bg = const Color(0xFFEAF1FA);
border = const Color(0xFFC3D5EC);
fg = const Color(0xFF3F6FB0);
}
return InkWell(
borderRadius: BorderRadius.circular(6),
onTap: (t.id == null && onToggleDone == null) ? null : _toggleDone,
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: border),
),
child: Text(text,
style:
TextStyle(fontSize: 13, color: fg, fontWeight: FontWeight.bold)),
),
);
}
// 오늘/전체 목록에서 상태 태그 다음 D-DAY 배지. 오늘=D-DAY(초록)·미래=D-n(회색)·지난=D+n(빨강).
Widget? _listDdayBadge() {
if (!showDday || todo.isDone || todo.dueDate == null) return null;
final due = DateTime.tryParse(todo.dueDate!);
if (due == null) return null;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final d0 = DateTime(due.year, due.month, due.day);
final n = d0.difference(today).inDays;
if (n == 0) return null; // 오늘(D-DAY)은 표시 안 함
final String text;
final Color bg, border, fg;
if (n > 0) {
text = 'D-$n';
bg = const Color(0xFFEEF1F6);
border = const Color(0xFFD6DCE6);
fg = const Color(0xFF4F5C72);
} else {
text = 'D+${-n}';
bg = const Color(0xFFFDEAEA);
border = const Color(0xFFF2B9B9);
fg = const Color(0xFFD94C4C);
}
return Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: border),
),
child: Text(text,
style:
TextStyle(fontSize: 11, color: fg, fontWeight: FontWeight.bold)),
);
}
Widget? _sub(Todo t) {
const style = TextStyle(fontSize: 12, color: Colors.grey);
// 이모지 대신 Material 아이콘(폰트 미탑재 이모지 '?' 두부 방지).
Widget token(IconData ic, [String s = '']) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(ic, size: 13, color: Colors.grey),
if (s.isNotEmpty) const SizedBox(width: 2),
if (s.isNotEmpty) Text(s, style: style),
],
);
// 날짜(생성/기한/완료)는 한 줄에 함께 표시(넘치면 …로 줄임).
final dateParts = <String>[];
if (t.createdAt != null && t.createdAt!.length >= 10) {
dateParts.add('생성 ${t.createdAt!.substring(0, 10)}');
}
final showDone = t.isDone && (t.doneDate?.isNotEmpty ?? false);
if (t.dueDate != null) {
dateParts.add('기한 ${t.dueDate!}');
}
if (showDone) dateParts.add('완료 ${t.doneDate!}');
// 아이콘 토큰(미리알림/반복/오늘/일정)은 날짜 줄 아래에 wrap.
final icons = <Widget>[];
if (t.remindTime != null) {
icons.add(token(Icons.notifications_none, t.remindTime!));
}
if (t.repeatUnit != null) {
icons.add(token(Icons.repeat, kRepeatLabels[t.repeatUnit] ?? ''));
}
if (t.isMyDay) icons.add(token(Icons.wb_sunny_outlined));
if (t.onCalendar) icons.add(token(Icons.event));
if (dateParts.isEmpty && icons.isEmpty) return null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (dateParts.isNotEmpty)
Text(dateParts.join(' '),
style: style, maxLines: 1, overflow: TextOverflow.ellipsis),
if (icons.isNotEmpty)
Wrap(
spacing: 10,
runSpacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
children: icons),
],
);
}
}
lib/widgets/todo_edit_sheet.dart
import 'package:flutter/material.dart';
import '../db/app_db.dart';
import '../logic/recurrence.dart';
import '../models/todo.dart';
import '../services/notify.dart';
import '../state.dart';
/// 저장 결과 — 달력 보기 전환 + (날짜 조정 시) 오늘 포커스.
class TodoEditorResult {
final String kind; // 'todo' | 'event' | 'record'
final bool focusToday;
const TodoEditorResult(this.kind, {this.focusToday = false});
}
/// 추가/수정 바텀시트. 저장/삭제 시 DB 반영 + 알림 예약 + bumpData().
///
/// kind: 'event'(일정) | 'record'(기록) | 'todo'(할일) | null(자동 판별).
/// - 일정 편집기: 제목 + 생성일 + 일정 시간 + 미리알림 + 메모 (기한 없음)
/// - 기록 편집기: 제목 + 일정 시간 + 기한 + 메모 (미리알림 없음)
/// - 할일 편집기: 기존(나의 하루·미리알림·반복). allowConvert 면 '일정만/기록만' 노출.
/// simple(구): true 면 일정/기록 편집으로 자동 매핑(하위호환).
Future<TodoEditorResult?> showTodoEditor(BuildContext context,
{Todo? todo,
String? defaultDate,
bool simple = false,
String? kind,
bool allowConvert = true,
bool defaultMyDay = false,
void Function(String kind)? onKindSelected}) async {
return await showModalBottomSheet<TodoEditorResult>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 닫기(X): 광고 위 별도 줄 + 네모 박스(오른쪽).
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 2, 12, 0),
child: InkWell(
onTap: () => Navigator.pop(ctx),
borderRadius: BorderRadius.circular(6),
child: Container(
width: 30,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFCFD3D8)),
borderRadius: BorderRadius.circular(6),
),
child: const Icon(Icons.close, size: 18),
),
),
),
),
Flexible(
child: _TodoEditor(
todo: todo,
defaultDate: defaultDate,
simple: simple,
kind: kind,
allowConvert: allowConvert,
defaultMyDay: defaultMyDay,
onKindSelected: onKindSelected),
),
],
),
),
);
}
// ── 삭제 플로우(편집기 삭제 버튼·리스트 휴지통 공용) ─────────────────────────
// 반복/겸용/완료/지난일정 분기를 한곳에 모아, 편집기 삭제와 '똑같이' 동작하게 한다.
// 삭제/변경되면 true. (Navigator.pop 은 호출측에서 처리)
Future<bool> _confirmDelDialog(BuildContext context, String msg) async {
final r = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
content: Text(msg),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('취소')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true), child: const Text('삭제')),
],
),
);
return r == true;
}
bool _repeatHasPastOf(Todo t) {
final start = DateTime.tryParse(t.dueDate ?? '');
if (start == null) return false;
final n = DateTime.now();
final yesterday =
DateTime(n.year, n.month, n.day).subtract(const Duration(days: 1));
final until =
t.repeatUntil == null ? null : DateTime.tryParse(t.repeatUntil!);
return latestOccurrenceOnOrBefore(
start, t.repeatUnit, t.repeatInterval, yesterday,
until: until) !=
null;
}
Future<bool> _deleteRepeatEventFlow(BuildContext context, Todo t) async {
final hasPast = _repeatHasPastOf(t);
final ok = await _confirmDelDialog(
context,
hasPast
? '이후 반복을 중단합니다. 지난 일정은 반복 없는 개별 일정으로 남습니다.\n계속할까요?'
: '이 반복 일정을 삭제할까요?');
if (!ok) return false;
if (t.isDone) {
await AppDb.instance.stopRepeatTodo(t);
} else if (hasPast) {
final n = DateTime.now();
final y =
DateTime(n.year, n.month, n.day).subtract(const Duration(days: 1));
await AppDb.instance.detachPastRepeatEvent(t, y);
} else {
await AppDb.instance.deleteIncomplete(t.id!);
}
await NotifyService.instance.cancel(t.id!);
bumpData();
return true;
}
Future<bool> _deleteWithChoiceFlow(BuildContext context, Todo t) async {
bool rmCal = true, rmTodo = true;
final res = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setD) => AlertDialog(
title: const Text('삭제 대상 선택'),
content: Column(mainAxisSize: MainAxisSize.min, children: [
CheckboxListTile(
value: rmCal,
onChanged: (v) => setD(() => rmCal = v ?? false),
title: const Text('일정(달력)'),
dense: true),
CheckboxListTile(
value: rmTodo,
onChanged: (v) => setD(() => rmTodo = v ?? false),
title: const Text('할일'),
dense: true),
]),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('취소')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('삭제')),
],
),
),
);
if (res != true || (!rmCal && !rmTodo)) return false;
final newCal = t.onCalendar && !rmCal;
final newTodo = t.isTodo && !rmTodo;
if (!newCal && !newTodo) {
await AppDb.instance.delete(t.id!);
await NotifyService.instance.cancel(t.id!);
} else {
await AppDb.instance.setPresence(t.id!, newCal, newTodo);
}
bumpData();
return true;
}
/// 항목 삭제(편집기 삭제 버튼·리스트 휴지통 공용). 삭제/변경되면 true.
Future<bool> deleteTodoFlow(BuildContext context, Todo t) async {
if (t.id == null) return false;
// 반복 할일: 반복 중단(완료 회차 보존). 겸용 대비 일정보다 먼저.
if (t.isRepeating && t.isTodo) {
final ok = await _confirmDelDialog(
context, '이후 반복을 중단합니다. 완료된 할일은 그대로 남습니다.\n계속할까요?');
if (!ok) return false;
await AppDb.instance.stopRepeatTodo(t);
await NotifyService.instance.cancel(t.id!);
bumpData();
return true;
}
// 반복 일정.
if (t.isRepeating && t.onCalendar) return _deleteRepeatEventFlow(context, t);
// 완료 할일 → 완료 기록 삭제(완료 상세의 삭제와 동일).
if (t.isDone) {
final ok = await _confirmDelDialog(context, '이 완료된 할일을 삭제할까요?');
if (!ok) return false;
await AppDb.instance.deleteDone(t.id!);
await NotifyService.instance.cancel(t.id!);
bumpData();
return true;
}
// (지난 일정도 삭제 허용 — 과거 항목 수정·삭제 가능)
// 할일+일정 겸용 → 선택 삭제.
if (t.onCalendar && t.isTodo) return _deleteWithChoiceFlow(context, t);
// 일반(할일/일정/기록).
final label = t.isRecord ? '기록' : (t.onCalendar ? '일정' : '할일');
final ok = await _confirmDelDialog(context, '이 $label을 삭제할까요?');
if (!ok) return false;
await AppDb.instance.delete(t.id!);
await NotifyService.instance.cancel(t.id!);
bumpData();
return true;
}
class _TodoEditor extends StatefulWidget {
final Todo? todo;
final String? defaultDate;
final bool simple; // 하위호환: true → 일정/기록 편집으로 매핑
final String? kind; // 'event' | 'record' | 'todo' | null(자동 판별)
final bool allowConvert; // 할일 편집기에서 '일정만/기록만' 노출 여부
final bool defaultMyDay; // 신규 할일에서 '나의 하루' 기본 체크(할일보기)
final void Function(String kind)? onKindSelected; // 유형 선택 시 달력 보기 전환 콜백
const _TodoEditor(
{this.todo,
this.defaultDate,
this.simple = false,
this.kind,
this.allowConvert = true,
this.defaultMyDay = false,
this.onKindSelected});
@override
State<_TodoEditor> createState() => _TodoEditorState();
}
class _TodoEditorState extends State<_TodoEditor> {
late Todo t;
late TextEditingController _title;
late TextEditingController _note;
late String kind; // 'event' | 'record' | 'todo'
List<Map<String, dynamic>> _recTemplates = []; // 빠른 기록 템플릿(신규 기록 시)
/// '오늘 할일'을 끄면 되돌릴 생성일(달력에서 고른 칸).
String? _anchorCreatedIso;
bool get isEdit => widget.todo != null;
String _dateOnly(String? raw) =>
(raw != null && raw.length >= 10) ? raw.substring(0, 10) : '';
String _createdStamp(String iso) => '$iso 00:00:00';
// 요일 한글(1=월 … 7=일)
static const _wd = ['', '월', '화', '수', '목', '금', '토', '일'];
@override
void initState() {
super.initState();
t = widget.todo?.copy() ??
Todo(
isTodo: true,
repeatInterval: 1); // 비반복은 달력 표시(기본 true). 반복 켤 때만 숨김으로.
// 종류 결정: 명시 kind > simple(구) > 항목 플래그로 자동 판별 > 기본 'todo'.
kind = widget.kind ??
(widget.simple
? (t.isRecord ? 'record' : 'event')
: (t.isRecord
? 'record'
: (t.onCalendar && !t.isTodo ? 'event' : 'todo')));
_title = TextEditingController(text: t.content);
_note = TextEditingController(text: t.note ?? '');
// 신규: 보기/FAB에서 넘긴 유형에 맞춰 플래그 초기화.
// 할일·일정의 선택 칸 날짜는 생성일. 기한은 비워 둔다.
if (!isEdit) {
t.isTodo = kind == 'todo';
t.onCalendar = kind == 'event';
t.isRecord = kind == 'record';
if (kind == 'todo' || kind == 'event') {
_applyNewTodoDates();
} else {
t.dueDate = widget.defaultDate;
}
} else {
final created = _dateOnly(t.createdAt);
_anchorCreatedIso = created.isNotEmpty ? created : t.dueDate;
}
// 신규(추가) 기본 미리알림: 일정=오전 10시, 할일=오전 9시. 기록은 알림 없음.
// 기준일이 오늘이면 '당일'(전일 알림은 이미 지난 날), 아니면 '전일'.
// 일정 기준일은 생성일, 할일 기준일은 기한.
if (!isEdit && t.remindTime == null) {
final anchor =
kind == 'event' ? _dateOnly(t.createdAt) : (t.dueDate ?? '');
final defDay = (anchor == isoOf(DateTime.now())) ? 'same' : 'prev';
if (kind == 'event') {
t.remindDay = defDay;
t.remindTime = '10:00';
} else if (kind == 'todo') {
t.remindDay = defDay;
t.remindTime = '09:00';
}
}
// 할일보기에서 신규 추가 시 '나의 하루' 기본 체크(웹과 동일).
if (!isEdit && widget.defaultMyDay) t.isMyDay = true;
// 기록(신규·수정): 빠른 기록 템플릿 로드(다이얼로그에서 옵션 선택 → 제목·시간 채움).
// 신규 기록은 기록 시간을 현재 시각으로 열어 둔다.
if (kind == 'record') {
if (!isEdit && (t.eventTime == null || t.eventTime!.isEmpty)) {
t.eventTime = _nowHm();
}
AppDb.instance.listRecordTemplates().then((v) {
if (mounted) setState(() => _recTemplates = v);
});
}
}
/// 빠른 기록: 선택 옵션을 제목에 넣고, 시간이 비었으면 현재 시각으로.
void _applyQuickOption(String opt) {
_title.text = opt;
if (t.eventTime == null || t.eventTime!.isEmpty) {
final now = TimeOfDay.now();
t.eventTime =
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
}
setState(() {});
}
/// 「빠른 기록」 버튼 → 템플릿 옵션을 다이얼로그로 띄워 선택. 선택 시 제목·시간 채움.
Future<void> _openQuickRecordDialog() async {
if (_recTemplates.isEmpty) {
_recTemplates = await AppDb.instance.listRecordTemplates();
}
if (!mounted) return;
// 빠른 기록에는 '고정(is_fixed)'한 기록 종류만 표시.
final fixed =
_recTemplates.where((t) => t['is_fixed'] == true).toList();
final opt = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Row(mainAxisSize: MainAxisSize.min, children: const [
Icon(Icons.note_add_outlined, size: 20, color: Color(0xFF7A4FBB)),
SizedBox(width: 8),
Text('빠른 기록',
style: TextStyle(fontSize: 17, color: Color(0xFF7A4FBB))),
]),
contentPadding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
content: SizedBox(
width: double.maxFinite,
child: fixed.isEmpty
? const Text('고정된 기록 종류가 없습니다.\n설정 › 기록 종류에서 고정(체크)해 주세요.',
style: TextStyle(color: Colors.grey))
: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final tpl in fixed)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(tpl['name'] as String,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Wrap(spacing: 8, runSpacing: 4, children: [
for (final o in (tpl['option_list'] as List)
.cast<String>())
ActionChip(
label: Text(o),
onPressed: () => Navigator.pop(ctx, o),
),
]),
]),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx), child: const Text('닫기')),
],
),
);
if (opt != null) _applyQuickOption(opt);
}
/// 「기록」 헤더 옆 빠른 기록 버튼(신규·수정 공통).
Widget _quickRecordButton() => OutlinedButton.icon(
onPressed: _openQuickRecordDialog,
icon: const Icon(Icons.note_add_outlined, size: 18),
label: const Text('빠른 기록'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF7A4FBB),
side: const BorderSide(color: Color(0xFFCBB8E8)),
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4)),
);
@override
void dispose() {
_title.dispose();
_note.dispose();
super.dispose();
}
DateTime? _dueDateTime() {
final iso = kind == 'event' ? _dateOnly(t.createdAt) : (t.dueDate ?? '');
if (iso.isEmpty) return null;
final d = DateTime.tryParse(iso);
if (d == null) return null;
if (t.remindTime == null) return null;
final parts = t.remindTime!.split(':');
if (parts.length < 2) return null;
var when = DateTime(d.year, d.month, d.day,
int.tryParse(parts[0]) ?? 0, int.tryParse(parts[1]) ?? 0);
if (t.remindDay == 'prev') when = when.subtract(const Duration(days: 1));
return when;
}
/// 일정/기록 편집기 저장 — 종류에 맞게 플래그 세팅 후 저장.
Future<void> _saveByKind() async {
if (kind == 'event') {
t.isTodo = false;
t.onCalendar = true;
t.isRecord = false;
t.dueDate = null;
} else if (kind == 'record') {
t.isTodo = false;
t.onCalendar = false;
t.isRecord = true;
t.dueDate ??= isoOf(DateTime.now());
}
await _save();
}
// ---- 일정 시간(HH:mm) ----
TimeOfDay? _parseTod(String? hm) {
if (hm == null || !hm.contains(':')) return null;
final p = hm.split(':');
final h = int.tryParse(p[0]);
final m = int.tryParse(p.length > 1 ? p[1] : '0');
if (h == null || m == null) return null;
return TimeOfDay(hour: h, minute: m);
}
String _fmtTod(String hm) {
final tod = _parseTod(hm);
if (tod == null) return hm;
final ampm = tod.hour < 12 ? '오전' : '오후';
final h12 = (tod.hour % 12) == 0 ? 12 : tod.hour % 12;
return '$ampm $h12:${tod.minute.toString().padLeft(2, '0')}';
}
Future<void> _pickEventTime() async {
final sel = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
if (kind == 'event') // 종일은 일정만(기록 제외)
ListTile(
leading: const Icon(Icons.wb_sunny_outlined),
title: const Text('종일'),
onTap: () => Navigator.pop(ctx, 'allday')),
ListTile(
leading: const Icon(Icons.schedule),
title: const Text('시간 직접 선택…'),
onTap: () => Navigator.pop(ctx, 'pick')),
if (t.eventTime != null && t.eventTime!.isNotEmpty)
ListTile(
leading: const Icon(Icons.clear),
title: const Text('시간 없음'),
onTap: () => Navigator.pop(ctx, 'clear')),
]),
),
);
if (sel == null) return;
if (sel == 'allday') {
setState(() => t.eventTime = '종일'); // 종일(시간 없음)
return;
}
if (sel == 'clear') {
setState(() => t.eventTime = null);
return;
}
if (!mounted) return;
final tod = await showTimePicker(
context: context,
initialTime: _parseTod(t.eventTime) ?? const TimeOfDay(hour: 9, minute: 0));
if (tod == null) return;
setState(() => t.eventTime =
'${tod.hour.toString().padLeft(2, '0')}:${tod.minute.toString().padLeft(2, '0')}');
}
Widget _rowEventTime() {
final has = t.eventTime != null && t.eventTime!.isNotEmpty;
final timeLabel = kind == 'record' ? '기록 시간' : '일정 시간';
return InkWell(
onTap: _pickEventTime,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(children: [
Icon(Icons.schedule,
color: has ? const Color(0xFF3F6FB0) : Colors.grey),
const SizedBox(width: 14),
Expanded(
child: Text(has ? '$timeLabel ${_fmtTod(t.eventTime!)}' : timeLabel,
style: TextStyle(
fontSize: 15,
color: has ? const Color(0xFF3F6FB0) : Colors.grey)),
),
if (has)
IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () => setState(() => t.eventTime = null),
),
]),
),
);
}
Future<void> _save() async {
final todayIso = isoOf(DateTime.now());
// 새로 만드는 반복 항목은 반복 기한이 비어 있으면 기본 오늘+3개월(무기한이 아니라 기간 한정).
if (!isEdit &&
t.isRepeating &&
(t.repeatUntil == null || t.repeatUntil!.isEmpty)) {
t.repeatUntil = _defaultRepeatUntil();
}
t.content = _title.text.trim().isEmpty ? '(제목 없음)' : _title.text.trim();
t.note = _note.text.trim().isEmpty ? null : _note.text.trim();
// 생성일이 비어 있을 때만 고른 칸(없으면 오늘)으로 채운다.
// '오늘 할일'이 기본으로 켜져 있어도 생성일을 오늘로 덮지 않는다.
if ((kind == 'todo' || kind == 'event') &&
_dateOnly(t.createdAt).isEmpty) {
t.createdAt = _createdStamp(
_anchorCreatedIso ?? todayIso,
);
}
// 날짜 허용 범위 확인은 '신규 생성'에만 적용.
// (수정은 과거/미래 날짜라도 그대로 저장 허용 — 지난 할일·일정도 수정 가능)
var focusToday = false;
if (!isEdit && t.dueDate != null && t.dueDate!.isNotEmpty) {
if ((kind == 'todo' || kind == 'event') &&
t.dueDate!.compareTo(todayIso) < 0) {
final label = kind == 'event' ? '일정' : '할 일';
final ok = await showAppConfirm(context,
'지난 날짜에는 $label을 만들 수 없습니다. '
'오늘로 자동 조정해서 저장할까요?');
if (!ok) return;
if (!mounted) return;
t.dueDate = todayIso;
focusToday = true;
} else if (kind == 'record' && t.dueDate!.compareTo(todayIso) > 0) {
final ok = await showAppConfirm(context,
'미래 날짜에는 기록을 만들 수 없습니다. '
'오늘로 자동 조정해서 저장할까요?');
if (!ok) return;
if (!mounted) return;
t.dueDate = todayIso;
focusToday = true;
}
}
final createdDay = _dateOnly(t.createdAt);
if (createdDay.isNotEmpty &&
t.dueDate != null &&
t.dueDate!.isNotEmpty &&
t.dueDate!.compareTo(createdDay) < 0) {
await showAppAlert(context, '기한은 생성일보다 빠를 수 없습니다.');
return;
}
// 당일(또는 과거→오늘) 할일은 '전일' 미리알림이 이미 지난 날 → '당일'로 보정(비반복만).
if (t.isTodo &&
(t.repeatUnit ?? '').isEmpty &&
t.dueDate != null &&
t.dueDate!.compareTo(todayIso) <= 0 &&
t.remindDay == 'prev') {
t.remindDay = 'same';
}
if (kind == 'event' &&
(t.repeatUnit ?? '').isEmpty &&
_dateOnly(t.createdAt) == todayIso &&
t.remindDay == 'prev') {
t.remindDay = 'same';
}
// 미리 알림(모든 유형·생성/수정): 알림 시각(년월일시)이 현재 이전이면 현재+1시간(당일)으로 보정.
final remindIso =
kind == 'event' ? _dateOnly(t.createdAt) : (t.dueDate ?? '');
if (t.remindTime != null && remindIso.isNotEmpty) {
final now = DateTime.now();
final when = _dueDateTime(); // due + remindTime + (prev면 -1일)
if (when != null && when.isBefore(now)) {
final target = now.add(const Duration(hours: 1));
if (target.day == now.day) {
// 자정 넘어가면 그대로 둠
t.remindTime =
'${target.hour.toString().padLeft(2, '0')}:${target.minute.toString().padLeft(2, '0')}';
final today = DateTime(now.year, now.month, now.day);
final dueD = DateTime.tryParse(remindIso);
if (dueD != null) {
final dd = DateTime(dueD.year, dueD.month, dueD.day);
if (dd == today) {
t.remindDay = 'same';
} else if (dd == today.add(const Duration(days: 1))) {
t.remindDay = 'prev';
}
}
}
}
}
final db = AppDb.instance;
int id;
if (isEdit) {
await db.update(t);
id = t.id!;
} else {
id = await db.insert(t);
}
// 알림 예약/실체화는 실패해도 '저장 완료 + 시트 닫기'를 막지 않는다.
try {
final when = _dueDateTime();
if (when != null) {
await NotifyService.instance
.scheduleReminder(id: id, title: t.content, when: when);
} else {
await NotifyService.instance.cancel(id);
}
// 반복 할 일이면 오늘까지 실체화(주기 변경/과거 시작일 반영).
if (t.isTodo) await db.rollForwardRepeatTodos(todayIso);
} catch (_) {/* 저장은 이미 DB에 반영됨 */}
bumpData();
if (mounted) {
Navigator.pop(context, TodoEditorResult(kind, focusToday: focusToday));
}
}
Future<void> _toast(String msg) async {
await showAppAlert(context, msg);
}
Future<void> _delete() async {
final both = t.onCalendar && t.isTodo;
// 할일 반복을 일정 반복보다 먼저 — 겸용이 일정 경로로 가면 미완료 계열이 남을 수 있음.
if (t.isRepeating && t.isTodo) {
await _stopRepeatTodo();
return;
}
if (t.isRepeating && t.onCalendar) {
await _deleteRepeatEvent();
return;
}
if (t.isDone) {
await _toast('완료된 할일은 삭제할 수 없습니다.');
return;
}
// (지난 일정도 삭제 허용 — 과거 항목 수정·삭제 가능)
if (both) {
await _deleteWithChoice();
} else {
final label = t.isRecord ? '기록' : (t.onCalendar ? '일정' : '할일');
final ok = await _confirm('이 $label을 삭제할까요?');
if (ok) {
await AppDb.instance.delete(t.id!);
await NotifyService.instance.cancel(t.id!);
bumpData();
if (mounted) Navigator.pop(context);
}
}
}
Future<void> _stopRepeatTodo() async {
final ok = await _confirm(
'이후 반복을 중단합니다. 완료된 할일은 그대로 남습니다.\n계속할까요?');
if (!ok) return;
await AppDb.instance.stopRepeatTodo(t);
await NotifyService.instance.cancel(t.id!);
bumpData();
if (mounted) Navigator.pop(context);
}
// 반복 일정 삭제: 지난 일정은 항상 보존.
// - 지난 회차가 있으면: 그 회차들을 '반복 없는 개별 일정'으로 실체화하고 기준행 제거
// → 지난 일정이 개별 항목으로 남아 각각 수정·삭제 가능, 오늘 이후 반복 중단.
// - 지난 회차가 없으면(시작이 오늘 이후): 보존할 일정이 없으므로 삭제.
Future<void> _deleteRepeatEvent() async {
final todayIso = isoOf(DateTime.now());
final hasPast = _repeatHasPast(t, todayIso);
final ok = await _confirm(hasPast
? '이후 반복을 중단합니다. 지난 일정은 반복 없는 개별 일정으로 남습니다.\n계속할까요?'
: '이 반복 일정을 삭제할까요?');
if (!ok) return;
if (t.isDone) {
// 완료+반복 행은 반복만 해제. 지우면 완료 기록이 사라진다.
await AppDb.instance.stopRepeatTodo(t);
} else if (hasPast) {
final n = DateTime.now();
final y =
DateTime(n.year, n.month, n.day).subtract(const Duration(days: 1));
await AppDb.instance.detachPastRepeatEvent(t, y);
} else {
await AppDb.instance.deleteIncomplete(t.id!);
}
await NotifyService.instance.cancel(t.id!);
bumpData();
if (mounted) Navigator.pop(context);
}
bool _repeatHasPast(Todo t, String todayIso) {
final start = DateTime.tryParse(t.repeatAnchor ?? '');
if (start == null) return false;
final today = DateTime.tryParse(todayIso);
if (today == null) return false;
final yesterday = DateTime(today.year, today.month, today.day)
.subtract(const Duration(days: 1));
final until =
t.repeatUntil == null ? null : DateTime.tryParse(t.repeatUntil!);
return latestOccurrenceOnOrBefore(
start, t.repeatUnit, t.repeatInterval, yesterday,
until: until,
) !=
null;
}
Future<void> _deleteWithChoice() async {
bool rmCal = true, rmTodo = true;
final res = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setD) => AlertDialog(
title: const Text('삭제 대상 선택'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CheckboxListTile(
value: rmCal,
onChanged: (v) => setD(() => rmCal = v ?? false),
title: const Text('일정(달력)'),
dense: true,
),
CheckboxListTile(
value: rmTodo,
onChanged: (v) => setD(() => rmTodo = v ?? false),
title: const Text('할일'),
dense: true,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('취소')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('삭제')),
],
),
),
);
if (res != true || (!rmCal && !rmTodo)) return;
final newCal = t.onCalendar && !rmCal;
final newTodo = t.isTodo && !rmTodo;
if (!newCal && !newTodo) {
await AppDb.instance.delete(t.id!);
await NotifyService.instance.cancel(t.id!);
// 편입본을 지워도 구글 원본 숨김은 유지 → 삭제한 구글 일정이 되살아나지 않음.
} else {
await AppDb.instance.setPresence(t.id!, newCal, newTodo);
}
bumpData();
if (mounted) Navigator.pop(context);
}
Future<bool> _confirm(String msg) async {
final r = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
content: Text(msg),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('취소')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('삭제')),
],
),
);
return r == true;
}
// 완료된 할 일: 간단 상세(제목·날짜·메모)로 표시(웹의 완료됨 상세와 동일).
bool get _isDoneSimple => kind == 'todo' && isEdit && t.isDone;
// 지난(비반복) 일정 = '완료' 개념. 제목·메모만 수정, 반복/기한/알림 컨트롤 없음,
// 개별 삭제 허용, 완료→미완료 전환 없음. 활성 반복 일정(진행 중)은 잠그지 않는다.
bool get _isDoneEvent {
if (kind != 'event' || !isEdit || t.isRepeating) return false;
final day = t.eventDay;
return day != null && day.compareTo(isoOf(DateTime.now())) < 0;
}
@override
Widget build(BuildContext context) {
if (_isDoneEvent) return _buildDoneEvent(context);
if (kind == 'event' || kind == 'record') return _buildEventRecord(context);
if (_isDoneSimple) return _buildDoneTodo(context);
return _buildTodo(context);
}
/// 완료 항목 저장 — 내용·메모만 갱신(기한/반복/알림 등 다른 필드 보존, 지연 보정 안 함).
Future<void> _saveContentNote() async {
t.content = _title.text.trim().isEmpty ? '(제목 없음)' : _title.text.trim();
t.note = _note.text.trim().isEmpty ? null : _note.text.trim();
await AppDb.instance.update(t);
bumpData();
if (mounted) Navigator.pop(context);
}
/// 완료된 할일 삭제 — 완료 기록 자체를 제거(확인 후).
Future<void> _deleteDoneTodo() async {
final ok = await showAppConfirm(context, '이 완료된 할일을 삭제할까요?');
if (!ok || !mounted) return;
await AppDb.instance.deleteDone(t.id!);
await NotifyService.instance.cancel(t.id!);
bumpData();
if (mounted) Navigator.pop(context);
}
// 완료된 할 일 간단 상세: 제목 + 날짜(표시) + 메모 + [저장 · 삭제].
Widget _buildDoneTodo(BuildContext context) {
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
16, 4, 16, 20 + MediaQuery.of(context).padding.bottom),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: TextField(
controller: _title,
maxLines: null,
decoration: const InputDecoration(labelText: '제목'),
),
),
const SizedBox(width: 8),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
border: Border.all(color: Colors.red),
borderRadius: BorderRadius.circular(6),
),
child: const Text('완료',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.red)),
),
],
),
if (t.dueDate != null) ...[
const SizedBox(height: 8),
Row(children: [
const Icon(Icons.event, size: 14, color: Color(0xFF5F6B7A)),
const SizedBox(width: 5),
Text(_fmtDue(t.dueDate!),
style: const TextStyle(fontSize: 13, color: Color(0xFF5F6B7A))),
]),
],
if (t.doneDate != null && t.doneDate!.isNotEmpty) ...[
const SizedBox(height: 6),
Row(children: [
const Icon(Icons.check_circle, size: 14, color: Color(0xFF0B7A3B)),
const SizedBox(width: 5),
Text('완료 ${_fmtDue(t.doneDate!)}',
style:
const TextStyle(fontSize: 13, color: Color(0xFF0B7A3B))),
]),
],
const SizedBox(height: 12),
TextField(
controller: _note,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
labelText: '메모', border: OutlineInputBorder()),
),
const SizedBox(height: 14),
Row(children: [
Expanded(
child: FilledButton(
onPressed: _saveContentNote,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48)),
child: const Text('저장'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: _deleteDoneTodo,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
minimumSize: const Size.fromHeight(48)),
child: const Text('삭제'),
),
),
]),
],
),
);
}
// 지난(완료) 일정 전용 상세: '완료' 라벨 + 제목(수정) + '완료 {날짜} {시각}' + 메모.
// 저장=제목·메모만 갱신(_saveContentNote), 삭제=_delete(비반복 과거 → 개별 삭제).
// 반복/기한/미리알림/미완료 전환 = 없음.
Widget _buildDoneEvent(BuildContext context) {
final day = t.eventDay ?? '';
final when = StringBuffer('완료 ${_fmtDue(day)}');
if (t.eventTime != null && t.eventTime!.isNotEmpty) {
when.write(' ${t.eventTime}');
}
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
16, 4, 16, 20 + MediaQuery.of(context).padding.bottom),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: TextField(
controller: _title,
maxLines: null,
decoration: const InputDecoration(labelText: '제목'),
),
),
const SizedBox(width: 8),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
border: Border.all(color: Colors.red),
borderRadius: BorderRadius.circular(6),
),
child: const Text('완료',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.red)),
),
],
),
const SizedBox(height: 8),
Row(children: [
const Icon(Icons.check_circle, size: 14, color: Color(0xFF0B7A3B)),
const SizedBox(width: 5),
Text(when.toString(),
style: const TextStyle(fontSize: 13, color: Color(0xFF0B7A3B))),
]),
const SizedBox(height: 12),
TextField(
controller: _note,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
labelText: '메모', border: OutlineInputBorder()),
),
const SizedBox(height: 14),
Row(children: [
Expanded(
child: FilledButton(
onPressed: _saveContentNote,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48)),
child: const Text('저장'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: _delete,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
minimumSize: const Size.fromHeight(48)),
child: const Text('삭제'),
),
),
]),
],
),
);
}
// 신규 추가 시 상단 유형 선택(할일/일정/기록) — 웹 상세폼 콤보와 동일.
// kind만 바꾸면 build()가 해당 폼으로 전환. 제목/메모는 컨트롤러라 자동 보존.
void _switchKind(String k) {
// '전체'는 실제 항목 유형이 아님 → 폼은 할일로 두고, 달력 보기만 전체로 전환.
final formKind = (k == 'all') ? 'todo' : k;
kind = formKind;
t.isTodo = formKind == 'todo';
t.onCalendar = formKind == 'event';
t.isRecord = formKind == 'record';
widget.onKindSelected?.call(k); // 달력 보기 연동('all'=전체, 그 외=해당 유형)
if (!isEdit && (formKind == 'todo' || formKind == 'event')) {
final fromDue = t.dueDate;
if (fromDue != null && fromDue.isNotEmpty &&
_dateOnly(t.createdAt).isEmpty) {
_anchorCreatedIso = fromDue;
if (!t.isMyDay) t.createdAt = _createdStamp(fromDue);
t.dueDate = null;
} else if (_dateOnly(t.createdAt).isEmpty) {
_applyNewTodoDates();
}
} else if (!isEdit && formKind == 'record') {
if (t.dueDate == null || t.dueDate!.isEmpty) {
final created = _dateOnly(t.createdAt);
t.dueDate = created.isNotEmpty
? created
: (widget.defaultDate ?? isoOf(DateTime.now()));
}
}
if (formKind == 'event') t.dueDate = null;
// 미리알림 전일/당일 기본: 기준일이 오늘이면 '당일'(initState와 동일 규칙).
final anchor =
formKind == 'event' ? _dateOnly(t.createdAt) : (t.dueDate ?? '');
final defDay = (anchor == isoOf(DateTime.now())) ? 'same' : 'prev';
if (formKind == 'event' && t.remindTime == null) {
t.remindDay = defDay;
t.remindTime = '10:00';
} else if (formKind == 'todo' && t.remindTime == null) {
t.remindDay = defDay;
t.remindTime = '09:00';
} else if (formKind == 'record') {
t.remindDay = null; // 기록은 미리알림 없음
t.remindTime = null;
if (!isEdit && (t.eventTime == null || t.eventTime!.isEmpty)) {
t.eventTime = _nowHm();
}
// '기록'으로 전환 시 빠른 기록 템플릿 로드(아직 안 불러왔으면).
if (!isEdit && _recTemplates.isEmpty) {
AppDb.instance.listRecordTemplates().then((v) {
if (mounted) setState(() => _recTemplates = v);
});
}
}
}
Widget _typeSelector() {
Widget chip(String k, String lbl) => Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(lbl),
selected: kind == k,
onSelected: (_) => setState(() => _switchKind(k)),
),
);
return Padding(
padding: const EdgeInsets.only(bottom: 10),
// 좁은 폭(iOS)에서 넘치지 않게 필요 시 축소.
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(children: [
chip('all', '전체'), // 선택 시 폼은 할일, 달력 보기는 전체로 전환
chip('todo', '할일'),
chip('event', '일정'),
chip('record', '기록'),
if (kind == 'record') ...[
const SizedBox(width: 12),
_quickRecordButton(),
],
]),
),
);
}
Widget _buildTodo(BuildContext context) {
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
16, 4, 16, 20 + MediaQuery.of(context).padding.bottom),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (!isEdit && widget.allowConvert) _typeSelector(),
TextField(
controller: _title,
autofocus: !isEdit,
// 여러 줄 입력 허용 + 줄이 늘면 입력란 높이 자동 확장(처음엔 한 줄).
maxLines: null,
keyboardType: TextInputType.multiline,
decoration: const InputDecoration(
labelText: '할 일', hintText: '할 일을 입력…'),
),
const SizedBox(height: 8),
// 할일/일정/기록 분리: '추가'=순수 할일, '일정만'/'기록만'=순수 일정/기록.
// (겸용 스위치 제거 — 편집 시 기존 onCalendar/isRecord는 t에 그대로 보존)
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: t.isMyDay,
onChanged: (v) => setState(() => _setTodayTask(v)),
// '나의 하루에 추가' → '오늘 할일'(☀아이콘 제거, 진한 파란색).
title: const Text('오늘 할일',
style: TextStyle(
color: Color(0xFF12448A), fontWeight: FontWeight.w600)),
dense: true,
),
_rowCreated(),
_rowDue(),
_rowRemind(),
_rowRepeat(),
const SizedBox(height: 8),
TextField(
controller: _note,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(
labelText: '메모', border: OutlineInputBorder()),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: FilledButton(
onPressed: _save,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48)),
child: Text(isEdit ? '저장' : '추가')),
),
if (isEdit) ...[
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: _delete,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
minimumSize: const Size.fromHeight(48)),
child: const Text('삭제'),
),
),
],
],
),
],
),
);
}
// ---- 일정/기록 편집기: 제목 + 기한 + 일정 시간 (+일정만 미리알림) + 메모 + 저장/삭제 ----
Widget _buildEventRecord(BuildContext context) {
final label = kind == 'record' ? '기록' : '일정';
final accent =
kind == 'record' ? const Color(0xFF7A4FBB) : const Color(0xFF0B7A3B);
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
16, 4, 16, 20 + MediaQuery.of(context).padding.bottom),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (!isEdit && widget.allowConvert)
_typeSelector(), // 신규: 유형칩(기록이면 칩 행 오른쪽에 빠른 기록 버튼)
if (kind == 'event')
// 일정만 라벨 유지. (기록은 라벨/버튼 없음 — 빠른 기록은 신규 유형칩에만)
Text(label,
style: TextStyle(
fontSize: 13, fontWeight: FontWeight.bold, color: accent)),
const SizedBox(height: 4),
TextField(
controller: _title,
autofocus: !isEdit,
maxLines: null,
decoration:
InputDecoration(labelText: '$label 제목', hintText: '$label을 입력…'),
),
if (kind == 'event') _rowCreated(),
if (kind == 'record') _rowRecordDate(), // 기록: 날짜 이동(과거 O, 미래 X)
if (kind == 'record') _rowRecordTime() else _rowEventTime(),
if (kind == 'event') _rowRemind(),
if (kind == 'event') _rowRepeat(), // 일정도 반복 지원(웹과 동일). 기록은 반복 없음.
const SizedBox(height: 8),
TextField(
controller: _note,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
labelText: '메모', border: OutlineInputBorder()),
),
const SizedBox(height: 14),
Row(children: [
Expanded(
child: FilledButton.icon(
onPressed: _saveByKind,
icon: const Icon(Icons.check, size: 20),
label: Text(isEdit ? '저장' : '추가',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
style: FilledButton.styleFrom(
backgroundColor: accent,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(52)),
),
),
if (isEdit) ...[
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: _delete,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
minimumSize: const Size.fromHeight(52)),
child: const Text('삭제'),
),
),
],
]),
],
),
);
}
/// 신규 할일·일정: 달력에서 고른 칸(없으면 오늘)을 생성일로. 기한은 비움.
/// '오늘 할일'이 기본으로 켜져 있어도 생성일은 고른 칸을 유지한다.
void _applyNewTodoDates() {
final today = isoOf(DateTime.now());
final picked = widget.defaultDate;
final anchor =
(picked != null && picked.isNotEmpty) ? picked : today;
_anchorCreatedIso = anchor;
t.dueDate = null;
t.createdAt = _createdStamp(anchor);
}
/// 오늘 할일: 생성일을 오늘로 이동. 끄면 고른 칸 날짜로 되돌린다(복사 아님).
void _setTodayTask(bool on) {
final today = isoOf(DateTime.now());
t.isMyDay = on;
if (on) {
final cur = _dateOnly(t.createdAt);
if (cur.isNotEmpty && cur != today) _anchorCreatedIso = cur;
// 떠나려는 날짜가 기한과 같으면 기한을 비워 그 칸에 복사로 남지 않게 한다.
if (t.dueDate != null && t.dueDate!.isNotEmpty && t.dueDate == cur) {
t.dueDate = null;
}
t.createdAt = _createdStamp(today);
} else {
final back = _anchorCreatedIso;
if (back != null && back.isNotEmpty) {
t.createdAt = _createdStamp(back);
}
}
}
Future<void> _pickCreated() async {
final now = DateTime.now();
final existing = DateTime.tryParse(_dateOnly(t.createdAt));
final due = DateTime.tryParse(t.dueDate ?? '');
final last = due ?? DateTime(2035, 12, 31);
var init = existing ?? DateTime(now.year, now.month, now.day);
if (init.isAfter(last)) init = last;
final d = await showDatePicker(
context: context,
initialDate: init,
firstDate: DateTime(2020, 1, 1),
lastDate: last,
);
if (d == null) return;
setState(() {
final iso = isoOf(d);
t.createdAt = _createdStamp(iso);
_anchorCreatedIso = iso;
if (iso != isoOf(DateTime.now())) t.isMyDay = false;
});
}
Widget _rowCreated() {
final iso = _dateOnly(t.createdAt);
final label = iso.isEmpty ? '생성일' : '생성일 · ${_fmtDue(iso)}';
return InkWell(
onTap: _pickCreated,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(children: [
Icon(Icons.edit_calendar,
color: iso.isEmpty ? Colors.grey : const Color(0xFF3F6FB0)),
const SizedBox(width: 14),
Expanded(
child: Text(label,
style: TextStyle(
fontSize: 15,
color: iso.isEmpty
? Colors.grey
: const Color(0xFF3F6FB0))),
),
const Icon(Icons.chevron_right, size: 20, color: Colors.grey),
]),
),
);
}
// ---- 기한 ----
String _fmtDue(String iso) {
final d = DateTime.tryParse(iso);
if (d == null) return iso;
return '${d.month}월 ${d.day}일 (${_wd[d.weekday]})';
}
DateTime _nextWeekSunday(DateTime from) {
final f = DateTime(from.year, from.month, from.day);
return f.add(Duration(days: 7 - (f.weekday % 7))); // 다음 주 일요일
}
Future<void> _pickDue() async {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final tomorrow = today.add(const Duration(days: 1));
final nextWk = _nextWeekSunday(today);
final sel = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
ListTile(
leading: const Icon(Icons.wb_sunny_outlined),
title: Text('오늘 (${_wd[today.weekday]}요일)'),
onTap: () => Navigator.pop(ctx, isoOf(today))),
ListTile(
leading: const Icon(Icons.arrow_forward),
title: Text('내일 (${_wd[tomorrow.weekday]}요일)'),
onTap: () => Navigator.pop(ctx, isoOf(tomorrow))),
ListTile(
leading: const Icon(Icons.keyboard_double_arrow_right),
title: Text('다음 주 (${_wd[nextWk.weekday]}요일)'),
onTap: () => Navigator.pop(ctx, isoOf(nextWk))),
ListTile(
leading: const Icon(Icons.calendar_month),
title: const Text('날짜 선택…'),
onTap: () => Navigator.pop(ctx, 'pick')),
if (t.dueDate != null)
ListTile(
leading: const Icon(Icons.clear),
title: const Text('기한 없음'),
onTap: () => Navigator.pop(ctx, 'clear')),
]),
),
);
if (sel == null) return;
if (sel == 'clear') {
setState(() => t.dueDate = null);
} else if (sel == 'pick') {
if (!mounted) return;
final first = _dueFirstDay();
final existing = DateTime.tryParse(t.dueDate ?? '');
final init =
(existing != null && !existing.isBefore(first)) ? existing : first;
final d = await showDatePicker(
context: context,
initialDate: init,
firstDate: first,
lastDate: DateTime(2035, 12, 31),
);
if (d != null) await _applyDue(isoOf(d));
} else {
await _applyDue(sel);
}
}
/// 기한은 생성일보다 빠를 수 없다. 신규는 오늘 이전도 불가.
DateTime _dueFirstDay() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
var first = isEdit ? DateTime(2020, 1, 1) : today;
final created = DateTime.tryParse(_dateOnly(t.createdAt));
if (created != null) {
final c = DateTime(created.year, created.month, created.day);
if (c.isAfter(first)) first = c;
}
return first;
}
Future<void> _applyDue(String iso) async {
final created = _dateOnly(t.createdAt);
if (created.isNotEmpty && iso.compareTo(created) < 0) {
await showAppAlert(context, '기한은 생성일보다 빠를 수 없습니다.');
return;
}
setState(() => t.dueDate = iso);
}
// 기한까지 남은 일수 배지(상세폼 기한 오른쪽). 지연=D+n(빨강)·예정=D-n(회색). 오늘(D-DAY)은 표시 안 함.
Widget? _dueDdayBadge() {
if (t.isDone || t.dueDate == null) return null;
final due = DateTime.tryParse(t.dueDate!);
if (due == null) return null;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final d0 = DateTime(due.year, due.month, due.day);
final n = d0.difference(today).inDays;
if (n == 0) return null; // 오늘은 표시 안 함
final String text;
final Color bg, border, fg;
if (n > 0) {
text = 'D-$n';
bg = const Color(0xFFEEF1F6);
border = const Color(0xFFD6DCE6);
fg = const Color(0xFF4F5C72);
} else {
text = 'D+${-n}';
bg = const Color(0xFFFDEAEA);
border = const Color(0xFFF2B9B9);
fg = const Color(0xFFD94C4C);
}
return Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: border),
),
child: Text(text,
style:
TextStyle(fontSize: 11, color: fg, fontWeight: FontWeight.bold)),
);
}
Widget _rowDue() {
final has = t.dueDate != null;
final dday = _dueDdayBadge();
return InkWell(
onTap: _pickDue,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(children: [
Icon(Icons.event,
color: has ? const Color(0xFF3F6FB0) : Colors.grey),
const SizedBox(width: 14),
Expanded(
child: Text(has ? '${_fmtDue(t.dueDate!)}까지' : '기한 설정',
style: TextStyle(
fontSize: 15,
color: has ? const Color(0xFF3F6FB0) : Colors.grey)),
),
?dday,
if (has)
IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () => setState(() => t.dueDate = null),
),
]),
),
);
}
// ---- 기록 날짜 이동(과거 O, 미래 X — 웹과 동일) ----
Future<void> _pickRecordDate() async {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day); // 기록 미래 불가
final existing = DateTime.tryParse(t.dueDate ?? '');
final init =
(existing != null && !existing.isAfter(today)) ? existing : today;
final d = await showDatePicker(
context: context,
initialDate: init,
firstDate: DateTime(2020, 1, 1),
lastDate: today,
);
if (d != null) setState(() => t.dueDate = isoOf(d));
}
Widget _rowRecordDate() {
final iso = t.dueDate ?? isoOf(DateTime.now());
return InkWell(
onTap: _pickRecordDate,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(children: [
const Icon(Icons.event, color: Color(0xFF7A4FBB)),
const SizedBox(width: 14),
Expanded(
child: Text('날짜 · ${_fmtDue(iso)}',
style: const TextStyle(fontSize: 15, color: Color(0xFF7A4FBB))),
),
const Icon(Icons.chevron_right, size: 20, color: Colors.grey),
]),
),
);
}
// 기록 시간 — 날짜와 같이 탭하면 시간 선택 창을 바로 연다.
String _nowHm() {
final now = TimeOfDay.now();
return '${now.hour.toString().padLeft(2, '0')}:'
'${now.minute.toString().padLeft(2, '0')}';
}
Future<void> _pickRecordTime() async {
final tod = await showTimePicker(
context: context,
initialTime: _parseTod(t.eventTime) ?? TimeOfDay.now(),
);
if (tod == null) return;
setState(() => t.eventTime =
'${tod.hour.toString().padLeft(2, '0')}:${tod.minute.toString().padLeft(2, '0')}');
}
Widget _rowRecordTime() {
final has = t.eventTime != null &&
t.eventTime!.isNotEmpty &&
t.eventTime != '종일';
final label = has ? '기록 시간 · ${_fmtTod(t.eventTime!)}' : '기록 시간';
return InkWell(
onTap: _pickRecordTime,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(children: [
const Icon(Icons.schedule, color: Color(0xFF7A4FBB)),
const SizedBox(width: 14),
Expanded(
child: Text(label,
style: const TextStyle(fontSize: 15, color: Color(0xFF7A4FBB))),
),
const Icon(Icons.chevron_right, size: 20, color: Colors.grey),
]),
),
);
}
// ---- 미리 알림(기한 기준: 전일/당일 + 시각) ----
String _fmtRemind() {
if (t.remindDay == null || t.remindTime == null) return '';
final dayTxt = t.remindDay == 'prev' ? '전일' : '당일';
final hm = t.remindTime!.split(':');
final h = int.tryParse(hm[0]) ?? 0;
final ampm = h < 12 ? '오전' : '오후';
final h12 = (h % 12) == 0 ? 12 : h % 12;
return '$dayTxt $ampm $h12:${hm.length > 1 ? hm[1] : '00'}';
}
Future<void> _pickReminder() async {
// 시트 안에서 전일/당일을 선택(기존 인라인 칩 → 시트로 이동). 기본값은 현재 값 또는 '당일'.
String day = (t.remindDay == 'prev') ? 'prev' : 'same';
final sel = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheet) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
// 전일/당일 선택(시간 프리셋·직접선택에 함께 적용).
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
child: Row(children: [
ChoiceChip(
label: const Text('전일'),
selected: day == 'prev',
onSelected: (_) => setSheet(() => day = 'prev'),
),
const SizedBox(width: 8),
ChoiceChip(
label: const Text('당일'),
selected: day == 'same',
onSelected: (_) => setSheet(() => day = 'same'),
),
]),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.notifications_none),
title: const Text('오전 9:00'),
onTap: () => Navigator.pop(ctx, '$day|09:00')),
ListTile(
leading: const Icon(Icons.notifications_none),
title: const Text('오후 6:00'),
onTap: () => Navigator.pop(ctx, '$day|18:00')),
ListTile(
leading: const Icon(Icons.schedule),
title: const Text('시간 직접 선택…'),
onTap: () => Navigator.pop(ctx, 'custom|$day')),
if (t.remindTime != null)
ListTile(
leading: const Icon(Icons.clear),
title: const Text('알림 없음'),
onTap: () => Navigator.pop(ctx, 'none')),
]),
),
),
);
if (sel == null) return;
if (sel == 'none') {
setState(() {
t.remindDay = null;
t.remindTime = null;
});
} else if (sel.startsWith('custom')) {
if (!mounted) return;
final p = sel.split('|');
final chosenDay = p.length > 1 ? p[1] : 'same';
final tod = await showTimePicker(
context: context, initialTime: const TimeOfDay(hour: 9, minute: 0));
if (tod == null) return;
setState(() {
t.remindDay = chosenDay;
t.remindTime =
'${tod.hour.toString().padLeft(2, '0')}:${tod.minute.toString().padLeft(2, '0')}';
});
} else {
final p = sel.split('|');
setState(() {
t.remindDay = p[0];
t.remindTime = p[1];
});
}
}
Widget _rowRemind() {
final txt = _fmtRemind();
final has = txt.isNotEmpty;
// 미리알림 행: [벨+텍스트(탭→시트, 시트 안에서 전일/당일 선택)] + 오른쪽 끝 별표.
return Row(children: [
Expanded(
child: InkWell(
onTap: _pickReminder,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(children: [
Icon(Icons.notifications_none,
color: has ? const Color(0xFF3F6FB0) : Colors.grey),
const SizedBox(width: 14),
Expanded(
child: Text(has ? txt : '미리 알림',
style: TextStyle(
fontSize: 15,
color: has ? const Color(0xFF3F6FB0) : Colors.grey)),
),
if (has)
IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () => setState(() {
t.remindDay = null;
t.remindTime = null;
}),
),
]),
),
),
),
// 오른쪽 끝 별표(타일에서 이 행으로 이동).
IconButton(
icon: Icon(t.isStar ? Icons.star : Icons.star_border,
color: t.isStar ? const Color(0xFFF5A623) : Colors.grey),
tooltip: '별표',
onPressed: () => setState(() => t.isStar = !t.isStar),
),
]);
}
// 반복 기한 기본값: 오늘 + 3개월.
String _defaultRepeatUntil() {
final n = DateTime.now();
return isoOf(DateTime(n.year, n.month + 3, n.day));
}
// 반복 기한(종료일) 선택: 날짜 지정 또는 무기한.
Future<void> _pickRepeatUntil() async {
final now = DateTime.now();
final first = DateTime(now.year, now.month, now.day);
final existing = DateTime.tryParse(t.repeatUntil ?? '');
final init =
(existing != null && !existing.isBefore(first)) ? existing : first;
final sel = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
ListTile(
leading: const Icon(Icons.calendar_month),
title: const Text('날짜 선택…'),
onTap: () => Navigator.pop(ctx, 'pick')),
ListTile(
leading: const Icon(Icons.all_inclusive),
title: const Text('무기한'),
onTap: () => Navigator.pop(ctx, 'none')),
]),
),
);
if (sel == null) return;
if (sel == 'none') {
setState(() => t.repeatUntil = null);
return;
}
if (!mounted) return;
final d = await showDatePicker(
context: context,
initialDate: init,
firstDate: first,
lastDate: DateTime(2035, 12, 31),
);
if (d != null) setState(() => t.repeatUntil = isoOf(d));
}
Widget _rowRepeat() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.repeat, size: 18, color: Color(0xFF3F6FB0)),
const SizedBox(width: 6),
const Text('반복'),
const SizedBox(width: 10),
DropdownButton<String>(
value: t.repeatUnit ?? '',
items: const [
DropdownMenuItem(value: '', child: Text('안 함')),
DropdownMenuItem(value: 'day', child: Text('매일')),
DropdownMenuItem(value: 'week', child: Text('매주')),
DropdownMenuItem(value: 'month', child: Text('매달')),
DropdownMenuItem(value: 'year', child: Text('매년')),
],
// 반복으로 생긴 회차(isRecurring)는 반복 속성 변경 불가(원본에서만 수정).
onChanged: t.isRecurring
? null
: (v) => setState(() {
final wasRepeat = t.isRepeating;
t.repeatUnit = (v == null || v.isEmpty) ? null : v;
if (t.repeatUnit == null) {
t.repeatUntil = null; // 안함 → 기한 제거
} else if (t.repeatUntil == null ||
t.repeatUntil!.isEmpty) {
t.repeatUntil = _defaultRepeatUntil(); // 반복 켜면 기본 3개월
}
// 반복을 새로 켜면 '달력 표시' 기본 해제(숨김), 끄면 표시로.
if (!wasRepeat && t.isRepeating) {
t.calVisible = false;
} else if (wasRepeat && !t.isRepeating) {
t.calVisible = true;
}
}),
),
const Spacer(),
// 달력 표시 여부(할일/일정 공통). 해제 시 달력에서만 숨김(목록 탭엔 유지).
// 반복으로 생긴 회차(isRecurring)는 비활성화(원본에서만 변경).
Row(mainAxisSize: MainAxisSize.min, children: [
Checkbox(
value: t.calVisible,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: t.isRecurring
? null
: (v) => setState(() => t.calVisible = v ?? true),
),
GestureDetector(
onTap: t.isRecurring
? null
: () => setState(() => t.calVisible = !t.calVisible),
child: Text('달력 표시',
style: TextStyle(
fontSize: 13,
color: t.isRecurring ? Colors.grey : null)),
),
]),
],
),
// 반복 기한(반복 설정 시만) — 콤보 아래. 반복 회차는 변경 불가.
if (t.isRepeating)
InkWell(
onTap: t.isRecurring ? null : _pickRepeatUntil,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(children: [
const SizedBox(width: 24),
const Icon(Icons.event_repeat,
size: 18, color: Color(0xFF3F6FB0)),
const SizedBox(width: 10),
Expanded(
child: Text(
t.repeatUntil == null
? '반복 기한 · 무기한'
: '반복 기한 · ${_fmtDue(t.repeatUntil!)}까지',
style:
const TextStyle(fontSize: 15, color: Color(0xFF3F6FB0)),
),
),
if (t.repeatUntil != null)
IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: t.isRecurring
? null
: () => setState(() => t.repeatUntil = null),
),
]),
),
),
],
);
}
}
1. 공통 데이터
한 레코드가 할일, 일정, 기록 중 하나다. 세 종류의 날짜 의미는 다르다.
| 필드 | 할일 | 일정 | 기록 |
|---|---|---|---|
created_at생성일 |
달력·목록에 놓는 날. 사용자가 고른 칸. | 일정 그 자체인 날. 기한을 대신한다. | 만든 시각. 칸 배치에는 쓰지 않는다. |
due_date기한 |
마감일. 비어 있을 수 있다. 칸 위치가 아니다. | 없음. 저장하지 않는다. | 기록한 날. 칸 위치다. |
done_date |
완료로 바꾼 날. | 쓰지 않는다. 지난 날은 날짜 비교로 완료다. | 해당 없음. |
is_my_day |
“오늘 할일” 스위치. | 해당 없음. | 해당 없음. |
is_star |
제목 끝 별 표시. 날짜 배치와 무관. | 같음. | 같음. |
2. 할일
어디에 보이나
- 비반복 할일의 칸은 생성일이다. 기한 날에는 만들지 않는다.
- 예: 생성 9월 25일, 기한 9월 27일 → 9월 25일에만 보인다.
- 생성일이 비어 있는 옛 데이터만 기한 날로 폴백한다.
- 반복 할일은 회차일(기존 기한 앵커)에 펼친다. 반복의 기준일을 생성일로 바꾸지 않는다.
새로 만들 때
- 달력에서 고른 칸이 생성일이다. 고른 칸이 없으면 오늘.
- 기한은 비운다. 사용자가 기한을 직접 넣을 때만 채운다.
- 할일 보기에서 시트가 열릴 때 “오늘 할일”이 이미 켜져 있어도 생성일은 고른 칸이다. 오늘로 덮지 않는다.
- 생성일은 시트에서 바꿀 수 있다.
- 생성일을 오늘이 아닌 날로 바꾸면 “오늘 할일”은 꺼진다.
“오늘 할일”은 이동이다
- 켜면 생성일을 오늘로 옮긴다. 원래 날에 복사로 남기지 않는다.
- 떠나려는 날이 기한과 같으면 기한을 비워, 그 칸에 복사처럼 남지 않게 한다.
- 끄면 생성일을 옮기기 전 칸(시트를 열 때 고른 날)으로 되돌린다.
기한 입력
- 기한은 생성일보다 빠를 수 없다. 날짜 선택기의 시작일도 생성일 이후다.
- 생성일은 기한이 있으면 그 날까지만 고른다.
- 오늘 / 내일 / 다음 주 같은 바로가기가 생성일보다 이전이면 거절한다.
- 저장 시점에도 기한 < 생성일이면 막는다. “기한은 생성일보다 빠를 수 없습니다.”
- 신규 할일의 기한은 오늘보다 이전일 수 없다. 수정은 과거 기한도 유지할 수 있다.
상태
| 상태 | 조건 |
|---|---|
| 완료 | 완료 플래그가 켜져 있다. 기한이 지났어도 완료다. |
| 지연 | 미완료이고 기한이 오늘보다 전이다. 기한 = 오늘은 지연이 아니다. |
| 오늘 | 미완료·미지연이고, 생성일이 오늘이거나 “오늘 할일”이 켜져 있거나 기한이 오늘이다. |
| 예정 | 그 밖. |
기한이 없으면 생성일이 지나도 지연이 되지 않는다. 지연은 기한만 본다.
3. 일정
일정에는 기한 필드가 없다. 일정 날은 생성일이다. 완료와 예정은 오늘과 그 날을 비교해서 정한다.
| 일정 날 | 상태 |
|---|---|
| 오늘보다 전 | 완료. 취소선. 비반복이면 제목·메모만 수정. |
| 오늘 | 오늘. 편집 가능. |
| 오늘보다 뒤 | 예정. 편집 가능. |
- 지연 상태는 없다. 기한이 없으므로 마감 초과가 성립하지 않는다.
- 입력 화면에서 기한 줄을 보여 주지 않는다. 저장할 때 기한을 비운다.
- 달력 칸, 일정 목록, 반복의 시작일, 미리 알림의 기준일은 모두 생성일이다.
- 미리 알림의 전일/당일은 생성일 기준이다. 생성일이 오늘이면 당일이 기본이다.
- 지난 일정은 알림을 오늘로 넘기지 않는다. 이미 완료이기 때문이다.
- 미래 반복 회차는 화면 태그를 “반복”으로 보여도, 정렬 묶음은 예정이다.
이미 기한이 있는 일정
- 생성일이 없거나 생성일 > 기한이면, 생성일을 그 기한 날로 당긴다.
- 그다음 일정의 기한을 모두 지운다.
- 생성일이 기한보다 이른 일정은 생성일을 유지하고 기한만 지운다. 칸은 생성일이다.
4. 기록
- 기록이 놓이는 날은 기한 필드다. 할일·일정의 생성일 규칙으로 바꾸지 않는다.
- 신규 기록은 미래 날을 기한으로 둘 수 없다. 과거는 가능하다.
- “기록 시간”을 누르면 중간에 시트를 띄우지 않고 시간 선택 창을 바로 연다. 위의 날짜 행이 날짜 선택 창을 바로 여는 것과 같다.
- 기록 시트가 열릴 때 기록 시간은 현재 시각(
HH:mm)이다.
5. 오늘 목록에 넣는 할일
미완료 할일만 본다. 아래 중 하나면 오늘이다.
- 기한 ≤ 오늘 (지연이거나 기한이 오늘)
- “오늘 할일”이 켜져 있다
- 반복이 아니고 생성일이 오늘이다
6. 별표
- 할일 탭 필터 이름는 “중요”가 아니라 별표다. 별표가 하나 이상일 때만 “오늘 할 일” 뒤에 나온다. 개수를 붙인다. 예:
별표 3. - 별표가 없으면 그 칩은 숨긴다. 그때 별표 보기에 머물러 있으면 오늘 할 일로 돌아간다.
- 목록 제목 끝에 노란 별을 붙인다. 할일 목록, 일정 목록, 기록 목록, 날짜 시트의 제목 줄 모두 같다.
- 별은 제목 글자 바로 뒤다. 오른쪽 끝 완료 체크와는 별개다.
7. 목록 정렬
할일 탭과 일정 탭은 같은 우선순위다. 오름차순이든 내림차순이든 상태 묶음 순서는 바뀌지 않는다.
- 오늘
- 예정
- 지연 (일정에는 이 묶음이 없다)
- 완료
한 상태 안에서만 오름/내림을 적용한다. 비교 키 순서:
- 생성일
- 기한 (일정은 비어 있으므로 건너뛴다)
- 완료일
날짜가 비어 있으면 오름이든 내림이든 그 키에서는 뒤로 보낸다. 같은 값이면 아이디순으로 안정시킨다.
8. 달력 칸 내용 순서
한 칸에 여러 종류가 있으면 위에서 아래로 이 순서로 그린다.
- 날짜 숫자
- 공휴일 이름
- 기록
- 일정
- 할일
- 구글 일정 점
완료 개수 점은 칸 오른쪽 위에 둔다. 제목 줄 순서와 섞지 않는다.
9. 하단 탭 제목
할일, 일정, 기록을 열면 상단 제목 옆에 오늘 날짜를 붙인다.
형식: 할 일 9월 23일 (수). 요일은 월화수목금토일.
달력 탭 제목에는 날짜를 붙이지 않는다.
10. 예시 데이터
- 할일·기록의 생성일은 시나리오 날(기한과 같은 날)로 넣는다. 넣는 시각을 생성일로 두면 생성일이 기한보다 늦어진다.
- 일정의 시나리오 날은 생성일에만 넣고 기한은 비운다.
- 앱을 다시 열 때, 생성일이 기한보다 늦은 예시 행은 생성일을 기한 날로 당긴다.
- 이어서 일정의 기한을 지운다. 2절과 3절의 마이그레이션과 같다.
- 늦게 완료한 행은 완료일이 기한보다 뒤여도 된다. 고치는 것은 생성일과 기한의 순서뿐이다.
11. 이식 체크
- 생성 9/25 · 기한 9/27 할일이 9/25에만 있다.
- 기한을 생성일보다 이르게 저장할 수 없다.
- 할일 보기에서 시트를 열면 “오늘 할일”이 켜져 있어도 생성일은 선택한 칸이다.
- “오늘 할일”을 켜면 원래 칸에서 사라지고 오늘 칸으로 이동한다. 끄면 원래 칸으로 돌아간다.
- 기한이 없고 생성일만 지난 할일은 지연이 아니다.
- 기한이 어제이고 미완료면 지연이며 오늘 목록에도 있다.
- 기한이 오늘이면 지연이 아니다.
- 별표만 있는 미래 할일은 오늘 목록에 없다. 제목 끝에 별이 있다.
- 일정 입력에 기한이 없고, 어제 일정은 완료, 내일 일정은 예정, 오늘 일정은 오늘이다.
- 일정 목록 정렬이 내림차순이어도 묶음은 오늘 → 예정 → 완료다.
- 할일 목록 정렬이 오름차순이어도 묶음은 오늘 → 예정 → 지연 → 완료다.
- 빈 날짜는 오름·내림 모두 뒤로 간다.
- 기록 시간 탭이 시간 창을 바로 열고, 새 기록의 시간은 현재 시각이다.
- 칸 안 줄 순서가 기록 → 일정 → 할일이다.
- 할일·일정·기록 제목 옆에 오늘 날짜가 있고, 달력 제목에는 없다.
- 필터 칩 문구가 “별표”다.