할일 · 일정 · 기록 날짜 규칙

2026-09-23 대화에서 day_plan_app 에 넣은 수정이다. 아이폰 앱 day_plan_appl_app 은 같은 Dart 소스이므로, 아래 파일 전체를 같은 경로에 복사하면 된다. 날짜 비교는 yyyy-MM-dd 하루 단위이고, 오늘은 기기 로컬 날짜다.

수정한 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 제목 끝 별 표시. 날짜 배치와 무관. 같음. 같음.
생성일이 기한보다 늦으면 안 된다. 생성 2026-09-23, 기한 2026-09-22 는 금지. 기한이 있으면 생성일 ≤ 기한.

2. 할일

어디에 보이나

새로 만들 때

“오늘 할일”은 이동이다

기한 입력

상태

상태조건
완료완료 플래그가 켜져 있다. 기한이 지났어도 완료다.
지연미완료이고 기한이 오늘보다 전이다. 기한 = 오늘은 지연이 아니다.
오늘미완료·미지연이고, 생성일이 오늘이거나 “오늘 할일”이 켜져 있거나 기한이 오늘이다.
예정그 밖.

기한이 없으면 생성일이 지나도 지연이 되지 않는다. 지연은 기한만 본다.

3. 일정

일정에는 기한 필드가 없다. 일정 날은 생성일이다. 완료와 예정은 오늘과 그 날을 비교해서 정한다.

일정 날상태
오늘보다 전완료. 취소선. 비반복이면 제목·메모만 수정.
오늘오늘. 편집 가능.
오늘보다 뒤예정. 편집 가능.

이미 기한이 있는 일정

  1. 생성일이 없거나 생성일 > 기한이면, 생성일을 그 기한 날로 당긴다.
  2. 그다음 일정의 기한을 모두 지운다.
  3. 생성일이 기한보다 이른 일정은 생성일을 유지하고 기한만 지운다. 칸은 생성일이다.

4. 기록

5. 오늘 목록에 넣는 할일

미완료 할일만 본다. 아래 중 하나면 오늘이다.

  1. 기한 ≤ 오늘 (지연이거나 기한이 오늘)
  2. “오늘 할일”이 켜져 있다
  3. 반복이 아니고 생성일이 오늘이다
별표는 오늘에 넣는 이유가 아니다. 별표가 있고 기한이 미래여도 오늘에 넣지 않는다. 예: 오늘이 9월 23일, 제목 “자동차 보험 갱신 비교”, 생성일 2026-09-27, 오늘 할일 꺼짐, 별표 켜짐 → 9월 27일에만 보인다. 오늘에는 보이지 않는다.

6. 별표

7. 목록 정렬

할일 탭과 일정 탭은 같은 우선순위다. 오름차순이든 내림차순이든 상태 묶음 순서는 바뀌지 않는다.

  1. 오늘
  2. 예정
  3. 지연 (일정에는 이 묶음이 없다)
  4. 완료

한 상태 안에서만 오름/내림을 적용한다. 비교 키 순서:

  1. 생성일
  2. 기한 (일정은 비어 있으므로 건너뛴다)
  3. 완료일

날짜가 비어 있으면 오름이든 내림이든 그 키에서는 뒤로 보낸다. 같은 값이면 아이디순으로 안정시킨다.

8. 달력 칸 내용 순서

한 칸에 여러 종류가 있으면 위에서 아래로 이 순서로 그린다.

  1. 날짜 숫자
  2. 공휴일 이름
  3. 기록
  4. 일정
  5. 할일
  6. 구글 일정 점

완료 개수 점은 칸 오른쪽 위에 둔다. 제목 줄 순서와 섞지 않는다.

9. 하단 탭 제목

할일, 일정, 기록을 열면 상단 제목 옆에 오늘 날짜를 붙인다.

형식: 할 일  9월 23일 (수). 요일은 월화수목금토일.

달력 탭 제목에는 날짜를 붙이지 않는다.

10. 예시 데이터

11. 이식 체크