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