웹 → 폰앱 반영

Flutter · 웹 → 폰앱 이식

웹 변경 폰앱 반영 & macOS 이식

이 세션에서 웹(하루계획)에 적용한 변경을 폰앱(auto_schedule_app · day_plan_app)에 통일하고, 동일 명세를 macOS 프로젝트로 옮기기 위한 문서입니다. 실제 코드 변경은 A. 알림 설정 열기, B. ‘오늘 할일’ 라벨, C. 기록 날짜 이동, D. 기록 종류(템플릿) + 서버 동기화, 그리고 실기기 피드백 반영인 E. 폰앱 UI 개선(버튼 가시성·빠른 기록 다이얼로그·완료 점 위치)입니다.

날짜 2026-08-30 (KST) 대상 auto · day_plan · (이식) macOS ✓ analyze 통과

A 알림 설정 열기 (신규 기능)

앱은 이미 flutter_local_notifications로 알림 권한을 요청하지만, 거부/OS 차단 시 앱 안에서 OS 알림 설정으로 이동할 경로가 없었습니다. 설정 탭 맨 아래에 「알림 설정 열기」 버튼을 추가했습니다.

① pubspec.yaml — 의존성 1줄
  icalendar_parser: ^2.1.0
  app_settings: ^6.1.1 # OS 알림 설정 화면 열기

+ 추가 후 flutter pub get

② lib/screens/settings_screen.dart — import
import 'package:flutter/material.dart';
import 'package:app_settings/app_settings.dart';
import '../services/gcal.dart';
③ settings_screen.dart — build()의 ListView 맨 끝(구글 캘린더 ‘저장’ 버튼 뒤)
                  child: const Text('저장'),
                ),

                const Divider(height: 40),

                // ---------- 알림 설정 ----------
                const Text('알림',
                    style:
                        TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
                const SizedBox(height: 6),
                const Text(
                  '미리 알림이 울리지 않으면 이 앱의 알림 권한이 꺼져 있을 수 있습니다. '
                  '아래 버튼으로 이 앱의 OS 알림 설정을 열어 알림을 켜 주세요.',
                  style: TextStyle(color: Colors.grey, fontSize: 12.5),
                ),
                const SizedBox(height: 12),
                FilledButton.icon(
                  onPressed: () => AppSettings.openAppSettings(
                      type: AppSettingsType.notification),
                  icon: const Icon(Icons.notifications_active_outlined),
                  label: const Text('알림 설정 열기'),
                  style: FilledButton.styleFrom(
                      minimumSize: const Size.fromHeight(50)),
                ),
              ],

핵심 · AppSettings.openAppSettings(type: AppSettingsType.notification)

B ‘나의 하루에 추가’ → ‘오늘 할일’

할일 입력의 ‘나의 하루에 추가’를 ‘오늘 할일’(☀아이콘 제거 + 진한 파란색)로 바꿨습니다. 웹은 ‘오늘’, 폰앱은 ‘오늘 할일’로 표기.

📄 lib/widgets/todo_edit_sheet.dart — SwitchListTile
            value: t.isMyDay,
            onChanged: (v) => setState(() => t.isMyDay = v),
-           secondary:
-               const Icon(Icons.wb_sunny_outlined, color: Color(0xFFF5A623)),
-           title: const Text('나의 하루에 추가'),
+           title: const Text('오늘 할일',
+               style: TextStyle(
+                   color: Color(0xFF12448A), fontWeight: FontWeight.w600)),
            dense: true,
          ),
삭제(−) 추가(+)

C 기록 날짜 이동 (신규)

기록은 기한 입력이 없어 선택 날짜에 귀속되므로, 폰앱에서 만든 기록은 다른 날짜로 옮길 수 없었습니다. 기록 편집기에 ‘날짜’ 이동 컨트롤을 추가했습니다 — 웹과 동일하게 과거 O · 미래 X(기록 미래 불가).

