Files
Client/lib/widget/debug/bloc_storage_view.dart
T

218 lines
7.1 KiB
Dart

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/hydrated_storage_bootstrap.dart';
import '../confirm_dialog.dart';
import '../placeholder_view.dart';
import 'json_viewer.dart';
/// Lists every key persisted through [HydratedBloc.storage].
class BlocStorageView extends StatefulWidget {
const BlocStorageView({super.key});
/// All persisted keys; `null` when the storage can't be enumerated.
static List<String>? keys() {
final storage = HydratedBloc.storage;
if (storage is InMemoryStorage) return storage.keys.toList()..sort();
try {
// hydrated_bloc exposes no key listing; the Hive instance is the only
// way in.
// ignore: invalid_use_of_visible_for_testing_member
final box = HydratedStorage.hive.box<dynamic>('hydrated_box');
return box.keys.map((k) => '$k').toList()..sort();
} on Object {
return null;
}
}
@override
State<BlocStorageView> 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<BlocStorageView> {
List<_Entry>? _entries;
@override
void initState() {
super.initState();
_load();
}
void _load() {
final keys = BlocStorageView.keys();
setState(
() => _entries = keys
?.map((k) => _Entry(k, HydratedBloc.storage.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<void> Function()? _liveReset(String key) {
Future<void> Function() loadable(
LoadableHydratedBloc<dynamic, dynamic, dynamic> bloc,
) => () async {
await bloc.reset();
bloc.retry();
};
return switch (key) {
'TimetableBloc' => loadable(context.read<TimetableBloc>()),
'ChatListBloc' => loadable(context.read<ChatListBloc>()),
'ChatBloc' => loadable(context.read<ChatBloc>()),
'BreakerBloc' => loadable(context.read<BreakerBloc>()),
'CapabilitiesCubit' => () async {
final cubit = context.read<CapabilitiesCubit>();
await cubit.reset();
await cubit.load();
},
'NextcloudCapabilitiesCubit' => () async {
final cubit = context.read<NextcloudCapabilitiesCubit>();
await cubit.reset();
await cubit.load();
},
'SettingsCubit' => context.read<SettingsCubit>().reset,
_ => null,
};
}
Future<void> _delete(String key) async {
final reset = _liveReset(key);
if (reset != null) {
await reset();
} else {
await HydratedBloc.storage.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),
),
);
},
),
);
}
}