import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miaoji_zhang/shared/api/backend_identity.dart'; import 'package:miaoji_zhang/shared/services/local_database.dart'; class OnlineFeatureRequiredException implements Exception { final String message; const OnlineFeatureRequiredException([this.message = '此功能需要登录并连接网络后使用']); @override String toString() => message; } class SessionStore extends ChangeNotifier { SessionStore._(); static final instance = SessionStore._(); SharedPreferences? _preferences; String _mode = 'none'; int? _userId; String? _username; String? _nickname; String _appMode = 'normal'; bool _onboardingDone = true; bool _cloudSyncEnabled = true; bool _needsReauth = false; bool _aiEnabled = false; String? _backendScope; String? _accountNamespace; Map? _pendingAccountSnapshot; String? _pendingAccountNamespace; String? _lastAccountNamespace; bool get isGuest => _mode == 'guest'; bool get isAccount => _mode == 'account' && _userId != null; bool get hasSession => isGuest || isAccount; bool get cloudSyncEnabled => isAccount && _cloudSyncEnabled; bool get needsReauth => _needsReauth; bool get aiEnabled => isAccount && _aiEnabled; bool get shouldUseLocalOnly => isGuest || !cloudSyncEnabled || needsReauth; int? get userId => _userId; String? get username => _username; String? get nickname => _nickname; String get appMode => _appMode; bool get onboardingDone => _onboardingDone; String? get backendScope => _backendScope; String get namespace => isGuest ? 'guest' : _accountNamespace ?? 'user_${_userId ?? 'none'}'; Future initialize() async { _preferences ??= await SharedPreferences.getInstance(); final preferences = _preferences!; _mode = preferences.getString('session_mode') ?? 'none'; _userId = preferences.getInt('session_user_id'); _username = preferences.getString('session_username'); _nickname = preferences.getString('session_nickname'); _appMode = preferences.getString('session_app_mode') ?? 'normal'; _onboardingDone = preferences.getBool('session_onboarding_done') ?? true; _cloudSyncEnabled = preferences.getBool('session_cloud_sync') ?? true; _needsReauth = preferences.getBool('session_needs_reauth') ?? false; _backendScope = preferences.getString('session_backend_scope'); _accountNamespace = preferences.getString('session_account_namespace'); _pendingAccountNamespace = preferences.getString( 'pending_account_archive_namespace', ); _lastAccountNamespace = preferences.getString('last_account_namespace'); _aiEnabled = preferences.getBool('session_ai_enabled') ?? (_mode == 'account'); if (hasSession) { if (isAccount) { _accountNamespace ??= 'user_$_userId'; if (_backendScope != BackendIdentity.scope) { // Legacy builds did not bind local data to a backend. Keep that // database available locally, but require an explicit login before // any sync can run against the currently configured server. _needsReauth = true; await preferences.setBool('session_needs_reauth', true); } } await LocalDatabase.instance.openNamespace(namespace); } else if (_mode != 'none') { _mode = 'none'; await preferences.setString('session_mode', 'none'); } } Future startGuest() async { final preferences = _preferences ??= await SharedPreferences.getInstance(); _mode = 'guest'; _userId = null; _username = '游客'; _nickname = '游客'; _appMode = 'normal'; _onboardingDone = true; _cloudSyncEnabled = false; _needsReauth = false; _aiEnabled = false; _backendScope = null; _accountNamespace = null; _pendingAccountSnapshot = null; await preferences.setString('session_mode', _mode); await preferences.remove('session_user_id'); await preferences.setString('session_username', _username!); await preferences.setString('session_nickname', _nickname!); await preferences.setString('session_app_mode', _appMode); await preferences.setBool('session_onboarding_done', true); await preferences.setBool('session_cloud_sync', false); await preferences.setBool('session_needs_reauth', false); await preferences.remove('session_backend_scope'); await preferences.remove('session_account_namespace'); await LocalDatabase.instance.openNamespace(namespace); notifyListeners(); } Future activateAccount({ required int userId, required String username, String? nickname, required String appMode, required bool onboardingDone, required bool aiEnabled, }) async { final preferences = _preferences ??= await SharedPreferences.getInstance(); final targetNamespace = BackendIdentity.accountNamespace(userId); final sourceNamespace = isAccount && LocalDatabase.instance.namespace == namespace ? namespace : _lastAccountNamespace; if (sourceNamespace != null && sourceNamespace != targetNamespace && (LocalDatabase.instance.namespace == sourceNamespace || await LocalDatabase.instance.namespaceExists(sourceNamespace))) { if (LocalDatabase.instance.namespace != sourceNamespace) { await LocalDatabase.instance.openNamespace(sourceNamespace); } final snapshot = LocalDatabase.instance.exportSnapshot(); if (_snapshotHasUserData(snapshot)) { _pendingAccountSnapshot = snapshot; _pendingAccountNamespace = sourceNamespace; await preferences.setString( 'pending_account_archive_namespace', sourceNamespace, ); } } _mode = 'account'; _userId = userId; _username = username; _nickname = nickname; _appMode = appMode; _onboardingDone = onboardingDone; _aiEnabled = aiEnabled; _backendScope = BackendIdentity.scope; _accountNamespace = targetNamespace; _lastAccountNamespace = targetNamespace; _cloudSyncEnabled = preferences.getBool('cloud_sync_${BackendIdentity.scope}_$userId') ?? true; _needsReauth = false; await preferences.setString('session_mode', _mode); await preferences.setInt('session_user_id', userId); await preferences.setString('session_username', username); if (nickname == null) { await preferences.remove('session_nickname'); } else { await preferences.setString('session_nickname', nickname); } await preferences.setString('session_app_mode', appMode); await preferences.setBool('session_onboarding_done', onboardingDone); await preferences.setBool('session_ai_enabled', aiEnabled); await preferences.setBool('session_cloud_sync', _cloudSyncEnabled); await preferences.setBool('session_needs_reauth', false); await preferences.setString('session_backend_scope', _backendScope!); await preferences.setString('session_account_namespace', targetNamespace); await preferences.setString('last_account_namespace', targetNamespace); await LocalDatabase.instance.openNamespace(namespace); notifyListeners(); } Future updateNickname(String? nickname) async { _nickname = nickname; notifyListeners(); final preferences = _preferences ??= await SharedPreferences.getInstance(); if (nickname == null) { await preferences.remove('session_nickname'); } else { await preferences.setString('session_nickname', nickname); } } Future updateCachedProfile({ required String appMode, required bool onboardingDone, String? nickname, bool? aiEnabled, }) async { _appMode = appMode; _onboardingDone = onboardingDone; _nickname = nickname ?? _nickname; _aiEnabled = aiEnabled ?? _aiEnabled; final preferences = _preferences ??= await SharedPreferences.getInstance(); await preferences.setString('session_app_mode', _appMode); await preferences.setBool('session_onboarding_done', _onboardingDone); await preferences.setBool('session_ai_enabled', _aiEnabled); if (_nickname != null) { await preferences.setString('session_nickname', _nickname!); } notifyListeners(); } Future setAiEnabled(bool enabled) async { if (_aiEnabled == enabled) return; _aiEnabled = enabled; if (!enabled) _appMode = 'normal'; final preferences = _preferences ??= await SharedPreferences.getInstance(); await preferences.setBool('session_ai_enabled', enabled); if (!enabled) await preferences.setString('session_app_mode', 'normal'); notifyListeners(); } Future setCloudSyncEnabled(bool enabled) async { if (!isAccount) return; _cloudSyncEnabled = enabled; final preferences = _preferences ??= await SharedPreferences.getInstance(); await preferences.setBool('session_cloud_sync', enabled); await preferences.setBool( 'cloud_sync_${_backendScope ?? BackendIdentity.scope}_$_userId', enabled, ); notifyListeners(); } Future markNeedsReauth() async { if (!isAccount || _needsReauth) return; _needsReauth = true; final preferences = _preferences ??= await SharedPreferences.getInstance(); await preferences.setBool('session_needs_reauth', true); notifyListeners(); } Future clearActiveSession() async { final preferences = _preferences ??= await SharedPreferences.getInstance(); _mode = 'none'; _userId = null; _username = null; _nickname = null; _appMode = 'normal'; _onboardingDone = true; _cloudSyncEnabled = true; _needsReauth = false; _aiEnabled = false; _backendScope = null; _accountNamespace = null; _pendingAccountSnapshot = null; await preferences.setString('session_mode', 'none'); await preferences.remove('session_user_id'); await preferences.remove('session_username'); await preferences.remove('session_nickname'); await preferences.remove('session_needs_reauth'); await preferences.remove('session_backend_scope'); await preferences.remove('session_account_namespace'); LocalDatabase.instance.close(); notifyListeners(); } void requireOnline([String? message]) { if (shouldUseLocalOnly) { throw OnlineFeatureRequiredException(message ?? '此功能需要登录并连接网络后使用'); } } Future?> pendingAccountSnapshot() async { final cached = _pendingAccountSnapshot; if (cached != null) return cached; final sourceNamespace = _pendingAccountNamespace; final activeNamespace = LocalDatabase.instance.namespace; if (sourceNamespace == null || activeNamespace == null || sourceNamespace == activeNamespace) { return null; } try { await LocalDatabase.instance.openNamespace(sourceNamespace); final snapshot = LocalDatabase.instance.exportSnapshot(); if (_snapshotHasUserData(snapshot)) { _pendingAccountSnapshot = snapshot; } } finally { await LocalDatabase.instance.openNamespace(activeNamespace); } return _pendingAccountSnapshot; } Future clearPendingAccountSnapshot() async { _pendingAccountSnapshot = null; _pendingAccountNamespace = null; final preferences = _preferences ??= await SharedPreferences.getInstance(); await preferences.remove('pending_account_archive_namespace'); } static bool _snapshotHasUserData(Map snapshot) { final transactions = snapshot['transactions'] as List? ?? const []; final budgets = snapshot['budgets'] as List? ?? const []; final ledgers = snapshot['ledgers'] as List? ?? const []; final categories = snapshot['categories'] as List? ?? const []; return transactions.any( (value) => (value as Map)['isDeleted'] != true, ) || budgets.isNotEmpty || ledgers.length > 1 || categories.any( (value) => (value as Map)['isCustom'] == true, ); } }