📄 lib/widgets/todo_edit_sheet.dart — _buildEventRecord (kind == 'record')
          if (kind == 'event') _rowDue(),
          if (kind == 'record') _rowRecordDate(), // 기록: 날짜 이동(과거 O, 미래 X)
          _rowEventTime(),

+ 날짜 선택기(미래 차단) & 행 위젯 추가:

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),
      ]),
    ),
  );
}

+ 저장 시 서버 방어(동기화된 미래 기록도 오늘로) — _save():

// 기록: 미래 불가 → 미래 날짜면 오늘로(과거는 유지). 웹과 동일.
if (t.isRecord &&
    t.dueDate != null &&
    t.dueDate!.compareTo(todayIso) > 0) {
  t.dueDate = todayIso;
}

D 기록 종류(템플릿) + 서버 동기화 (신규 기능)

설정에서 기록 항목명 + 옵션(예: 수면관리 → 기상/취침)을 만들어 두고, 달력에서 📝 빠른 기록으로 옵션·시간을 골라 기록 1건을 바로 저장합니다. 저장 형식은 웹과 동일 — content=옵션(예: ‘기상’), event_time=시간.

동기화: auto는 서버(modam_doc)와 sync_uid 기준 병합(LWW·툼스톤). day_plan은 로컬 전용(SyncService 미참조, bumpData()만).

진입점 명명

웹: 좌측 메뉴 ⚙ 기록 설정 → 페이지 제목 기록 설정 종류.   폰: 설정 탭 → 기록 종류 관리 버튼.  달력 빠른 입력 버튼은 웹·폰 모두 📝 빠른 기록.

① lib/db/app_db.dart — DB 버전 8→9 + 테이블
      path,
-     version: 8,
+     version: 9,
      onConfigure: ...

// onCreate + onUpgrade(oldV < 9):
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)');

+ DB 메서드: list/save/delete(소프트) · allRecordTemplatesForSync · applyRecordTemplates(sync_uid LWW)

② 신규 파일 2개 (두 앱 동일, SyncService 미참조)
lib/screens/record_templates_screen.dart  // 기록 종류 관리(항목명·옵션 행·고정·목록)
lib/widgets/record_quick_sheet.dart        // showRecordQuickSheet(dateIso) 바텀시트

설정 진입 · 달력 버튼:

// settings_screen.dart — 알림 섹션 뒤
FilledButton.icon(
  onPressed: () => Navigator.of(context).push(MaterialPageRoute(
      builder: (_) => const RecordTemplatesScreen())),
  icon: const Icon(Icons.playlist_add), label: const Text('기록 종류 관리'), ...),

// calendar_tab.dart — 하단 시트 날짜 헤더 아래
if (!_holidaysOnly)
  OutlinedButton.icon(
    onPressed: () async {
      final saved = await showRecordQuickSheet(context, dateIso: selIso);
      if (saved && mounted) _load();
    },
    icon: const Text('📝'), label: const Text('빠른 기록')),
③ lib/services/sync.dart — auto만(서버 동기화)
final tpls = await AppDb.instance.allRecordTemplatesForSync();
// POST body 에 'record_templates': tpls 추가, 응답 스냅샷 적용:
final rtList = (data['record_templates'] as List?) ?? const [];
final rtN = await AppDb.instance.applyRecordTemplates(rtList);

서버(modam_doc)도 tb_record_template에 sync_uid·updated_at·is_deleted 추가 + api_todos_sync 확장(하위호환). day_plan은 sync.dart 없음 → 이 단계 생략.

E 폰앱 UI 개선 (실기기 피드백 반영)

실기기(삼성 Z Fold5)에서 확인된 UI 문제를 폰앱 전용으로 손봤습니다. 웹·macOS 이식 대상이 아니라 폰앱 고유 항목입니다(참고용).

① 기록 종류 관리 · 편집기 버튼 가시성

