feat(web): W3 browser Notification reminders for snooze

Schedule in-tab timers with the Notification API when permitted,
surface past-due snoozes on focus, and add a settings entry to request
permission. In-app badges remain the fallback. Mark N4-W3 done.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-04 01:18:13 +00:00
parent ec5761d602
commit b61b3bc0fb
No known key found for this signature in database
11 changed files with 178 additions and 25 deletions

View File

@ -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의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -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** 테스터 가이드

View File

@ -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 액션

View File

@ -96,6 +96,9 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
// ). OS가 QA .
if (state == AppLifecycleState.resumed) {
_catchUp();
// N4-W3 past-due snooze Notification on web focus (badges already refresh).
context.read<SessionState>().snoozeController?.notifyPastDueOnFocus();
_loadSnooze();
}
}

View File

@ -23,7 +23,8 @@ class ConversationListScreen extends StatefulWidget {
State<ConversationListScreen> createState() => _ConversationListScreenState();
}
class _ConversationListScreenState extends State<ConversationListScreen> {
class _ConversationListScreenState extends State<ConversationListScreen>
with WidgetsBindingObserver {
List<ConversationSummary> _rooms = [];
Map<int, String> _peerNames = {};
// (roadmap.md §2.7-F) id별 "이따 답장" .
@ -41,9 +42,30 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
@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<void> _onFocus() async {
final controller = context.read<SessionState>().snoozeController;
await controller?.notifyPastDueOnFocus();
if (mounted) await _load();
}
Future<void> _load() async {
final session = context.read<SessionState>();
setState(() {

View File

@ -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),

View File

@ -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<void> 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} — 답장할 시간입니다',
);
}
}
}

View File

@ -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'

View File

@ -20,6 +20,11 @@ abstract class SnoozeNotificationScheduler {
});
Future<void> cancelReminder(int conversationId);
/// Immediate notification (web past-due catch-up). Native uses OS schedule only.
Future<void> showImmediate({required String title, required String body}) async {}
Future<bool> ensurePermission() async => true;
}
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
@ -77,4 +82,12 @@ class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
Future<void> cancelReminder(int conversationId) async {
await _plugin.cancel(id: conversationId);
}
@override
Future<void> showImmediate({required String title, required String body}) async {
// Native path relies on zonedSchedule; no separate immediate channel here.
}
@override
Future<bool> ensurePermission() async => true;
}

View File

@ -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<void> scheduleReminder({
required int conversationId,
@ -11,17 +17,68 @@ abstract class SnoozeNotificationScheduler {
});
Future<void> cancelReminder(int conversationId);
Future<void> showImmediate({required String title, required String body});
Future<bool> ensurePermission();
}
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
final Map<int, Timer> _timers = {};
bool get permissionGranted {
try {
return html.Notification.permission == 'granted';
} catch (_) {
return false;
}
}
@override
Future<bool> 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<void> showImmediate({required String title, required String body}) async {
try {
if (!permissionGranted) return;
html.Notification(title, body: body);
} catch (_) {
// Unsupported browser ignore.
}
}
@override
Future<void> 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<void> cancelReminder(int conversationId) async {}
Future<void> cancelReminder(int conversationId) async {
_timers.remove(conversationId)?.cancel();
}
}

View File

@ -9,6 +9,7 @@ import 'package:ykavu_mobile/services/snooze_notification_service.dart';
class _FakeScheduler implements SnoozeNotificationScheduler {
final scheduledCalls = <Map<String, Object?>>[];
final cancelledIds = <int>[];
final immediateCalls = <Map<String, Object?>>[];
@override
Future<void> scheduleReminder({
@ -29,6 +30,14 @@ class _FakeScheduler implements SnoozeNotificationScheduler {
Future<void> cancelReminder(int conversationId) async {
cancelledIds.add(conversationId);
}
@override
Future<void> showImmediate({required String title, required String body}) async {
immediateCalls.add({'title': title, 'body': body});
}
@override
Future<bool> 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'));
});
}