merged develop
This commit is contained in:
@@ -14,6 +14,7 @@ abstract final class A11yLabels {
|
||||
static const unread = 'Ungelesen';
|
||||
static const favorite = 'Favorit';
|
||||
static const draft = 'Entwurf';
|
||||
static const muted = 'Stummgeschaltet';
|
||||
static const yourReaction = 'deine Reaktion';
|
||||
|
||||
// Bilder / Kontakte
|
||||
|
||||
@@ -42,7 +42,7 @@ Future<AvatarSheetResult?> showAvatarActionsSheet(
|
||||
onTap: () async {
|
||||
final bytes = await _pickAndCrop(
|
||||
sheetContext,
|
||||
() => FilePick.singleGalleryPick(sheetContext),
|
||||
() => FilePick.singleGalleryPick(sheetContext, forCrop: true),
|
||||
);
|
||||
if (bytes == null || !sheetContext.mounted) return;
|
||||
result = AvatarUploadResult(bytes);
|
||||
@@ -55,7 +55,7 @@ Future<AvatarSheetResult?> showAvatarActionsSheet(
|
||||
onTap: () async {
|
||||
final bytes = await _pickAndCrop(
|
||||
sheetContext,
|
||||
() => FilePick.cameraPick(sheetContext),
|
||||
() => FilePick.cameraPick(sheetContext, forCrop: true),
|
||||
);
|
||||
if (bytes == null || !sheetContext.mounted) return;
|
||||
result = AvatarUploadResult(bytes);
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../utils/directory_size.dart';
|
||||
|
||||
/// Persistent disk cache for the in-app [UserAvatar] widget.
|
||||
///
|
||||
/// The widget keeps a session-scoped in-memory LRU; this store survives app
|
||||
@@ -156,6 +158,8 @@ class AvatarDiskCache {
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> totalSize() async => directorySize(await _directory());
|
||||
|
||||
/// Wipes the whole cache — used by the argument-less `invalidateAvatarCache`
|
||||
/// (e.g. on logout).
|
||||
Future<void> clear() async {
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../storage/chat_background_settings.dart';
|
||||
import '../theming/app_theme.dart';
|
||||
import '../utils/app_paths.dart';
|
||||
import '../utils/screen_bound_image.dart';
|
||||
|
||||
/// Renders the configurable chat background behind [child].
|
||||
///
|
||||
@@ -24,7 +25,13 @@ class ChatBackground extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = context.watch<SettingsCubit>().val().chatBackgroundSettings;
|
||||
// Value snapshot: the settings object is mutated in place, so only copied
|
||||
// values can tell whether the background actually changed.
|
||||
context.select((SettingsCubit c) {
|
||||
final s = c.state.chatBackgroundSettings;
|
||||
return (s.type, s.fit, s.colorValue, s.imageVersion, s.dim, s.blur);
|
||||
});
|
||||
final s = context.read<SettingsCubit>().val().chatBackgroundSettings;
|
||||
final dark = AppTheme.isDarkMode(context);
|
||||
|
||||
final Widget background;
|
||||
@@ -32,7 +39,9 @@ class ChatBackground extends StatelessWidget {
|
||||
case ChatBackgroundType.none:
|
||||
background = ColoredBox(color: Theme.of(context).colorScheme.surface);
|
||||
case ChatBackgroundType.color:
|
||||
background = ColoredBox(color: Color(s.colorValue ?? _fallbackColor.toARGB32()));
|
||||
background = ColoredBox(
|
||||
color: Color(s.colorValue ?? _fallbackColor.toARGB32()),
|
||||
);
|
||||
case ChatBackgroundType.pattern:
|
||||
background = _imageLayer(
|
||||
const AssetImage('assets/background/chat.png'),
|
||||
@@ -41,12 +50,17 @@ class ChatBackground extends StatelessWidget {
|
||||
isPattern: true,
|
||||
);
|
||||
case ChatBackgroundType.image:
|
||||
final image = FileImage(File(AppPaths.chatBackgroundImage));
|
||||
background = KeyedSubtree(
|
||||
// imageVersion changes on every replacement, forcing a fresh subtree
|
||||
// alongside the explicit ImageCache evict in the settings handler.
|
||||
key: ValueKey(s.imageVersion),
|
||||
// Only "cover" scales the photo to the screen; tile/center render
|
||||
// it unscaled, so those keep the natural size.
|
||||
child: _imageLayer(
|
||||
FileImage(File(AppPaths.chatBackgroundImage)),
|
||||
s.fit == ChatBackgroundFit.cover
|
||||
? screenBoundImage(context, image)
|
||||
: image,
|
||||
s,
|
||||
dark,
|
||||
isPattern: false,
|
||||
@@ -57,7 +71,9 @@ class ChatBackground extends StatelessWidget {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(child: background),
|
||||
// Own layer: without it every scroll frame of the message list above
|
||||
// repaints the (possibly tiled or blurred) background as well.
|
||||
Positioned.fill(child: RepaintBoundary(child: background)),
|
||||
if (s.dim > 0)
|
||||
Positioned.fill(
|
||||
child: ColoredBox(color: Colors.black.withValues(alpha: s.dim)),
|
||||
|
||||
@@ -22,7 +22,7 @@ Future<Uint8List?> showChatBackgroundPickerSheet(BuildContext context) async {
|
||||
title: const Text('Aus Galerie wählen'),
|
||||
onTap: () async {
|
||||
final bytes = await _pickRaw(
|
||||
() => FilePick.singleGalleryPick(sheetContext),
|
||||
() => FilePick.singleGalleryPick(sheetContext, forCrop: true),
|
||||
);
|
||||
if (bytes == null || !sheetContext.mounted) return;
|
||||
result = bytes;
|
||||
@@ -33,7 +33,9 @@ Future<Uint8List?> showChatBackgroundPickerSheet(BuildContext context) async {
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('Foto aufnehmen'),
|
||||
onTap: () async {
|
||||
final bytes = await _pickRaw(() => FilePick.cameraPick(sheetContext));
|
||||
final bytes = await _pickRaw(
|
||||
() => FilePick.cameraPick(sheetContext, forCrop: true),
|
||||
);
|
||||
if (bytes == null || !sheetContext.mounted) return;
|
||||
result = bytes;
|
||||
Navigator.of(sheetContext).pop();
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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/account_storage.dart';
|
||||
import '../../storage/hydrated_storage_bootstrap.dart';
|
||||
import '../confirm_dialog.dart';
|
||||
import '../placeholder_view.dart';
|
||||
import 'json_viewer.dart';
|
||||
|
||||
/// Lists every key persisted by the active account's blocs and the app-wide
|
||||
/// settings.
|
||||
class BlocStorageView extends StatefulWidget {
|
||||
const BlocStorageView({super.key});
|
||||
|
||||
static List<Storage> get _storages => [
|
||||
HydratedBloc.storage,
|
||||
AccountStorage.global,
|
||||
];
|
||||
|
||||
/// All persisted keys; `null` when a storage can't be enumerated.
|
||||
static List<String>? keys() {
|
||||
final keys = <String>{};
|
||||
for (final storage in _storages) {
|
||||
final own = hydratedStorageKeys(storage);
|
||||
if (own == null) return null;
|
||||
keys.addAll(own);
|
||||
}
|
||||
return keys.toList()..sort();
|
||||
}
|
||||
|
||||
static Storage _storageOf(String key) => _storages.firstWhere(
|
||||
(storage) => hydratedStorageKeys(storage)?.contains(key) ?? false,
|
||||
orElse: () => HydratedBloc.storage,
|
||||
);
|
||||
|
||||
@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, BlocStorageView._storageOf(k).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 BlocStorageView._storageOf(key).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,84 +1,116 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:localstore/localstore.dart';
|
||||
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import '../../api/request_cache.dart';
|
||||
import '../../api/cache_store.dart';
|
||||
import '../../extensions/date_time.dart';
|
||||
import '../../routing/app_routes.dart';
|
||||
import '../app_progress_indicator.dart';
|
||||
import 'json_viewer.dart';
|
||||
import '../confirm_dialog.dart';
|
||||
import '../placeholder_view.dart';
|
||||
|
||||
/// Lists the [CacheStore] entries (one file per request key), newest first.
|
||||
class CacheView extends StatefulWidget {
|
||||
const CacheView({super.key});
|
||||
|
||||
@override
|
||||
State<CacheView> createState() => _CacheViewState();
|
||||
|
||||
Future<void> clear() async {
|
||||
await Localstore.instance.collection(RequestCache.collection).delete();
|
||||
}
|
||||
|
||||
Future<int> totalSize() async {
|
||||
final data = await Localstore.instance
|
||||
.collection(RequestCache.collection)
|
||||
.get();
|
||||
if (data == null || data.isEmpty) return 0;
|
||||
return data.values.fold<int>(
|
||||
0,
|
||||
(sum, value) => sum + jsonEncode(value).length,
|
||||
) *
|
||||
8;
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheViewState extends State<CacheView> {
|
||||
late Future<Map<String, dynamic>?> files;
|
||||
late Future<List<MapEntry<String, CacheEntry>>> _entries = _read();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
files = Localstore.instance.collection(RequestCache.collection).get();
|
||||
super.initState();
|
||||
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
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Cache storage')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Request-Cache'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Neu laden',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _reload,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Alles löschen',
|
||||
icon: const Icon(Icons.delete_sweep_outlined),
|
||||
onPressed: _confirmClear,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder(
|
||||
future: files,
|
||||
future: _entries,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final key = snapshot.data!.keys.elementAt(index);
|
||||
final element = snapshot.data![key] as Map<String, dynamic>;
|
||||
final filename = key.split('/').last;
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.text_snippet_outlined),
|
||||
title: Text(filename),
|
||||
subtitle: Text(
|
||||
'${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate'] as int).fromNow()}',
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => JsonViewer.asDialog(
|
||||
context,
|
||||
jsonDecode(element['json'] as String) as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (snapshot.connectionState != ConnectionState.done) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
} else {
|
||||
return const Center(
|
||||
child: PlaceholderView(
|
||||
icon: Icons.hourglass_empty,
|
||||
text: 'Keine Daten',
|
||||
),
|
||||
}
|
||||
final entries = snapshot.data ?? const [];
|
||||
if (entries.isEmpty) {
|
||||
return const PlaceholderView(
|
||||
icon: Icons.hourglass_empty,
|
||||
text: 'Keine Daten',
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final MapEntry(:key, :value) = entries[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.text_snippet_outlined),
|
||||
title: Text(key),
|
||||
subtitle: Text(
|
||||
'${filesize(utf8.encode(value.json).length)} · '
|
||||
'${DateTime.fromMillisecondsSinceEpoch(value.lastUpdate).formatRelative()}',
|
||||
),
|
||||
onTap: () => AppRoutes.openJsonViewer(
|
||||
context,
|
||||
jsonDecode(value.json),
|
||||
title: key,
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Löschen',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => _confirmDelete(key),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../routing/app_routes.dart';
|
||||
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../centered_leading.dart';
|
||||
import 'json_viewer.dart';
|
||||
|
||||
class DebugTile {
|
||||
BuildContext context;
|
||||
@@ -16,9 +16,9 @@ class DebugTile {
|
||||
(onlyInDebug ? kDebugMode : true);
|
||||
|
||||
Widget jsonData(Map<String, dynamic> data) => callback(
|
||||
title: 'JSON daten anzeigen',
|
||||
onTab: () => JsonViewer.asDialog(context, data),
|
||||
);
|
||||
title: 'JSON daten anzeigen',
|
||||
onTab: () => AppRoutes.openJsonViewer(context, data),
|
||||
);
|
||||
|
||||
Widget callback({
|
||||
String title = 'Debugaktion',
|
||||
|
||||
@@ -4,43 +4,201 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../utils/clipboard_helper.dart';
|
||||
|
||||
class JsonViewer {
|
||||
/// Collapsible tree view for decoded JSON (maps, lists, scalars). Rows are
|
||||
/// flattened lazily so multi-megabyte cache payloads stay scrollable.
|
||||
class JsonViewer extends StatefulWidget {
|
||||
final String title;
|
||||
final Object? data;
|
||||
|
||||
const JsonViewer({required this.data, this.title = 'Rohdaten', super.key});
|
||||
|
||||
static final _encoder = const JsonEncoder.withIndent(' ');
|
||||
|
||||
static String format(Map<String, dynamic> jsonInput) =>
|
||||
_encoder.convert(jsonInput);
|
||||
static String format(Object? data) => _encoder.convert(toEncodable(data));
|
||||
|
||||
static void asDialog(BuildContext context, Map<String, dynamic> dataMap) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Row(
|
||||
children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')],
|
||||
/// Makes arbitrary decoded storage values (Hive maps with dynamic keys,
|
||||
/// DateTimes, …) JSON-encodable.
|
||||
static Object? toEncodable(Object? value) => switch (value) {
|
||||
null || bool() || num() || String() => value,
|
||||
Map() => {for (final e in value.entries) '${e.key}': toEncodable(e.value)},
|
||||
Iterable() => [for (final e in value) toEncodable(e)],
|
||||
DateTime() => value.toIso8601String(),
|
||||
_ => value.toString(),
|
||||
};
|
||||
|
||||
@override
|
||||
State<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)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => copyToClipboard(
|
||||
dialogCtx,
|
||||
JsonViewer.format(dataMap),
|
||||
successMessage: 'Formatiertes JSON kopiert',
|
||||
IconButton(
|
||||
tooltip: 'Alles zuklappen',
|
||||
icon: const Icon(Icons.unfold_less),
|
||||
onPressed: () => _setAll(expanded: false),
|
||||
),
|
||||
PopupMenuButton<bool>(
|
||||
onSelected: (pretty) => copyToClipboard(
|
||||
context,
|
||||
pretty ? JsonViewer.format(_data) : jsonEncode(_data),
|
||||
successMessage: pretty
|
||||
? 'Formatiertes JSON kopiert'
|
||||
: 'Inline JSON kopiert',
|
||||
),
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(value: true, child: Text('JSON kopieren')),
|
||||
PopupMenuItem(value: false, child: Text('Inline kopieren')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView.builder(
|
||||
itemCount: _rows.length,
|
||||
itemBuilder: (context, index) => _buildRow(context, _rows[index]),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildRow(BuildContext context, _Row row) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final mono = theme.textTheme.bodyMedium!.copyWith(fontFamily: 'monospace');
|
||||
final value = row.value;
|
||||
final expanded = _expanded.contains(row.path);
|
||||
|
||||
final (String text, Color color) = switch (value) {
|
||||
Map() => (expanded ? '{' : '{ ${value.length} }', colors.outline),
|
||||
List() => (expanded ? '[' : '[ ${value.length} ]', colors.outline),
|
||||
String() => ('"$value"', colors.tertiary),
|
||||
num() || bool() => ('$value', colors.primary),
|
||||
_ => ('null', colors.outline),
|
||||
};
|
||||
|
||||
return InkWell(
|
||||
onTap: row.isContainer ? () => _toggle(row.path) : null,
|
||||
onLongPress: () => copyToClipboard(
|
||||
context,
|
||||
value is String ? value : JsonViewer.format(value),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 8.0 + row.depth * 16,
|
||||
right: 8,
|
||||
top: 3,
|
||||
bottom: 3,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
child: row.isContainer
|
||||
? Icon(
|
||||
expanded ? Icons.arrow_drop_down : Icons.arrow_right,
|
||||
size: 20,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: const Text('Kopieren'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => copyToClipboard(
|
||||
dialogCtx,
|
||||
dataMap.toString(),
|
||||
successMessage: 'Inline JSON kopiert',
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: mono,
|
||||
children: [
|
||||
if (row.label != null)
|
||||
TextSpan(
|
||||
text: '${row.label}: ',
|
||||
style: TextStyle(color: colors.secondary),
|
||||
),
|
||||
TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: row.isContainer ? 1 : 8,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
child: const Text('Inline Kopieren'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogCtx).pop(),
|
||||
child: const Text('Schließen'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,11 +23,31 @@ class FilePick {
|
||||
return pickedImages.isNotEmpty ? pickedImages : null;
|
||||
});
|
||||
|
||||
static Future<XFile?> singleGalleryPick(BuildContext context) =>
|
||||
_guarded(context, () => _picker.pickImage(source: ImageSource.gallery));
|
||||
static Future<XFile?> singleGalleryPick(
|
||||
BuildContext context, {
|
||||
bool forCrop = false,
|
||||
}) => _pickImage(context, ImageSource.gallery, forCrop: forCrop);
|
||||
|
||||
static Future<XFile?> cameraPick(BuildContext context) =>
|
||||
_guarded(context, () => _picker.pickImage(source: ImageSource.camera));
|
||||
static Future<XFile?> cameraPick(
|
||||
BuildContext context, {
|
||||
bool forCrop = false,
|
||||
}) => _pickImage(context, ImageSource.camera, forCrop: forCrop);
|
||||
|
||||
/// [forCrop] makes the picker re-encode to a bounded JPEG: the Dart cropper
|
||||
/// can't decode HEIC (iOS gallery default) and is slow on full-size photos.
|
||||
static Future<XFile?> _pickImage(
|
||||
BuildContext context,
|
||||
ImageSource source, {
|
||||
required bool forCrop,
|
||||
}) => _guarded(
|
||||
context,
|
||||
() => _picker.pickImage(
|
||||
source: source,
|
||||
maxWidth: forCrop ? 2048 : null,
|
||||
maxHeight: forCrop ? 2048 : null,
|
||||
imageQuality: forCrop ? 90 : null,
|
||||
),
|
||||
);
|
||||
|
||||
static Future<List<String>?> documentPick() async {
|
||||
final result = await FilePicker.pickFiles(allowMultiple: true);
|
||||
|
||||
+58
-37
@@ -4,6 +4,7 @@ import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
@@ -15,6 +16,7 @@ import '../routing/app_routes.dart';
|
||||
import '../share_intent/remote_file_ref.dart';
|
||||
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../utils/downloads/download_manager.dart';
|
||||
import '../utils/screen_bound_image.dart';
|
||||
import 'app_progress_indicator.dart';
|
||||
import 'async_action_button.dart';
|
||||
import 'centered_leading.dart';
|
||||
@@ -49,6 +51,7 @@ class FileViewer extends StatefulWidget {
|
||||
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
|
||||
|
||||
class _FileViewerState extends State<FileViewer> {
|
||||
Future<_TextPayload>? _textPayload;
|
||||
final PhotoViewController photoViewController = PhotoViewController();
|
||||
|
||||
late SettingsCubit settings = context.read<SettingsCubit>();
|
||||
@@ -302,7 +305,13 @@ class _FileViewerState extends State<FileViewer> {
|
||||
controller: photoViewController,
|
||||
maxScale: 3.0,
|
||||
minScale: 0.1,
|
||||
imageProvider: Image.file(File(widget.path)).image,
|
||||
// 2× the screen stays sharp while zooming in; the 4096 px cap only
|
||||
// bites on camera-sized photos.
|
||||
imageProvider: screenBoundImage(
|
||||
context,
|
||||
FileImage(File(widget.path)),
|
||||
scale: 2,
|
||||
),
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
@@ -348,13 +357,17 @@ class _FileViewerState extends State<FileViewer> {
|
||||
Widget _buildTextView() => Scaffold(
|
||||
appBar: _appbar(),
|
||||
body: FutureBuilder<_TextPayload>(
|
||||
future: _readTextPayload(),
|
||||
// Cached: a future created in build re-read the file on every rebuild.
|
||||
future: _textPayload ??= compute(
|
||||
_loadTextPayload,
|
||||
(widget.path, _textViewMaxBytes),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
final payload = snapshot.data!;
|
||||
final lines = const LineSplitter().convert(payload.content);
|
||||
final lines = payload.lines;
|
||||
// Stable gutter width — sized by the highest line number's digit count.
|
||||
final gutterWidth = (lines.length.toString().length * 9.0) + 16;
|
||||
return SelectionArea(
|
||||
@@ -443,38 +456,6 @@ class _FileViewerState extends State<FileViewer> {
|
||||
}
|
||||
|
||||
static const int _textViewMaxBytes = 5 * 1024 * 1024;
|
||||
|
||||
Future<_TextPayload> _readTextPayload() async {
|
||||
final file = File(widget.path);
|
||||
final size = await file.length();
|
||||
final ext = widget.path.split('.').last.toLowerCase();
|
||||
if (size <= _textViewMaxBytes) {
|
||||
final raw = await file.readAsString();
|
||||
return _TextPayload(content: _maybePrettify(raw, ext), truncated: false);
|
||||
}
|
||||
final raf = await file.open();
|
||||
try {
|
||||
final bytes = await raf.read(_textViewMaxBytes);
|
||||
// Truncated payloads stay raw — a parser would choke on the dangling tail.
|
||||
return _TextPayload(
|
||||
content: utf8.decode(bytes, allowMalformed: true),
|
||||
truncated: true,
|
||||
);
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Falls through to the original text on parse errors.
|
||||
String _maybePrettify(String content, String ext) {
|
||||
if (ext != 'json') return content;
|
||||
try {
|
||||
final parsed = jsonDecode(content);
|
||||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||||
} on Object {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionDescriptor {
|
||||
@@ -489,7 +470,47 @@ class _ActionDescriptor {
|
||||
}
|
||||
|
||||
class _TextPayload {
|
||||
final String content;
|
||||
final List<String> lines;
|
||||
final bool truncated;
|
||||
const _TextPayload({required this.content, required this.truncated});
|
||||
const _TextPayload({required this.lines, required this.truncated});
|
||||
}
|
||||
|
||||
/// Reads, prettifies (JSON) and splits a text file on a background isolate:
|
||||
/// up to 5 MB of decoding and line splitting would otherwise freeze the UI.
|
||||
Future<_TextPayload> _loadTextPayload((String, int) args) async {
|
||||
final (path, maxBytes) = args;
|
||||
final file = File(path);
|
||||
final size = await file.length();
|
||||
final ext = path.split('.').last.toLowerCase();
|
||||
if (size <= maxBytes) {
|
||||
final raw = await file.readAsString();
|
||||
return _TextPayload(
|
||||
lines: const LineSplitter().convert(_maybePrettify(raw, ext)),
|
||||
truncated: false,
|
||||
);
|
||||
}
|
||||
final raf = await file.open();
|
||||
try {
|
||||
final bytes = await raf.read(maxBytes);
|
||||
// Truncated payloads stay raw — a parser would choke on the dangling tail.
|
||||
return _TextPayload(
|
||||
lines: const LineSplitter().convert(
|
||||
utf8.decode(bytes, allowMalformed: true),
|
||||
),
|
||||
truncated: true,
|
||||
);
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Falls through to the original text on parse errors.
|
||||
String _maybePrettify(String content, String ext) {
|
||||
if (ext != 'json') return content;
|
||||
try {
|
||||
final parsed = jsonDecode(content);
|
||||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||||
} on Object {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,33 @@ class PmImageView extends StatelessWidget {
|
||||
|
||||
const PmImageView({required this.node, super.key});
|
||||
|
||||
/// Full-resolution source, used for the zoomable fullscreen view.
|
||||
static ImageProvider? sourceProvider(PmImage node) {
|
||||
final bytes = node.bytes;
|
||||
if (bytes != null) return MemoryImage(bytes);
|
||||
if (node.src.startsWith('http')) {
|
||||
return CachedNetworkImageProvider(node.src);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Inline images never render wider than the screen, so they are decoded at
|
||||
/// most at its physical width instead of the (often camera-sized) original.
|
||||
/// [PmJsonView] precaches through this too, so both hit the same cache key.
|
||||
static ImageProvider? inlineProvider(BuildContext context, PmImage node) {
|
||||
final source = sourceProvider(node);
|
||||
if (source == null) return null;
|
||||
final width =
|
||||
(MediaQuery.sizeOf(context).width *
|
||||
MediaQuery.devicePixelRatioOf(context))
|
||||
.round();
|
||||
return ResizeImage(source, width: width);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = _imageProvider();
|
||||
final provider = sourceProvider(node);
|
||||
final inline = inlineProvider(context, node);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final available = constraints.maxWidth.isFinite
|
||||
@@ -22,7 +46,13 @@ class PmImageView extends StatelessWidget {
|
||||
|
||||
Widget image = ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: targetWidth ?? available),
|
||||
child: _imageWidget(context, provider),
|
||||
child: inline == null
|
||||
? _brokenImage(context)
|
||||
: Image(
|
||||
image: inline,
|
||||
errorBuilder: (context, error, stack) =>
|
||||
_brokenImage(context),
|
||||
),
|
||||
);
|
||||
|
||||
final href = node.href;
|
||||
@@ -43,30 +73,6 @@ class PmImageView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
ImageProvider? _imageProvider() {
|
||||
if (node.bytes != null) return MemoryImage(node.bytes!);
|
||||
if (node.src.startsWith('http')) {
|
||||
return CachedNetworkImageProvider(node.src);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _imageWidget(BuildContext context, ImageProvider? provider) {
|
||||
if (node.bytes != null) {
|
||||
return Image.memory(
|
||||
node.bytes!,
|
||||
errorBuilder: (context, error, stack) => _brokenImage(context),
|
||||
);
|
||||
}
|
||||
if (node.src.startsWith('http')) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: node.src,
|
||||
errorWidget: (context, url, error) => _brokenImage(context),
|
||||
);
|
||||
}
|
||||
return _brokenImage(context);
|
||||
}
|
||||
|
||||
Widget _brokenImage(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return DecoratedBox(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app_progress_indicator.dart';
|
||||
import 'pm_document_view.dart';
|
||||
import 'pm_image_view.dart';
|
||||
import 'pm_node.dart';
|
||||
|
||||
/// Renders raw ProseMirror JSON and memoises the parsed tree across rebuilds.
|
||||
@@ -31,7 +31,7 @@ class PmJsonView extends StatefulWidget {
|
||||
class _PmJsonViewState extends State<PmJsonView> {
|
||||
PmNode? _shown;
|
||||
PmNode? _pending;
|
||||
List<ImageProvider> _pendingProviders = const [];
|
||||
List<PmImage> _pendingImages = const [];
|
||||
bool _precacheStarted = false;
|
||||
int _generation = 0;
|
||||
|
||||
@@ -62,17 +62,17 @@ class _PmJsonViewState extends State<PmJsonView> {
|
||||
|
||||
void _parse() {
|
||||
final doc = PmNode.fromJson(widget.json);
|
||||
final providers = <ImageProvider>[];
|
||||
_collectProviders(doc, providers);
|
||||
final images = <PmImage>[];
|
||||
_collectImages(doc, images);
|
||||
_generation++;
|
||||
_precacheStarted = false;
|
||||
if (providers.isEmpty) {
|
||||
if (images.isEmpty) {
|
||||
_shown = doc;
|
||||
_pending = null;
|
||||
_pendingProviders = const [];
|
||||
_pendingImages = const [];
|
||||
} else {
|
||||
_pending = doc;
|
||||
_pendingProviders = providers;
|
||||
_pendingImages = images;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,9 @@ class _PmJsonViewState extends State<PmJsonView> {
|
||||
_precacheStarted = true;
|
||||
final generation = _generation;
|
||||
Future.wait([
|
||||
for (final provider in _pendingProviders)
|
||||
precacheImage(provider, context, onError: (_, _) {}),
|
||||
for (final image in _pendingImages)
|
||||
if (PmImageView.inlineProvider(context, image) case final provider?)
|
||||
precacheImage(provider, context, onError: (_, _) {}),
|
||||
])
|
||||
.timeout(PmJsonView.precacheTimeout, onTimeout: () => const [])
|
||||
.whenComplete(() {
|
||||
@@ -91,23 +92,16 @@ class _PmJsonViewState extends State<PmJsonView> {
|
||||
setState(() {
|
||||
_shown = pending;
|
||||
_pending = null;
|
||||
_pendingProviders = const [];
|
||||
_pendingImages = const [];
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _collectProviders(PmNode node, List<ImageProvider> out) {
|
||||
if (node is PmImage) {
|
||||
final bytes = node.bytes;
|
||||
if (bytes != null) {
|
||||
out.add(MemoryImage(bytes));
|
||||
} else if (node.src.startsWith('http')) {
|
||||
out.add(CachedNetworkImageProvider(node.src));
|
||||
}
|
||||
}
|
||||
void _collectImages(PmNode node, List<PmImage> out) {
|
||||
if (node is PmImage) out.add(node);
|
||||
for (final child in node.children) {
|
||||
_collectProviders(child, out);
|
||||
_collectImages(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ class SharePositionOrigin {
|
||||
static Rect get(BuildContext context) => Rect.fromLTWH(
|
||||
0,
|
||||
0,
|
||||
MediaQuery.of(context).size.width,
|
||||
MediaQuery.of(context).size.height / 2,
|
||||
MediaQuery.sizeOf(context).width,
|
||||
MediaQuery.sizeOf(context).height / 2,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,8 +162,8 @@ Future<AvatarPayload?> _fetchAvatarPayload(String url) async {
|
||||
() => http.get(
|
||||
Uri.parse(url),
|
||||
headers: {
|
||||
// User avatars are public in Nextcloud, so the account switcher can
|
||||
// show them while a guardian (no Nextcloud identity) is active.
|
||||
// User avatars are public in Nextcloud, so they also load while the
|
||||
// active session has no Nextcloud identity.
|
||||
...?SessionManager().current?.nextcloud?.authHeaders,
|
||||
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
|
||||
},
|
||||
@@ -390,6 +390,10 @@ class _UserAvatarState extends State<UserAvatar> {
|
||||
payload.bytes,
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
// Group avatars arrive at a fixed large server size; decoding them
|
||||
// at display size keeps a chat list from filling the image cache.
|
||||
cacheWidth: (radius * 2 * MediaQuery.devicePixelRatioOf(context))
|
||||
.round(),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user