218 lines
7.1 KiB
Dart
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),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|