added support for multiple accounts with an account switcher in settings

This commit is contained in:
2026-09-27 14:54:10 +02:00
parent 5e65c4671a
commit 27563a6dc1
33 changed files with 1801 additions and 462 deletions
+78
View File
@@ -0,0 +1,78 @@
import 'dart:developer';
import 'dart:io';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../api/cache_store.dart';
import '../session/account_codec.dart';
import 'hydrated_storage_bootstrap.dart';
/// Per-account local data: every account gets its own HydratedBloc box and
/// request cache directory (see [CacheStore]), so switching back is instant
/// and offline. Only the app settings live in the shared [global] box.
abstract final class AccountStorage {
static const _settingsKey = 'SettingsCubit';
static late String _baseDir;
static final Map<String, Storage> _open = {};
static Storage _global = InMemoryStorage();
static Storage get global => _global;
/// Opens the global box. Settings of installs from before multi-account
/// support are copied over from the legacy box once.
static Future<void> init(String baseDir) async {
_baseDir = baseDir;
_global = await buildHydratedStorageWithFallback(
_ensureDir('$baseDir/hydrated_global'),
);
HydratedBloc.storage = InMemoryStorage();
if (_global.read(_settingsKey) != null) return;
final legacySettings = (await _storageFor('')).read(_settingsKey);
if (legacySettings != null) {
await _global.write(_settingsKey, legacySettings);
}
}
/// Points HydratedBloc at [account]'s data; the request cache follows the
/// active account on its own. Blocs keep the storage they were created
/// with, so the per-account blocs have to be recreated afterwards.
static Future<void> activate(AccountEntry? account) async {
HydratedBloc.storage = account == null
? InMemoryStorage()
: await _storageFor(account.namespace);
}
/// Removes all local data of the account with [namespace].
static Future<void> delete(String namespace) async {
try {
final storage = await _storageFor(namespace);
await storage.clear();
// Closed boxes ignore writes, so blocs of the account that are still
// mounted until the remount cannot leave data behind.
await storage.close();
_open.remove(namespace);
if (namespace.isNotEmpty) {
final dir = Directory(_dirFor(namespace));
if (dir.existsSync()) await dir.delete(recursive: true);
}
} on Object catch (e, s) {
log('Account storage delete failed: $e', stackTrace: s);
}
await CacheStore.instance.deleteNamespace(namespace);
}
static Future<Storage> _storageFor(String namespace) async =>
_open[namespace] ??= await buildHydratedStorageWithFallback(
_ensureDir(_dirFor(namespace)),
);
// The legacy account keeps the box at the root of the base directory.
static String _dirFor(String namespace) =>
namespace.isEmpty ? _baseDir : '$_baseDir/accounts/$namespace';
static String _ensureDir(String path) {
Directory(path).createSync(recursive: true);
return path;
}
}
+26 -2
View File
@@ -9,19 +9,43 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
Future<Storage> buildHydratedStorageWithFallback(String directoryPath) async {
final directory = HydratedStorageDirectory(directoryPath);
try {
return await HydratedStorage.build(storageDirectory: directory);
return await _build(directory);
} catch (e, s) {
log('HydratedStorage open failed, rebuilding: $e', stackTrace: s);
}
try {
await _deleteHydratedBox(directoryPath);
return await HydratedStorage.build(storageDirectory: directory);
return await _build(directory);
} catch (e, s) {
log('HydratedStorage rebuild failed, using memory: $e', stackTrace: s);
return InMemoryStorage();
}
}
final Expando<List<String> Function()> _keyReaders = Expando();
Future<Storage> _build(HydratedStorageDirectory directory) async {
final storage = await HydratedStorage.build(storageDirectory: directory);
// hydrated_bloc exposes no key listing; the Hive instance is the only way
// in. Every build replaces the static one, so it is captured per storage.
// ignore: invalid_use_of_visible_for_testing_member
final hive = HydratedStorage.hive;
_keyReaders[storage] = () =>
hive.box<dynamic>('hydrated_box').keys.map((k) => '$k').toList();
return storage;
}
/// All keys persisted in [storage], sorted; `null` when they can't be
/// enumerated.
List<String>? hydratedStorageKeys(Storage storage) {
if (storage is InMemoryStorage) return storage.keys.toList()..sort();
try {
return _keyReaders[storage]?.call()?..sort();
} on Object {
return null;
}
}
Future<void> _deleteHydratedBox(String directoryPath) async {
final directory = Directory(directoryPath);
if (!directory.existsSync()) return;