기본 FilledButton이 일부 기기에서 배경과 거의 같은 색으로 흐릿하게 나와 저장/수정/추가 버튼이 안 보이던 문제. 배경·글자색을 명시(파란/보라 배경 + 흰 굵은 글씨 + ✓ 아이콘 + 전체폭)로 교체.

// record_templates_screen.dart · todo_edit_sheet.dart
- FilledButton(child: Text('수정'))            // 흐릿
+ FilledButton.icon(
+   icon: const Icon(Icons.check, size: 20),
+   label: Text('수정 저장', style: bold 16),
+   style: FilledButton.styleFrom(
+       backgroundColor: 0xFF2F6AA8, foregroundColor: Colors.white,
+       minimumSize: Size.fromHeight(52)))
② 빠른 기록 → 신규 기록 유형칩 행의 버튼 + 다이얼로그

달력 날짜 시트의 「📝 빠른 기록」(모든 모드 노출)을 제거. 신규 기록 작성 시 상단 유형칩(할일/일정/기록) 행의 「기록」 오른쪽에만 버튼 배치. (‘기록’ 라벨 행은 삭제, 수정 화면엔 버튼 없음.) 버튼 클릭 → 다이얼로그에서 템플릿 옵션 선택 → 제목·시간 자동 입력 → 검증된 기존 「추가」 저장 경로 사용.

// calendar_tab.dart 날짜시트: 빠른 기록 버튼 제거(+ record_quick_sheet import 제거)
// todo_edit_sheet.dart _typeSelector()
Row([ chip('할일'), chip('일정'), chip('기록'),
      if (kind=='record') [ Spacer(), _quickRecordButton() ] ])
// _quickRecordButton → _openQuickRecordDialog():
//   showDialog → 템플릿별 옵션 ActionChip → 선택 시 Navigator.pop(opt)
//   _applyQuickOption(opt): _title.text = opt; 시간 비면 현재시각

기록 생성은 편집기의 _saveByKind()(isTodo=false·onCalendar=false·isRecord=true) 그대로 → 확실히 저장됨.

※ 기존 widgets/record_quick_sheet.dart(별도 바텀시트)는 미사용(사장).

④ 기록 종류 관리 화면 세부
  • 저장/수정 버튼을 세로 전체폭으로 배치(Row+Expanded가 일부 기기에서 안 그려지던 문제 회피). 라벨은 「수정」, 불필요한 「취소」 제거.
  • 옵션 입력 placeholder를 인덱스별로: 1행 예: 기상, 2행 예: 취침.
  • 개별 고정 체크박스: 저장된 목록 각 행의 수정 아이콘 앞에 배치 — 체크=고정/해제=비고정, 그 항목만 즉시 저장·동기화. 폼의 고정 스위치는 제거.
⑤ 기록 종류 고정(is_fixed) 동기화 — LWW 시계오차 버그 수정

증상: 한 템플릿의 고정을 끄면 모든 템플릿이 비고정이 됨. 원인: 웹에서 만든 템플릿은 updated_at이 서버 실시각인데, 폰 시계가 뒤처지면 폰 수정의 updated_at이 더 작아 LWW에서 서버의 옛 값(0)이 이겨 되돌려짐(저장 시 전체 동기화라 전부 0으로 보임). 서버 병합 자체는 정상(행별 is_fixed)임을 서버에서 직접 테스트로 확인.

// app_db.dart — 로컬 수정용 updated_at 을 기존값보다 항상 크게(단조 증가)
String _laterStamp(String? existing) {
  final now = nowUtcStamp();
  if (existing == null || now.compareTo(existing) > 0) return now;
  return existing + 1초;   // 폰 시계가 뒤처져도 폰 수정이 LWW 에서 이김
}
// saveRecordTemplate(수정)·deleteRecordTemplate 에서 사용

※ 같은 원리가 다른 웹 생성 항목(할일/일정)에도 잠재. 필요 시 update() 등으로 확장 적용 예정.

⑥ 달력을 표(행·열) 격자로

