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:
parent
7486e6e15e
commit
866f36e319
|
|
@ -37,8 +37,7 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
|
|||
|
||||
- 앱 코드는 클로즈드 베타 직전 수준
|
||||
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
|
||||
- **웹 고도화 설계:** [`web-upgrade.md`](./web-upgrade.md) (데모/프리뷰 + Web Push + PWA).
|
||||
W0 경계 **done** · W1~W7 구현은 Wi 단위 Master 승인 후
|
||||
- **웹 고도화:** [`web-upgrade.md`](./web-upgrade.md) — W0·**W1 done** · 다음 W2~W7는 Wi 승인 후
|
||||
- 후순위: **N4 Android FCM/UI** — Master 시크릿·실기기
|
||||
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
|
||||
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
|
||||
|
|
@ -215,7 +214,7 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
|
|||
| ID | 작업 | Status | 비고 |
|
||||
|----|------|--------|------|
|
||||
| **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-W3** | 인앱·Notification API | todo | 스누즈/에스컬레이션 인지 · Push 전 단계 |
|
||||
| **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 프로덕션
|
||||
재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03).
|
||||
|
||||
**바로 다음 (웹 우선 가능):** [`web-upgrade.md`](./web-upgrade.md) **W1** (지속성) → W2… —
|
||||
Master Wi 승인 후 구현. Android는 **N4-1/3 → N4-4 → N4-5~10** 후순위 병행.
|
||||
**바로 다음 (웹):** [`web-upgrade.md`](./web-upgrade.md) **W2** (데모 UX) — Wi 승인 후.
|
||||
W1 지속성 **done**. Android는 **N4-1/3 → N4-4 → N4-5~10** 후순위 병행.
|
||||
|
||||
|
||||
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||
|
|
|
|||
|
|
@ -414,7 +414,7 @@ Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5, **N4-W0…W7**)는
|
|||
##### 웹 고도화 (데모/프리뷰) — [`web-upgrade.md`](./web-upgrade.md)
|
||||
|
||||
- [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 복사)
|
||||
- [ ] **W3** 인앱 + Notification API
|
||||
- [ ] **W4** Web Push (FCM + VAPID) — Master Firebase Web 설정
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ flowchart LR
|
|||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Status** | todo (구현 전 · Master Wi 승인 후) |
|
||||
| **Status** | **done** (2026-08-04) |
|
||||
| **목표** | 하드 리프레시 후에도 말투·온보딩·스누즈·install id가 유지되는 프리뷰 UX |
|
||||
| **비범위** | SQLCipher/암호화 DB, Drift WASM, 서버 측 말투 동기화 |
|
||||
|
||||
|
|
@ -115,17 +115,17 @@ flowchart LR
|
|||
|
||||
### 작업 분해
|
||||
|
||||
- [ ] `app_database_web.dart`: open 시 prefs/idb 로드, mutate 시 persist
|
||||
- [ ] 키 네임스페이스 고정 예: `ykavu_web_kv_*`, `ykavu_web_tone_samples`, `ykavu_web_snoozes_json`
|
||||
- [ ] `SessionState.restore` 경로에서 웹도 동일 API로 말투/온보딩 복원되는지 확인
|
||||
- [ ] 단위 테스트: 웹 stub에 fake prefs 주입해 round-trip (가능하면)
|
||||
- [ ] `DataFlowScreen` / 테스터 카피 1줄 갱신
|
||||
- [x] `app_database_web.dart`: open 시 SharedPreferences 로드, mutate 시 persist
|
||||
- [x] 키 네임스페이스: `ykavu_web_db_kv_*`, `ykavu_web_db_tone_samples`, `ykavu_web_db_snoozes`
|
||||
- [x] `AppDatabase.memory()`는 비지속(테스트용) 유지 — `open()`만 durable
|
||||
- [x] 단위 테스트: `mobile/test/web_db_persistence_test.dart` (mock prefs round-trip)
|
||||
- [x] `DataFlowScreen` 웹 카피 갱신
|
||||
|
||||
### 수락 기준
|
||||
|
||||
- [ ] Chrome에서 가입 → 말투 저장 → **하드 리프레시** → 온보딩 스킵·말투 유지
|
||||
- [ ] 스누즈 설정 → 리프레시 → 목록 배지/채팅 배너 유지
|
||||
- [ ] 시크릿 창(다른 프로필)과는 저장소 격리 (기대 동작으로 문서화)
|
||||
- [x] 단위 테스트: open → 저장 → 재 open 시 말투·KV·스누즈 복원
|
||||
- [ ] Chrome 라이브 하드 리프레시 스모크 (배포 후 Master/에이전트 1회)
|
||||
- [x] 시크릿 창(다른 프로필)과는 저장소 격리 — 브라우저 프로필 단위 SharedPreferences (기대 동작)
|
||||
|
||||
### Master 액션
|
||||
|
||||
|
|
|
|||
|
|
@ -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 / 인앱
|
||||
- 푸시: 웹에서 FCM skip → **W4** Web Push + VAPID ([`docs/fcm-setup.md`](../docs/fcm-setup.md) Web 절)
|
||||
- PWA: `web/manifest.json` 골격 있음 → **W5** 테마·설치·셸 캐시
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// in-tab memory map so UI + API flows still work. Persistence is not encrypted
|
||||
/// and is lost on refresh (web is a preview surface, not the release target).
|
||||
/// SQLCipher / dart:ffi cannot compile for Flutter Web. Persistence is
|
||||
/// **not encrypted** — acceptable for the demo/preview surface
|
||||
/// (`docs/web-upgrade.md` N4-W1). Drift WASM is intentionally not used.
|
||||
///
|
||||
/// [AppDatabase.memory] stays ephemeral (unit tests / fallbacks).
|
||||
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.
|
||||
final bool encrypted;
|
||||
|
||||
final SharedPreferences? _prefs;
|
||||
final bool _persist;
|
||||
|
||||
final Map<String, String> _kv = {};
|
||||
List<String> _toneSamples = [];
|
||||
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<void> replaceToneSamples(List<String> samples) async {
|
||||
_toneSamples = List<String>.from(samples);
|
||||
await _persistTone();
|
||||
}
|
||||
|
||||
Future<String?> getKv(String key) async => _kv[key];
|
||||
|
||||
Future<void> setKv(String key, String value) async {
|
||||
_kv[key] = value;
|
||||
await _persistKv(key, value);
|
||||
}
|
||||
|
||||
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
|
||||
|
|
@ -39,16 +132,17 @@ class AppDatabase {
|
|||
await setKv(key, value ? '1' : '0');
|
||||
}
|
||||
|
||||
// roadmap.md §2.7-F 답장 마감 알림 — native stub과 동일한 시그니처
|
||||
// (in-memory, encrypted persistence 없음 — web은 프리뷰 서피스일 뿐 릴리스 타깃이 아님).
|
||||
/// roadmap.md §2.7-F — on-device snooze; web preview persists via SharedPreferences.
|
||||
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<void> clearSnooze(int conversationId) async {
|
||||
_snoozes.remove(conversationId);
|
||||
await _persistSnoozes();
|
||||
}
|
||||
|
||||
Future<Map<int, DateTime>> loadAllSnoozes() async => Map<int, DateTime>.from(_snoozes);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
|
@ -74,7 +75,9 @@ class DataFlowScreen extends StatelessWidget {
|
|||
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
|
||||
session.localDbEncrypted
|
||||
? '저장: drift + SQLCipher(암호화)'
|
||||
: '저장: 메모리/웹 스텁(이 환경에 SQLCipher 없음 — Chrome·Linux 폴백)',
|
||||
: kIsWeb
|
||||
? '저장: 브라우저 저장소(비암호화 · 웹 프리뷰 — 같은 프로필에서는 새로고침 후에도 유지)'
|
||||
: '저장: 메모리 폴백(이 환경에 SQLCipher 없음 — Linux CI 등)',
|
||||
].join('\n'),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ class SessionState extends ChangeNotifier {
|
|||
} catch (e) {
|
||||
// Linux CI / hosts without libsqlcipher.so — fall back to memory so the
|
||||
// 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');
|
||||
localDbEncrypted = false;
|
||||
return AppDatabase.memory();
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue