318 lines
12 KiB
Dart
318 lines
12 KiB
Dart
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<String, dynamic>? _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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> markNeedsReauth() async {
|
|
if (!isAccount || _needsReauth) return;
|
|
_needsReauth = true;
|
|
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
|
await preferences.setBool('session_needs_reauth', true);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> 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<Map<String, dynamic>?> 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<void> clearPendingAccountSnapshot() async {
|
|
_pendingAccountSnapshot = null;
|
|
_pendingAccountNamespace = null;
|
|
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
|
await preferences.remove('pending_account_archive_namespace');
|
|
}
|
|
|
|
static bool _snapshotHasUserData(Map<String, dynamic> snapshot) {
|
|
final transactions = snapshot['transactions'] as List<dynamic>? ?? const [];
|
|
final budgets = snapshot['budgets'] as List<dynamic>? ?? const [];
|
|
final ledgers = snapshot['ledgers'] as List<dynamic>? ?? const [];
|
|
final categories = snapshot['categories'] as List<dynamic>? ?? const [];
|
|
return transactions.any(
|
|
(value) => (value as Map<String, dynamic>)['isDeleted'] != true,
|
|
) ||
|
|
budgets.isNotEmpty ||
|
|
ledgers.length > 1 ||
|
|
categories.any(
|
|
(value) => (value as Map<String, dynamic>)['isCustom'] == true,
|
|
);
|
|
}
|
|
}
|