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: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/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; const DevToolsSection({required this.settings, super.key}); @override State createState() => _DevToolsSectionState(); } class _DevToolsSectionState extends State { @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('Debug-Overlays'), trailing: const Icon(Icons.arrow_right), onTap: () { showDetailsBottomSheet( context, children: (sheetCtx) => [ BlocBuilder( 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', value: dev.showPerformanceOverlay, onChanged: (e) => write().showPerformanceOverlay = e, ), SettingsCheckboxTile( 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.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, ), ], ); }, ), ], ); }, ), ListTile( leading: const CenteredLeading(Icon(Icons.cloud_outlined)), title: const Text('Marianum-Connect-Server'), subtitle: Text( MarianumConnectEndpointPicker.labelFor( widget.settings.val().devToolsSettings, ), ), trailing: const Icon(Icons.arrow_right), onTap: () => MarianumConnectEndpointPicker.show(context, widget.settings), ), BlocBuilder( bloc: widget.settings, builder: (_, _) { final override = widget.settings .val() .devToolsSettings .emergencyNoticeUrl .trim(); return ListTile( leading: const CenteredLeading(Icon(Icons.emergency_outlined)), title: const Text('Notfall-Nachrichtenquelle'), subtitle: Text(override.isEmpty ? 'Standardquelle' : override), trailing: const Icon(Icons.arrow_right), onTap: () => _EmergencyNoticeUrlEditor.show(context, widget.settings), ); }, ), const Divider(), _StorageTile( icon: Icons.settings_applications_outlined, title: 'Einstellungen', describe: () async => filesize( utf8.encode(jsonEncode(widget.settings.val().toJson())).length, ), 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(); }, ), _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 /// URL. Empty clears the override so the compiled-in default source is used. class _EmergencyNoticeUrlEditor extends StatefulWidget { final SettingsCubit settings; const _EmergencyNoticeUrlEditor({required this.settings}); static void show(BuildContext context, SettingsCubit settings) { showDetailsBottomSheet( context, header: const ListTile(title: Text('Notfall-Nachrichtenquelle')), children: (sheetCtx) => [_EmergencyNoticeUrlEditor(settings: settings)], ); } @override State<_EmergencyNoticeUrlEditor> createState() => _EmergencyNoticeUrlEditorState(); } class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> { late final TextEditingController _controller; String? _error; @override void initState() { super.initState(); _controller = TextEditingController( text: widget.settings.val().devToolsSettings.emergencyNoticeUrl, ); } @override void dispose() { _controller.dispose(); super.dispose(); } 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) { setState( () => _error = DevToolsSettings.allowsHttpCustomEndpoint ? 'Ungültige URL (http(s)://host[:port]/...)' : 'Ungültige URL — nur HTTPS erlaubt', ); return; } widget.settings.val(write: true).devToolsSettings.emergencyNoticeUrl = raw; Navigator.of(context).pop(); } @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: _controller, keyboardType: TextInputType.url, decoration: InputDecoration( labelText: 'Leer für Standard', errorText: _error, ), onChanged: (_) { if (_error != null) setState(() => _error = null); }, ), Padding( padding: const EdgeInsets.only(top: 16), child: FilledButton( onPressed: _save, child: const Text('Übernehmen'), ), ), ], ), ); }