둥근 카드형 날짜칸 → 진짜 표 격자. TableCalendar의 calendarStyle.tableBorder(안팎 가로·세로선)로 단일 격자선, cellMargin/cellPadding = 0. 셀 자체 테두리 제거, 선택 칸만 파란 강조. 선 색은 #9AA5B1(진회색).

③ 달력 완료 점(●) 위치 — 우측 상단

할일 완료 표시 점을 날짜칸 하단 중앙 → 우측 상단으로 이동. 셀을 Stack으로 감싸 Positioned 배치(구글 일정 파란 점은 하단 중앙 유지).

child: Stack(children: [
  Column(... 날짜/제목 ...),
  if (doneCnt > 0 && ...) Positioned(top: 4, right: 4, child: _dot(Colors.black)),
])
* 확인만 하고 유지한 동작

달력 오늘 날짜 시트는 미완료('오늘 할 일')만 표시가 정상 — 오늘 완료 항목은 ‘완료됨’ 뷰에서 조회. (한때 완료 항목을 시트에 추가했다가 되돌림.)

F 9월 3일 패치 이식 (애플 → 안드로이드)

애플 버전(iOS)에서 확인·수정한 3건을 두 안드로이드 앱(auto·day_plan)에 동일 이식. 모두 공통 Dart lib/ 변경이며 네이티브 수정 없음. 두 앱 flutter analyze 통과.

PATCH-01 · 편입본 삭제 시 구글 일정 숨김 해제

구글 일정을 [할일/일정/기록]으로 편입하면 원본이 gcal_hidden(tb_setting)에 영구 숨김됨. 편입본을 삭제해도 숨김이 남아 원본이 안 보이던 문제. 완전 삭제 시 날짜·제목으로 매칭해 숨김 해제.

// gcal.dart — 키: '$iso|$time|$title', 시각 무시하고 날짜+제목 매칭
static Future<bool> unhideByContent(String iso, String title) async {
  await _loadHidden();
  final before = _hidden!.length;
  _hidden!.removeWhere((k) => k.startsWith('$iso|') && k.endsWith('|$title'));
  if (_hidden!.length == before) return false;
  await _saveHidden(); return true;
}
// todo_edit_sheet.dart — 완전 삭제 2곳(_delete 단일 / _deleteWithChoice 둘다) 뒤:
await GcalService.unhideByContent(t.dueDate ?? '', t.content);
// 부분 삭제(setPresence, 일정만/할일만)는 제외

한계: 같은 날 같은 제목 다른 시각이 여럿이면 함께 풀릴 수 있음. 기존 숨김 잔재는 소급 안 됨(앞으로의 삭제부터).

PATCH-02 · 할 일 별표(즐겨찾기) 상단 고정

별표가 2차 정렬 기준이라 완료·나의하루 그룹 안에서만 위로 가던 것을, 상태와 무관하게 항상 최상단으로. DB orderBy는 유지, 앱단에서 안정 분할.

// calendar_tab.dart
List<Todo> _starFirst(List<Todo> items) =>
    [...items.where((t) => t.isStar), ...items.where((t) => !t.isStar)];
_selItems = _starFirst(sel);   // (기존: _selItems = sel;)
PATCH-03 · 달력 탭 제목 “모담 하루계획”
// home_screen.dart — 달력 탭 앱바만 변경(다른 탭·런처 이름 그대로)
static const _titles = ['모담 하루계획', '할 일', '일정', '기록'];  // 기존 '하루계획'
변경 파일 · 문서

파일: services/gcal.dart · widgets/todo_edit_sheet.dart · screens/calendar_tab.dart · screens/home_screen.dart.  문서: docs/2026-09-03/ 3건(편입삭제·별표상단·제목변경).

▸ 웹 변경 커버리지 (폰 반영 현황)

이 세션의 웹 변경을 폰앱 기준으로 분류했습니다. 실제 코드 수정이 필요했던 건 위 A·B뿐이고, 나머지는 이미 처리돼 있거나 웹 전용이라 해당이 없습니다.

