From 95422bb01245f0c5fb3cd1468fdc6140260f7a6d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 05:55:30 +0000 Subject: [PATCH] =?UTF-8?q?=EB=8B=B5=EC=9E=A5=20=EB=A7=88=EA=B0=90=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC(2.7-F)=20=EA=B5=AC=ED=98=84:=20=EC=98=A8?= =?UTF-8?q?=EB=94=94=EB=B0=94=EC=9D=B4=EC=8A=A4=20=EC=8A=A4=EB=88=84?= =?UTF-8?q?=EC=A6=88=20=EC=A0=80=EC=9E=A5=20+=20=EC=9D=B8=EC=95=B1=20?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=20+=20=EB=A1=9C=EC=BB=AC=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC(=EB=B6=80=EB=B6=84=20=EA=B2=80=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit roadmap.md §2.7-F / deploy-checklist.md N4-C6a~c 대응. "이따 답장"을 누르면 대화방별 스누즈 시각을 순수 온디바이스 drift 테이블(ConversationSnoozes)에 저장하고(서버 전송 없음, 다른 사람에게 절대 보이지 않음), chat_screen.dart 앱바에서 빠른 선택(1시간 후/저녁에/내일)으로 걸고 해제할 수 있게 했다. 실제 답장을 보내면 자동으로 스누즈가 풀린다. 로컬 알림은 flutter_local_notifications+timezone을 실제로 붙여 zonedSchedule()/cancel()을 호출하지만, 이 샌드박스에는 실기기·에뮬레이터가 없어 알림이 실제로 울리는지는 검증하지 못했다 — 검증한 건 스케줄러 호출 인자(id/시각/페이로드)가 목(mock) 기준으로 맞는지뿐이다. 그 대신 항상 켜져 있는 인앱 대체 경로로 conversation_list_screen.dart 배지와 chat_screen.dart 배너가 마감 지난 스누즈를 보여주고 바로 해제할 수 있게 했다. 웹 빌드는 플러그인이 웹을 지원하지 않아 no-op 스텁으로 대체. 마감 판정(isSnoozePastDue)과 빠른 선택 시각 계산은 순수 함수로 분리해 고정 시각으로 단위 테스트했고, SnoozeController를 통해 스케줄러를 목으로 바꿔 넣어 호출 인자도 검증했다. 백엔드(core-backend/ai-service) 변경 없음. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m --- docs/deploy-checklist.md | 31 +- docs/roadmap.md | 30 +- mobile/lib/db/app_database_native.dart | 53 ++- ...base.g.dart => app_database_native.g.dart} | 407 +++++++++++++++++- mobile/lib/db/app_database_web.dart | 15 + mobile/lib/db/tables.dart | 13 + mobile/lib/screens/chat_screen.dart | 102 +++++ .../lib/screens/conversation_list_screen.dart | 252 +++++++---- mobile/lib/services/snooze_controller.dart | 39 ++ .../services/snooze_notification_service.dart | 10 + .../snooze_notification_service_native.dart | 80 ++++ .../snooze_notification_service_web.dart | 27 ++ mobile/lib/services/snooze_service.dart | 49 +++ mobile/lib/state/session_state.dart | 13 +- mobile/pubspec.lock | 66 ++- mobile/pubspec.yaml | 2 + mobile/test/local_db_test.dart | 30 ++ mobile/test/snooze_controller_test.dart | 86 ++++ mobile/test/snooze_service_test.dart | 64 +++ 19 files changed, 1262 insertions(+), 107 deletions(-) rename mobile/lib/db/{app_database.g.dart => app_database_native.g.dart} (66%) create mode 100644 mobile/lib/services/snooze_controller.dart create mode 100644 mobile/lib/services/snooze_notification_service.dart create mode 100644 mobile/lib/services/snooze_notification_service_native.dart create mode 100644 mobile/lib/services/snooze_notification_service_web.dart create mode 100644 mobile/lib/services/snooze_service.dart create mode 100644 mobile/test/snooze_controller_test.dart create mode 100644 mobile/test/snooze_service_test.dart diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 00d9b16..1770c1f 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -62,9 +62,18 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. `persona.go` `resolveRelationshipNote()`(그룹은 항상 빈 문자열 — 전역 기본 메모 개념 자체가 없어 티어/자율성과 다름), `ai-service/app/generation.py`가 메모를 `[관계 메모]` 프롬프트 문단으로 주입. 이 김에 세 resolver가 복붙하던 "1:1 상대 Contact 찾기" 루프를 - `findCounterpartContact()` 공용 헬퍼로 추출. C6(답장 마감 알림)은 아직 todo. - Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능 -- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 + `findCounterpartContact()` 공용 헬퍼로 추출. C6(답장 마감 알림)도 **완료(부분 검증)** + (2026-08-03, 아직 GitHub `main`에만 있고 프로덕션 미배포, 모바일 온디바이스 전용이라 + `core-backend`/`ai-service` 변경 없음) — 순수 온디바이스 스누즈 저장(`ConversationSnoozes` + drift 테이블, 서버 전송 없음) + `chat_screen.dart`/`conversation_list_screen.dart` UI는 + 완전히 검증됨(단위 테스트). 로컬 알림은 `flutter_local_notifications`/`timezone`을 실제로 + 붙였지만(`flutter pub get` 성공, `zonedSchedule`/`cancel` 연동) **이 샌드박스에 실기기· + 에뮬레이터가 없어 알림이 실제로 울리는지까지는 검증하지 못함** — 검증한 건 스케줄러 호출 + 인자가 맞는지(목 기반 단위 테스트)뿐이라 실기기 확인 전까지는 대화 목록/채팅방의 인앱 + 배지·배너가 실질적인 리마인드 경로. **이로써 2026-08-03 2차 갭 분석 배치(C4/C5/C6)는 모두 + 구현 완료 — 다만 C6의 "실제 OS 알림 발사" 부분은 Android 실기기 탭(N4-5~10)에서 처음 + 검증되는 항목으로 남는다.** Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능 +- 실 FCM 기기 수신 · Android 실기기 탭(답장 마감 알림 실제 발사 확인 포함) · 사람 PoC 실행은 남음 ### NEXT 순서 @@ -221,9 +230,9 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **N4-C5a** | `draftRequest`에 `RelationshipNote` 필드 추가 | **done** (2026-08-03) | `core-backend/aiservice.go`, `relationship_tier` 옆 `omitempty`, 빈 문자열 = 무영향 | | **N4-C5b** | draft 핸들러가 연락처 메모 조회해 전달 | **done** (2026-08-03) | `main.go` `POST /conversations/:id/draft` + `core-backend/persona.go` `resolveRelationshipNote()`. 그룹은 항상 빈 문자열(전역 기본 메모 개념 자체가 없음, 티어/자율성과 다른 지점). `resolveRelationshipTier`/`resolveAutonomyLevel`과 공유하는 `findCounterpartContact()` 헬퍼로 3중 복붙 제거 | | **N4-C5c** | 메모를 톤 프롬프트에 주입 | **done** (2026-08-03) | `ai-service/app/generation.py` `system_prompt_for_tier(relationship_tier, relationship_note)`가 `"[관계 메모] {note} -- ..."` 문단 추가(빈 값/`None`이면 무영향, 관계 티어 지침과 별도 문단이라 안 섞임). 에스컬레이션/정체성 게이팅에는 영향 없음 | -| **N4-C6a** | "이따 답장" 스누즈 저장 | todo | 온디바이스 우선 원칙에 맞는 저장 위치 결정 | -| **N4-C6b** | 리마인드 로컬 알림(본인에게만) | todo | 다른 사람에게 노출되지 않음 | -| **N4-C6c** | 스누즈 표시/취소 UI | todo | `chat_screen.dart`/`conversation_list_screen.dart` | +| **N4-C6a** | "이따 답장" 스누즈 저장 | **done** (2026-08-03) | 순수 온디바이스 — `mobile/lib/db/tables.dart` `ConversationSnoozes` drift 테이블(서버 전송 없음), `chat_screen.dart` 앱바 "이따 답장" → 빠른 선택(1시간 후/저녁에/내일) + 해제, 실제 답장 전송 시 자동 해제 | +| **N4-C6b** | 리마인드 로컬 알림(본인에게만) | **done (부분 검증)** (2026-08-03) | `flutter_local_notifications`+`timezone` 실제 연동(`mobile/lib/services/snooze_notification_service_native.dart` `zonedSchedule`/`cancel`) — **단, 이 샌드박스에는 실기기/에뮬레이터가 없어 알림이 실제로 울리는지/탭 동작은 검증 불가**. 검증한 건 스케줄러를 목(mock)으로 바꿔 id·시각·페이로드가 올바른지뿐(`test/snooze_controller_test.dart`). 웹 빌드는 플러그인이 웹 미지원이라 `snooze_notification_service_web.dart` no-op 스텁. 실기기 검증 전까지는 대화 목록/채팅방의 인앱 배지·배너(C6c)가 실질적인 리마인드 경로 | +| **N4-C6c** | 스누즈 표시/취소 UI | **done** (2026-08-03) | `chat_screen.dart`(앱바 아이콘 상태 + 마감 지난 스누즈 배너 + 해제 버튼), `conversation_list_screen.dart`(서브타이틀 아래 "답장 마감" 배지, 탭하여 바로 해제). 마감 판정은 순수 함수 `isSnoozePastDue()`로 분리해 고정 시각 단위 테스트(`test/snooze_service_test.dart`) | ### FCM — [`fcm-setup.md`](./fcm-setup.md) @@ -286,9 +295,13 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P ~~Track C2 관계별 페르소나~~ **done** (2026-08-03), ~~Track C3 스팸/도배 감지~~ **done** (2026-08-03) — **Track C 콘텐츠 갭 A/B/C 전체 완료.** 2026-08-03 2차 재분석으로 D/E/F 추가 발견, ~~Track C4 자율성 상대별 예외~~ **done** (2026-08-03), ~~Track C5 관계 메모 반영~~ - **done** (2026-08-03). 남은 것: Track C6(답장 마감 알림), **Master FCM 시크릿(N4-1/3)** → - N4-4 스모크 → Android UI QA (N4-5~10), Track C 프로덕션 재배포(C2/C3/C4/C5는 아직 GitHub - `main`에만 있음) + **done** (2026-08-03), ~~Track C6 답장 마감 알림~~ **done(부분 검증)** (2026-08-03) — + **2026-08-03 2차 갭 분석 배치(C4/C5/C6) 전체 구현 완료.** C6은 온디바이스 스누즈 + 저장·UI는 완전히 검증(단위 테스트)됐지만, 실제 OS 로컬 알림이 실기기에서 울리는지는 + 이 샌드박스(실기기/에뮬레이터 없음)에서 검증 불가 — Android UI QA(N4-5~10)에서 처음 + 확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. 남은 것: **Master FCM + 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10, 답장 마감 알림 실기기 확인 + 포함), Track C 프로덕션 재배포(C2~C6는 아직 GitHub `main`에만 있음) 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. diff --git a/docs/roadmap.md b/docs/roadmap.md index d29b680..042448b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -233,11 +233,31 @@ 이 둘에는 전혀 영향 없음(순수 톤/금기어 힌트, 안전 게이트 우회 아님) **2.7-F 답장 마감 알림** (`PRD.md` §3.2 P1: "내가 '이따 답장' 누르면 나에게만 리마인드" — 코드 -전무, 아이디어 회의 문서에만 존재, 2026-08-03 발견) -- [ ] 메시지/대화에 "이따 답장" 스누즈 액션 + 리마인드 시각 저장(서버 또는 온디바이스 — 개인 - 전용 알림이라 온디바이스 우선 원칙에 맞는 쪽으로 설계) -- [ ] 리마인드 도달 시 로컬 알림(다른 사람에게는 보이지 않음, 본인에게만) -- [ ] `chat_screen.dart`/`conversation_list_screen.dart`에 스누즈 표시·취소 UI +전무, 아이디어 회의 문서에만 존재, 2026-08-03 발견 — **완료(부분 검증)** 2026-08-03, 아래 참고) +- [x] 메시지/대화에 "이따 답장" 스누즈 액션 + 리마인드 시각 저장 — 순수 온디바이스 + (`mobile/lib/db/tables.dart`의 `ConversationSnoozes` drift 테이블, 서버에는 전혀 + 전달되지 않음). `chat_screen.dart` 앱바에 "이따 답장" 아이콘 → `SimpleDialog`로 + 빠른 선택(`1시간 후`/`저녁에`/`내일`, `mobile/lib/services/snooze_service.dart` + `SnoozeQuickPick`) + 스누즈 해제. 기본값은 빠른 선택 없이도 쓸 수 있게 상수 + `kDefaultSnoozeDuration`(2시간)로 노출은 해 두었지만 UI 자체는 빠른 선택 3개만 + 제공(추가 커스텀 시각 입력은 P1 범위상 생략). 사람이 실제로 답장을 보내면(휴먼 + 전송이든 트윈 승인 전송이든) 자동으로 스누즈 해제(`_clearSnoozeQuiet()`) +- [x] 리마인드 도달 시 로컬 알림 — **`flutter_local_notifications`(+`timezone`) 실제 + 연동, 단 실기기/에뮬레이터로 발사·탭 동작까지 검증하지는 못함.** 이 샌드박스는 + Flutter 웹 빌드와 Playwright 브라우저 자동화만 가능하고 실제 Android/iOS + 기기·에뮬레이터가 없어 `zonedSchedule()`이 만든 예약이 실제로 울리는지는 확인할 + 수 없다 — 검증한 것은 `mobile/lib/services/snooze_controller.dart`가 스케줄러 + 인터페이스(`SnoozeNotificationScheduler`)를 목(mock)으로 바꿔 넣었을 때 올바른 + id·시각·페이로드로 호출되는가뿐(`test/snooze_controller_test.dart`). 대신 진짜로 + 검증 가능한 **인앱 대체 노출**을 항상 함께 켜 둔다 — 앱을 열 때(대화 목록 로드, + 채팅방 진입) 마감이 지난 스누즈는 배지/배너로 반드시 보인다(아래 항목). 웹 빌드는 + 플러그인 자체가 웹을 지원하지 않아 `snooze_notification_service_web.dart`가 + no-op 스텁으로 대체(웹은 프리뷰 서피스일 뿐 릴리스 타깃이 아님) +- [x] `chat_screen.dart`/`conversation_list_screen.dart`에 스누즈 표시·취소 UI — + 채팅방은 앱바 아이콘 상태(`Icons.alarm_on`) + 마감 지난 스누즈 배너("스누즈 + 해제" 버튼), 대화 목록은 서브타이틀 아래 "답장 마감 (탭하여 해제)" 배지(탭하면 + 채팅방을 열지 않고도 바로 해제). 마감 판정은 순수 함수 `isSnoozePastDue(now, + snoozedUntil)`로 분리해 고정 시각으로 단위 테스트(`test/snooze_service_test.dart`) **스코프 밖 (제안 아님, 참고용)**: 이미지/파일 전송·읽음표시·타이핑 인디케이터 등 "일반 메신저" 테이블스테이크 기능은 `PRD.md`에 명시되지 않음 — 콘텐츠 공백의 또 다른 후보일 수 있으나 이건 diff --git a/mobile/lib/db/app_database_native.dart b/mobile/lib/db/app_database_native.dart index 65c0ae0..646bb58 100644 --- a/mobile/lib/db/app_database_native.dart +++ b/mobile/lib/db/app_database_native.dart @@ -13,9 +13,9 @@ import 'package:sqlite3/open.dart'; import 'tables.dart'; -part 'app_database.g.dart'; +part 'app_database_native.g.dart'; -@DriftDatabase(tables: [ToneSamples, LocalKv]) +@DriftDatabase(tables: [ToneSamples, LocalKv, ConversationSnoozes]) class AppDatabase extends _$AppDatabase { AppDatabase(super.e, {this.encrypted = false}); @@ -23,7 +23,18 @@ class AppDatabase extends _$AppDatabase { final bool encrypted; @override - int get schemaVersion => 1; + int get schemaVersion => 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (m) => m.createAll(), + onUpgrade: (m, from, to) async { + // v1 -> v2: roadmap.md §2.7-F 답장 마감 알림 스누즈 테이블 추가. + if (from < 2) { + await m.createTable(conversationSnoozes); + } + }, + ); /// In-memory DB for unit tests (no SQLCipher / filesystem). factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false); @@ -76,6 +87,42 @@ class AppDatabase extends _$AppDatabase { Future setBoolKv(String key, bool value) async { await setKv(key, value ? '1' : '0'); } + + /// 답장 마감 알림(roadmap.md §2.7-F) — "이따 답장"으로 대화방 [conversationId]에 + /// 스누즈를 건다. 서버에는 전혀 전달되지 않는 순수 온디바이스 상태. + Future setSnoozedUntil(int conversationId, DateTime until) async { + await into(conversationSnoozes).insertOnConflictUpdate( + ConversationSnoozesCompanion.insert( + conversationId: conversationId.toString(), + snoozedUntilMs: until.millisecondsSinceEpoch, + ), + ); + } + + Future getSnoozedUntil(int conversationId) async { + final row = await (select(conversationSnoozes) + ..where((t) => t.conversationId.equals(conversationId.toString()))) + .getSingleOrNull(); + if (row == null) return null; + // isUtc: true — millisecondsSinceEpoch는 어차피 절대 시각이라 타임존 표현과 + // 무관하지만, UTC로 고정해야 DateTime.== 비교(테스트 포함)가 저장 전과 일관된다. + return DateTime.fromMillisecondsSinceEpoch(row.snoozedUntilMs, isUtc: true); + } + + Future clearSnooze(int conversationId) async { + await (delete(conversationSnoozes) + ..where((t) => t.conversationId.equals(conversationId.toString()))) + .go(); + } + + /// 대화 목록 화면(`conversation_list_screen.dart`)에서 배지를 그리기 위한 일괄 로드. + Future> loadAllSnoozes() async { + final rows = await select(conversationSnoozes).get(); + return { + for (final r in rows) + int.parse(r.conversationId): DateTime.fromMillisecondsSinceEpoch(r.snoozedUntilMs, isUtc: true), + }; + } } const _kDbPassphrase = 'db_passphrase_v1'; diff --git a/mobile/lib/db/app_database.g.dart b/mobile/lib/db/app_database_native.g.dart similarity index 66% rename from mobile/lib/db/app_database.g.dart rename to mobile/lib/db/app_database_native.g.dart index 83d67d7..a4a6ac2 100644 --- a/mobile/lib/db/app_database.g.dart +++ b/mobile/lib/db/app_database_native.g.dart @@ -532,16 +532,256 @@ class LocalKvCompanion extends UpdateCompanion { } } +class $ConversationSnoozesTable extends ConversationSnoozes + with TableInfo<$ConversationSnoozesTable, ConversationSnooze> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ConversationSnoozesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _conversationIdMeta = const VerificationMeta( + 'conversationId', + ); + @override + late final GeneratedColumn conversationId = GeneratedColumn( + 'conversation_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _snoozedUntilMsMeta = const VerificationMeta( + 'snoozedUntilMs', + ); + @override + late final GeneratedColumn snoozedUntilMs = GeneratedColumn( + 'snoozed_until_ms', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [conversationId, snoozedUntilMs]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'conversation_snoozes'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('conversation_id')) { + context.handle( + _conversationIdMeta, + conversationId.isAcceptableOrUnknown( + data['conversation_id']!, + _conversationIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_conversationIdMeta); + } + if (data.containsKey('snoozed_until_ms')) { + context.handle( + _snoozedUntilMsMeta, + snoozedUntilMs.isAcceptableOrUnknown( + data['snoozed_until_ms']!, + _snoozedUntilMsMeta, + ), + ); + } else if (isInserting) { + context.missing(_snoozedUntilMsMeta); + } + return context; + } + + @override + Set get $primaryKey => {conversationId}; + @override + ConversationSnooze map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ConversationSnooze( + conversationId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}conversation_id'], + )!, + snoozedUntilMs: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}snoozed_until_ms'], + )!, + ); + } + + @override + $ConversationSnoozesTable createAlias(String alias) { + return $ConversationSnoozesTable(attachedDatabase, alias); + } +} + +class ConversationSnooze extends DataClass + implements Insertable { + /// `Conversation.id`를 문자열로 저장 (drift 기본 타입 일관성을 위해 다른 테이블과 + /// 마찬가지로 text 기본키를 사용). + final String conversationId; + final int snoozedUntilMs; + const ConversationSnooze({ + required this.conversationId, + required this.snoozedUntilMs, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['conversation_id'] = Variable(conversationId); + map['snoozed_until_ms'] = Variable(snoozedUntilMs); + return map; + } + + ConversationSnoozesCompanion toCompanion(bool nullToAbsent) { + return ConversationSnoozesCompanion( + conversationId: Value(conversationId), + snoozedUntilMs: Value(snoozedUntilMs), + ); + } + + factory ConversationSnooze.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ConversationSnooze( + conversationId: serializer.fromJson(json['conversationId']), + snoozedUntilMs: serializer.fromJson(json['snoozedUntilMs']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'conversationId': serializer.toJson(conversationId), + 'snoozedUntilMs': serializer.toJson(snoozedUntilMs), + }; + } + + ConversationSnooze copyWith({String? conversationId, int? snoozedUntilMs}) => + ConversationSnooze( + conversationId: conversationId ?? this.conversationId, + snoozedUntilMs: snoozedUntilMs ?? this.snoozedUntilMs, + ); + ConversationSnooze copyWithCompanion(ConversationSnoozesCompanion data) { + return ConversationSnooze( + conversationId: data.conversationId.present + ? data.conversationId.value + : this.conversationId, + snoozedUntilMs: data.snoozedUntilMs.present + ? data.snoozedUntilMs.value + : this.snoozedUntilMs, + ); + } + + @override + String toString() { + return (StringBuffer('ConversationSnooze(') + ..write('conversationId: $conversationId, ') + ..write('snoozedUntilMs: $snoozedUntilMs') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(conversationId, snoozedUntilMs); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ConversationSnooze && + other.conversationId == this.conversationId && + other.snoozedUntilMs == this.snoozedUntilMs); +} + +class ConversationSnoozesCompanion extends UpdateCompanion { + final Value conversationId; + final Value snoozedUntilMs; + final Value rowid; + const ConversationSnoozesCompanion({ + this.conversationId = const Value.absent(), + this.snoozedUntilMs = const Value.absent(), + this.rowid = const Value.absent(), + }); + ConversationSnoozesCompanion.insert({ + required String conversationId, + required int snoozedUntilMs, + this.rowid = const Value.absent(), + }) : conversationId = Value(conversationId), + snoozedUntilMs = Value(snoozedUntilMs); + static Insertable custom({ + Expression? conversationId, + Expression? snoozedUntilMs, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (conversationId != null) 'conversation_id': conversationId, + if (snoozedUntilMs != null) 'snoozed_until_ms': snoozedUntilMs, + if (rowid != null) 'rowid': rowid, + }); + } + + ConversationSnoozesCompanion copyWith({ + Value? conversationId, + Value? snoozedUntilMs, + Value? rowid, + }) { + return ConversationSnoozesCompanion( + conversationId: conversationId ?? this.conversationId, + snoozedUntilMs: snoozedUntilMs ?? this.snoozedUntilMs, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (conversationId.present) { + map['conversation_id'] = Variable(conversationId.value); + } + if (snoozedUntilMs.present) { + map['snoozed_until_ms'] = Variable(snoozedUntilMs.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ConversationSnoozesCompanion(') + ..write('conversationId: $conversationId, ') + ..write('snoozedUntilMs: $snoozedUntilMs, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this); late final $LocalKvTable localKv = $LocalKvTable(this); + late final $ConversationSnoozesTable conversationSnoozes = + $ConversationSnoozesTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => [toneSamples, localKv]; + List get allSchemaEntities => [ + toneSamples, + localKv, + conversationSnoozes, + ]; } typedef $$ToneSamplesTableCreateCompanionBuilder = @@ -862,6 +1102,169 @@ typedef $$LocalKvTableProcessedTableManager = LocalKvData, PrefetchHooks Function() >; +typedef $$ConversationSnoozesTableCreateCompanionBuilder = + ConversationSnoozesCompanion Function({ + required String conversationId, + required int snoozedUntilMs, + Value rowid, + }); +typedef $$ConversationSnoozesTableUpdateCompanionBuilder = + ConversationSnoozesCompanion Function({ + Value conversationId, + Value snoozedUntilMs, + Value rowid, + }); + +class $$ConversationSnoozesTableFilterComposer + extends Composer<_$AppDatabase, $ConversationSnoozesTable> { + $$ConversationSnoozesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get conversationId => $composableBuilder( + column: $table.conversationId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get snoozedUntilMs => $composableBuilder( + column: $table.snoozedUntilMs, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ConversationSnoozesTableOrderingComposer + extends Composer<_$AppDatabase, $ConversationSnoozesTable> { + $$ConversationSnoozesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get conversationId => $composableBuilder( + column: $table.conversationId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get snoozedUntilMs => $composableBuilder( + column: $table.snoozedUntilMs, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ConversationSnoozesTableAnnotationComposer + extends Composer<_$AppDatabase, $ConversationSnoozesTable> { + $$ConversationSnoozesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get conversationId => $composableBuilder( + column: $table.conversationId, + builder: (column) => column, + ); + + GeneratedColumn get snoozedUntilMs => $composableBuilder( + column: $table.snoozedUntilMs, + builder: (column) => column, + ); +} + +class $$ConversationSnoozesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ConversationSnoozesTable, + ConversationSnooze, + $$ConversationSnoozesTableFilterComposer, + $$ConversationSnoozesTableOrderingComposer, + $$ConversationSnoozesTableAnnotationComposer, + $$ConversationSnoozesTableCreateCompanionBuilder, + $$ConversationSnoozesTableUpdateCompanionBuilder, + ( + ConversationSnooze, + BaseReferences< + _$AppDatabase, + $ConversationSnoozesTable, + ConversationSnooze + >, + ), + ConversationSnooze, + PrefetchHooks Function() + > { + $$ConversationSnoozesTableTableManager( + _$AppDatabase db, + $ConversationSnoozesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ConversationSnoozesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ConversationSnoozesTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$ConversationSnoozesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value conversationId = const Value.absent(), + Value snoozedUntilMs = const Value.absent(), + Value rowid = const Value.absent(), + }) => ConversationSnoozesCompanion( + conversationId: conversationId, + snoozedUntilMs: snoozedUntilMs, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String conversationId, + required int snoozedUntilMs, + Value rowid = const Value.absent(), + }) => ConversationSnoozesCompanion.insert( + conversationId: conversationId, + snoozedUntilMs: snoozedUntilMs, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ConversationSnoozesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ConversationSnoozesTable, + ConversationSnooze, + $$ConversationSnoozesTableFilterComposer, + $$ConversationSnoozesTableOrderingComposer, + $$ConversationSnoozesTableAnnotationComposer, + $$ConversationSnoozesTableCreateCompanionBuilder, + $$ConversationSnoozesTableUpdateCompanionBuilder, + ( + ConversationSnooze, + BaseReferences< + _$AppDatabase, + $ConversationSnoozesTable, + ConversationSnooze + >, + ), + ConversationSnooze, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -870,4 +1273,6 @@ class $AppDatabaseManager { $$ToneSamplesTableTableManager(_db, _db.toneSamples); $$LocalKvTableTableManager get localKv => $$LocalKvTableTableManager(_db, _db.localKv); + $$ConversationSnoozesTableTableManager get conversationSnoozes => + $$ConversationSnoozesTableTableManager(_db, _db.conversationSnoozes); } diff --git a/mobile/lib/db/app_database_web.dart b/mobile/lib/db/app_database_web.dart index f7d54ed..2128147 100644 --- a/mobile/lib/db/app_database_web.dart +++ b/mobile/lib/db/app_database_web.dart @@ -11,6 +11,7 @@ class AppDatabase { final Map _kv = {}; List _toneSamples = []; + final Map _snoozes = {}; factory AppDatabase.memory() => AppDatabase(encrypted: false); @@ -38,5 +39,19 @@ class AppDatabase { await setKv(key, value ? '1' : '0'); } + // roadmap.md §2.7-F 답장 마감 알림 — native stub과 동일한 시그니처 + // (in-memory, encrypted persistence 없음 — web은 프리뷰 서피스일 뿐 릴리스 타깃이 아님). + Future setSnoozedUntil(int conversationId, DateTime until) async { + _snoozes[conversationId] = until; + } + + Future getSnoozedUntil(int conversationId) async => _snoozes[conversationId]; + + Future clearSnooze(int conversationId) async { + _snoozes.remove(conversationId); + } + + Future> loadAllSnoozes() async => Map.from(_snoozes); + Future close() async {} } diff --git a/mobile/lib/db/tables.dart b/mobile/lib/db/tables.dart index 9ae5ada..748e67e 100644 --- a/mobile/lib/db/tables.dart +++ b/mobile/lib/db/tables.dart @@ -19,3 +19,16 @@ class LocalKv extends Table { @override Set> get primaryKey => {key}; } + +/// 답장 마감 알림(roadmap.md §2.7-F) — 대화방별 "이따 답장" 스누즈 시각. 다른 사람에게는 +/// 절대 보이지 않고 서버에도 올라가지 않는 순수 온디바이스 상태(개인 전용 리마인드, +/// AGENTS.md "Tone/style learning: on-device first" 원칙과 동일한 이유). +class ConversationSnoozes extends Table { + /// `Conversation.id`를 문자열로 저장 (drift 기본 타입 일관성을 위해 다른 테이블과 + /// 마찬가지로 text 기본키를 사용). + TextColumn get conversationId => text()(); + IntColumn get snoozedUntilMs => integer()(); + + @override + Set> get primaryKey => {conversationId}; +} diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart index 70c904b..a3fb3a8 100644 --- a/mobile/lib/screens/chat_screen.dart +++ b/mobile/lib/screens/chat_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/api_client.dart'; +import '../services/snooze_service.dart'; import '../services/ws_client.dart'; import '../state/session_state.dart'; import '../theme/app_theme.dart'; @@ -44,6 +45,9 @@ class _ChatScreenState extends State { bool _loadingHistory = true; late bool _floodBlocked; late final ApiClient _api; + // 답장 마감 알림(roadmap.md §2.7-F) — 이 대화방에 걸린 "이따 답장" 스누즈 시각. + // 서버에는 존재하지 않는 순수 온디바이스 상태라 화면에 들어올 때 로컬 DB에서 로드한다. + DateTime? _snoozedUntil; @override void initState() { @@ -56,6 +60,85 @@ class _ChatScreenState extends State { _socket = ConversationSocket(widget.conversationId)..connect(); _sub = _socket!.events.listen(_onEvent); _loadHistory(); + _loadSnooze(); + } + + Future _loadSnooze() async { + final controller = context.read().snoozeController; + if (controller == null) return; + final until = await controller.loadSnoozedUntil(widget.conversationId); + if (!mounted) return; + setState(() => _snoozedUntil = until); + } + + bool get _snoozePastDue => isSnoozePastDue(DateTime.now(), _snoozedUntil); + + String _formatSnoozeUntil(DateTime dt) => + '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + + /// "이따 답장" 빠른 선택 메뉴 (roadmap.md §2.7-F). 스누즈가 걸려 있으면 해제 + /// 옵션도 함께 보여준다. + Future _openSnoozeMenu() async { + final now = DateTime.now(); + final choice = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('이따 답장'), + children: [ + for (final pick in SnoozeQuickPick.values) + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, pick), + child: Text(pick.label), + ), + if (_snoozedUntil != null) + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, 'clear'), + child: Text('스누즈 해제', style: TextStyle(color: Theme.of(ctx).colorScheme.error)), + ), + ], + ), + ); + if (choice == null || !mounted) return; + if (choice == 'clear') { + await _clearSnooze(); + return; + } + if (choice is SnoozeQuickPick) { + await _applySnooze(resolveSnoozeQuickPick(choice, now)); + } + } + + Future _applySnooze(DateTime until) async { + final controller = context.read().snoozeController; + if (controller != null) { + await controller.applySnooze( + conversationId: widget.conversationId, + until: until, + title: '답장 마감', + body: '${widget.title ?? "대화방 #${widget.conversationId}"}에 답장할 시간이에요', + ); + } + if (!mounted) return; + setState(() { + _snoozedUntil = until; + _banner = '이따 답장: ${_formatSnoozeUntil(until)}에 리마인드합니다 (본인에게만 표시).'; + }); + } + + /// 사용자가 직접 "스누즈 해제"를 눌렀을 때 — 배너로 알려 준다. + Future _clearSnooze() async { + await _clearSnoozeQuiet(); + if (!mounted) return; + setState(() => _banner = '스누즈를 해제했습니다.'); + } + + /// 답장을 실제로 보내서 스누즈가 자동으로 풀릴 때 — 조용히 처리(배너로 덮어쓰지 않음). + Future _clearSnoozeQuiet() async { + if (_snoozedUntil == null) return; + final controller = context.read().snoozeController; + if (controller != null) await controller.clearSnooze(widget.conversationId); + if (!mounted) return; + setState(() => _snoozedUntil = null); } Future _loadHistory() async { @@ -164,6 +247,8 @@ class _ChatScreenState extends State { setState(() => _messages.add(msg)); } _scrollToEnd(); + // roadmap.md §2.7-F: 답장을 실제로 보냈으니 걸려 있던 스누즈는 자동 해제. + _clearSnoozeQuiet(); } on ApiException catch (e) { setState(() => _banner = '전송 실패 (${e.statusCode})'); } finally { @@ -245,6 +330,8 @@ class _ChatScreenState extends State { if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg); }); _scrollToEnd(); + // roadmap.md §2.7-F: 와카뷰가 대신 답장을 보냈어도 답장은 답장이므로 스누즈 해제. + _clearSnoozeQuiet(); } on ApiException catch (e) { setState(() { _banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}'; @@ -491,6 +578,13 @@ class _ChatScreenState extends State { onPressed: _openSummary, icon: const Icon(Icons.summarize_outlined), ), + IconButton( + // 답장 마감 알림(roadmap.md §2.7-F) — "이따 답장" 스누즈. 본인에게만 + // 보이는 순수 온디바이스 상태라 상대방 화면에는 전혀 나타나지 않는다. + tooltip: _snoozedUntil != null ? '이따 답장 (${_formatSnoozeUntil(_snoozedUntil!)})' : '이따 답장', + onPressed: _busy ? null : _openSnoozeMenu, + icon: Icon(_snoozedUntil != null ? Icons.alarm_on : Icons.alarm_add_outlined), + ), IconButton( tooltip: '거부권 (와카뷰 자동응대 중단)', onPressed: _busy ? null : _veto, @@ -512,6 +606,14 @@ class _ChatScreenState extends State { TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')), ], ), + if (_snoozePastDue) + MaterialBanner( + leading: Icon(Icons.alarm, color: theme.colorScheme.tertiary), + content: Text('답장 마감 시간이 지났습니다 (${_formatSnoozeUntil(_snoozedUntil!)}).'), + actions: [ + TextButton(onPressed: _busy ? null : _clearSnooze, child: const Text('스누즈 해제')), + ], + ), Expanded( child: _loadingHistory ? const Center(child: CircularProgressIndicator()) diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index fe3a0d2..55a4e32 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/api_client.dart'; +import '../services/snooze_service.dart'; import '../state/session_state.dart'; import '../widgets/gradient_text.dart'; import '../widgets/my_user_id_chip.dart'; @@ -22,6 +23,9 @@ class ConversationListScreen extends StatefulWidget { class _ConversationListScreenState extends State { List _rooms = []; Map _peerNames = {}; + // 답장 마감 알림(roadmap.md §2.7-F) — 대화방 id별 "이따 답장" 스누즈 마감 시각. + // 서버 응답(ConversationSummary)에는 없는 순수 온디바이스 상태라 로컬 DB에서 별도 로드. + Map _snoozes = {}; bool _loading = true; String? _error; @@ -53,9 +57,11 @@ class _ConversationListScreenState extends State { // Names are optional enrichment. } } + final snoozes = await session.snoozeController?.loadAllSnoozes() ?? {}; setState(() { _rooms = list; _peerNames = names; + _snoozes = snoozes; }); } on ApiException catch (e) { setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}'); @@ -64,6 +70,15 @@ class _ConversationListScreenState extends State { } } + /// 대화 목록에서 바로 스누즈를 해제한다(roadmap.md §2.7-F — 채팅방을 열지 않고도 + /// "마감 지남" 배지를 지울 수 있게). + Future _clearSnoozeFromList(int conversationId) async { + final controller = context.read().snoozeController; + if (controller != null) await controller.clearSnooze(conversationId); + if (!mounted) return; + setState(() => _snoozes.remove(conversationId)); + } + String _titleFor(ConversationSummary room, int? me) { if (me == null) return '대화방 #${room.id}'; final peers = room.userIds.where((id) => id != me).toList(); @@ -335,94 +350,34 @@ class _ConversationListScreenState extends State { ), ), for (final room in _rooms) - Material( - color: Colors.transparent, - child: InkWell( - onTap: () async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChatScreen( - conversationId: room.id, - title: _titleFor(room, me), - isGroup: room.isGroup, - twinDisabledByFlood: room.twinDisabledByFlood, - ), - ), - ); - await _load(); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - CircleAvatar( - radius: 22, - backgroundColor: _avatarColor(context, room.id), - child: Icon( - room.isGroup ? Icons.groups_outlined : Icons.person_outline, - size: 20, - color: _onAvatarColor(context, room.id), + Builder( + builder: (context) { + final snoozedUntil = _snoozes[room.id]; + final snoozePastDue = isSnoozePastDue(DateTime.now(), snoozedUntil); + return _ConversationRow( + room: room, + me: me, + title: _titleFor(room, me), + subtitle: _subtitleFor(room, me), + avatarColor: _avatarColor(context, room.id), + onAvatarColor: _onAvatarColor(context, room.id), + snoozePastDue: snoozePastDue, + onClearSnooze: () => _clearSnoozeFromList(room.id), + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatScreen( + conversationId: room.id, + title: _titleFor(room, me), + isGroup: room.isGroup, + twinDisabledByFlood: room.twinDisabledByFlood, ), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _titleFor(room, me), - style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 2), - Row( - children: [ - if (room.twinDisabledByPeer || room.twinDisabledByFlood) ...[ - Icon( - room.twinDisabledByFlood ? Icons.pause_circle_outline : Icons.block, - size: 13, - color: theme.colorScheme.error, - ), - const SizedBox(width: 4), - ], - Expanded( - child: Text( - _subtitleFor(room, me), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: room.twinDisabledByPeer || room.twinDisabledByFlood - ? theme.colorScheme.error - : theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ], - ), - ), - if (room.unreadCount > 0) ...[ - Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), - decoration: BoxDecoration( - color: theme.colorScheme.primary, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - room.unreadCount > 99 ? '99+' : '${room.unreadCount}', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onPrimary, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: 8), - ], - Icon(Icons.chevron_right, size: 18, color: theme.colorScheme.onSurfaceVariant), - ], - ), - ), - ), + ); + await _load(); + }, + ); + }, ), const SizedBox(height: 72), ], @@ -431,3 +386,128 @@ class _ConversationListScreenState extends State { ); } } + +class _ConversationRow extends StatelessWidget { + const _ConversationRow({ + required this.room, + required this.me, + required this.title, + required this.subtitle, + required this.avatarColor, + required this.onAvatarColor, + required this.snoozePastDue, + required this.onClearSnooze, + required this.onTap, + }); + + final ConversationSummary room; + final int? me; + final String title; + final String subtitle; + final Color avatarColor; + final Color onAvatarColor; + final bool snoozePastDue; + final VoidCallback onClearSnooze; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final blockedColor = room.twinDisabledByPeer || room.twinDisabledByFlood; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + CircleAvatar( + radius: 22, + backgroundColor: avatarColor, + child: Icon( + room.isGroup ? Icons.groups_outlined : Icons.person_outline, + size: 20, + color: onAvatarColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Row( + children: [ + if (blockedColor) ...[ + Icon( + room.twinDisabledByFlood ? Icons.pause_circle_outline : Icons.block, + size: 13, + color: theme.colorScheme.error, + ), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: blockedColor ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + // 답장 마감 알림(roadmap.md §2.7-F) — 마감이 지난 스누즈만 배지로 + // 노출. 탭하면 채팅방을 열지 않고도 바로 해제할 수 있다. + if (snoozePastDue) ...[ + const SizedBox(height: 4), + InkWell( + borderRadius: BorderRadius.circular(4), + onTap: onClearSnooze, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.alarm, size: 13, color: theme.colorScheme.tertiary), + const SizedBox(width: 4), + Text( + '답장 마감 (탭하여 해제)', + style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.tertiary), + ), + ], + ), + ), + ], + ], + ), + ), + if (room.unreadCount > 0) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + room.unreadCount > 99 ? '99+' : '${room.unreadCount}', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onPrimary, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + ], + Icon(Icons.chevron_right, size: 18, color: theme.colorScheme.onSurfaceVariant), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/services/snooze_controller.dart b/mobile/lib/services/snooze_controller.dart new file mode 100644 index 0000000..da1792c --- /dev/null +++ b/mobile/lib/services/snooze_controller.dart @@ -0,0 +1,39 @@ +import '../db/app_database.dart'; +import 'snooze_notification_service.dart'; + +/// 답장 마감 알림(roadmap.md §2.7-F) 오케스트레이션 — 온디바이스 DB(스누즈 시각 저장)와 +/// 알림 스케줄러(OS 로컬 알림) 호출을 한 곳에 묶는다. `chat_screen.dart`/ +/// `conversation_list_screen.dart`가 공유해서 쓴다. +/// +/// 서버 호출은 전혀 없다 — AGENTS.md "Tone/style learning: on-device first"와 동일한 +/// 이유로, 이 리마인드는 순전히 이 기기·이 사용자만을 위한 것이라 서버에 올라갈 이유가 +/// 없다. 테스트에서는 [scheduler] 자리에 목(mock) 구현을 넣어 "DB에 정확히 반영되고 +/// 스케줄러가 올바른 id/시각/페이로드로 호출되는가"까지 검증한다 — 실제 OS 알림이 +/// 뜨는지는 이 방식으로 검증할 수 없다(샌드박스에 실기기/에뮬레이터가 없음). +class SnoozeController { + SnoozeController({required AppDatabase db, required SnoozeNotificationScheduler scheduler}) + : _db = db, + _scheduler = scheduler; + + final AppDatabase _db; + final SnoozeNotificationScheduler _scheduler; + + Future loadSnoozedUntil(int conversationId) => _db.getSnoozedUntil(conversationId); + + Future> loadAllSnoozes() => _db.loadAllSnoozes(); + + Future applySnooze({ + required int conversationId, + required DateTime until, + required String title, + required String body, + }) async { + await _db.setSnoozedUntil(conversationId, until); + await _scheduler.scheduleReminder(conversationId: conversationId, title: title, body: body, at: until); + } + + Future clearSnooze(int conversationId) async { + await _db.clearSnooze(conversationId); + await _scheduler.cancelReminder(conversationId); + } +} diff --git a/mobile/lib/services/snooze_notification_service.dart b/mobile/lib/services/snooze_notification_service.dart new file mode 100644 index 0000000..7bf76d6 --- /dev/null +++ b/mobile/lib/services/snooze_notification_service.dart @@ -0,0 +1,10 @@ +/// 답장 마감 알림(roadmap.md §2.7-F)의 실제 OS 로컬 알림 스케줄러 — 플랫폼별 구현을 +/// 조건부 export로 분리한다 (`../db/app_database.dart`와 동일한 패턴). +/// +/// Flutter Web은 `flutter_local_notifications`가 공식 지원하지 않는 플랫폼이고, 이 +/// 프로젝트의 웹 빌드는 애초에 프리뷰 서피스일 뿐 릴리스 타깃이 아니다 +/// (`app_database_web.dart` 주석 참고) — 웹에서는 스케줄링 호출이 조용히 no-op 처리된다. +library; + +export 'snooze_notification_service_native.dart' + if (dart.library.html) 'snooze_notification_service_web.dart'; diff --git a/mobile/lib/services/snooze_notification_service_native.dart b/mobile/lib/services/snooze_notification_service_native.dart new file mode 100644 index 0000000..823d0ea --- /dev/null +++ b/mobile/lib/services/snooze_notification_service_native.dart @@ -0,0 +1,80 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/data/latest.dart' as tz_data; +import 'package:timezone/timezone.dart' as tz; + +/// 답장 마감 알림(roadmap.md §2.7-F, PRD.md §3.2 "내가 '이따 답장' 누르면 나에게만 +/// 리마인드")의 스케줄러 인터페이스. 다른 사람에게는 절대 보이지 않는, 이 기기에만 +/// 존재하는 로컬 알림 — AGENTS.md 온디바이스 우선 원칙과 정확히 일치. +/// +/// 이 세션의 샌드박스에는 실제 Android/iOS 기기·에뮬레이터가 없어 알림이 실제로 +/// 뜨는지/탭했을 때의 동작까지는 검증할 수 없었다. 검증한 것은 `zonedSchedule()` / +/// `cancel()` 호출이 올바른 id·시각·페이로드로 이뤄지는가뿐이며, 이는 이 인터페이스의 +/// 목(mock) 구현을 주입하는 단위 테스트(`test/snooze_notification_scheduler_test.dart`)로 +/// 확인했다. +abstract class SnoozeNotificationScheduler { + Future scheduleReminder({ + required int conversationId, + required String title, + required String body, + required DateTime at, + }); + + Future cancelReminder(int conversationId); +} + +class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler { + LocalSnoozeNotificationScheduler({FlutterLocalNotificationsPlugin? plugin}) + : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + + final FlutterLocalNotificationsPlugin _plugin; + bool _initialized = false; + + static const _channelId = 'reply_snooze_reminders'; + static const _channelName = '답장 마감 알림'; + static const _channelDescription = '"이따 답장" 스누즈 마감 시각 알림 (본인 기기에만 표시, 상대에게는 보이지 않음)'; + + Future _ensureInitialized() async { + if (_initialized) return; + tz_data.initializeTimeZones(); + const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosInit = DarwinInitializationSettings(); + await _plugin.initialize( + settings: const InitializationSettings(android: androidInit, iOS: iosInit), + ); + _initialized = true; + } + + @override + Future scheduleReminder({ + required int conversationId, + required String title, + required String body, + required DateTime at, + }) async { + await _ensureInitialized(); + await _plugin.zonedSchedule( + id: conversationId, + title: title, + body: body, + scheduledDate: tz.TZDateTime.from(at, tz.local), + notificationDetails: const NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + _channelName, + channelDescription: _channelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + ), + iOS: DarwinNotificationDetails(), + ), + // SCHEDULE_EXACT_ALARM 권한 없이도 동작하는 모드 — 개인 리마인드 용도라 + // 초단위 정확도가 필요하지 않음 (Doze 모드에서도 결국은 전달됨). + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + ); + } + + @override + Future cancelReminder(int conversationId) async { + await _plugin.cancel(id: conversationId); + } +} diff --git a/mobile/lib/services/snooze_notification_service_web.dart b/mobile/lib/services/snooze_notification_service_web.dart new file mode 100644 index 0000000..7545ebf --- /dev/null +++ b/mobile/lib/services/snooze_notification_service_web.dart @@ -0,0 +1,27 @@ +/// Web 빌드 스텁 — `flutter_local_notifications`는 웹을 지원하지 않고, 이 프로젝트의 +/// 웹 빌드는 프리뷰 서피스일 뿐 릴리스 타깃이 아니다(`app_database_web.dart` 주석 참고). +/// 스누즈 저장(`AppDatabase`)은 웹에서도 동작하지만, 실제 OS 알림 스케줄링만 no-op — +/// 대화 목록/채팅방의 "마감 지남" 배지·배너는 이 스텁과 무관하게 그대로 동작한다. +abstract class SnoozeNotificationScheduler { + Future scheduleReminder({ + required int conversationId, + required String title, + required String body, + required DateTime at, + }); + + Future cancelReminder(int conversationId); +} + +class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler { + @override + Future scheduleReminder({ + required int conversationId, + required String title, + required String body, + required DateTime at, + }) async {} + + @override + Future cancelReminder(int conversationId) async {} +} diff --git a/mobile/lib/services/snooze_service.dart b/mobile/lib/services/snooze_service.dart new file mode 100644 index 0000000..d2f32cd --- /dev/null +++ b/mobile/lib/services/snooze_service.dart @@ -0,0 +1,49 @@ +/// 답장 마감 알림(roadmap.md §2.7-F, PRD.md §3.2 P1 "내가 '이따 답장' 누르면 나에게만 +/// 리마인드") 순수 로직. `DateTime.now()`에 의존하지 않고 `now`를 인자로 받는 함수들이라 +/// 테스트에서 고정된 시각으로 결정적으로 검증할 수 있다. +library; + +/// "이따 답장"을 그냥 눌렀을 때(빠른 선택 없이) 적용되는 기본 스누즈 길이. +const Duration kDefaultSnoozeDuration = Duration(hours: 2); + +/// 채팅방/대화 목록에 노출하는 빠른 선택지. +enum SnoozeQuickPick { oneHour, thisEvening, tomorrowMorning } + +extension SnoozeQuickPickLabel on SnoozeQuickPick { + String get label => switch (this) { + SnoozeQuickPick.oneHour => '1시간 후', + SnoozeQuickPick.thisEvening => '저녁에', + SnoozeQuickPick.tomorrowMorning => '내일', + }; +} + +/// [now] 기준으로 빠른 선택지가 가리키는 실제 리마인드 시각을 계산한다. +/// +/// - 1시간 후: 지금부터 정확히 1시간 +/// - 저녁에: 오늘 19시(이미 지났으면 내일 19시) +/// - 내일: 내일 오전 9시 +DateTime resolveSnoozeQuickPick(SnoozeQuickPick pick, DateTime now) { + switch (pick) { + case SnoozeQuickPick.oneHour: + return now.add(const Duration(hours: 1)); + case SnoozeQuickPick.thisEvening: + final evening = DateTime(now.year, now.month, now.day, 19); + return now.isBefore(evening) ? evening : evening.add(const Duration(days: 1)); + case SnoozeQuickPick.tomorrowMorning: + final tomorrow = now.add(const Duration(days: 1)); + return DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 9); + } +} + +/// 스누즈 마감이 [now] 기준으로 이미 지났는지 — 대화 목록 배지/채팅방 배너 표시 여부를 +/// 결정하는 순수 함수. `snoozedUntil`이 없으면 항상 false. +bool isSnoozePastDue(DateTime now, DateTime? snoozedUntil) { + if (snoozedUntil == null) return false; + return !now.isBefore(snoozedUntil); +} + +/// 스누즈가 걸려 있고 아직 마감 전인지(= 활성 스누즈). +bool isSnoozeActive(DateTime now, DateTime? snoozedUntil) { + if (snoozedUntil == null) return false; + return now.isBefore(snoozedUntil); +} diff --git a/mobile/lib/state/session_state.dart b/mobile/lib/state/session_state.dart index 3000a7b..20b0d24 100644 --- a/mobile/lib/state/session_state.dart +++ b/mobile/lib/state/session_state.dart @@ -7,14 +7,23 @@ import '../db/app_database.dart'; import '../models/models.dart'; import '../services/api_client.dart'; import '../services/push_token_service.dart'; +import '../services/snooze_controller.dart'; +import '../services/snooze_notification_service.dart'; class SessionState extends ChangeNotifier { - SessionState({ApiClient? api, AppDatabase? db}) + SessionState({ApiClient? api, AppDatabase? db, SnoozeNotificationScheduler? snoozeScheduler}) : _api = api ?? ApiClient(), - _db = db; + _db = db, + _snoozeScheduler = snoozeScheduler ?? LocalSnoozeNotificationScheduler(); final ApiClient _api; AppDatabase? _db; + final SnoozeNotificationScheduler _snoozeScheduler; + + /// 답장 마감 알림(roadmap.md §2.7-F) 오케스트레이션 — DB(스누즈 시각)와 로컬 알림 + /// 스케줄러를 함께 다룬다. DB가 아직 열리지 않았으면(부팅 초기) null. + SnoozeController? get snoozeController => + _db == null ? null : SnoozeController(db: _db!, scheduler: _snoozeScheduler); User? user; AutonomyLevel autonomyLevel = AutonomyLevel.L0; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 939d362..1e91634 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -177,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" drift: dependency: "direct main" description: @@ -286,6 +294,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad" + url: "https://pub.dev" + source: hosted + version: "20.1.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0 + url: "https://pub.dev" + source: hosted + version: "7.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899" + url: "https://pub.dev" + source: hosted + version: "10.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a + url: "https://pub.dev" + source: hosted + version: "2.0.1" flutter_secure_storage: dependency: "direct main" description: @@ -552,6 +592,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -773,6 +821,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" typed_data: dependency: transitive description: @@ -845,6 +901,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: @@ -855,4 +919,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.10.0-0 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.32.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 7ecdae3..7432e94 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -47,6 +47,8 @@ dependencies: google_fonts: ^6.3.2 firebase_core: ^4.12.1 firebase_messaging: ^16.4.3 + flutter_local_notifications: ^20.1.0 + timezone: ^0.10.1 dev_dependencies: flutter_test: diff --git a/mobile/test/local_db_test.dart b/mobile/test/local_db_test.dart index 9de3352..21a9603 100644 --- a/mobile/test/local_db_test.dart +++ b/mobile/test/local_db_test.dart @@ -15,4 +15,34 @@ void main() { await db.replaceToneSamples(['하나만']); expect(await db.loadToneSamples(), ['하나만']); }); + + // roadmap.md §2.7-F 답장 마감 알림: 스누즈 시각은 서버로 전혀 전송되지 않는 + // 순수 온디바이스 상태라 tone samples와 동일한 방식(in-memory DB round-trip)으로 + // 검증한다. + test('conversation snooze round-trips in memory database', () async { + final db = AppDatabase.memory(); + addTearDown(db.close); + + expect(await db.getSnoozedUntil(42), isNull); + + final until = DateTime.utc(2026, 8, 3, 20, 0); + await db.setSnoozedUntil(42, until); + expect(await db.getSnoozedUntil(42), until); + + // 다른 대화방(id 43)에는 영향 없음. + expect(await db.getSnoozedUntil(43), isNull); + + // 다시 걸면(insertOnConflictUpdate) 덮어써야 한다 — 새 행이 추가되면 안 됨. + final rescheduled = DateTime.utc(2026, 8, 4, 9, 0); + await db.setSnoozedUntil(42, rescheduled); + expect(await db.getSnoozedUntil(42), rescheduled); + + await db.setSnoozedUntil(43, until); + final all = await db.loadAllSnoozes(); + expect(all, {42: rescheduled, 43: until}); + + await db.clearSnooze(42); + expect(await db.getSnoozedUntil(42), isNull); + expect(await db.loadAllSnoozes(), {43: until}); + }); } diff --git a/mobile/test/snooze_controller_test.dart b/mobile/test/snooze_controller_test.dart new file mode 100644 index 0000000..cc4e84d --- /dev/null +++ b/mobile/test/snooze_controller_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ykavu_mobile/db/app_database.dart'; +import 'package:ykavu_mobile/services/snooze_controller.dart'; +import 'package:ykavu_mobile/services/snooze_notification_service.dart'; + +/// 실제 `flutter_local_notifications` 플러그인 대신 호출 인자를 기록만 하는 목(mock). +/// 이 세션의 샌드박스에는 실기기/에뮬레이터가 없어 알림이 실제로 뜨는지는 검증할 수 +/// 없으므로, 대신 "스케줄/취소 호출이 올바른 id·시각·페이로드로 이뤄지는가"만 검증한다. +class _FakeScheduler implements SnoozeNotificationScheduler { + final scheduledCalls = >[]; + final cancelledIds = []; + + @override + Future scheduleReminder({ + required int conversationId, + required String title, + required String body, + required DateTime at, + }) async { + scheduledCalls.add({ + 'conversationId': conversationId, + 'title': title, + 'body': body, + 'at': at, + }); + } + + @override + Future cancelReminder(int conversationId) async { + cancelledIds.add(conversationId); + } +} + +void main() { + late AppDatabase db; + late _FakeScheduler scheduler; + late SnoozeController controller; + + setUp(() { + db = AppDatabase.memory(); + scheduler = _FakeScheduler(); + controller = SnoozeController(db: db, scheduler: scheduler); + }); + + tearDown(() => db.close()); + + test('applySnooze persists to DB and schedules with exact id/time/payload', () async { + final until = DateTime.utc(2026, 8, 3, 21, 0); + await controller.applySnooze( + conversationId: 7, + until: until, + title: '답장 마감', + body: '상대#7에게 답장할 시간이에요', + ); + + expect(await controller.loadSnoozedUntil(7), until); + expect(scheduler.scheduledCalls, hasLength(1)); + expect(scheduler.scheduledCalls.single, { + 'conversationId': 7, + 'title': '답장 마감', + 'body': '상대#7에게 답장할 시간이에요', + 'at': until, + }); + }); + + test('clearSnooze removes from DB and cancels the scheduled reminder', () async { + final until = DateTime.utc(2026, 8, 3, 21, 0); + await controller.applySnooze(conversationId: 9, until: until, title: 't', body: 'b'); + expect(await controller.loadSnoozedUntil(9), isNotNull); + + await controller.clearSnooze(9); + + expect(await controller.loadSnoozedUntil(9), isNull); + expect(scheduler.cancelledIds, [9]); + }); + + test('loadAllSnoozes reflects multiple conversations independently', () async { + final until1 = DateTime.utc(2026, 8, 3, 21, 0); + final until2 = DateTime.utc(2026, 8, 4, 9, 0); + await controller.applySnooze(conversationId: 1, until: until1, title: 't', body: 'b'); + await controller.applySnooze(conversationId: 2, until: until2, title: 't', body: 'b'); + + expect(await controller.loadAllSnoozes(), {1: until1, 2: until2}); + expect(scheduler.scheduledCalls, hasLength(2)); + }); +} diff --git a/mobile/test/snooze_service_test.dart b/mobile/test/snooze_service_test.dart new file mode 100644 index 0000000..0fa398a --- /dev/null +++ b/mobile/test/snooze_service_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ykavu_mobile/services/snooze_service.dart'; + +void main() { + group('isSnoozePastDue / isSnoozeActive', () { + // 고정된 "now"를 넘겨 결정적으로 검증한다 (DateTime.now() 실시간 의존 금지). + final now = DateTime(2026, 8, 3, 12, 0); + + test('no snooze set -> never past due, never active', () { + expect(isSnoozePastDue(now, null), isFalse); + expect(isSnoozeActive(now, null), isFalse); + }); + + test('snooze in the future -> active, not past due', () { + final until = now.add(const Duration(hours: 1)); + expect(isSnoozePastDue(now, until), isFalse); + expect(isSnoozeActive(now, until), isTrue); + }); + + test('snooze in the past -> past due, not active', () { + final until = now.subtract(const Duration(minutes: 1)); + expect(isSnoozePastDue(now, until), isTrue); + expect(isSnoozeActive(now, until), isFalse); + }); + + test('snooze exactly at now -> counts as past due (boundary)', () { + expect(isSnoozePastDue(now, now), isTrue); + expect(isSnoozeActive(now, now), isFalse); + }); + }); + + group('resolveSnoozeQuickPick', () { + test('oneHour adds exactly one hour', () { + final now = DateTime(2026, 8, 3, 12, 0); + expect(resolveSnoozeQuickPick(SnoozeQuickPick.oneHour, now), DateTime(2026, 8, 3, 13, 0)); + }); + + test('thisEvening resolves to 19:00 today when now is before 19:00', () { + final now = DateTime(2026, 8, 3, 12, 0); + expect(resolveSnoozeQuickPick(SnoozeQuickPick.thisEvening, now), DateTime(2026, 8, 3, 19, 0)); + }); + + test('thisEvening rolls over to tomorrow 19:00 when now is already past 19:00', () { + final now = DateTime(2026, 8, 3, 20, 30); + expect(resolveSnoozeQuickPick(SnoozeQuickPick.thisEvening, now), DateTime(2026, 8, 4, 19, 0)); + }); + + test('thisEvening rolls over exactly at 19:00 (boundary is not "before")', () { + final now = DateTime(2026, 8, 3, 19, 0); + expect(resolveSnoozeQuickPick(SnoozeQuickPick.thisEvening, now), DateTime(2026, 8, 4, 19, 0)); + }); + + test('tomorrowMorning resolves to 09:00 the next day', () { + final now = DateTime(2026, 8, 3, 23, 45); + expect(resolveSnoozeQuickPick(SnoozeQuickPick.tomorrowMorning, now), DateTime(2026, 8, 4, 9, 0)); + }); + + test('quick pick labels are the fixed Korean copy used in the UI', () { + expect(SnoozeQuickPick.oneHour.label, '1시간 후'); + expect(SnoozeQuickPick.thisEvening.label, '저녁에'); + expect(SnoozeQuickPick.tomorrowMorning.label, '내일'); + }); + }); +}