import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../db/app_database.dart'; import '../models/models.dart'; import '../services/api_client.dart'; import '../services/push_token_service.dart'; class SessionState extends ChangeNotifier { SessionState({ApiClient? api, AppDatabase? db}) : _api = api ?? ApiClient(), _db = db; final ApiClient _api; AppDatabase? _db; User? user; AutonomyLevel autonomyLevel = AutonomyLevel.L0; String? error; bool loading = false; bool toneOnboardingDone = false; List styleExamples = const []; bool localDbEncrypted = false; static const _kUserId = 'user_id'; static const _kDisplayName = 'display_name'; static const _kInvite = 'invite_code'; static const _kToken = 'session_token'; // Legacy SharedPreferences keys — migrated into encrypted drift on first open. static const _kToneDoneLegacy = 'tone_onboarding_done'; static const _kStyleExamplesLegacy = 'style_examples_json'; static const _kToneDone = 'tone_onboarding_done'; static const _kDeviceInstallId = 'device_install_id'; AppDatabase? get db => _db; Future restore() async { _db ??= await _openDb(); await _migrateLegacyTonePrefs(); toneOnboardingDone = await _db!.getBoolKv(_kToneDone); styleExamples = await _db!.loadToneSamples(); final prefs = await SharedPreferences.getInstance(); final id = prefs.getInt(_kUserId); final name = prefs.getString(_kDisplayName); final invite = prefs.getString(_kInvite); final token = prefs.getString(_kToken); if (id != null && name != null && invite != null) { user = User(id: id, displayName: name, inviteCode: invite); _api.authToken = token; await _registerDeviceTokenBestEffort(); notifyListeners(); } } Future _openDb() async { try { final db = await AppDatabase.open(); localDbEncrypted = db.encrypted; return db; } 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). debugPrint('Encrypted DB unavailable ($e); using in-memory fallback'); localDbEncrypted = false; return AppDatabase.memory(); } } Future _migrateLegacyTonePrefs() async { final prefs = await SharedPreferences.getInstance(); final rawExamples = prefs.getString(_kStyleExamplesLegacy); final legacyDone = prefs.getBool(_kToneDoneLegacy); final existing = await _db!.loadToneSamples(); if (existing.isEmpty && rawExamples != null && rawExamples.isNotEmpty) { final decoded = (jsonDecode(rawExamples) as List) .map((e) => e.toString()) .where((s) => s.trim().isNotEmpty) .toList(); if (decoded.isNotEmpty) { await _db!.replaceToneSamples(decoded); } await prefs.remove(_kStyleExamplesLegacy); } if (legacyDone != null) { await _db!.setBoolKv(_kToneDone, legacyDone); await prefs.remove(_kToneDoneLegacy); } } Future signup(String inviteCode, String displayName) async { loading = true; error = null; notifyListeners(); try { final result = await _api.signup(inviteCode: inviteCode, displayName: displayName); user = result.user; toneOnboardingDone = false; _db ??= await _openDb(); await _db!.setBoolKv(_kToneDone, false); final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_kUserId, result.user.id); await prefs.setString(_kDisplayName, result.user.displayName); await prefs.setString(_kInvite, result.user.inviteCode); await prefs.setString(_kToken, result.token); await _registerDeviceTokenBestEffort(); } on ApiException catch (e) { error = '가입 실패 (${e.statusCode}): ${e.body}'; } catch (e) { error = e.toString(); } finally { loading = false; notifyListeners(); } } Future saveToneSamples(List samples, {bool markDone = true}) async { final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); styleExamples = cleaned; if (markDone) toneOnboardingDone = true; _db ??= await _openDb(); await _db!.replaceToneSamples(cleaned); if (markDone) await _db!.setBoolKv(_kToneDone, true); notifyListeners(); } Future skipToneOnboarding() async { toneOnboardingDone = true; _db ??= await _openDb(); await _db!.setBoolKv(_kToneDone, true); notifyListeners(); } Future setAutonomy(AutonomyLevel level) async { if (user == null) return; try { final settings = await _api.patchTwinSettings(user!.id, level); autonomyLevel = settings.autonomyLevel; notifyListeners(); } on ApiException catch (e) { error = '자율성 변경 실패 (${e.statusCode})'; notifyListeners(); } } /// Registers a real FCM token when Firebase is configured; otherwise a stable /// `install:` placeholder (server skips placeholders for delivery). Future _registerDeviceTokenBestEffort() async { if (user == null) return; try { _db ??= await _openDb(); final resolved = await PushTokenService( readInstallId: () => _db!.getKv(_kDeviceInstallId), storeInstallId: (id) => _db!.setKv(_kDeviceInstallId, id), ).resolve(); await _api.registerDeviceToken( userId: user!.id, token: resolved.token, platform: resolved.platform, ); debugPrint( resolved.isFcm ? 'device token registered (FCM)' : 'device token registered (install placeholder)', ); } catch (e) { debugPrint('device token register skipped: $e'); } } ApiClient get api => _api; }