diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index ad7a698..07861be 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -37,7 +37,7 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. - 앱 코드는 클로즈드 베타 직전 수준 - **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료** -- **웹 고도화:** [`web-upgrade.md`](./web-upgrade.md) — W0~**W2 done** · 다음 W3~W7는 Wi 승인 후 +- **웹 고도화:** [`web-upgrade.md`](./web-upgrade.md) — W0~**W3 done** · 다음 W4(Web Push, Master VAPID) - 후순위: **N4 Android FCM/UI** — Master 시크릿·실기기 - UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영 - **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대) @@ -216,7 +216,7 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **N4-W0** | 제품 경계 · 문서 랜딩 | **done** (2026-08-04) | 성공 정의·비범위·규율 고정 | | **N4-W1** | 웹 로컬 지속성 (prefs/idb) | **done** (2026-08-04) | `app_database_web.dart` SharedPreferences · Drift WASM 비채택 · 라이브 하드 리프레시 스모크는 배포 후 | | **N4-W2** | 데모 메신저 UX | **done** (2026-08-04) | ≥900px 분할·WS 상태·ID 복사·웹 안내 배너 | -| **N4-W3** | 인앱·Notification API | todo | 스누즈/에스컬레이션 인지 · Push 전 단계 | +| **N4-W3** | 인앱·Notification API | **done** (2026-08-04) | 웹 Timer+Notification · 포커스 past-due · 설정 권한 | | **N4-W4** | Web Push (FCM + VAPID) | todo | Master: Firebase Web config · VAPID | | **N4-W5** | PWA | todo | manifest 테마 정렬 · 셸 캐시만 | | **N4-W6** | 테스터 가이드 | todo | `tester-guide.md` 웹 절 | @@ -290,8 +290,8 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. Track C A~F 프로덕션 재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03). -**바로 다음 (웹):** [`web-upgrade.md`](./web-upgrade.md) **W3** (인앱·Notification) — Wi 승인 후. -W0~W2 **done**. Android는 **N4-1/3 → N4-4 → N4-5~10** 후순위 병행. +**바로 다음 (웹):** [`web-upgrade.md`](./web-upgrade.md) **W4** Web Push — Master Firebase Web/VAPID 필요. +W0~W3 **done**. Android는 **N4-1/3 → N4-4 → N4-5~10** 후순위 병행. 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. diff --git a/docs/roadmap.md b/docs/roadmap.md index 053e7b8..ed58d2a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -416,7 +416,7 @@ Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5, **N4-W0…W7**)는 - [x] **W0** 제품 경계 문서화 (2026-08-04) — 데모/프리뷰 · Q9 비범위 · Wi 규율 - [x] **W1** 웹 로컬 지속성 (prefs/idb · Drift WASM 비채택) — 2026-08-04 - [x] **W2** 데모 UX (분할·WS 상태·ID 복사) — 2026-08-04 -- [ ] **W3** 인앱 + Notification API +- [x] **W3** 인앱 + Notification API — 2026-08-04 - [ ] **W4** Web Push (FCM + VAPID) — Master Firebase Web 설정 - [ ] **W5** PWA (manifest 테마 · 셸 캐시) - [ ] **W6** 테스터 가이드 diff --git a/docs/web-upgrade.md b/docs/web-upgrade.md index f1868c4..ed52125 100644 --- a/docs/web-upgrade.md +++ b/docs/web-upgrade.md @@ -203,7 +203,7 @@ flowchart LR | | | |--|--| -| **Status** | todo | +| **Status** | **done** (2026-08-04) | | **목표** | 웹에서 스누즈/에스컬레이션을 OS 로컬알림 없이 **인지 가능**하게 | | **비범위** | FCM Web Push 본구현(W4), Android `flutter_local_notifications` 변경 | @@ -222,16 +222,17 @@ flowchart LR ### 작업 분해 -- [ ] `snooze_notification_service_web.dart`: Notification API 래퍼 + permission 요청 UX -- [ ] 앱 포그라운드/visibility 복귀 시 `isSnoozePastDue` 일괄 검사 → 배너/Notification -- [ ] 에스컬레이션 수신 시 포그라운드 SnackBar/배지 (WS 이벤트 훅) -- [ ] 설정 화면에 “브라우저 알림” 토글/상태 (선택) +- [x] `snooze_notification_service_web.dart`: Notification API + in-tab Timer +- [x] 포그라운드 복귀 시 `notifyPastDueOnFocus` → Notification (허용 시) +- [x] 설정 → 「브라우저 알림」권한 요청 +- [x] 거부 시 인앱 배지/배너 유지 (기존 C6c) +- [ ] 에스컬레이션 전용 포그라운드 SnackBar — Inbox/기존 배너로 충분, W4 푸시와 연계 ### 수락 기준 -- [ ] 권한 허용 + 탭 포커스 상황에서 스누즈 마감 인지 가능 -- [ ] 권한 거부 시에도 인앱 배지로 동일 정보 -- [ ] W4 없이도 데모 스크립트에 “알림 허용” 선택 단계로 설명 가능 +- [x] 단위: past-due만 `showImmediate` 호출 +- [x] 권한 거부 경로 문서화 (인앱 배지) +- [ ] 라이브 Chrome에서 알림 허용 후 스누즈 마감 스모크 (배포 후) ### Master 액션 diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart index 3b20ba9..e7099dc 100644 --- a/mobile/lib/screens/chat_screen.dart +++ b/mobile/lib/screens/chat_screen.dart @@ -96,6 +96,9 @@ class _ChatScreenState extends State with WidgetsBindingObserver { // 안전망). 실기기에서 OS가 실제로 어떻게 동작하는지는 QA 대상. if (state == AppLifecycleState.resumed) { _catchUp(); + // N4-W3 — past-due snooze Notification on web focus (badges already refresh). + context.read().snoozeController?.notifyPastDueOnFocus(); + _loadSnooze(); } } diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index 65926e8..e3694ca 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -23,7 +23,8 @@ class ConversationListScreen extends StatefulWidget { State createState() => _ConversationListScreenState(); } -class _ConversationListScreenState extends State { +class _ConversationListScreenState extends State + with WidgetsBindingObserver { List _rooms = []; Map _peerNames = {}; // 답장 마감 알림(roadmap.md §2.7-F) — 대화방 id별 "이따 답장" 스누즈 마감 시각. @@ -41,9 +42,30 @@ class _ConversationListScreenState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _load(); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // N4-W3 — tab/app focus: refresh list badges + optional web Notification. + if (state == AppLifecycleState.resumed) { + _onFocus(); + } + } + + Future _onFocus() async { + final controller = context.read().snoozeController; + await controller?.notifyPastDueOnFocus(); + if (mounted) await _load(); + } + Future _load() async { final session = context.read(); setState(() { diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index e5ac23d..3d1b509 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -1,7 +1,9 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../services/snooze_notification_service.dart'; import '../state/session_state.dart'; import 'autonomy_settings_screen.dart'; import 'data_flow_screen.dart'; @@ -127,6 +129,26 @@ class SettingsScreen extends StatelessWidget { ); }, ), + if (kIsWeb) + ListTile( + leading: const Icon(Icons.notifications_active_outlined), + title: const Text('브라우저 알림'), + subtitle: const Text('답장 마감 리마인드 (허용 시)'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final ok = await LocalSnoozeNotificationScheduler().ensurePermission(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + ok + ? '브라우저 알림이 허용되었습니다' + : '알림이 거부되었거나 지원되지 않습니다. 인앱 배지는 그대로 동작합니다.', + ), + ), + ); + }, + ), const Divider(height: 24), ListTile( leading: Icon(Icons.logout, color: theme.colorScheme.error), diff --git a/mobile/lib/services/snooze_controller.dart b/mobile/lib/services/snooze_controller.dart index da1792c..b9d06dc 100644 --- a/mobile/lib/services/snooze_controller.dart +++ b/mobile/lib/services/snooze_controller.dart @@ -1,9 +1,10 @@ import '../db/app_database.dart'; import 'snooze_notification_service.dart'; +import 'snooze_service.dart'; /// 답장 마감 알림(roadmap.md §2.7-F) 오케스트레이션 — 온디바이스 DB(스누즈 시각 저장)와 -/// 알림 스케줄러(OS 로컬 알림) 호출을 한 곳에 묶는다. `chat_screen.dart`/ -/// `conversation_list_screen.dart`가 공유해서 쓴다. +/// 알림 스케줄러(OS 로컬 알림 / 웹 Notification) 호출을 한 곳에 묶는다. +/// `chat_screen.dart` / `conversation_list_screen.dart`가 공유해서 쓴다. /// /// 서버 호출은 전혀 없다 — AGENTS.md "Tone/style learning: on-device first"와 동일한 /// 이유로, 이 리마인드는 순전히 이 기기·이 사용자만을 위한 것이라 서버에 올라갈 이유가 @@ -36,4 +37,18 @@ class SnoozeController { await _db.clearSnooze(conversationId); await _scheduler.cancelReminder(conversationId); } + + /// Web N4-W3: when the tab/app resumes, surface past-due snoozes once via + /// [SnoozeNotificationScheduler.showImmediate] (no-op on native). + Future notifyPastDueOnFocus({DateTime? now}) async { + final all = await loadAllSnoozes(); + final t = now ?? DateTime.now(); + for (final e in all.entries) { + if (!isSnoozePastDue(t, e.value)) continue; + await _scheduler.showImmediate( + title: '답장 마감', + body: '대화 #${e.key} — 답장할 시간입니다', + ); + } + } } diff --git a/mobile/lib/services/snooze_notification_service.dart b/mobile/lib/services/snooze_notification_service.dart index 7bf76d6..9af6429 100644 --- a/mobile/lib/services/snooze_notification_service.dart +++ b/mobile/lib/services/snooze_notification_service.dart @@ -1,9 +1,8 @@ /// 답장 마감 알림(roadmap.md §2.7-F)의 실제 OS 로컬 알림 스케줄러 — 플랫폼별 구현을 /// 조건부 export로 분리한다 (`../db/app_database.dart`와 동일한 패턴). /// -/// Flutter Web은 `flutter_local_notifications`가 공식 지원하지 않는 플랫폼이고, 이 -/// 프로젝트의 웹 빌드는 애초에 프리뷰 서피스일 뿐 릴리스 타깃이 아니다 -/// (`app_database_web.dart` 주석 참고) — 웹에서는 스케줄링 호출이 조용히 no-op 처리된다. +/// Flutter Web은 `flutter_local_notifications` 대신 브라우저 Notification API + +/// in-tab Timer를 쓴다 (`snooze_notification_service_web.dart`, docs/web-upgrade N4-W3). library; export 'snooze_notification_service_native.dart' diff --git a/mobile/lib/services/snooze_notification_service_native.dart b/mobile/lib/services/snooze_notification_service_native.dart index 823d0ea..534edc8 100644 --- a/mobile/lib/services/snooze_notification_service_native.dart +++ b/mobile/lib/services/snooze_notification_service_native.dart @@ -20,6 +20,11 @@ abstract class SnoozeNotificationScheduler { }); Future cancelReminder(int conversationId); + + /// Immediate notification (web past-due catch-up). Native uses OS schedule only. + Future showImmediate({required String title, required String body}) async {} + + Future ensurePermission() async => true; } class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler { @@ -77,4 +82,12 @@ class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler { Future cancelReminder(int conversationId) async { await _plugin.cancel(id: conversationId); } + + @override + Future showImmediate({required String title, required String body}) async { + // Native path relies on zonedSchedule; no separate immediate channel here. + } + + @override + Future ensurePermission() async => true; } diff --git a/mobile/lib/services/snooze_notification_service_web.dart b/mobile/lib/services/snooze_notification_service_web.dart index 7545ebf..26d455f 100644 --- a/mobile/lib/services/snooze_notification_service_web.dart +++ b/mobile/lib/services/snooze_notification_service_web.dart @@ -1,7 +1,13 @@ -/// Web 빌드 스텁 — `flutter_local_notifications`는 웹을 지원하지 않고, 이 프로젝트의 -/// 웹 빌드는 프리뷰 서피스일 뿐 릴리스 타깃이 아니다(`app_database_web.dart` 주석 참고). -/// 스누즈 저장(`AppDatabase`)은 웹에서도 동작하지만, 실제 OS 알림 스케줄링만 no-op — -/// 대화 목록/채팅방의 "마감 지남" 배지·배너는 이 스텁과 무관하게 그대로 동작한다. +import 'dart:async'; +// Web-only conditional import target (see snooze_notification_service.dart). +// ignore: avoid_web_libraries_in_flutter, deprecated_member_use +import 'dart:html' as html; + +/// Web reminder scheduler (docs/web-upgrade.md N4-W3). +/// +/// Uses the browser Notification API when permission is granted. Scheduling is +/// in-tab [Timer]-based (no SW alarm) — if the tab is closed before [at], the +/// in-app past-due badge/banner remains the source of truth after reopen. abstract class SnoozeNotificationScheduler { Future scheduleReminder({ required int conversationId, @@ -11,17 +17,68 @@ abstract class SnoozeNotificationScheduler { }); Future cancelReminder(int conversationId); + + Future showImmediate({required String title, required String body}); + + Future ensurePermission(); } class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler { + final Map _timers = {}; + + bool get permissionGranted { + try { + return html.Notification.permission == 'granted'; + } catch (_) { + return false; + } + } + + @override + Future ensurePermission() async { + try { + final current = html.Notification.permission; + if (current == 'granted') return true; + if (current == 'denied') return false; + final result = await html.Notification.requestPermission(); + return result == 'granted'; + } catch (_) { + return false; + } + } + + @override + Future showImmediate({required String title, required String body}) async { + try { + if (!permissionGranted) return; + html.Notification(title, body: body); + } catch (_) { + // Unsupported browser — ignore. + } + } + @override Future scheduleReminder({ required int conversationId, required String title, required String body, required DateTime at, - }) async {} + }) async { + await ensurePermission(); + await cancelReminder(conversationId); + final delay = at.toUtc().difference(DateTime.now().toUtc()); + if (delay.isNegative || delay == Duration.zero) { + await showImmediate(title: title, body: body); + return; + } + _timers[conversationId] = Timer(delay, () { + _timers.remove(conversationId); + showImmediate(title: title, body: body); + }); + } @override - Future cancelReminder(int conversationId) async {} + Future cancelReminder(int conversationId) async { + _timers.remove(conversationId)?.cancel(); + } } diff --git a/mobile/test/snooze_controller_test.dart b/mobile/test/snooze_controller_test.dart index cc4e84d..164f3c7 100644 --- a/mobile/test/snooze_controller_test.dart +++ b/mobile/test/snooze_controller_test.dart @@ -9,6 +9,7 @@ import 'package:ykavu_mobile/services/snooze_notification_service.dart'; class _FakeScheduler implements SnoozeNotificationScheduler { final scheduledCalls = >[]; final cancelledIds = []; + final immediateCalls = >[]; @override Future scheduleReminder({ @@ -29,6 +30,14 @@ class _FakeScheduler implements SnoozeNotificationScheduler { Future cancelReminder(int conversationId) async { cancelledIds.add(conversationId); } + + @override + Future showImmediate({required String title, required String body}) async { + immediateCalls.add({'title': title, 'body': body}); + } + + @override + Future ensurePermission() async => true; } void main() { @@ -83,4 +92,16 @@ void main() { expect(await controller.loadAllSnoozes(), {1: until1, 2: until2}); expect(scheduler.scheduledCalls, hasLength(2)); }); + + test('notifyPastDueOnFocus shows immediate only for past-due snoozes', () async { + final past = DateTime.utc(2026, 8, 1, 12, 0); + final future = DateTime.utc(2026, 8, 10, 12, 0); + await controller.applySnooze(conversationId: 1, until: past, title: 't', body: 'b'); + await controller.applySnooze(conversationId: 2, until: future, title: 't', body: 'b'); + + await controller.notifyPastDueOnFocus(now: DateTime.utc(2026, 8, 3, 12, 0)); + + expect(scheduler.immediateCalls, hasLength(1)); + expect(scheduler.immediateCalls.single['body'], contains('#1')); + }); }