423 lines
15 KiB
Dart
423 lines
15 KiB
Dart
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'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|