feat(web): W1 durable preview DB via SharedPreferences

Persist tone samples, KV, and snoozes across hard refresh on Flutter
web. Keep AppDatabase.memory ephemeral; document DataFlow copy and
mark N4-W1 done. Unit tests cover prefs round-trip and corrupt JSON.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-04 01:08:21 +00:00
parent 7486e6e15e
commit 866f36e319
No known key found for this signature in database
8 changed files with 181 additions and 28 deletions

View File

@ -37,8 +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) (데모/프리뷰 + Web Push + PWA). - **웹 고도화:** [`web-upgrade.md`](./web-upgrade.md) — W0·**W1 done** · 다음 W2~W7는 Wi 승인 후
W0 경계 **done** · W1~W7 구현은 Wi 단위 Master 승인 후
- 후순위: **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`대)
@ -215,7 +214,7 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| ID | 작업 | Status | 비고 | | ID | 작업 | Status | 비고 |
|----|------|--------|------| |----|------|--------|------|
| **N4-W0** | 제품 경계 · 문서 랜딩 | **done** (2026-08-04) | 성공 정의·비범위·규율 고정 | | **N4-W0** | 제품 경계 · 문서 랜딩 | **done** (2026-08-04) | 성공 정의·비범위·규율 고정 |
| **N4-W1** | 웹 로컬 지속성 (prefs/idb) | todo | Drift WASM 비채택 · 새로고침 후 말투/스누즈 유지 | | **N4-W1** | 웹 로컬 지속성 (prefs/idb) | **done** (2026-08-04) | `app_database_web.dart` SharedPreferences · Drift WASM 비채택 · 라이브 하드 리프레시 스모크는 배포 후 |
| **N4-W2** | 데모 메신저 UX | todo | 분할 레이아웃·WS 상태·ID 복사 | | **N4-W2** | 데모 메신저 UX | todo | 분할 레이아웃·WS 상태·ID 복사 |
| **N4-W3** | 인앱·Notification API | todo | 스누즈/에스컬레이션 인지 · Push 전 단계 | | **N4-W3** | 인앱·Notification API | todo | 스누즈/에스컬레이션 인지 · Push 전 단계 |
| **N4-W4** | Web Push (FCM + VAPID) | todo | Master: Firebase Web config · VAPID | | **N4-W4** | Web Push (FCM + VAPID) | todo | Master: Firebase Web config · VAPID |
@ -291,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) **W1** (지속성) → W2… — **바로 다음 (웹):** [`web-upgrade.md`](./web-upgrade.md) **W2** (데모 UX) — Wi 승인 후.
Master Wi 승인 후 구현. Android는 **N4-1/3 → N4-4 → N4-5~10** 후순위 병행. W1 지속성 **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의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -414,7 +414,7 @@ Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5, **N4-W0…W7**)는
##### 웹 고도화 (데모/프리뷰) — [`web-upgrade.md`](./web-upgrade.md) ##### 웹 고도화 (데모/프리뷰) — [`web-upgrade.md`](./web-upgrade.md)
- [x] **W0** 제품 경계 문서화 (2026-08-04) — 데모/프리뷰 · Q9 비범위 · Wi 규율 - [x] **W0** 제품 경계 문서화 (2026-08-04) — 데모/프리뷰 · Q9 비범위 · Wi 규율
- [ ] **W1** 웹 로컬 지속성 (prefs/idb · Drift WASM 비채택) - [x] **W1** 웹 로컬 지속성 (prefs/idb · Drift WASM 비채택) — 2026-08-04
- [ ] **W2** 데모 UX (분할·WS 상태·ID 복사) - [ ] **W2** 데모 UX (분할·WS 상태·ID 복사)
- [ ] **W3** 인앱 + Notification API - [ ] **W3** 인앱 + Notification API
- [ ] **W4** Web Push (FCM + VAPID) — Master Firebase Web 설정 - [ ] **W4** Web Push (FCM + VAPID) — Master Firebase Web 설정

View File

@ -98,7 +98,7 @@ flowchart LR
| | | | | |
|--|--| |--|--|
| **Status** | todo (구현 전 · Master Wi 승인 후) | | **Status** | **done** (2026-08-04) |
| **목표** | 하드 리프레시 후에도 말투·온보딩·스누즈·install id가 유지되는 프리뷰 UX | | **목표** | 하드 리프레시 후에도 말투·온보딩·스누즈·install id가 유지되는 프리뷰 UX |
| **비범위** | SQLCipher/암호화 DB, Drift WASM, 서버 측 말투 동기화 | | **비범위** | SQLCipher/암호화 DB, Drift WASM, 서버 측 말투 동기화 |
@ -115,17 +115,17 @@ flowchart LR
### 작업 분해 ### 작업 분해
- [ ] `app_database_web.dart`: open 시 prefs/idb 로드, mutate 시 persist - [x] `app_database_web.dart`: open 시 SharedPreferences 로드, mutate 시 persist
- [ ] 키 네임스페이스 고정 예: `ykavu_web_kv_*`, `ykavu_web_tone_samples`, `ykavu_web_snoozes_json` - [x] 키 네임스페이스: `ykavu_web_db_kv_*`, `ykavu_web_db_tone_samples`, `ykavu_web_db_snoozes`
- [ ] `SessionState.restore` 경로에서 웹도 동일 API로 말투/온보딩 복원되는지 확인 - [x] `AppDatabase.memory()`는 비지속(테스트용) 유지 — `open()`만 durable
- [ ] 단위 테스트: 웹 stub에 fake prefs 주입해 round-trip (가능하면) - [x] 단위 테스트: `mobile/test/web_db_persistence_test.dart` (mock prefs round-trip)
- [ ] `DataFlowScreen` / 테스터 카피 1줄 갱신 - [x] `DataFlowScreen` 웹 카피 갱신
### 수락 기준 ### 수락 기준
- [ ] Chrome에서 가입 → 말투 저장 → **하드 리프레시** → 온보딩 스킵·말투 유지 - [x] 단위 테스트: open → 저장 → 재 open 시 말투·KV·스누즈 복원
- [ ] 스누즈 설정 → 리프레시 → 목록 배지/채팅 배너 유지 - [ ] Chrome 라이브 하드 리프레시 스모크 (배포 후 Master/에이전트 1회)
- [ ] 시크릿 창(다른 프로필)과는 저장소 격리 (기대 동작으로 문서화) - [x] 시크릿 창(다른 프로필)과는 저장소 격리 — 브라우저 프로필 단위 SharedPreferences (기대 동작)
### Master 액션 ### Master 액션

View File

@ -67,7 +67,7 @@ sudo apt-get install -y libsqlite3-dev libsqlcipher1
## 웹 (데모/프리뷰) ## 웹 (데모/프리뷰)
- 로컬 DB: `lib/db/app_database_web.dart`현재 **메모리 스텁**(새로고침 유실) → **W1**에서 prefs/idb 지속성 - 로컬 DB: `lib/db/app_database_web.dart`**W1 done**: SharedPreferences 지속성(비암호화 프리뷰)
- 스누즈 OS 알림: 웹 no-op → **W3** Notification API / 인앱 - 스누즈 OS 알림: 웹 no-op → **W3** Notification API / 인앱
- 푸시: 웹에서 FCM skip → **W4** Web Push + VAPID ([`docs/fcm-setup.md`](../docs/fcm-setup.md) Web 절) - 푸시: 웹에서 FCM skip → **W4** Web Push + VAPID ([`docs/fcm-setup.md`](../docs/fcm-setup.md) Web 절)
- PWA: `web/manifest.json` 골격 있음 → **W5** 테마·설치·셸 캐시 - PWA: `web/manifest.json` 골격 있음 → **W5** 테마·설치·셸 캐시

View File

@ -1,32 +1,125 @@
/// Web/Chrome stub for [AppDatabase]. import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// Web/Chrome [AppDatabase] backed by [SharedPreferences] (IndexedDB under the hood).
/// ///
/// SQLCipher / dart:ffi cannot compile for Flutter Web. Chrome runs use an /// SQLCipher / dart:ffi cannot compile for Flutter Web. Persistence is
/// in-tab memory map so UI + API flows still work. Persistence is not encrypted /// **not encrypted** acceptable for the demo/preview surface
/// and is lost on refresh (web is a preview surface, not the release target). /// (`docs/web-upgrade.md` N4-W1). Drift WASM is intentionally not used.
///
/// [AppDatabase.memory] stays ephemeral (unit tests / fallbacks).
class AppDatabase { class AppDatabase {
AppDatabase({this.encrypted = false}); AppDatabase({
this.encrypted = false,
SharedPreferences? prefs,
bool persist = false,
}) : _prefs = prefs,
_persist = persist && prefs != null;
/// Always false on web there is no SQLCipher path. /// Always false on web there is no SQLCipher path.
final bool encrypted; final bool encrypted;
final SharedPreferences? _prefs;
final bool _persist;
final Map<String, String> _kv = {}; final Map<String, String> _kv = {};
List<String> _toneSamples = []; List<String> _toneSamples = [];
final Map<int, DateTime> _snoozes = {}; final Map<int, DateTime> _snoozes = {};
factory AppDatabase.memory() => AppDatabase(encrypted: false); static const _kTone = 'ykavu_web_db_tone_samples';
static const _kSnoozes = 'ykavu_web_db_snoozes';
static const _kvPrefix = 'ykavu_web_db_kv_';
static Future<AppDatabase> open() async => AppDatabase(encrypted: false); factory AppDatabase.memory() => AppDatabase(encrypted: false, persist: false);
/// Opens a durable preview DB (survives hard refresh in the same browser profile).
static Future<AppDatabase> open() async {
final prefs = await SharedPreferences.getInstance();
final db = AppDatabase(encrypted: false, prefs: prefs, persist: true);
await db._hydrate();
return db;
}
Future<void> _hydrate() async {
final prefs = _prefs;
if (!_persist || prefs == null) return;
try {
final toneRaw = prefs.getString(_kTone);
if (toneRaw != null && toneRaw.isNotEmpty) {
final decoded = jsonDecode(toneRaw);
if (decoded is List) {
_toneSamples = decoded.map((e) => e.toString()).toList();
}
}
} catch (_) {
_toneSamples = [];
await prefs.remove(_kTone);
}
try {
final snoozeRaw = prefs.getString(_kSnoozes);
if (snoozeRaw != null && snoozeRaw.isNotEmpty) {
final decoded = jsonDecode(snoozeRaw);
if (decoded is Map) {
_snoozes
..clear()
..addEntries(
decoded.entries.map((e) {
final id = int.tryParse(e.key.toString());
final ms = e.value is int
? e.value as int
: int.tryParse(e.value.toString());
if (id == null || ms == null) return null;
return MapEntry(id, DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true));
}).whereType<MapEntry<int, DateTime>>(),
);
}
}
} catch (_) {
_snoozes.clear();
await prefs.remove(_kSnoozes);
}
for (final key in prefs.getKeys()) {
if (!key.startsWith(_kvPrefix)) continue;
final logical = key.substring(_kvPrefix.length);
final value = prefs.getString(key);
if (value != null) _kv[logical] = value;
}
}
Future<void> _persistTone() async {
if (!_persist || _prefs == null) return;
await _prefs.setString(_kTone, jsonEncode(_toneSamples));
}
Future<void> _persistSnoozes() async {
if (!_persist || _prefs == null) return;
final map = <String, int>{
for (final e in _snoozes.entries) e.key.toString(): e.value.toUtc().millisecondsSinceEpoch,
};
await _prefs.setString(_kSnoozes, jsonEncode(map));
}
Future<void> _persistKv(String key, String value) async {
if (!_persist || _prefs == null) return;
await _prefs.setString('$_kvPrefix$key', value);
}
Future<List<String>> loadToneSamples() async => List<String>.from(_toneSamples); Future<List<String>> loadToneSamples() async => List<String>.from(_toneSamples);
Future<void> replaceToneSamples(List<String> samples) async { Future<void> replaceToneSamples(List<String> samples) async {
_toneSamples = List<String>.from(samples); _toneSamples = List<String>.from(samples);
await _persistTone();
} }
Future<String?> getKv(String key) async => _kv[key]; Future<String?> getKv(String key) async => _kv[key];
Future<void> setKv(String key, String value) async { Future<void> setKv(String key, String value) async {
_kv[key] = value; _kv[key] = value;
await _persistKv(key, value);
} }
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async { Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
@ -39,16 +132,17 @@ class AppDatabase {
await setKv(key, value ? '1' : '0'); await setKv(key, value ? '1' : '0');
} }
// roadmap.md §2.7-F native stub과 /// roadmap.md §2.7-F on-device snooze; web preview persists via SharedPreferences.
// (in-memory, encrypted persistence web은 ).
Future<void> setSnoozedUntil(int conversationId, DateTime until) async { Future<void> setSnoozedUntil(int conversationId, DateTime until) async {
_snoozes[conversationId] = until; _snoozes[conversationId] = until.toUtc();
await _persistSnoozes();
} }
Future<DateTime?> getSnoozedUntil(int conversationId) async => _snoozes[conversationId]; Future<DateTime?> getSnoozedUntil(int conversationId) async => _snoozes[conversationId];
Future<void> clearSnooze(int conversationId) async { Future<void> clearSnooze(int conversationId) async {
_snoozes.remove(conversationId); _snoozes.remove(conversationId);
await _persistSnoozes();
} }
Future<Map<int, DateTime>> loadAllSnoozes() async => Map<int, DateTime>.from(_snoozes); Future<Map<int, DateTime>> loadAllSnoozes() async => Map<int, DateTime>.from(_snoozes);

View File

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -74,7 +75,9 @@ class DataFlowScreen extends StatelessWidget {
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '), samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
session.localDbEncrypted session.localDbEncrypted
? '저장: drift + SQLCipher(암호화)' ? '저장: drift + SQLCipher(암호화)'
: '저장: 메모리/웹 스텁(이 환경에 SQLCipher 없음 — Chrome·Linux 폴백)', : kIsWeb
? '저장: 브라우저 저장소(비암호화 · 웹 프리뷰 — 같은 프로필에서는 새로고침 후에도 유지)'
: '저장: 메모리 폴백(이 환경에 SQLCipher 없음 — Linux CI 등)',
].join('\n'), ].join('\n'),
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,

View File

@ -75,7 +75,8 @@ class SessionState extends ChangeNotifier {
} catch (e) { } catch (e) {
// Linux CI / hosts without libsqlcipher.so fall back to memory so the // Linux CI / hosts without libsqlcipher.so fall back to memory so the
// app still boots; Android release path uses SQLCipher. // app still boots; Android release path uses SQLCipher.
// Web uses an in-memory stub via conditional import (no FFI). // Web uses SharedPreferences-backed AppDatabase via conditional import
// (docs/web-upgrade.md N4-W1); this catch is for native open failures.
debugPrint('Encrypted DB unavailable ($e); using in-memory fallback'); debugPrint('Encrypted DB unavailable ($e); using in-memory fallback');
localDbEncrypted = false; localDbEncrypted = false;
return AppDatabase.memory(); return AppDatabase.memory();

View File

@ -0,0 +1,56 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:ykavu_mobile/db/app_database_web.dart';
/// N4-W1 web AppDatabase durable round-trip (imports web impl directly so
/// VM tests exercise prefs persistence, not the native Drift path).
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('web open persists tone, kv, and snooze across reopen', () async {
final db1 = await AppDatabase.open();
addTearDown(db1.close);
await db1.replaceToneSamples(['웹에서도', '유지돼야 함']);
await db1.setBoolKv('tone_onboarding_done', true);
await db1.setKv('device_install_id', 'abc123deadbeef');
final until = DateTime.utc(2026, 8, 4, 15, 30);
await db1.setSnoozedUntil(7, until);
final db2 = await AppDatabase.open();
addTearDown(db2.close);
expect(await db2.loadToneSamples(), ['웹에서도', '유지돼야 함']);
expect(await db2.getBoolKv('tone_onboarding_done'), isTrue);
expect(await db2.getKv('device_install_id'), 'abc123deadbeef');
expect(await db2.getSnoozedUntil(7), until);
expect(db2.encrypted, isFalse);
});
test('web memory factory does not write prefs', () async {
final mem = AppDatabase.memory();
addTearDown(mem.close);
await mem.replaceToneSamples(['ephemeral']);
await mem.setBoolKv('tone_onboarding_done', true);
final opened = await AppDatabase.open();
addTearDown(opened.close);
expect(await opened.loadToneSamples(), isEmpty);
expect(await opened.getBoolKv('tone_onboarding_done'), isFalse);
});
test('corrupt tone JSON is wiped and open still succeeds', () async {
SharedPreferences.setMockInitialValues({
'ykavu_web_db_tone_samples': '{not-json',
});
final db = await AppDatabase.open();
addTearDown(db.close);
expect(await db.loadToneSamples(), isEmpty);
await db.replaceToneSamples(['ok']);
expect(await db.loadToneSamples(), ['ok']);
});
}