import 'dart:convert'; import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../extensions/date_time.dart'; import '../../routing/app_routes.dart'; import '../../state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../state/app/modules/breaker/bloc/breaker_bloc.dart'; import '../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import '../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../storage/account_storage.dart'; import '../../storage/hydrated_storage_bootstrap.dart'; import '../confirm_dialog.dart'; import '../placeholder_view.dart'; import 'json_viewer.dart'; /// Lists every key persisted by the active account's blocs and the app-wide /// settings. class BlocStorageView extends StatefulWidget { const BlocStorageView({super.key}); static List get _storages => [ HydratedBloc.storage, AccountStorage.global, ]; /// All persisted keys; `null` when a storage can't be enumerated. static List? keys() { final keys = {}; for (final storage in _storages) { final own = hydratedStorageKeys(storage); if (own == null) return null; keys.addAll(own); } return keys.toList()..sort(); } static Storage _storageOf(String key) => _storages.firstWhere( (storage) => hydratedStorageKeys(storage)?.contains(key) ?? false, orElse: () => HydratedBloc.storage, ); @override State createState() => _BlocStorageViewState(); } class _Entry { final String key; final Object? value; final int size; final DateTime? lastFetch; _Entry(this.key, this.value) : size = utf8.encode(jsonEncode(JsonViewer.toEncodable(value))).length, lastFetch = _lastFetch(value); static DateTime? _lastFetch(Object? value) { if (value is! Map) return null; final meta = value['meta']; if (meta is! Map || meta['timestamp'] is! int) return null; final ts = meta['timestamp'] as int; return ts > 0 ? DateTime.fromMillisecondsSinceEpoch(ts) : null; } } class _BlocStorageViewState extends State { List<_Entry>? _entries; @override void initState() { super.initState(); _load(); } void _load() { final keys = BlocStorageView.keys(); setState( () => _entries = keys ?.map((k) => _Entry(k, BlocStorageView._storageOf(k).read(k))) .toList(), ); } /// Blocs that live for the whole app session would simply re-persist their /// in-memory state, so those are reset through the bloc itself. Future Function()? _liveReset(String key) { Future Function() loadable( LoadableHydratedBloc bloc, ) => () async { await bloc.reset(); bloc.retry(); }; return switch (key) { 'TimetableBloc' => loadable(context.read()), 'ChatListBloc' => loadable(context.read()), 'ChatBloc' => loadable(context.read()), 'BreakerBloc' => loadable(context.read()), 'CapabilitiesCubit' => () async { final cubit = context.read(); await cubit.reset(); await cubit.load(); }, 'NextcloudCapabilitiesCubit' => () async { final cubit = context.read(); await cubit.reset(); await cubit.load(); }, 'SettingsCubit' => context.read().reset, _ => null, }; } Future _delete(String key) async { final reset = _liveReset(key); if (reset != null) { await reset(); } else { await BlocStorageView._storageOf(key).delete(key); } } void _confirmDelete(_Entry entry) { final isSettings = entry.key == 'SettingsCubit'; ConfirmDialog( title: isSettings ? 'Einstellungen zurücksetzen' : 'Eintrag löschen', content: isSettings ? 'Alle Einstellungen werden auf die Standardwerte zurückgesetzt.' : '„${entry.key}“ wird gelöscht und bei Bedarf neu geladen.', confirmButton: 'Löschen', errorBuilder: (e) => '$e', onConfirmAsync: () async { await _delete(entry.key); if (mounted) _load(); }, ).asDialog(context); } void _confirmDeleteAll() { ConfirmDialog( title: 'BLoC-Speicher leeren', content: 'Alle gespeicherten Modul-Zustände außer den Einstellungen werden ' 'gelöscht und bei Bedarf neu geladen.', confirmButton: 'Alles löschen', errorBuilder: (e) => '$e', onConfirmAsync: () async { for (final entry in [...?_entries]) { if (entry.key == 'SettingsCubit') continue; await _delete(entry.key); } if (mounted) _load(); }, ).asDialog(context); } @override Widget build(BuildContext context) { final entries = _entries; return Scaffold( appBar: AppBar( title: const Text('BLoC-Speicher'), actions: [ IconButton( tooltip: 'Neu laden', icon: const Icon(Icons.refresh), onPressed: _load, ), if (entries != null && entries.isNotEmpty) IconButton( tooltip: 'Alles löschen', icon: const Icon(Icons.delete_sweep_outlined), onPressed: _confirmDeleteAll, ), ], ), body: entries == null ? const PlaceholderView( icon: Icons.error_outline, text: 'Speicher lässt sich nicht auflisten', ) : entries.isEmpty ? const PlaceholderView( icon: Icons.hourglass_empty, text: 'Keine Daten', ) : ListView.builder( itemCount: entries.length, itemBuilder: (context, index) { final entry = entries[index]; final live = _liveReset(entry.key) != null; return ListTile( leading: Icon( live ? Icons.bolt_outlined : Icons.inventory_2_outlined, ), title: Text(entry.key), subtitle: Text( [ filesize(entry.size), if (entry.lastFetch != null) 'geladen ${entry.lastFetch!.formatRelative()}', ].join(' · '), ), onTap: () => AppRoutes.openJsonViewer( context, entry.value, title: entry.key, ), trailing: IconButton( tooltip: 'Löschen', icon: const Icon(Icons.delete_outline), onPressed: () => _confirmDelete(entry), ), ); }, ), ); } }