174 lines
5.1 KiB
Dart
174 lines
5.1 KiB
Dart
/// Thin wrapper around [SharedPreferences] for local key-value persistence.
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../models/transaction_meta.dart';
|
|
import '../utils/constants.dart';
|
|
|
|
/// Persisted annotation store for transactions.
|
|
class TxMetaStoreData {
|
|
const TxMetaStoreData({
|
|
this.byId = const {},
|
|
this.categoryByDescription = const {},
|
|
this.exactTemplates = const {},
|
|
});
|
|
|
|
final Map<String, TxMeta> byId;
|
|
final Map<String, TxCategory> categoryByDescription;
|
|
final Map<String, TxExactTemplate> exactTemplates;
|
|
}
|
|
|
|
/// Loads and stores user preferences. Call [init] once at startup.
|
|
class StorageService {
|
|
StorageService({this._preferences});
|
|
|
|
SharedPreferences? _preferences;
|
|
|
|
Future<void> init() async {
|
|
_preferences ??= await SharedPreferences.getInstance();
|
|
}
|
|
|
|
SharedPreferences get _prefs {
|
|
final prefs = _preferences;
|
|
if (prefs == null) {
|
|
throw StateError('StorageService.init() must be called before use.');
|
|
}
|
|
return prefs;
|
|
}
|
|
|
|
String get themeModeName =>
|
|
_prefs.getString(StorageKeys.themeMode) ?? 'system';
|
|
|
|
Future<void> setThemeModeName(String value) {
|
|
return _prefs.setString(StorageKeys.themeMode, value);
|
|
}
|
|
|
|
bool get useRemoteData => _prefs.getBool(StorageKeys.useRemoteData) ?? false;
|
|
|
|
Future<void> setUseRemoteData(bool value) {
|
|
return _prefs.setBool(StorageKeys.useRemoteData, value);
|
|
}
|
|
|
|
TxMetaStoreData loadTxMetaStore() {
|
|
final raw = _prefs.getString(StorageKeys.txMeta);
|
|
if (raw != null && raw.isNotEmpty) {
|
|
return _decodeStore(raw);
|
|
}
|
|
|
|
// Migrate flat v1 map → v2 store.
|
|
final legacy = _prefs.getString(StorageKeys.txMetaLegacy);
|
|
if (legacy == null || legacy.isEmpty) return const TxMetaStoreData();
|
|
try {
|
|
final decoded = jsonDecode(legacy);
|
|
if (decoded is! Map) return const TxMetaStoreData();
|
|
final byId = <String, TxMeta>{};
|
|
final categoryByDescription = <String, TxCategory>{};
|
|
decoded.forEach((key, value) {
|
|
if (key is! String || value is! Map) return;
|
|
final meta = TxMeta.fromJson(Map<String, dynamic>.from(value));
|
|
byId[key] = meta;
|
|
if (meta.hasCategory) {
|
|
final desc = _descriptionFromLegacyId(key);
|
|
if (desc != null) {
|
|
categoryByDescription[desc] = meta.category;
|
|
}
|
|
}
|
|
});
|
|
return TxMetaStoreData(
|
|
byId: byId,
|
|
categoryByDescription: categoryByDescription,
|
|
);
|
|
} catch (_) {
|
|
return const TxMetaStoreData();
|
|
}
|
|
}
|
|
|
|
Future<void> saveTxMetaStore(TxMetaStoreData store) {
|
|
final encoded = {
|
|
'byId': {
|
|
for (final e in store.byId.entries) e.key: e.value.toJson(),
|
|
},
|
|
'categoryByDescription': {
|
|
for (final e in store.categoryByDescription.entries)
|
|
e.key: e.value.name,
|
|
},
|
|
'exactTemplates': {
|
|
for (final e in store.exactTemplates.entries) e.key: e.value.toJson(),
|
|
},
|
|
};
|
|
return _prefs.setString(StorageKeys.txMeta, jsonEncode(encoded));
|
|
}
|
|
|
|
/// Deprecated shim used by older call sites during transition.
|
|
Map<String, TxMeta> loadTxMeta() => loadTxMetaStore().byId;
|
|
|
|
Future<void> saveTxMeta(Map<String, TxMeta> meta) {
|
|
final current = loadTxMetaStore();
|
|
return saveTxMetaStore(
|
|
TxMetaStoreData(
|
|
byId: meta,
|
|
categoryByDescription: current.categoryByDescription,
|
|
exactTemplates: current.exactTemplates,
|
|
),
|
|
);
|
|
}
|
|
|
|
TxMetaStoreData _decodeStore(String raw) {
|
|
try {
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is! Map) return const TxMetaStoreData();
|
|
final root = Map<String, dynamic>.from(decoded);
|
|
|
|
final byId = <String, TxMeta>{};
|
|
final byIdRaw = root['byId'];
|
|
if (byIdRaw is Map) {
|
|
byIdRaw.forEach((key, value) {
|
|
if (key is String && value is Map) {
|
|
byId[key] = TxMeta.fromJson(Map<String, dynamic>.from(value));
|
|
}
|
|
});
|
|
}
|
|
|
|
final categoryByDescription = <String, TxCategory>{};
|
|
final cats = root['categoryByDescription'];
|
|
if (cats is Map) {
|
|
cats.forEach((key, value) {
|
|
if (key is String && value is String) {
|
|
categoryByDescription[key] = TxCategoryX.fromName(value);
|
|
}
|
|
});
|
|
}
|
|
|
|
final exactTemplates = <String, TxExactTemplate>{};
|
|
final templates = root['exactTemplates'];
|
|
if (templates is Map) {
|
|
templates.forEach((key, value) {
|
|
if (key is String && value is Map) {
|
|
exactTemplates[key] =
|
|
TxExactTemplate.fromJson(Map<String, dynamic>.from(value));
|
|
}
|
|
});
|
|
}
|
|
|
|
return TxMetaStoreData(
|
|
byId: byId,
|
|
categoryByDescription: categoryByDescription,
|
|
exactTemplates: exactTemplates,
|
|
);
|
|
} catch (_) {
|
|
return const TxMetaStoreData();
|
|
}
|
|
}
|
|
|
|
/// Legacy id format: `date|currency|amount|description`.
|
|
String? _descriptionFromLegacyId(String id) {
|
|
final parts = id.split('|');
|
|
if (parts.length < 4) return null;
|
|
final description = parts.sublist(3).join('|');
|
|
return description.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
|
|
}
|
|
}
|