웹 변경폰 상태메모
‘나의 하루에 추가’ → ‘오늘 할일’✓ 적용B · todo_edit_sheet.dart (웹 ‘오늘’ / 폰 ‘오늘 할일’)
알림 설정 열기(app_settings)✓ 적용A · settings_screen.dart
미리알림 ‘안함’ 시 시간 숨김이미 처리알림 없을 때 전일/당일·시간 숨김(has 게이트)
과거 기록/일정 수정 시 날짜 유지이미 처리과거→오늘 보정이 순수 할일(isTodo)만 적용
할일 날짜 이동이미 처리편집 시트 기한 선택(오늘/내일/다음주/날짜선택), firstDate=오늘로 과거 이동 차단(웹과 동일)
기록 날짜 이동 (미래 불가)✓ 적용C · 기록 편집기에 ‘날짜’ 컨트롤 추가, lastDate=오늘로 미래 차단 + 저장 시 클램프
기록 종류(템플릿) + 서버 동기화✓ 적용D · DB v9 + 설정 관리 + 📝 빠른 기록 + 동기화(auto) / 로컬(day_plan)
폰앱 UI 개선 묶음(실기기 피드백)✓ 적용E · 버튼 가시성·빠른 기록(신규 유형칩 옆 버튼→다이얼로그)·완료 점 우측 상단·기록종류 목록 개별 고정 체크박스·달력 표 격자·고정 동기화 LWW 시계오차 수정
상세폼 헤더(× + 라벨) 제거웹 전용폰은 바텀시트 편집기 — 해당 UI 없음
클릭 눌림/플래시 효과웹 전용폰은 Material 리플 기본 제공
기록 마커 원→네모(달력)해당 없음폰 달력은 기록을 보라색 텍스트로 표시(마커 없음)
설정 페이지 여백(‘전체 기록’과 정렬)웹 전용설정 콘텐츠를 .sch-listwrap로 감싸 다른 목록 페이지와 동일한 상단 22px·좌우 24px 여백 적용(폰은 별도 화면 — 해당 없음)
상단 날짜 이동 · 사이드바 순서 · 메모높이 · 제목폰트웹 전용웹 상세폼/사이드바 레이아웃 고유

▸ 검증 (폰앱에서 통과 확인됨)

▸ macOS 프로젝트 적용 순서

  1. pubspec.yaml 에 app_settings: ^6.1.1 추가 → flutter pub get.
  2. settings_screen.dart: import 1줄 + 알림 섹션 블록(A-③) 삽입 — 앵커 “구글 캘린더 저장 버튼 뒤”.
  3. todo_edit_sheet.dart: ① my_day SwitchListTile 아이콘 제거 + ‘오늘 할일’(진한 파랑) 교체(B), ② 기록 편집기에 _rowRecordDate()/_pickRecordDate() 추가 + _save() 기록 미래 클램프(C).
  4. 기록 종류(D): app_db.dart DB v9 테이블+메서드, 신규 파일 2개(record_templates_screen.dart·record_quick_sheet.dart), 설정 진입 + 달력 📝 버튼. 서버 동기화 쓰면 sync.dart에 record_templates 병합 추가(로컬 전용이면 생략).
  5. flutter analyze 후 flutter run -d macos 로 동작 확인.
⚠︎

macOS 특이사항 — 반드시 확인

같은 코드지만 플랫폼 동작이 다를 수 있습니다. macOS에서 AppSettingsType.notification은 시스템 설정 › 알림을 엽니다(앱별 항목 위치는 macOS 버전에 따라 다를 수 있음). 또 macOS에서 실제 알림 수신에는 데스크톱 알림 권한/엔터틀먼트가 별도로 필요합니다 — 이 버튼은 “설정 이동”만 담당합니다. 빌드 후 실동작을 꼭 확인하세요.