Files
Client/lib/view/pages/settings/sections/dev_tools_section.dart
T

423 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<DevToolsSection> createState() => _DevToolsSectionState();
}
class _DevToolsSectionState extends State<DevToolsSection> {
@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<SettingsCubit, model.Settings>(
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<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,
),
],
);
},
),
],
);
},
),
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<SettingsCubit, model.Settings>(
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<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
/// 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'),
),
),
],
),
);
}