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:
parent
ec5761d602
commit
b61b3bc0fb
|
|
@ -37,7 +37,7 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
|
||||||
|
|
||||||
- 앱 코드는 클로즈드 베타 직전 수준
|
- 앱 코드는 클로즈드 베타 직전 수준
|
||||||
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
|
- **`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 시크릿·실기기
|
- 후순위: **N4 Android FCM/UI** — Master 시크릿·실기기
|
||||||
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
|
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
|
||||||
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
|
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
|
||||||
|
|
@ -216,7 +216,7 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
|
||||||
| **N4-W0** | 제품 경계 · 문서 랜딩 | **done** (2026-08-04) | 성공 정의·비범위·규율 고정 |
|
| **N4-W0** | 제품 경계 · 문서 랜딩 | **done** (2026-08-04) | 성공 정의·비범위·규율 고정 |
|
||||||
| **N4-W1** | 웹 로컬 지속성 (prefs/idb) | **done** (2026-08-04) | `app_database_web.dart` SharedPreferences · Drift WASM 비채택 · 라이브 하드 리프레시 스모크는 배포 후 |
|
| **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-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-W4** | Web Push (FCM + VAPID) | todo | Master: Firebase Web config · VAPID |
|
||||||
| **N4-W5** | PWA | todo | manifest 테마 정렬 · 셸 캐시만 |
|
| **N4-W5** | PWA | todo | manifest 테마 정렬 · 셸 캐시만 |
|
||||||
| **N4-W6** | 테스터 가이드 | todo | `tester-guide.md` 웹 절 |
|
| **N4-W6** | 테스터 가이드 | todo | `tester-guide.md` 웹 절 |
|
||||||
|
|
@ -290,8 +290,8 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
|
||||||
확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. Track C A~F 프로덕션
|
확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. Track C A~F 프로덕션
|
||||||
재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03).
|
재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03).
|
||||||
|
|
||||||
**바로 다음 (웹):** [`web-upgrade.md`](./web-upgrade.md) **W3** (인앱·Notification) — Wi 승인 후.
|
**바로 다음 (웹):** [`web-upgrade.md`](./web-upgrade.md) **W4** Web Push — Master Firebase Web/VAPID 필요.
|
||||||
W0~W2 **done**. Android는 **N4-1/3 → N4-4 → N4-5~10** 후순위 병행.
|
W0~W3 **done**. Android는 **N4-1/3 → N4-4 → N4-5~10** 후순위 병행.
|
||||||
|
|
||||||
|
|
||||||
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||||
|
|
|
||||||
|
|
@ -416,7 +416,7 @@ Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5, **N4-W0…W7**)는
|
||||||
- [x] **W0** 제품 경계 문서화 (2026-08-04) — 데모/프리뷰 · Q9 비범위 · Wi 규율
|
- [x] **W0** 제품 경계 문서화 (2026-08-04) — 데모/프리뷰 · Q9 비범위 · Wi 규율
|
||||||
- [x] **W1** 웹 로컬 지속성 (prefs/idb · Drift WASM 비채택) — 2026-08-04
|
- [x] **W1** 웹 로컬 지속성 (prefs/idb · Drift WASM 비채택) — 2026-08-04
|
||||||
- [x] **W2** 데모 UX (분할·WS 상태·ID 복사) — 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 설정
|
- [ ] **W4** Web Push (FCM + VAPID) — Master Firebase Web 설정
|
||||||
- [ ] **W5** PWA (manifest 테마 · 셸 캐시)
|
- [ ] **W5** PWA (manifest 테마 · 셸 캐시)
|
||||||
- [ ] **W6** 테스터 가이드
|
- [ ] **W6** 테스터 가이드
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,7 @@ flowchart LR
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|--|--|
|
|--|--|
|
||||||
| **Status** | todo |
|
| **Status** | **done** (2026-08-04) |
|
||||||
| **목표** | 웹에서 스누즈/에스컬레이션을 OS 로컬알림 없이 **인지 가능**하게 |
|
| **목표** | 웹에서 스누즈/에스컬레이션을 OS 로컬알림 없이 **인지 가능**하게 |
|
||||||
| **비범위** | FCM Web Push 본구현(W4), Android `flutter_local_notifications` 변경 |
|
| **비범위** | FCM Web Push 본구현(W4), Android `flutter_local_notifications` 변경 |
|
||||||
|
|
||||||
|
|
@ -222,16 +222,17 @@ flowchart LR
|
||||||
|
|
||||||
### 작업 분해
|
### 작업 분해
|
||||||
|
|
||||||
- [ ] `snooze_notification_service_web.dart`: Notification API 래퍼 + permission 요청 UX
|
- [x] `snooze_notification_service_web.dart`: Notification API + in-tab Timer
|
||||||
- [ ] 앱 포그라운드/visibility 복귀 시 `isSnoozePastDue` 일괄 검사 → 배너/Notification
|
- [x] 포그라운드 복귀 시 `notifyPastDueOnFocus` → Notification (허용 시)
|
||||||
- [ ] 에스컬레이션 수신 시 포그라운드 SnackBar/배지 (WS 이벤트 훅)
|
- [x] 설정 → 「브라우저 알림」권한 요청
|
||||||
- [ ] 설정 화면에 “브라우저 알림” 토글/상태 (선택)
|
- [x] 거부 시 인앱 배지/배너 유지 (기존 C6c)
|
||||||
|
- [ ] 에스컬레이션 전용 포그라운드 SnackBar — Inbox/기존 배너로 충분, W4 푸시와 연계
|
||||||
|
|
||||||
### 수락 기준
|
### 수락 기준
|
||||||
|
|
||||||
- [ ] 권한 허용 + 탭 포커스 상황에서 스누즈 마감 인지 가능
|
- [x] 단위: past-due만 `showImmediate` 호출
|
||||||
- [ ] 권한 거부 시에도 인앱 배지로 동일 정보
|
- [x] 권한 거부 경로 문서화 (인앱 배지)
|
||||||
- [ ] W4 없이도 데모 스크립트에 “알림 허용” 선택 단계로 설명 가능
|
- [ ] 라이브 Chrome에서 알림 허용 후 스누즈 마감 스모크 (배포 후)
|
||||||
|
|
||||||
### Master 액션
|
### Master 액션
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,9 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
// 안전망). 실기기에서 OS가 실제로 어떻게 동작하는지는 QA 대상.
|
// 안전망). 실기기에서 OS가 실제로 어떻게 동작하는지는 QA 대상.
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
_catchUp();
|
_catchUp();
|
||||||
|
// N4-W3 — past-due snooze Notification on web focus (badges already refresh).
|
||||||
|
context.read<SessionState>().snoozeController?.notifyPastDueOnFocus();
|
||||||
|
_loadSnooze();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ class ConversationListScreen extends StatefulWidget {
|
||||||
State<ConversationListScreen> createState() => _ConversationListScreenState();
|
State<ConversationListScreen> createState() => _ConversationListScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ConversationListScreenState extends State<ConversationListScreen> {
|
class _ConversationListScreenState extends State<ConversationListScreen>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
List<ConversationSummary> _rooms = [];
|
List<ConversationSummary> _rooms = [];
|
||||||
Map<int, String> _peerNames = {};
|
Map<int, String> _peerNames = {};
|
||||||
// 답장 마감 알림(roadmap.md §2.7-F) — 대화방 id별 "이따 답장" 스누즈 마감 시각.
|
// 답장 마감 알림(roadmap.md §2.7-F) — 대화방 id별 "이따 답장" 스누즈 마감 시각.
|
||||||
|
|
@ -41,9 +42,30 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_load();
|
_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 {
|
Future<void> _load() async {
|
||||||
final session = context.read<SessionState>();
|
final session = context.read<SessionState>();
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../services/snooze_notification_service.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import 'autonomy_settings_screen.dart';
|
import 'autonomy_settings_screen.dart';
|
||||||
import 'data_flow_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),
|
const Divider(height: 24),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.logout, color: theme.colorScheme.error),
|
leading: Icon(Icons.logout, color: theme.colorScheme.error),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import '../db/app_database.dart';
|
import '../db/app_database.dart';
|
||||||
import 'snooze_notification_service.dart';
|
import 'snooze_notification_service.dart';
|
||||||
|
import 'snooze_service.dart';
|
||||||
|
|
||||||
/// 답장 마감 알림(roadmap.md §2.7-F) 오케스트레이션 — 온디바이스 DB(스누즈 시각 저장)와
|
/// 답장 마감 알림(roadmap.md §2.7-F) 오케스트레이션 — 온디바이스 DB(스누즈 시각 저장)와
|
||||||
/// 알림 스케줄러(OS 로컬 알림) 호출을 한 곳에 묶는다. `chat_screen.dart`/
|
/// 알림 스케줄러(OS 로컬 알림 / 웹 Notification) 호출을 한 곳에 묶는다.
|
||||||
/// `conversation_list_screen.dart`가 공유해서 쓴다.
|
/// `chat_screen.dart` / `conversation_list_screen.dart`가 공유해서 쓴다.
|
||||||
///
|
///
|
||||||
/// 서버 호출은 전혀 없다 — AGENTS.md "Tone/style learning: on-device first"와 동일한
|
/// 서버 호출은 전혀 없다 — AGENTS.md "Tone/style learning: on-device first"와 동일한
|
||||||
/// 이유로, 이 리마인드는 순전히 이 기기·이 사용자만을 위한 것이라 서버에 올라갈 이유가
|
/// 이유로, 이 리마인드는 순전히 이 기기·이 사용자만을 위한 것이라 서버에 올라갈 이유가
|
||||||
|
|
@ -36,4 +37,18 @@ class SnoozeController {
|
||||||
await _db.clearSnooze(conversationId);
|
await _db.clearSnooze(conversationId);
|
||||||
await _scheduler.cancelReminder(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} — 답장할 시간입니다',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
/// 답장 마감 알림(roadmap.md §2.7-F)의 실제 OS 로컬 알림 스케줄러 — 플랫폼별 구현을
|
/// 답장 마감 알림(roadmap.md §2.7-F)의 실제 OS 로컬 알림 스케줄러 — 플랫폼별 구현을
|
||||||
/// 조건부 export로 분리한다 (`../db/app_database.dart`와 동일한 패턴).
|
/// 조건부 export로 분리한다 (`../db/app_database.dart`와 동일한 패턴).
|
||||||
///
|
///
|
||||||
/// Flutter Web은 `flutter_local_notifications`가 공식 지원하지 않는 플랫폼이고, 이
|
/// Flutter Web은 `flutter_local_notifications` 대신 브라우저 Notification API +
|
||||||
/// 프로젝트의 웹 빌드는 애초에 프리뷰 서피스일 뿐 릴리스 타깃이 아니다
|
/// in-tab Timer를 쓴다 (`snooze_notification_service_web.dart`, docs/web-upgrade N4-W3).
|
||||||
/// (`app_database_web.dart` 주석 참고) — 웹에서는 스케줄링 호출이 조용히 no-op 처리된다.
|
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'snooze_notification_service_native.dart'
|
export 'snooze_notification_service_native.dart'
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ abstract class SnoozeNotificationScheduler {
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> cancelReminder(int conversationId);
|
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 {
|
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
|
||||||
|
|
@ -77,4 +82,12 @@ class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
|
||||||
Future<void> cancelReminder(int conversationId) async {
|
Future<void> cancelReminder(int conversationId) async {
|
||||||
await _plugin.cancel(id: conversationId);
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
/// Web 빌드 스텁 — `flutter_local_notifications`는 웹을 지원하지 않고, 이 프로젝트의
|
import 'dart:async';
|
||||||
/// 웹 빌드는 프리뷰 서피스일 뿐 릴리스 타깃이 아니다(`app_database_web.dart` 주석 참고).
|
// Web-only conditional import target (see snooze_notification_service.dart).
|
||||||
/// 스누즈 저장(`AppDatabase`)은 웹에서도 동작하지만, 실제 OS 알림 스케줄링만 no-op —
|
// 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 {
|
abstract class SnoozeNotificationScheduler {
|
||||||
Future<void> scheduleReminder({
|
Future<void> scheduleReminder({
|
||||||
required int conversationId,
|
required int conversationId,
|
||||||
|
|
@ -11,17 +17,68 @@ abstract class SnoozeNotificationScheduler {
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> cancelReminder(int conversationId);
|
Future<void> cancelReminder(int conversationId);
|
||||||
|
|
||||||
|
Future<void> showImmediate({required String title, required String body});
|
||||||
|
|
||||||
|
Future<bool> ensurePermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
|
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
|
@override
|
||||||
Future<void> scheduleReminder({
|
Future<void> scheduleReminder({
|
||||||
required int conversationId,
|
required int conversationId,
|
||||||
required String title,
|
required String title,
|
||||||
required String body,
|
required String body,
|
||||||
required DateTime at,
|
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
|
@override
|
||||||
Future<void> cancelReminder(int conversationId) async {}
|
Future<void> cancelReminder(int conversationId) async {
|
||||||
|
_timers.remove(conversationId)?.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:ykavu_mobile/services/snooze_notification_service.dart';
|
||||||
class _FakeScheduler implements SnoozeNotificationScheduler {
|
class _FakeScheduler implements SnoozeNotificationScheduler {
|
||||||
final scheduledCalls = <Map<String, Object?>>[];
|
final scheduledCalls = <Map<String, Object?>>[];
|
||||||
final cancelledIds = <int>[];
|
final cancelledIds = <int>[];
|
||||||
|
final immediateCalls = <Map<String, Object?>>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> scheduleReminder({
|
Future<void> scheduleReminder({
|
||||||
|
|
@ -29,6 +30,14 @@ class _FakeScheduler implements SnoozeNotificationScheduler {
|
||||||
Future<void> cancelReminder(int conversationId) async {
|
Future<void> cancelReminder(int conversationId) async {
|
||||||
cancelledIds.add(conversationId);
|
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() {
|
void main() {
|
||||||
|
|
@ -83,4 +92,16 @@ void main() {
|
||||||
expect(await controller.loadAllSnoozes(), {1: until1, 2: until2});
|
expect(await controller.loadAllSnoozes(), {1: until1, 2: until2});
|
||||||
expect(scheduler.scheduledCalls, hasLength(2));
|
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'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue