feat(#168): BlockStore + BlockService (global block-list foundation)
App-global (NOT per-device) block list, source of truth for the client: - BlockStore persists blockedKeys (pubkey hex) + blockedNames (name->first-seen epoch, for A7 promote-and-prune) via PrefsManager, global keys. - BlockService (ChangeNotifier): isBlocked/isNameBlocked/block/unblock/ blockName/unblockName + unmodifiable accessors. - Wired into main.dart MultiProvider. Epic A #165 / A1 #168. Design: docs/architecture/block-contract-as-built.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>pull/209/head
parent
dc20cc17f1
commit
d63f57a698
@ -0,0 +1,64 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../storage/block_store.dart';
|
||||
|
||||
/// App-global block list, backed by [BlockStore] and exposed to the UI.
|
||||
///
|
||||
/// Source of truth for the client: DMs + adverts are filtered by public key,
|
||||
/// channel posts by resolved key or claimed name. Always active regardless of
|
||||
/// firmware; the firmware offload (Epic B) mirrors this, never replaces it.
|
||||
/// See `docs/architecture/block-contract-as-built.md`.
|
||||
class BlockService extends ChangeNotifier {
|
||||
BlockService({BlockStore? store}) : _store = store ?? BlockStore();
|
||||
|
||||
final BlockStore _store;
|
||||
|
||||
final Set<String> _blockedKeys = {};
|
||||
final Map<String, int> _blockedNames = {};
|
||||
|
||||
/// Load persisted state. Call once during app startup.
|
||||
Future<void> load() async {
|
||||
_blockedKeys
|
||||
..clear()
|
||||
..addAll(await _store.loadKeys());
|
||||
_blockedNames
|
||||
..clear()
|
||||
..addAll(await _store.loadNames());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Set<String> get blockedKeys => Set.unmodifiable(_blockedKeys);
|
||||
Map<String, int> get blockedNames => Map.unmodifiable(_blockedNames);
|
||||
|
||||
bool isBlocked(String publicKeyHex) =>
|
||||
_blockedKeys.contains(publicKeyHex.toLowerCase());
|
||||
|
||||
bool isNameBlocked(String name) =>
|
||||
_blockedNames.containsKey(name.trim().toLowerCase());
|
||||
|
||||
Future<void> block(String publicKeyHex) async {
|
||||
if (!_blockedKeys.add(publicKeyHex.toLowerCase())) return;
|
||||
await _store.saveKeys(_blockedKeys);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> unblock(String publicKeyHex) async {
|
||||
if (!_blockedKeys.remove(publicKeyHex.toLowerCase())) return;
|
||||
await _store.saveKeys(_blockedKeys);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> blockName(String name) async {
|
||||
final n = name.trim().toLowerCase();
|
||||
if (n.isEmpty || _blockedNames.containsKey(n)) return;
|
||||
_blockedNames[n] = DateTime.now().millisecondsSinceEpoch;
|
||||
await _store.saveNames(_blockedNames);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> unblockName(String name) async {
|
||||
if (_blockedNames.remove(name.trim().toLowerCase()) == null) return;
|
||||
await _store.saveNames(_blockedNames);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
/// Persistence for the app-global block list.
|
||||
///
|
||||
/// Deliberately **global** — unlike the other stores it is NOT scoped by the
|
||||
/// connected device key. A block is "I don't want to see this person," keyed by
|
||||
/// their public key, and must hold regardless of which radio is connected.
|
||||
/// See `docs/architecture/block-contract-as-built.md`.
|
||||
class BlockStore {
|
||||
static const String _keysKey = 'block_keys_v1';
|
||||
static const String _namesKey = 'block_names_v1';
|
||||
|
||||
/// Blocked public keys (lowercased hex).
|
||||
Future<Set<String>> loadKeys() async {
|
||||
final list = PrefsManager.instance.getStringList(_keysKey) ?? const [];
|
||||
return list.map((k) => k.toLowerCase()).toSet();
|
||||
}
|
||||
|
||||
Future<void> saveKeys(Set<String> keys) async {
|
||||
await PrefsManager.instance.setStringList(_keysKey, keys.toList());
|
||||
}
|
||||
|
||||
/// Name-only blocks: lowercased claimed name -> first-blocked epoch millis.
|
||||
/// The timestamp feeds the promote-and-prune age-out (Epic A / A7).
|
||||
Future<Map<String, int>> loadNames() async {
|
||||
final raw = PrefsManager.instance.getString(_namesKey);
|
||||
if (raw == null || raw.isEmpty) return {};
|
||||
try {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
|
||||
} catch (e) {
|
||||
appLogger.error('Failed to decode blocked names: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveNames(Map<String, int> names) async {
|
||||
await PrefsManager.instance.setString(_namesKey, jsonEncode(names));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue