reworked developer tools with diagnostics, storage viewers and release-capable debug overlays
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user