diff --git a/lib/api/cache_store.dart b/lib/api/cache_store.dart index bc97c69..81412f3 100644 --- a/lib/api/cache_store.dart +++ b/lib/api/cache_store.dart @@ -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 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 totalSize() async => directorySize(await _directory()); /// Parses the stored file format; public for tests. static CacheEntry? parse(String raw) { diff --git a/lib/main.dart b/lib/main.dart index 2aee13f..6005a26 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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
{ 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
{ 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
{ // 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( - 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). - child: DownloadTrayHost(child: child ?? const SizedBox.shrink()), - ), + 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). + 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, }); diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 63232c6..7d64606 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -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) { - pushScreen(context, withNavBar: false, screen: const CacheView()); + static Future openCacheView(BuildContext context) => + pushScreen(context, withNavBar: false, screen: const CacheView()); + + static Future 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) { diff --git a/lib/storage/dev_tools_settings.dart b/lib/storage/dev_tools_settings.dart index a50f540..b9d8be2 100644 --- a/lib/storage/dev_tools_settings.dart +++ b/lib/storage/dev_tools_settings.dart @@ -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) ?? diff --git a/lib/storage/dev_tools_settings.g.dart b/lib/storage/dev_tools_settings.g.dart index 75540e6..f3f4972 100644 --- a/lib/storage/dev_tools_settings.g.dart +++ b/lib/storage/dev_tools_settings.g.dart @@ -6,27 +6,33 @@ part of 'dev_tools_settings.dart'; // JsonSerializableGenerator // ************************************************************************** -DevToolsSettings _$DevToolsSettingsFromJson( - Map json, -) => DevToolsSettings( - showPerformanceOverlay: json['showPerformanceOverlay'] as bool, - checkerboardOffscreenLayers: json['checkerboardOffscreenLayers'] as bool, - checkerboardRasterCacheImages: json['checkerboardRasterCacheImages'] as bool, - marianumConnectEndpoint: - $enumDecodeNullable( - _$MarianumConnectEndpointEnumMap, - json['marianumConnectEndpoint'], - ) ?? - MarianumConnectEndpoint.live, - marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '', - emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '', -); +DevToolsSettings _$DevToolsSettingsFromJson(Map json) => + DevToolsSettings( + showPerformanceOverlay: json['showPerformanceOverlay'] 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? ?? '', + emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '', + ); Map _$DevToolsSettingsToJson(DevToolsSettings instance) => { '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, diff --git a/lib/storage/hydrated_storage_bootstrap.dart b/lib/storage/hydrated_storage_bootstrap.dart index 4a44969..08369b3 100644 --- a/lib/storage/hydrated_storage_bootstrap.dart +++ b/lib/storage/hydrated_storage_bootstrap.dart @@ -37,6 +37,8 @@ Future _deleteHydratedBox(String directoryPath) async { class InMemoryStorage implements Storage { final Map _values = {}; + Iterable get keys => _values.keys; + @override dynamic read(String key) => _values[key]; diff --git a/lib/utils/directory_size.dart b/lib/utils/directory_size.dart new file mode 100644 index 0000000..b71aa88 --- /dev/null +++ b/lib/utils/directory_size.dart @@ -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 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; +} diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 4a54706..670796f 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -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: '', diff --git a/lib/view/pages/settings/diagnostics_page.dart b/lib/view/pages/settings/diagnostics_page.dart new file mode 100644 index 0000000..f11bdb6 --- /dev/null +++ b/lib/view/pages/settings/diagnostics_page.dart @@ -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 createState() => _DiagnosticsPageState(); +} + +class _DiagnosticsPageState extends State { + late Future> _sections = _collect(); + + Future> _collect() async { + final settings = context.read().val(); + final accountStatus = context.read().state.status; + final account = AccountData(); + const tokens = MarianumConnectTokenStorage(); + final push = PushRegistrationStore(); + + Future safe(Future 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> rows(Map 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(), + ], + ], + ); + }, + ), + ); +} diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index ac8963d..0066644 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -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 { - // Kept across rebuilds: the size walk lists the whole cache directory. - Future? _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 { 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( + 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 { .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, ), - 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.account_tree_outlined, + title: 'BLoC-Speicher', + describe: () async { + final keys = BlocStorageView.keys(); + return keys == null ? 'nicht auflistbar' : '${keys.length} Einträge'; }, - 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().reset(); - }, - ).asDialog(context); - }, - trailing: const Icon(Icons.arrow_right), + onOpen: AppRoutes.openBlocStorageView, ), - 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", + _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}', + ), ), ), - 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), + clearTitle: 'Netzwerk-Bilder löschen', + clearContent: + 'Heruntergeladene Chat- und Vorschaubilder werden gelöscht und bei ' + 'Bedarf neu geladen.', + onClear: DefaultCacheManager().emptyCache, ), - 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.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(); + }, + ), + _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(); + }, + ), + _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 _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 Function() describe; + final Future Function(BuildContext context)? onOpen; + final String? clearTitle; + final String? clearContent; + final Future 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 _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: (_) { diff --git a/lib/widget/avatar_disk_cache.dart b/lib/widget/avatar_disk_cache.dart index 14801b7..2d038f4 100644 --- a/lib/widget/avatar_disk_cache.dart +++ b/lib/widget/avatar_disk_cache.dart @@ -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 totalSize() async => directorySize(await _directory()); + /// Wipes the whole cache — used by the argument-less `invalidateAvatarCache` /// (e.g. on logout). Future clear() async { diff --git a/lib/widget/debug/bloc_storage_view.dart b/lib/widget/debug/bloc_storage_view.dart new file mode 100644 index 0000000..e1a302f --- /dev/null +++ b/lib/widget/debug/bloc_storage_view.dart @@ -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? 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('hydrated_box'); + return box.keys.map((k) => '$k').toList()..sort(); + } on Object { + return null; + } + } + + @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, 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 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 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), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widget/debug/cache_view.dart b/lib/widget/debug/cache_view.dart index 75cd750..4a98c61 100644 --- a/lib/widget/debug/cache_view.dart +++ b/lib/widget/debug/cache_view.dart @@ -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 createState() => _CacheViewState(); - } class _CacheViewState extends State { - late final Future> files = CacheStore.instance - .readAll(); + late Future>> _entries = _read(); + + Future>> _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) { - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - final key = snapshot.data!.keys.elementAt(index); - final element = snapshot.data![key]!; - - 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()}', - ), - trailing: const Icon(Icons.arrow_right), - onTap: () => JsonViewer.asDialog( - context, - jsonDecode(element.json) as Map, - ), - ); - }, - ); - } else if (snapshot.connectionState != ConnectionState.done) { + if (snapshot.connectionState != ConnectionState.done) { return const Center(child: AppProgressIndicator.large()); - } else { - return const Center( - child: PlaceholderView( - icon: Icons.hourglass_empty, - text: 'Keine Daten', - ), + } + final entries = snapshot.data ?? const []; + if (entries.isEmpty) { + return const PlaceholderView( + icon: Icons.hourglass_empty, + text: 'Keine Daten', ); } + return ListView.builder( + itemCount: entries.length, + itemBuilder: (context, index) { + final MapEntry(:key, :value) = entries[index]; + return ListTile( + leading: const Icon(Icons.text_snippet_outlined), + title: Text(key), + subtitle: Text( + '${filesize(utf8.encode(value.json).length)} · ' + '${DateTime.fromMillisecondsSinceEpoch(value.lastUpdate).formatRelative()}', + ), + onTap: () => AppRoutes.openJsonViewer( + context, + jsonDecode(value.json), + title: key, + ), + trailing: IconButton( + tooltip: 'Löschen', + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmDelete(key), + ), + ); + }, + ); }, ), ); diff --git a/lib/widget/debug/debug_stats_overlay.dart b/lib/widget/debug/debug_stats_overlay.dart new file mode 100644 index 0000000..b23e2fb --- /dev/null +++ b/lib/widget/debug/debug_stats_overlay.dart @@ -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 createState() => _DebugStatsOverlayState(); +} + +class _DebugStatsOverlayState extends State { + static const _sampleWindow = 240; + + final List _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 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 _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 _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 _sortedMs(Duration Function(FrameTiming) pick) => + _frames.map((t) => pick(t).inMicroseconds / 1000).toList()..sort(); + + String _p(List 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); +} diff --git a/lib/widget/debug/debug_tile.dart b/lib/widget/debug/debug_tile.dart index 389ff5f..d47d475 100644 --- a/lib/widget/debug/debug_tile.dart +++ b/lib/widget/debug/debug_tile.dart @@ -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; @@ -16,9 +16,9 @@ class DebugTile { (onlyInDebug ? kDebugMode : true); Widget jsonData(Map data) => callback( - title: 'JSON daten anzeigen', - onTab: () => JsonViewer.asDialog(context, data), - ); + title: 'JSON daten anzeigen', + onTab: () => AppRoutes.openJsonViewer(context, data), + ); Widget callback({ String title = 'Debugaktion', diff --git a/lib/widget/debug/json_viewer.dart b/lib/widget/debug/json_viewer.dart index 01f7cd4..b7f49db 100644 --- a/lib/widget/debug/json_viewer.dart +++ b/lib/widget/debug/json_viewer.dart @@ -4,43 +4,201 @@ 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 jsonInput) => - _encoder.convert(jsonInput); + static String format(Object? data) => _encoder.convert(toEncodable(data)); - static void asDialog(BuildContext context, Map dataMap) { - showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - scrollable: true, - title: const Row( - children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')], + /// 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 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 { + late final Object? _data = JsonViewer.toEncodable(widget.data); + final Set _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: [ + IconButton( + tooltip: 'Alles aufklappen', + icon: const Icon(Icons.unfold_more), + onPressed: () => _setAll(expanded: true), ), - content: Text(JsonViewer.format(dataMap)), - actions: [ - TextButton( - onPressed: () => copyToClipboard( - dialogCtx, - JsonViewer.format(dataMap), - successMessage: 'Formatiertes JSON kopiert', + IconButton( + tooltip: 'Alles zuklappen', + icon: const Icon(Icons.unfold_less), + onPressed: () => _setAll(expanded: false), + ), + PopupMenuButton( + onSelected: (pretty) => copyToClipboard( + context, + pretty ? JsonViewer.format(_data) : jsonEncode(_data), + successMessage: pretty + ? 'Formatiertes JSON kopiert' + : 'Inline JSON kopiert', + ), + 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, ), - child: const Text('Kopieren'), - ), - TextButton( - onPressed: () => copyToClipboard( - dialogCtx, - dataMap.toString(), - successMessage: 'Inline JSON kopiert', + 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, + ), ), - child: const Text('Inline Kopieren'), - ), - TextButton( - onPressed: () => Navigator.of(dialogCtx).pop(), - child: const Text('Schließen'), - ), - ], + ], + ), ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index 4fed116..0bb45e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/utils/directory_size_test.dart b/test/utils/directory_size_test.dart new file mode 100644 index 0000000..2eddb29 --- /dev/null +++ b/test/utils/directory_size_test.dart @@ -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); + }); +}