reworked developer tools with diagnostics, storage viewers and release-capable debug overlays

This commit is contained in:
2026-09-26 14:34:47 +02:00
parent 0f6c388940
commit e6f74ca6fe
18 changed files with 1330 additions and 239 deletions
+3 -12
View File
@@ -5,6 +5,8 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../utils/directory_size.dart';
/// One cached response: the raw JSON payload and when it was stored.
class CacheEntry {
final String json;
@@ -140,18 +142,7 @@ class CacheStore {
}
/// Total size of all entries in bytes.
Future<int> totalSize() async {
try {
final dir = await _directory();
var sum = 0;
await for (final entity in dir.list()) {
if (entity is File) sum += await entity.length();
}
return sum;
} on Object {
return 0;
}
}
Future<int> totalSize() async => directorySize(await _directory());
/// Parses the stored file format; public for tests.
static CacheEntry? parse(String raw) {
+34 -13
View File
@@ -7,6 +7,7 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
@@ -55,6 +56,7 @@ import 'view/login/login.dart';
import 'view/login/post_login_splash.dart';
import 'widget/avatar_disk_cache.dart';
import 'widget/breaker/breaker.dart';
import 'widget/debug/debug_stats_overlay.dart';
import 'widget/downloads/download_tray.dart';
import 'widget/emergency/emergency_notice_gate.dart';
import 'widget_data/widget_sync.dart';
@@ -384,10 +386,11 @@ class _MainState extends State<Main> {
notificationsEnabled: settings.notificationSettings.enabled,
showPerformanceOverlay:
settings.devToolsSettings.showPerformanceOverlay,
checkerboardOffscreenLayers:
settings.devToolsSettings.checkerboardOffscreenLayers,
checkerboardRasterCacheImages:
settings.devToolsSettings.checkerboardRasterCacheImages,
showSemanticsDebugger: settings.devToolsSettings.showSemanticsDebugger,
showFrameStats: settings.devToolsSettings.showFrameStats,
showMemoryStats: settings.devToolsSettings.showMemoryStats,
slowAnimations: settings.devToolsSettings.slowAnimations,
textScaleOverride: settings.devToolsSettings.textScaleOverride,
),
builder: (context, root) {
// Keep the MC dio singleton aligned with the currently selected
@@ -403,10 +406,10 @@ class _MainState extends State<Main> {
root.notificationsEnabled,
),
);
timeDilation = root.slowAnimations ? 5.0 : 1.0;
return MaterialApp(
showPerformanceOverlay: root.showPerformanceOverlay,
checkerboardOffscreenLayers: root.checkerboardOffscreenLayers,
checkerboardRasterCacheImages: root.checkerboardRasterCacheImages,
showSemanticsDebugger: root.showSemanticsDebugger,
debugShowCheckedModeBanner: false,
navigatorKey: AppRoutes.rootNavigatorKey,
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
@@ -428,13 +431,28 @@ class _MainState extends State<Main> {
// expose the layer below the topmost Scaffold; without this
// the dark Material default shows through and the user sees a
// black flash.
builder: (context, child) => ColoredBox(
builder: (context, child) {
Widget app = ColoredBox(
color: LightAppTheme.marianumRed,
// Downloads tray mounted ABOVE the navigator so its chip floats over
// every route (folder views, chat, viewer are full-page pushes that
// would otherwise cover it).
// Downloads tray mounted ABOVE the navigator so its chip floats
// over every route (folder views, chat, viewer are full-page
// pushes that would otherwise cover it).
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
),
);
final scale = root.textScaleOverride;
if (scale > 0) {
app = MediaQuery.withClampedTextScaling(
minScaleFactor: scale,
maxScaleFactor: scale,
child: app,
);
}
return DebugStatsOverlay(
showFrameStats: root.showFrameStats,
showMemoryStats: root.showMemoryStats,
child: app,
);
},
home: EmergencyNoticeGate(
child: LoaderOverlay(
child: Breaker(
@@ -549,6 +567,9 @@ typedef _RootSettings = ({
String mcBaseUrl,
bool notificationsEnabled,
bool showPerformanceOverlay,
bool checkerboardOffscreenLayers,
bool checkerboardRasterCacheImages,
bool showSemanticsDebugger,
bool showFrameStats,
bool showMemoryStats,
bool slowAnimations,
double textScaleOverride,
});
+22 -1
View File
@@ -27,6 +27,7 @@ import '../view/pages/more/feedback/feedback_dialog.dart';
import '../view/pages/more/roomplan/roomplan.dart';
import '../view/pages/more/share/qr_share_view.dart';
import '../view/pages/settings/chat_background_settings_page.dart';
import '../view/pages/settings/diagnostics_page.dart';
import '../view/pages/settings/modules_settings_page.dart';
import '../view/pages/settings/settings.dart';
import '../view/pages/share_intent/share_chat_picker.dart';
@@ -39,7 +40,9 @@ import '../view/pages/ticker/ticker_page_view.dart';
import '../view/pages/timetable/custom_events/custom_events_view.dart';
import '../view/pages/timetable/subject_colors/subject_colors_view.dart';
import '../widget/avatar_crop_page.dart';
import '../widget/debug/bloc_storage_view.dart';
import '../widget/debug/cache_view.dart';
import '../widget/debug/json_viewer.dart';
import '../widget/file_viewer.dart';
import '../widget/large_profile_picture_view.dart';
import '../widget/user_avatar.dart';
@@ -285,8 +288,26 @@ class AppRoutes {
pushScreen(context, withNavBar: false, screen: const FeedbackDialog());
}
static void openCacheView(BuildContext context) {
static Future<void> openCacheView(BuildContext context) =>
pushScreen(context, withNavBar: false, screen: const CacheView());
static Future<void> openBlocStorageView(BuildContext context) =>
pushScreen(context, withNavBar: false, screen: const BlocStorageView());
static void openDiagnostics(BuildContext context) {
pushScreen(context, withNavBar: false, screen: const DiagnosticsPage());
}
static void openJsonViewer(
BuildContext context,
Object? data, {
String? title,
}) {
pushScreen(
context,
withNavBar: false,
screen: JsonViewer(data: data, title: title ?? 'Rohdaten'),
);
}
static void openRoomplan(BuildContext context) {
+23 -5
View File
@@ -8,8 +8,22 @@ enum MarianumConnectEndpoint { live, beta, custom }
@JsonSerializable()
class DevToolsSettings {
bool showPerformanceOverlay;
bool checkerboardOffscreenLayers;
bool checkerboardRasterCacheImages;
@JsonKey(defaultValue: false)
bool showFrameStats;
@JsonKey(defaultValue: false)
bool showMemoryStats;
@JsonKey(defaultValue: false)
bool showSemanticsDebugger;
@JsonKey(defaultValue: false)
bool slowAnimations;
/// Forced text scale factor; `0` follows the system setting.
@JsonKey(defaultValue: 0)
double textScaleOverride;
@JsonKey(defaultValue: MarianumConnectEndpoint.live)
MarianumConnectEndpoint marianumConnectEndpoint;
@@ -24,8 +38,11 @@ class DevToolsSettings {
DevToolsSettings({
required this.showPerformanceOverlay,
required this.checkerboardOffscreenLayers,
required this.checkerboardRasterCacheImages,
this.showFrameStats = false,
this.showMemoryStats = false,
this.showSemanticsDebugger = false,
this.slowAnimations = false,
this.textScaleOverride = 0,
this.marianumConnectEndpoint = MarianumConnectEndpoint.live,
this.marianumConnectCustomUrl = '',
this.emergencyNoticeUrl = '',
@@ -51,7 +68,8 @@ class DevToolsSettings {
/// Compiled-in source for the backend-independent emergency notice. Must live
/// on infrastructure that is reachable even when MarianumConnect is down.
static const String emergencyNoticeDefaultUrl = 'https://www.marianum-fulda.de/~aushang/marMobile.override';
static const String emergencyNoticeDefaultUrl =
'https://www.marianum-fulda.de/~aushang/marMobile.override';
String? resolveEmergencyNoticeUrl() =>
sanitizeCustomUrl(emergencyNoticeUrl) ??
+14 -8
View File
@@ -6,27 +6,33 @@ part of 'dev_tools_settings.dart';
// JsonSerializableGenerator
// **************************************************************************
DevToolsSettings _$DevToolsSettingsFromJson(
Map<String, dynamic> json,
) => DevToolsSettings(
DevToolsSettings _$DevToolsSettingsFromJson(Map<String, dynamic> json) =>
DevToolsSettings(
showPerformanceOverlay: json['showPerformanceOverlay'] as bool,
checkerboardOffscreenLayers: json['checkerboardOffscreenLayers'] as bool,
checkerboardRasterCacheImages: json['checkerboardRasterCacheImages'] as bool,
showFrameStats: json['showFrameStats'] as bool? ?? false,
showMemoryStats: json['showMemoryStats'] as bool? ?? false,
showSemanticsDebugger: json['showSemanticsDebugger'] as bool? ?? false,
slowAnimations: json['slowAnimations'] as bool? ?? false,
textScaleOverride: (json['textScaleOverride'] as num?)?.toDouble() ?? 0,
marianumConnectEndpoint:
$enumDecodeNullable(
_$MarianumConnectEndpointEnumMap,
json['marianumConnectEndpoint'],
) ??
MarianumConnectEndpoint.live,
marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '',
marianumConnectCustomUrl:
json['marianumConnectCustomUrl'] as String? ?? '',
emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '',
);
Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
<String, dynamic>{
'showPerformanceOverlay': instance.showPerformanceOverlay,
'checkerboardOffscreenLayers': instance.checkerboardOffscreenLayers,
'checkerboardRasterCacheImages': instance.checkerboardRasterCacheImages,
'showFrameStats': instance.showFrameStats,
'showMemoryStats': instance.showMemoryStats,
'showSemanticsDebugger': instance.showSemanticsDebugger,
'slowAnimations': instance.slowAnimations,
'textScaleOverride': instance.textScaleOverride,
'marianumConnectEndpoint':
_$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!,
'marianumConnectCustomUrl': instance.marianumConnectCustomUrl,
@@ -37,6 +37,8 @@ Future<void> _deleteHydratedBox(String directoryPath) async {
class InMemoryStorage implements Storage {
final Map<String, dynamic> _values = {};
Iterable<String> get keys => _values.keys;
@override
dynamic read(String key) => _values[key];
+16
View File
@@ -0,0 +1,16 @@
import 'dart:io';
/// Total byte size of all files below [dir]; `0` when it doesn't exist or
/// can't be listed.
Future<int> directorySize(Directory dir) async {
var sum = 0;
try {
if (!dir.existsSync()) return 0;
await for (final entity in dir.list(recursive: true, followLinks: false)) {
if (entity is File) sum += await entity.length();
}
} on FileSystemException {
// Files can vanish mid-walk (cache pruning); report what was counted.
}
return sum;
}
@@ -70,8 +70,6 @@ class DefaultSettings {
fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS),
notificationSettings: NotificationSettings(enabled: true),
devToolsSettings: DevToolsSettings(
checkerboardOffscreenLayers: false,
checkerboardRasterCacheImages: false,
showPerformanceOverlay: false,
marianumConnectEndpoint: MarianumConnectEndpoint.live,
marianumConnectCustomUrl: '',
@@ -0,0 +1,222 @@
import 'dart:async';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../api/marianumconnect/auth/device_token_name.dart';
import '../../../api/marianumconnect/auth/token_storage.dart';
import '../../../api/marianumconnect/marianumconnect_endpoint.dart';
import '../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart';
import '../../../background/widget_background_task.dart';
import '../../../extensions/date_time.dart';
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart';
import '../../../push/push_registration_store.dart';
import '../../../push/push_registration_type.dart';
import '../../../state/app/modules/account/bloc/account_bloc.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../utils/clipboard_helper.dart';
import '../../../widget/app_progress_indicator.dart';
import '../../../widget/async_action_button.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget_data/widget_sync.dart';
import 'widgets/push_status_sheet.dart';
typedef _Row = ({String label, String value});
typedef _Section = ({String title, List<_Row> rows});
/// Read-only snapshot of build, endpoint, session, push and widget state for
/// bug reports. Secrets (passwords, bearer token) are never shown.
class DiagnosticsPage extends StatefulWidget {
const DiagnosticsPage({super.key});
@override
State<DiagnosticsPage> createState() => _DiagnosticsPageState();
}
class _DiagnosticsPageState extends State<DiagnosticsPage> {
late Future<List<_Section>> _sections = _collect();
Future<List<_Section>> _collect() async {
final settings = context.read<SettingsCubit>().val();
final accountStatus = context.read<AccountBloc>().state.status;
final account = AccountData();
const tokens = MarianumConnectTokenStorage();
final push = PushRegistrationStore();
Future<String> safe(Future<Object?> Function() read) async {
try {
final value = await read().timeout(const Duration(seconds: 5));
return switch (value) {
null => '–',
bool() => value ? 'ja' : 'nein',
DateTime() => '${value.formatDateTime()} (${value.formatRelative()})',
_ => '$value',
};
} on Object catch (e) {
return 'Fehler: $e';
}
}
Future<List<_Row>> rows(Map<String, Future<Object?> Function()> reads) =>
Future.wait(
reads.entries.map(
(e) async => (label: e.key, value: await safe(e.value)),
),
);
final info = await PackageInfo.fromPlatform();
return [
(
title: 'App',
rows: await rows({
'Version': () async => '${info.version} (${info.buildNumber})',
'Paket': () async => info.packageName,
'Build-Modus': () async => kReleaseMode
? 'Release'
: kProfileMode
? 'Profile'
: 'Debug',
'Plattform': () async =>
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
'Gerät': DeviceTokenName.resolve,
'Dart': () async => Platform.version.split(' ').first,
}),
),
(
title: 'Server',
rows: await rows({
'Marianum Connect': () async => MarianumConnectEndpoint.current(),
'Nextcloud': () async =>
'https://${EndpointData().nextcloud().full()}',
'Notfall-Nachricht': () async =>
settings.devToolsSettings.resolveEmergencyNoticeUrl(),
}),
),
(
title: 'Sitzung',
rows: await rows({
'Status': () async => accountStatus.name,
'Benutzer': () async =>
account.isPopulated() ? account.getUsername() : null,
'Demo-Modus': () async => account.isDemo,
'Login Flow v2': () async => account.usesLoginFlow,
'App-Passwort (Dateien)': () async => account.hasAppPassword(),
'App-Passwort (Talk)': () async => account.hasAppPasswordTalk(),
'Session-Epoche': () async => account.sessionEpoch,
'MC-Token-ID': tokens.readTokenId,
'MC-Token läuft ab': tokens.readExpiresAt,
}),
),
(
title: 'Push',
rows: await rows({
'Benachrichtigungen': () async =>
settings.notificationSettings.enabled,
'FCM-Token': FirebaseMessaging.instance.getToken,
for (final type in PushRegistrationType.values) ...{
'Registriert (${type.name})': () => push.isRegistered(type),
'Letzte Registrierung (${type.name})': () =>
push.lastRegistrationAt(type),
'Letzter Fehler (${type.name})': () =>
push.lastRegistrationError(type),
},
}),
),
(
title: 'Sonstiges',
rows: await rows({
'Telemetrie-Geräte-ID': TelemetryDeviceId.resolve,
'Widget zuletzt abgerufen': WidgetSync.getFetchedAt,
}),
),
];
}
String _asText(List<_Section> sections) => [
for (final s in sections) ...[
'## ${s.title}',
for (final r in s.rows) '${r.label}: ${r.value}',
'',
],
].join('\n');
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Diagnose'),
actions: [
IconButton(
tooltip: 'Neu laden',
icon: const Icon(Icons.refresh),
onPressed: () => setState(() {
_sections = _collect();
}),
),
FutureBuilder(
future: _sections,
builder: (context, snapshot) => IconButton(
tooltip: 'Alles kopieren',
icon: const Icon(Icons.copy_all_outlined),
onPressed: snapshot.hasData
? () => copyToClipboard(context, _asText(snapshot.data!))
: null,
),
),
],
),
body: FutureBuilder(
future: _sections,
builder: (context, snapshot) {
final sections = snapshot.data;
if (sections == null) {
return const Center(child: AppProgressIndicator.large());
}
return ListView(
children: [
for (final section in sections) ...[
ListTile(
dense: true,
title: Text(
section.title,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
for (final row in section.rows)
ListTile(
dense: true,
title: Text(row.label),
subtitle: SelectableText(row.value),
onLongPress: () => copyToClipboard(context, row.value),
),
if (section.title == 'Push')
ListTile(
leading: const CenteredLeading(
Icon(Icons.notifications_active_outlined),
),
title: const Text('Push-Status prüfen'),
trailing: const Icon(Icons.arrow_right),
onTap: () => showPushStatusSheet(context),
),
if (section.title == 'Sonstiges')
AsyncListTile(
leading: const CenteredLeading(Icon(Icons.widgets_outlined)),
title: const Text('Widget jetzt aktualisieren'),
closeOnSuccess: false,
onPressed: WidgetBackgroundTask.requestImmediateRefresh,
),
const Divider(),
],
],
);
},
),
);
}
@@ -1,19 +1,30 @@
import 'dart:convert';
import 'dart:io';
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 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:path_provider/path_provider.dart';
import '../../../../api/cache_store.dart';
import '../../../../push/chat_thread_store.dart';
import '../../../../push/nid_store.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../storage/dev_tools_settings.dart';
import '../../../../storage/settings.dart' as model;
import '../../../../utils/directory_size.dart';
import '../../../../utils/downloads/download_manager.dart';
import '../../../../widget/avatar_disk_cache.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/json_viewer.dart';
import '../../../../widget/debug/bloc_storage_view.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/user_avatar.dart';
import '../widgets/endpoint_picker.dart';
import '../widgets/settings_checkbox_tile.dart';
import '../widgets/settings_dropdown_tile.dart';
class DevToolsSection extends StatefulWidget {
final SettingsCubit settings;
@@ -24,15 +35,19 @@ class DevToolsSection extends StatefulWidget {
}
class _DevToolsSectionState extends State<DevToolsSection> {
// Kept across rebuilds: the size walk lists the whole cache directory.
Future<int>? _cacheSize;
@override
Widget build(BuildContext context) => Column(
children: [
ListTile(
leading: const CenteredLeading(Icon(Icons.monitor_heart_outlined)),
title: const Text('Diagnose'),
subtitle: const Text('Version, Server, Sitzung, Push'),
trailing: const Icon(Icons.arrow_right),
onTap: () => AppRoutes.openDiagnostics(context),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.speed_outlined)),
title: const Text('Performance overlays'),
title: const Text('Debug-Overlays'),
trailing: const Icon(Icons.arrow_right),
onTap: () {
showDetailsBottomSheet(
@@ -42,35 +57,57 @@ class _DevToolsSectionState extends State<DevToolsSection> {
bloc: widget.settings,
builder: (_, _) {
final dev = widget.settings.val().devToolsSettings;
DevToolsSettings write() =>
widget.settings.val(write: true).devToolsSettings;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SettingsCheckboxTile(
icon: Icons.auto_graph_outlined,
title: 'Performance graph',
title: 'Performance-Graph',
value: dev.showPerformanceOverlay,
onChanged: (e) => widget.settings
.val(write: true)
.devToolsSettings
.showPerformanceOverlay = e,
onChanged: (e) => write().showPerformanceOverlay = e,
),
SettingsCheckboxTile(
icon: Icons.screen_search_desktop_outlined,
title: 'Indicate offscreen layers',
value: dev.checkerboardOffscreenLayers,
onChanged: (e) => widget.settings
.val(write: true)
.devToolsSettings
.checkerboardOffscreenLayers = e,
icon: Icons.timer_outlined,
title: 'Frame-Statistik',
subtitle: 'Build-/Raster-Zeiten und Jank-Zähler',
value: dev.showFrameStats,
onChanged: (e) => write().showFrameStats = e,
),
SettingsCheckboxTile(
icon: Icons.imagesearch_roller_outlined,
title: 'Indicate raster cache images',
value: dev.checkerboardRasterCacheImages,
onChanged: (e) => widget.settings
.val(write: true)
.devToolsSettings
.checkerboardRasterCacheImages = e,
icon: Icons.memory_outlined,
title: 'Speicher-Statistik',
subtitle: 'Prozess-RAM und Bild-Cache',
value: dev.showMemoryStats,
onChanged: (e) => write().showMemoryStats = e,
),
SettingsCheckboxTile(
icon: Icons.slow_motion_video_outlined,
title: 'Langsame Animationen',
subtitle: '5× verlangsamt',
value: dev.slowAnimations,
onChanged: (e) => write().slowAnimations = e,
),
SettingsCheckboxTile(
icon: Icons.accessibility_new_outlined,
title: 'Semantics-Debugger',
subtitle: 'Zeigt die Screenreader-Sicht',
value: dev.showSemanticsDebugger,
onChanged: (e) => write().showSemanticsDebugger = e,
),
SettingsDropdownTile<double>(
icon: Icons.format_size_outlined,
title: 'Textgröße',
value: dev.textScaleOverride,
options: const [0, 0.85, 1, 1.3, 1.6, 2],
optionIcon: (e) => e == 0
? Icons.phone_android_outlined
: Icons.text_fields_outlined,
optionLabel: (e) => e == 0
? 'System'
: '${e == e.roundToDouble() ? e.toInt() : e}×',
onChanged: (e) => write().textScaleOverride = e,
),
],
);
@@ -102,99 +139,204 @@ class _DevToolsSectionState extends State<DevToolsSection> {
.trim();
return ListTile(
leading: const CenteredLeading(Icon(Icons.emergency_outlined)),
title: const Text('Notfall-Nachricht (Quelle)'),
subtitle: Text(
override.isEmpty
? 'Standardquelle (im Code hinterlegt)'
: override,
),
title: const Text('Notfall-Nachrichtenquelle'),
subtitle: Text(override.isEmpty ? 'Standardquelle' : override),
trailing: const Icon(Icons.arrow_right),
onTap: () => _EmergencyNoticeUrlEditor.show(context, widget.settings),
onTap: () =>
_EmergencyNoticeUrlEditor.show(context, widget.settings),
);
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.image_outlined)),
title: const Text('Thumb-storage'),
subtitle: Text(
'etwa ${filesize(PaintingBinding.instance.imageCache.currentSizeBytes)}\nLange tippen um zu löschen',
const Divider(),
_StorageTile(
icon: Icons.settings_applications_outlined,
title: 'Einstellungen',
describe: () async => filesize(
utf8.encode(jsonEncode(widget.settings.val().toJson())).length,
),
onLongPress: () {
ConfirmDialog(
title: 'Thumbs cache löschen',
content: 'Alle zwischengespeicherten Bilder werden gelöscht.',
confirmButton: 'Unwiederruflich löschen',
onConfirm: () => PaintingBinding.instance.imageCache.clear(),
).asDialog(context);
onOpen: (_) async => AppRoutes.openJsonViewer(
context,
widget.settings.val().toJson(),
title: 'Einstellungen',
),
clearTitle: 'Einstellungen zurücksetzen',
clearContent:
'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten '
'sind nicht betroffen.',
onClear: widget.settings.reset,
),
_StorageTile(
icon: Icons.account_tree_outlined,
title: 'BLoC-Speicher',
describe: () async {
final keys = BlocStorageView.keys();
return keys == null ? 'nicht auflistbar' : '${keys.length} Einträge';
},
onOpen: AppRoutes.openBlocStorageView,
),
_StorageTile(
icon: Icons.data_object,
title: 'Request-Cache',
describe: () async => filesize(await CacheStore.instance.totalSize()),
onOpen: AppRoutes.openCacheView,
),
_StorageTile(
icon: Icons.image_outlined,
title: 'Bild-Speicher (RAM)',
describe: () async {
final cache = PaintingBinding.instance.imageCache;
return '${cache.currentSize} Bilder · '
'${filesize(cache.currentSizeBytes)} von '
'${filesize(cache.maximumSizeBytes)}';
},
clearTitle: 'Bild-Speicher leeren',
clearContent:
'Alle dekodierten Bilder im Arbeitsspeicher werden verworfen.',
onClear: () async => PaintingBinding.instance.imageCache.clear(),
),
_StorageTile(
icon: Icons.photo_library_outlined,
title: 'Netzwerk-Bilder',
describe: () async => filesize(
await directorySize(
Directory(
'${(await getTemporaryDirectory()).path}/${DefaultCacheManager.key}',
),
),
),
clearTitle: 'Netzwerk-Bilder löschen',
clearContent:
'Heruntergeladene Chat- und Vorschaubilder werden gelöscht und bei '
'Bedarf neu geladen.',
onClear: DefaultCacheManager().emptyCache,
),
_StorageTile(
icon: Icons.account_circle_outlined,
title: 'Avatar-Cache',
describe: () async =>
filesize(await AvatarDiskCache.instance.totalSize()),
clearTitle: 'Avatar-Cache löschen',
clearContent: 'Profilbilder werden bei Bedarf neu geladen.',
onClear: () async {
invalidateAvatarCache();
await AvatarDiskCache.instance.clear();
},
),
ListTile(
leading: const CenteredLeading(
Icon(Icons.settings_applications_outlined),
),
title: const Text('Settings-storage JSON dump'),
subtitle: Text(
'etwa ${filesize(widget.settings.val().toJson().toString().length * 8)}\nLange tippen um zu löschen',
),
onTap: () {
JsonViewer.asDialog(context, widget.settings.val().toJson());
_StorageTile(
icon: Icons.notifications_outlined,
title: 'Push-Zuordnungen',
describe: () async =>
'${(await NidStore().all()).length} Benachrichtigungen zugeordnet',
clearTitle: 'Push-Zuordnungen löschen',
clearContent:
'Die Zuordnung angezeigter Benachrichtigungen zu Chats und der '
'gruppierte Chat-Verlauf gehen verloren. Bereits angezeigte '
'Benachrichtigungen werden danach nicht mehr automatisch entfernt.',
onClear: () async {
await NidStore().clear();
await ChatThreadStore().clearAll();
},
onLongPress: () {
ConfirmDialog(
title: 'Einstellungen löschen',
content:
'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.',
confirmButton: 'Unwiederruflich Löschen',
onConfirm: () {
context.read<SettingsCubit>().reset();
},
).asDialog(context);
},
trailing: const Icon(Icons.arrow_right),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.data_object)),
title: const Text('Cache-storage JSON dump'),
subtitle: FutureBuilder(
future: _cacheSize ??= CacheStore.instance.totalSize(),
builder: (context, snapshot) => Text(
"etwa ${snapshot.hasError
? "?"
: snapshot.hasData
? filesize(snapshot.data)
: "..."}\nLange tippen um zu löschen",
),
),
onTap: () => AppRoutes.openCacheView(context),
onLongPress: () {
ConfirmDialog(
title: 'App-Cache löschen',
content:
'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
confirmButton: 'Unwiederruflich löschen',
onConfirm: () => CacheStore.instance.clear().then(
(value) => setState(() => _cacheSize = null),
),
).asDialog(context);
},
trailing: const Icon(Icons.arrow_right),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.data_object)),
title: const Text('BLOC-storage state cache'),
subtitle: const Text('Lange tippen um zu löschen'),
onLongPress: () {
ConfirmDialog(
title: 'BLOC-Cache löschen',
content:
'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
confirmButton: 'Unwiederruflich löschen',
onConfirm: () => HydratedBloc.storage.clear(),
).asDialog(context);
_StorageTile(
icon: Icons.download_outlined,
title: 'Downloads',
describe: () async =>
filesize(await directorySize(await _downloadsDir())),
clearTitle: 'Downloads löschen',
clearContent:
'Laufende Downloads werden abgebrochen und heruntergeladene '
'Dateien gelöscht.',
onClear: () async {
DownloadManager.instance.clearAll();
final dir = await _downloadsDir();
if (!dir.existsSync()) return;
// Cancelled tasks can still be flushing into the folder, so deleting
// it as a whole races them; whatever survives shows up in the size.
await for (final entity in dir.list()) {
try {
await entity.delete(recursive: true);
} on FileSystemException {
// Retried on the next clear.
}
}
},
),
],
);
static Future<Directory> _downloadsDir() async =>
Directory('${(await getTemporaryDirectory()).path}/downloads');
}
/// Storage row: tap opens a detail view ([onOpen]), the trailing button clears
/// it after confirmation ([onClear]). The subtitle re-evaluates [describe]
/// after either.
class _StorageTile extends StatefulWidget {
final IconData icon;
final String title;
final Future<String> Function() describe;
final Future<void> Function(BuildContext context)? onOpen;
final String? clearTitle;
final String? clearContent;
final Future<void> Function()? onClear;
const _StorageTile({
required this.icon,
required this.title,
required this.describe,
this.onOpen,
this.clearTitle,
this.clearContent,
this.onClear,
});
@override
State<_StorageTile> createState() => _StorageTileState();
}
class _StorageTileState extends State<_StorageTile> {
late Future<String> _description = widget.describe();
void _refresh() => setState(() {
_description = widget.describe();
});
@override
Widget build(BuildContext context) {
final onOpen = widget.onOpen;
final onClear = widget.onClear;
return ListTile(
leading: CenteredLeading(Icon(widget.icon)),
title: Text(widget.title),
subtitle: FutureBuilder(
future: _description,
builder: (context, snapshot) =>
Text(snapshot.hasError ? '?' : snapshot.data ?? '…'),
),
onTap: onOpen == null
? null
: () async {
await onOpen(context);
if (mounted) _refresh();
},
trailing: onClear == null
? const Icon(Icons.arrow_right)
: IconButton(
tooltip: 'Löschen',
icon: const Icon(Icons.delete_outline),
onPressed: () => ConfirmDialog(
title: widget.clearTitle ?? '${widget.title} löschen',
content: widget.clearContent ?? '',
confirmButton: 'Löschen',
errorBuilder: (e) => '$e',
onConfirmAsync: () async {
await onClear();
if (mounted) _refresh();
},
).asDialog(context),
),
);
}
}
/// Bottom-sheet editor for the backend-independent emergency-notice override
@@ -206,7 +348,7 @@ class _EmergencyNoticeUrlEditor extends StatefulWidget {
static void show(BuildContext context, SettingsCubit settings) {
showDetailsBottomSheet(
context,
header: const ListTile(title: Text('Notfall-Nachricht (Quelle)')),
header: const ListTile(title: Text('Notfall-Nachrichtenquelle')),
children: (sheetCtx) => [_EmergencyNoticeUrlEditor(settings: settings)],
);
}
@@ -237,8 +379,7 @@ class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> {
void _save() {
final raw = _controller.text.trim();
// Empty is valid: it clears the override and falls back to the default.
if (raw.isNotEmpty &&
DevToolsSettings.sanitizeCustomUrl(raw) == null) {
if (raw.isNotEmpty && DevToolsSettings.sanitizeCustomUrl(raw) == null) {
setState(
() => _error = DevToolsSettings.allowsHttpCustomEndpoint
? 'Ungültige URL (http(s)://host[:port]/...)'
@@ -257,19 +398,11 @@ class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 12),
child: Text(
'Überschreibt die im Code hinterlegte Standardquelle. Leer '
'lassen, um die Standardquelle zu verwenden.',
style: TextStyle(fontSize: 12),
),
),
TextField(
controller: _controller,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: 'https://...',
labelText: 'Leer für Standard',
errorText: _error,
),
onChanged: (_) {
+4
View File
@@ -5,6 +5,8 @@ import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
import '../utils/directory_size.dart';
/// Persistent disk cache for the in-app [UserAvatar] widget.
///
/// The widget keeps a session-scoped in-memory LRU; this store survives app
@@ -156,6 +158,8 @@ class AvatarDiskCache {
}
}
Future<int> totalSize() async => directorySize(await _directory());
/// Wipes the whole cache — used by the argument-less `invalidateAvatarCache`
/// (e.g. on logout).
Future<void> clear() async {
+217
View File
@@ -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),
),
);
},
),
);
}
}
+81 -27
View File
@@ -1,62 +1,116 @@
import 'dart:convert';
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import '../../../widget/placeholder_view.dart';
import '../../api/cache_store.dart';
import '../../extensions/date_time.dart';
import '../../routing/app_routes.dart';
import '../app_progress_indicator.dart';
import 'json_viewer.dart';
import '../confirm_dialog.dart';
import '../placeholder_view.dart';
/// Lists the [CacheStore] entries (one file per request key), newest first.
class CacheView extends StatefulWidget {
const CacheView({super.key});
@override
State<CacheView> createState() => _CacheViewState();
}
class _CacheViewState extends State<CacheView> {
late final Future<Map<String, CacheEntry>> files = CacheStore.instance
.readAll();
late Future<List<MapEntry<String, CacheEntry>>> _entries = _read();
Future<List<MapEntry<String, CacheEntry>>> _read() async =>
(await CacheStore.instance.readAll()).entries.toList()
..sort((a, b) => b.value.lastUpdate.compareTo(a.value.lastUpdate));
void _reload() => setState(() {
_entries = _read();
});
void _confirmDelete(String key) {
ConfirmDialog(
title: 'Eintrag löschen',
content: '„$key“ wird gelöscht und bei Bedarf neu geladen.',
confirmButton: 'Löschen',
errorBuilder: (e) => '$e',
onConfirmAsync: () async {
await CacheStore.instance.delete(key);
if (mounted) _reload();
},
).asDialog(context);
}
void _confirmClear() {
ConfirmDialog(
title: 'Request-Cache leeren',
content:
'Alle Einträge werden gelöscht. Der Cache baut sich bei Nutzung der '
'App automatisch wieder auf.',
confirmButton: 'Alles löschen',
errorBuilder: (e) => '$e',
onConfirmAsync: () async {
await CacheStore.instance.clear();
if (mounted) _reload();
},
).asDialog(context);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Cache storage')),
appBar: AppBar(
title: const Text('Request-Cache'),
actions: [
IconButton(
tooltip: 'Neu laden',
icon: const Icon(Icons.refresh),
onPressed: _reload,
),
IconButton(
tooltip: 'Alles löschen',
icon: const Icon(Icons.delete_sweep_outlined),
onPressed: _confirmClear,
),
],
),
body: FutureBuilder(
future: files,
future: _entries,
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: AppProgressIndicator.large());
}
final entries = snapshot.data ?? const [];
if (entries.isEmpty) {
return const PlaceholderView(
icon: Icons.hourglass_empty,
text: 'Keine Daten',
);
}
return ListView.builder(
itemCount: snapshot.data!.length,
itemCount: entries.length,
itemBuilder: (context, index) {
final key = snapshot.data!.keys.elementAt(index);
final element = snapshot.data![key]!;
final MapEntry(:key, :value) = entries[index];
return ListTile(
leading: const Icon(Icons.text_snippet_outlined),
title: Text(key),
subtitle: Text(
'${filesize(utf8.encode(element.json).length)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element.lastUpdate).fromNow()}',
'${filesize(utf8.encode(value.json).length)} · '
'${DateTime.fromMillisecondsSinceEpoch(value.lastUpdate).formatRelative()}',
),
trailing: const Icon(Icons.arrow_right),
onTap: () => JsonViewer.asDialog(
onTap: () => AppRoutes.openJsonViewer(
context,
jsonDecode(element.json) as Map<String, dynamic>,
jsonDecode(value.json),
title: key,
),
trailing: IconButton(
tooltip: 'Löschen',
icon: const Icon(Icons.delete_outline),
onPressed: () => _confirmDelete(key),
),
);
},
);
} else if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: AppProgressIndicator.large());
} else {
return const Center(
child: PlaceholderView(
icon: Icons.hourglass_empty,
text: 'Keine Daten',
),
);
}
},
),
);
+205
View File
@@ -0,0 +1,205 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:ui';
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// Release-capable frame and memory statistics drawn above [child]. Frame data
/// comes from [SchedulerBinding.addTimingsCallback], which the engine reports
/// in batches (about once per second in release builds).
class DebugStatsOverlay extends StatefulWidget {
final bool showFrameStats;
final bool showMemoryStats;
final Widget child;
const DebugStatsOverlay({
required this.showFrameStats,
required this.showMemoryStats,
required this.child,
super.key,
});
@override
State<DebugStatsOverlay> createState() => _DebugStatsOverlayState();
}
class _DebugStatsOverlayState extends State<DebugStatsOverlay> {
static const _sampleWindow = 240;
final List<FrameTiming> _frames = [];
int _jankTotal = 0;
int _frameTotal = 0;
Timer? _ticker;
bool _listening = false;
bool get _active => widget.showFrameStats || widget.showMemoryStats;
@override
void initState() {
super.initState();
_sync();
}
@override
void didUpdateWidget(DebugStatsOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
_sync();
}
void _sync() {
if (widget.showFrameStats && !_listening) {
SchedulerBinding.instance.addTimingsCallback(_onTimings);
_listening = true;
} else if (!widget.showFrameStats && _listening) {
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
_listening = false;
_frames.clear();
_jankTotal = 0;
_frameTotal = 0;
}
if (_active) {
_ticker ??= Timer.periodic(
const Duration(seconds: 1),
(_) => setState(() {}),
);
} else {
_ticker?.cancel();
_ticker = null;
}
}
Duration get _budget {
final rate = View.maybeOf(context)?.display.refreshRate ?? 60;
return Duration(microseconds: (1e6 / (rate > 0 ? rate : 60)).round());
}
void _onTimings(List<FrameTiming> timings) {
final budget = _budget;
for (final t in timings) {
_frameTotal++;
if (t.buildDuration > budget || t.rasterDuration > budget) _jankTotal++;
}
_frames.addAll(timings);
if (_frames.length > _sampleWindow) {
_frames.removeRange(0, _frames.length - _sampleWindow);
}
}
@override
void dispose() {
if (_listening) SchedulerBinding.instance.removeTimingsCallback(_onTimings);
_ticker?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_active) return widget.child;
return Stack(
textDirection: TextDirection.ltr,
children: [
widget.child,
Positioned(
left: 4,
bottom: MediaQuery.paddingOf(context).bottom + 4,
child: IgnorePointer(
child: MediaQuery.withNoTextScaling(
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 4,
),
child: DefaultTextStyle(
style: const TextStyle(
color: Colors.white,
fontSize: 10,
height: 1.3,
fontFamily: 'monospace',
decoration: TextDecoration.none,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.showFrameStats) ..._frameLines(),
if (widget.showMemoryStats) ..._memoryLines(),
],
),
),
),
),
),
),
),
],
);
}
List<Widget> _frameLines() {
final budgetMs = _budget.inMicroseconds / 1000;
if (_frames.isEmpty) {
return [Text('Frames: – (Budget ${budgetMs.toStringAsFixed(1)} ms)')];
}
final build = _sortedMs((t) => t.buildDuration);
final raster = _sortedMs((t) => t.rasterDuration);
final jankPct = _frameTotal == 0 ? 0 : _jankTotal * 100 / _frameTotal;
final lastWallclock = _frames.last.timestampInMicroseconds(
FramePhase.rasterFinish,
);
final recent = _frames
.where(
(t) =>
lastWallclock -
t.timestampInMicroseconds(FramePhase.rasterFinish) <
1000000,
)
.length;
return [
Text(
'Budget ${budgetMs.toStringAsFixed(1)} ms · letzte Sek. $recent Frames',
),
Text(
'Build p50 ${_p(build, 50)} p90 ${_p(build, 90)} max ${_ms(build.last)}',
),
Text(
'Raster p50 ${_p(raster, 50)} p90 ${_p(raster, 90)} max ${_ms(raster.last)}',
),
Text('Jank $_jankTotal/$_frameTotal (${jankPct.toStringAsFixed(1)} %)'),
];
}
List<Widget> _memoryLines() {
final cache = PaintingBinding.instance.imageCache;
return [
Text(
'RSS ${filesize(ProcessInfo.currentRss)} · max ${filesize(ProcessInfo.maxRss)}',
),
Text(
'Bilder ${cache.currentSize}/${cache.maximumSize} · '
'${filesize(cache.currentSizeBytes)}/${filesize(cache.maximumSizeBytes)} · '
'live ${cache.liveImageCount}',
),
];
}
List<double> _sortedMs(Duration Function(FrameTiming) pick) =>
_frames.map((t) => pick(t).inMicroseconds / 1000).toList()..sort();
String _p(List<double> sorted, int percentile) {
final i = min(
sorted.length - 1,
(sorted.length * percentile / 100).floor(),
);
return _ms(sorted[i]);
}
String _ms(double v) => v.toStringAsFixed(1).padLeft(5);
}
+2 -2
View File
@@ -2,9 +2,9 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../routing/app_routes.dart';
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../centered_leading.dart';
import 'json_viewer.dart';
class DebugTile {
BuildContext context;
@@ -17,7 +17,7 @@ class DebugTile {
Widget jsonData(Map<String, dynamic> data) => callback(
title: 'JSON daten anzeigen',
onTab: () => JsonViewer.asDialog(context, data),
onTab: () => AppRoutes.openJsonViewer(context, data),
);
Widget callback({
+186 -28
View File
@@ -4,44 +4,202 @@ import 'package:flutter/material.dart';
import '../../utils/clipboard_helper.dart';
class JsonViewer {
/// Collapsible tree view for decoded JSON (maps, lists, scalars). Rows are
/// flattened lazily so multi-megabyte cache payloads stay scrollable.
class JsonViewer extends StatefulWidget {
final String title;
final Object? data;
const JsonViewer({required this.data, this.title = 'Rohdaten', super.key});
static final _encoder = const JsonEncoder.withIndent(' ');
static String format(Map<String, dynamic> jsonInput) =>
_encoder.convert(jsonInput);
static String format(Object? data) => _encoder.convert(toEncodable(data));
static void asDialog(BuildContext context, Map<String, dynamic> dataMap) {
showDialog(
context: context,
builder: (dialogCtx) => AlertDialog(
scrollable: true,
title: const Row(
children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')],
),
content: Text(JsonViewer.format(dataMap)),
/// Makes arbitrary decoded storage values (Hive maps with dynamic keys,
/// DateTimes, …) JSON-encodable.
static Object? toEncodable(Object? value) => switch (value) {
null || bool() || num() || String() => value,
Map() => {for (final e in value.entries) '${e.key}': toEncodable(e.value)},
Iterable() => [for (final e in value) toEncodable(e)],
DateTime() => value.toIso8601String(),
_ => value.toString(),
};
@override
State<JsonViewer> createState() => _JsonViewerState();
}
class _Row {
final String path;
final String? label;
final Object? value;
final int depth;
const _Row(this.path, this.label, this.value, this.depth);
bool get isContainer => value is Map || value is List;
}
class _JsonViewerState extends State<JsonViewer> {
late final Object? _data = JsonViewer.toEncodable(widget.data);
final Set<String> _expanded = {''};
List<_Row> _rows = const [];
@override
void initState() {
super.initState();
_rebuildRows();
}
void _rebuildRows() {
final rows = <_Row>[];
void walk(String path, String? label, Object? value, int depth) {
rows.add(_Row(path, label, value, depth));
if (!_expanded.contains(path)) return;
if (value is Map) {
for (final e in value.entries) {
walk('$path/${e.key}', '${e.key}', e.value, depth + 1);
}
} else if (value is List) {
for (var i = 0; i < value.length; i++) {
walk('$path/$i', '[$i]', value[i], depth + 1);
}
}
}
walk('', null, _data, 0);
_rows = rows;
}
void _toggle(String path) => setState(() {
if (!_expanded.remove(path)) _expanded.add(path);
_rebuildRows();
});
void _setAll({required bool expanded}) => setState(() {
_expanded
..clear()
..add('');
if (expanded) {
void collect(String path, Object? value) {
if (value is Map) {
_expanded.add(path);
for (final e in value.entries) {
collect('$path/${e.key}', e.value);
}
} else if (value is List) {
_expanded.add(path);
for (var i = 0; i < value.length; i++) {
collect('$path/$i', value[i]);
}
}
}
collect('', _data);
}
_rebuildRows();
});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: [
TextButton(
onPressed: () => copyToClipboard(
dialogCtx,
JsonViewer.format(dataMap),
successMessage: 'Formatiertes JSON kopiert',
IconButton(
tooltip: 'Alles aufklappen',
icon: const Icon(Icons.unfold_more),
onPressed: () => _setAll(expanded: true),
),
child: const Text('Kopieren'),
IconButton(
tooltip: 'Alles zuklappen',
icon: const Icon(Icons.unfold_less),
onPressed: () => _setAll(expanded: false),
),
TextButton(
onPressed: () => copyToClipboard(
dialogCtx,
dataMap.toString(),
successMessage: 'Inline JSON kopiert',
PopupMenuButton<bool>(
onSelected: (pretty) => copyToClipboard(
context,
pretty ? JsonViewer.format(_data) : jsonEncode(_data),
successMessage: pretty
? 'Formatiertes JSON kopiert'
: 'Inline JSON kopiert',
),
child: const Text('Inline Kopieren'),
),
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Schließen'),
itemBuilder: (_) => const [
PopupMenuItem(value: true, child: Text('JSON kopieren')),
PopupMenuItem(value: false, child: Text('Inline kopieren')),
],
),
],
),
body: ListView.builder(
itemCount: _rows.length,
itemBuilder: (context, index) => _buildRow(context, _rows[index]),
),
);
Widget _buildRow(BuildContext context, _Row row) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final mono = theme.textTheme.bodyMedium!.copyWith(fontFamily: 'monospace');
final value = row.value;
final expanded = _expanded.contains(row.path);
final (String text, Color color) = switch (value) {
Map() => (expanded ? '{' : '{ ${value.length} }', colors.outline),
List() => (expanded ? '[' : '[ ${value.length} ]', colors.outline),
String() => ('"$value"', colors.tertiary),
num() || bool() => ('$value', colors.primary),
_ => ('null', colors.outline),
};
return InkWell(
onTap: row.isContainer ? () => _toggle(row.path) : null,
onLongPress: () => copyToClipboard(
context,
value is String ? value : JsonViewer.format(value),
),
child: Padding(
padding: EdgeInsets.only(
left: 8.0 + row.depth * 16,
right: 8,
top: 3,
bottom: 3,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 20,
child: row.isContainer
? Icon(
expanded ? Icons.arrow_drop_down : Icons.arrow_right,
size: 20,
)
: null,
),
Expanded(
child: Text.rich(
TextSpan(
style: mono,
children: [
if (row.label != null)
TextSpan(
text: '${row.label}: ',
style: TextStyle(color: colors.secondary),
),
TextSpan(
text: text,
style: TextStyle(color: color),
),
],
),
maxLines: row.isContainer ? 1 : 8,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
+3
View File
@@ -38,6 +38,9 @@ dependencies:
emoji_picker_flutter: ^4.3.0
file_picker: ^11.0.2
filesize: ^2.0.1
# Disk cache behind cached_network_image; used directly by the dev tools to
# size and empty it. Already present transitively.
flutter_cache_manager: ^3.4.1
firebase_core: ^4.1.0
firebase_messaging: ^16.0.1
flutter_app_badge: ^2.0.2
+22
View File
@@ -0,0 +1,22 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/utils/directory_size.dart';
void main() {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('dir_size_test'));
tearDown(() => tmp.deleteSync(recursive: true));
test('sums files recursively', () async {
File('${tmp.path}/a').writeAsBytesSync(List.filled(10, 0));
Directory('${tmp.path}/sub').createSync();
File('${tmp.path}/sub/b').writeAsBytesSync(List.filled(5, 0));
expect(await directorySize(tmp), 15);
});
test('returns 0 for a missing directory', () async {
expect(await directorySize(Directory('${tmp.path}/missing')), 0);
});
}