simplified code across the app and consolidated duplicated helpers

This commit is contained in:
2026-09-27 17:29:54 +02:00
parent 3e856d0b1c
commit ee1240f71a
155 changed files with 1772 additions and 2546 deletions
@@ -32,20 +32,12 @@ class AsyncActionButton extends StatelessWidget {
onError: onError,
onSuccess: onSuccess,
builder: (context, busy, handler) {
final spinner = AppProgressIndicator.small(
color: Theme.of(context).colorScheme.onPrimary,
);
final content = busy
? Row(
mainAxisSize: MainAxisSize.min,
children: [spinner, const SizedBox(width: 8), child],
final lead = busy
? AppProgressIndicator.small(
color: Theme.of(context).colorScheme.onPrimary,
)
: (icon != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [Icon(icon), const SizedBox(width: 8), child],
)
: child);
: (icon == null ? null : Icon(icon));
final content = lead == null ? child : _leadingLabel(lead, child);
final button = ElevatedButton(
onPressed: handler,
style: style,
@@ -8,6 +8,11 @@ typedef AsyncErrorBuilder = String Function(Object error);
TextStyle _asyncErrorTextStyle(BuildContext context) =>
TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13);
Widget _leadingLabel(Widget leading, Widget label) => Row(
mainAxisSize: MainAxisSize.min,
children: [leading, const SizedBox(width: 8), label],
);
/// Wraps [action] with a try/catch that pops up an [InfoDialog] on failure
/// (using [errorBuilder] or the default error mapper). Returns `true` on
/// success, `false` on caught failure.
@@ -23,14 +28,7 @@ Future<bool> runWithErrorDialog(
} catch (e) {
Haptics.error();
if (!context.mounted) return false;
final message = errorBuilder != null
? errorBuilder(e)
: errorToUserMessage(e);
final details = errorToTechnicalDetails(e);
final body = details != null && details != message
? '$message\n\n$details'
: message;
InfoDialog.show(context, body, copyable: true, title: 'Fehler');
InfoDialog.showError(context, e, message: errorBuilder?.call(e));
return false;
}
}
@@ -55,14 +53,13 @@ class AsyncActionController extends ChangeNotifier {
notifyListeners();
try {
await action();
_busy = false;
notifyListeners();
return true;
} catch (e) {
_busy = false;
_error = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e);
notifyListeners();
return false;
} finally {
_busy = false;
notifyListeners();
}
}
@@ -68,15 +68,11 @@ class _AsyncDialogActionState extends State<AsyncDialogAction> {
}
},
child: _controller.busy
? Row(
mainAxisSize: MainAxisSize.min,
children: [
AppProgressIndicator.small(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(widget.confirmLabel),
],
? _leadingLabel(
AppProgressIndicator.small(
color: Theme.of(context).colorScheme.primary,
),
Text(widget.confirmLabel),
)
: Text(widget.confirmLabel),
),
+8 -12
View File
@@ -23,15 +23,13 @@ class _AsyncMixin extends StatefulWidget {
}
class _AsyncMixinState extends State<_AsyncMixin> {
late final AsyncActionController _internal;
AsyncActionController get _controller => widget.controller ?? _internal;
AsyncActionController? _own;
AsyncActionController get _controller =>
widget.controller ?? (_own ??= AsyncActionController());
@override
void initState() {
super.initState();
if (widget.controller == null) {
_internal = AsyncActionController();
}
_controller.addListener(_onControllerChange);
}
@@ -39,7 +37,7 @@ class _AsyncMixinState extends State<_AsyncMixin> {
void didUpdateWidget(covariant _AsyncMixin oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
(oldWidget.controller ?? _internal).removeListener(_onControllerChange);
(oldWidget.controller ?? _own)?.removeListener(_onControllerChange);
_controller.addListener(_onControllerChange);
}
}
@@ -47,9 +45,7 @@ class _AsyncMixinState extends State<_AsyncMixin> {
@override
void dispose() {
_controller.removeListener(_onControllerChange);
if (widget.controller == null) {
_internal.dispose();
}
_own?.dispose();
super.dispose();
}
@@ -76,11 +72,11 @@ class _AsyncMixinState extends State<_AsyncMixin> {
@override
Widget build(BuildContext context) {
final handler = widget.onPressed == null ? null : _trigger;
final busy = _controller.busy;
return widget.builder(
context,
_controller.busy,
_controller.busy ? null : handler,
busy,
busy || widget.onPressed == null ? null : _trigger,
);
}
}
@@ -29,15 +29,11 @@ class AsyncTextButton extends StatelessWidget {
onSuccess: onSuccess,
builder: (context, busy, handler) {
final content = busy
? Row(
mainAxisSize: MainAxisSize.min,
children: [
AppProgressIndicator.small(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
child,
],
? _leadingLabel(
AppProgressIndicator.small(
color: Theme.of(context).colorScheme.primary,
),
child,
)
: child;
final button = TextButton(onPressed: handler, child: content);
+5 -37
View File
@@ -1,7 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../routing/app_routes.dart';
import 'details_bottom_sheet.dart';
@@ -36,31 +35,11 @@ Future<AvatarSheetResult?> showAvatarActionsSheet(
await showDetailsBottomSheet(
context,
children: (sheetContext) => [
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Aus Galerie wählen'),
onTap: () async {
final bytes = await _pickAndCrop(
sheetContext,
() => FilePick.singleGalleryPick(sheetContext, forCrop: true),
);
if (bytes == null || !sheetContext.mounted) return;
result = AvatarUploadResult(bytes);
Navigator.of(sheetContext).pop();
},
),
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Foto aufnehmen'),
onTap: () async {
final bytes = await _pickAndCrop(
sheetContext,
() => FilePick.cameraPick(sheetContext, forCrop: true),
);
if (bytes == null || !sheetContext.mounted) return;
result = AvatarUploadResult(bytes);
Navigator.of(sheetContext).pop();
},
...imageSourceTiles(
sheetContext,
transform: (bytes) =>
AppRoutes.openAvatarCrop(sheetContext, imageBytes: bytes),
onPicked: (bytes) => result = AvatarUploadResult(bytes),
),
if (allowRemove) ...[
const Divider(),
@@ -83,14 +62,3 @@ Future<AvatarSheetResult?> showAvatarActionsSheet(
);
return result;
}
Future<Uint8List?> _pickAndCrop(
BuildContext context,
Future<XFile?> Function() pick,
) async {
final picked = await pick();
if (picked == null) return null;
final bytes = await picked.readAsBytes();
if (!context.mounted) return null;
return AppRoutes.openAvatarCrop(context, imageBytes: bytes);
}
+18 -18
View File
@@ -66,13 +66,15 @@ class AvatarDiskCache {
return isGroup ? 'g_$hex' : 'u_${hex}_$size';
}
static String _hex(String value) {
final buffer = StringBuffer();
for (final b in utf8.encode(value)) {
buffer.write(b.toRadixString(16).padLeft(2, '0'));
}
return buffer.toString();
}
static String _hex(String value) =>
utf8.encode(value).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
File _file(
String dirPath, {
required String id,
required bool isGroup,
required int size,
}) => File('$dirPath/${fileName(id: id, isGroup: isGroup, size: size)}');
/// Synchronous read for warm sessions (cache directory already resolved).
/// Returns null when the directory isn't known yet — the caller falls back
@@ -86,9 +88,7 @@ class AvatarDiskCache {
final path = _dirPath;
if (path == null) return null;
try {
final file = File(
'$path/${fileName(id: id, isGroup: isGroup, size: size)}',
);
final file = _file(path, id: id, isGroup: isGroup, size: size);
if (!file.existsSync()) return null;
final bytes = file.readAsBytesSync();
return bytes.isEmpty ? null : bytes;
@@ -104,9 +104,7 @@ class AvatarDiskCache {
}) async {
try {
final dir = await _directory();
final file = File(
'${dir.path}/${fileName(id: id, isGroup: isGroup, size: size)}',
);
final file = _file(dir.path, id: id, isGroup: isGroup, size: size);
if (!file.existsSync()) return null;
final bytes = await file.readAsBytes();
return bytes.isEmpty ? null : bytes;
@@ -123,10 +121,12 @@ class AvatarDiskCache {
}) async {
try {
final dir = await _directory();
final file = File(
'${dir.path}/${fileName(id: id, isGroup: isGroup, size: size)}',
);
await file.writeAsBytes(bytes, flush: true);
await _file(
dir.path,
id: id,
isGroup: isGroup,
size: size,
).writeAsBytes(bytes);
if (!_pruned) {
_pruned = true;
unawaited(_prune(dir));
@@ -142,7 +142,7 @@ class AvatarDiskCache {
try {
final dir = await _directory();
if (isGroup) {
final file = File('${dir.path}/${fileName(id: id, isGroup: true, size: 0)}');
final file = _file(dir.path, id: id, isGroup: true, size: 0);
if (file.existsSync()) await file.delete();
return;
}
+2 -33
View File
@@ -1,7 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../routing/app_routes.dart';
import 'details_bottom_sheet.dart';
@@ -16,32 +15,8 @@ Future<Uint8List?> showChatBackgroundPickerSheet(BuildContext context) async {
Uint8List? result;
await showDetailsBottomSheet(
context,
children: (sheetContext) => [
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Aus Galerie wählen'),
onTap: () async {
final bytes = await _pickRaw(
() => FilePick.singleGalleryPick(sheetContext, forCrop: true),
);
if (bytes == null || !sheetContext.mounted) return;
result = bytes;
Navigator.of(sheetContext).pop();
},
),
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Foto aufnehmen'),
onTap: () async {
final bytes = await _pickRaw(
() => FilePick.cameraPick(sheetContext, forCrop: true),
);
if (bytes == null || !sheetContext.mounted) return;
result = bytes;
Navigator.of(sheetContext).pop();
},
),
],
children: (sheetContext) =>
imageSourceTiles(sheetContext, onPicked: (bytes) => result = bytes),
);
return result;
}
@@ -53,9 +28,3 @@ Future<Uint8List?> cropChatBackgroundImage(
BuildContext context,
Uint8List bytes,
) => AppRoutes.openAvatarCrop(context, imageBytes: bytes, aspectRatio: null);
Future<Uint8List?> _pickRaw(Future<XFile?> Function() pick) async {
final picked = await pick();
if (picked == null) return null;
return picked.readAsBytes();
}
+6 -11
View File
@@ -67,15 +67,10 @@ class ConfirmDialog extends StatelessWidget {
],
);
static void openBrowser(BuildContext context, String url) {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Link öffnen',
content: 'Möchtest du den folgenden Link öffnen?\n$url',
confirmButton: 'Öffnen',
onConfirm: () => UrlOpener.openUrl(url),
),
);
}
static void openBrowser(BuildContext context, String url) => ConfirmDialog(
title: 'Link öffnen',
content: 'Möchtest du den folgenden Link öffnen?\n$url',
confirmButton: 'Öffnen',
onConfirm: () => UrlOpener.openUrl(url),
).asDialog(context);
}
+1 -5
View File
@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import '../../routing/app_routes.dart';
import '../../utils/downloads/download_job.dart';
import '../../utils/downloads/download_manager.dart';
import '../../utils/haptics.dart';
import 'downloads_sheet.dart';
import 'stale_download_guard.dart';
@@ -204,10 +203,7 @@ class _DownloadTrayHostState extends State<DownloadTrayHost>
_manager.markOpened(job);
return;
}
if (!ensureDownloadStillExists(ctx, job)) return;
_manager.markOpened(job);
Haptics.success();
AppRoutes.openFileViewer(ctx, path, remoteFile: job.remoteFile);
openFinishedDownload(ctx, job, path);
}
/// Distance from the bottom to float the chip. Anchored bottom-LEFT (FABs are
+6 -13
View File
@@ -2,10 +2,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../../routing/app_routes.dart';
import '../../utils/downloads/download_job.dart';
import '../../utils/downloads/download_manager.dart';
import '../../utils/haptics.dart';
import '../../view/pages/files/data/file_type_icon.dart';
import '../centered_leading.dart';
import '../details_bottom_sheet.dart';
@@ -90,14 +88,7 @@ class _DownloadsListState extends State<_DownloadsList> {
// Pop the sheet first so a stale-file dialog lands on the underlying
// screen instead of an emptied sheet.
Navigator.of(widget.sheetContext).pop();
if (!ensureDownloadStillExists(widget.rootContext, job)) return;
Haptics.success();
DownloadManager.instance.markOpened(job);
AppRoutes.openFileViewer(
widget.rootContext,
path,
remoteFile: job.remoteFile,
);
openFinishedDownload(widget.rootContext, job, path);
}
}
@@ -140,8 +131,7 @@ class _DownloadRow extends StatelessWidget {
DownloadInProgress() => IconButton(
icon: const Icon(Icons.close),
tooltip: 'Abbrechen',
onPressed: () =>
unawaited(DownloadManager.instance.cancel(job)),
onPressed: () => unawaited(DownloadManager.instance.cancel(job)),
),
DownloadFailed() => IconButton(
icon: const Icon(Icons.refresh),
@@ -167,7 +157,10 @@ class _DownloadRow extends StatelessWidget {
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 24),
color: theme.colorScheme.errorContainer,
child: Icon(Icons.delete_outline, color: theme.colorScheme.onErrorContainer),
child: Icon(
Icons.delete_outline,
color: theme.colorScheme.onErrorContainer,
),
),
onDismissed: (_) => DownloadManager.instance.dismiss(job),
child: tile,
+42 -15
View File
@@ -3,8 +3,10 @@ import 'dart:io';
import 'package:flutter/material.dart';
import '../../routing/app_routes.dart';
import '../../utils/downloads/download_job.dart';
import '../../utils/downloads/download_manager.dart';
import '../../utils/haptics.dart';
import '../confirm_dialog.dart';
import '../info_dialog.dart';
@@ -21,20 +23,45 @@ bool ensureDownloadStillExists(BuildContext context, DownloadJob job) {
if (path == null || File(path).existsSync()) return true;
DownloadManager.instance.markOpened(job);
if (job.remotePath.isNotEmpty) {
ConfirmDialog(
title: 'Datei nicht mehr verfügbar',
content:
'Die heruntergeladene Datei wurde vom System aus dem Zwischenspeicher entfernt.\nErneut herunterladen?',
confirmButton: 'Herunterladen',
onConfirm: () => unawaited(DownloadManager.instance.retry(job)),
).asDialog(context);
} else {
InfoDialog.show(
context,
'Die heruntergeladene Datei wurde vom System aus dem Zwischenspeicher entfernt. Bitte lade sie erneut herunter.',
title: 'Datei nicht mehr verfügbar',
);
}
showFileGoneDialog(
context,
subject: 'Die heruntergeladene Datei',
onRedownload: job.remotePath.isEmpty
? null
: () => unawaited(DownloadManager.instance.retry(job)),
);
return false;
}
/// Opens a finished download in the viewer, unless its file vanished.
void openFinishedDownload(BuildContext context, DownloadJob job, String path) {
if (!ensureDownloadStillExists(context, job)) return;
DownloadManager.instance.markOpened(job);
Haptics.success();
AppRoutes.openFileViewer(context, path, remoteFile: job.remoteFile);
}
/// Tells the user that the system cleared a cached file ([subject], e.g.
/// "Die Datei") and offers a re-download when [onRedownload] is given.
void showFileGoneDialog(
BuildContext context, {
required String subject,
VoidCallback? onRedownload,
}) {
const title = 'Datei nicht mehr verfügbar';
final gone = '$subject wurde vom System aus dem Zwischenspeicher entfernt.';
if (onRedownload == null) {
InfoDialog.show(
context,
'$gone Bitte lade sie erneut herunter.',
title: title,
);
return;
}
ConfirmDialog(
title: title,
content: '$gone\nErneut herunterladen?',
confirmButton: 'Herunterladen',
onConfirm: onRedownload,
).asDialog(context);
}
+41 -9
View File
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import '../api/errors/error_mapper.dart';
import '../api/errors/permission_exception.dart';
import 'confirm_dialog.dart';
import 'info_dialog.dart';
@@ -86,12 +85,45 @@ class FilePick {
onConfirm: AppSettings.openAppSettings,
).asDialog(context);
static void _showErrorDialog(BuildContext context, Object error) {
final message = errorToUserMessage(error);
final details = errorToTechnicalDetails(error);
final body = details != null && details != message
? '$message\n\n$details'
: message;
InfoDialog.show(context, body, copyable: true, title: 'Fehler');
}
static void _showErrorDialog(BuildContext context, Object error) =>
InfoDialog.showError(context, error);
}
/// "Aus Galerie wählen" / "Foto aufnehmen" tiles for an image-source sheet.
/// The optional [transform] (e.g. a cropper) runs with the sheet still
/// mounted; the sheet pops only once [onPicked] received the final bytes.
List<Widget> imageSourceTiles(
BuildContext sheetContext, {
required void Function(Uint8List bytes) onPicked,
Future<Uint8List?> Function(Uint8List bytes)? transform,
}) {
Widget tile(IconData icon, String title, Future<XFile?> Function() pick) =>
ListTile(
leading: Icon(icon),
title: Text(title),
onTap: () async {
final picked = await pick();
if (picked == null) return;
Uint8List? bytes = await picked.readAsBytes();
if (transform != null) {
if (!sheetContext.mounted) return;
bytes = await transform(bytes);
}
if (bytes == null || !sheetContext.mounted) return;
onPicked(bytes);
Navigator.of(sheetContext).pop();
},
);
return [
tile(
Icons.photo_library_outlined,
'Aus Galerie wählen',
() => FilePick.singleGalleryPick(sheetContext, forCrop: true),
),
tile(
Icons.photo_camera_outlined,
'Foto aufnehmen',
() => FilePick.cameraPick(sheetContext, forCrop: true),
),
];
}
+34 -41
View File
@@ -20,7 +20,7 @@ import '../utils/screen_bound_image.dart';
import 'app_progress_indicator.dart';
import 'async_action_button.dart';
import 'centered_leading.dart';
import 'confirm_dialog.dart';
import 'downloads/stale_download_guard.dart';
import 'file_viewer/code_line.dart';
import 'file_viewer/deferred_pdf_viewer.dart';
import 'file_viewer/file_kind.dart';
@@ -54,15 +54,20 @@ class _FileViewerState extends State<FileViewer> {
Future<_TextPayload>? _textPayload;
final PhotoViewController photoViewController = PhotoViewController();
late SettingsCubit settings = context.read<SettingsCubit>();
late bool openExternal;
Future<FileKind>? _fileKind;
String get _fileName => widget.path.split('/').last;
@override
void initState() {
super.initState();
openExternal =
settings.val().fileViewSettings.alwaysOpenExternally ||
context
.read<SettingsCubit>()
.val()
.fileViewSettings
.alwaysOpenExternally ||
widget.openExternal;
if (openExternal) {
WidgetsBinding.instance.addPostFrameCallback(
@@ -93,31 +98,23 @@ class _FileViewerState extends State<FileViewer> {
bool _ensureLocalFile() {
if (File(widget.path).existsSync()) return true;
final remote = widget.remoteFile;
if (remote == null) {
InfoDialog.show(
context,
'Die Datei wurde vom System aus dem Zwischenspeicher entfernt. Bitte lade sie erneut herunter.',
title: 'Datei nicht mehr verfügbar',
);
return false;
}
ConfirmDialog(
title: 'Datei nicht mehr verfügbar',
content:
'Die Datei wurde vom System aus dem Zwischenspeicher entfernt.\nErneut herunterladen?',
confirmButton: 'Herunterladen',
onConfirm: () {
// Pop the viewer before starting so the fresh download auto-opens.
Navigator.of(context).pop();
unawaited(
DownloadManager.instance.start(
remotePath: remote.path,
name: remote.name,
remoteFile: remote,
),
);
},
).asDialog(context);
showFileGoneDialog(
context,
subject: 'Die Datei',
onRedownload: remote == null
? null
: () {
// Pop the viewer before starting so the fresh download auto-opens.
Navigator.of(context).pop();
unawaited(
DownloadManager.instance.start(
remotePath: remote.path,
name: remote.name,
remoteFile: remote,
),
);
},
);
return false;
}
@@ -172,7 +169,7 @@ class _FileViewerState extends State<FileViewer> {
}
final bytes = await source.readAsBytes();
final saved = await FilePicker.saveFile(
fileName: widget.path.split('/').last,
fileName: _fileName,
bytes: bytes,
);
if (!mounted) return;
@@ -226,7 +223,7 @@ class _FileViewerState extends State<FileViewer> {
List<Widget> actions = const [],
bool showActionsMenu = true,
}) => AppBar(
title: Text(widget.path.split('/').last),
title: Text(_fileName),
actions: [
...actions,
if (showActionsMenu)
@@ -253,7 +250,7 @@ class _FileViewerState extends State<FileViewer> {
Widget build(BuildContext context) {
if (openExternal) {
return Scaffold(
appBar: AppBar(title: Text(widget.path.split('/').last)),
appBar: _appbar(showActionsMenu: false),
body: const Center(child: AppProgressIndicator.large()),
);
}
@@ -347,21 +344,17 @@ class _FileViewerState extends State<FileViewer> {
Widget _buildAudioView() => Scaffold(
appBar: _appbar(),
body: MediaPlayer(
path: widget.path,
isAudio: true,
filename: widget.path.split('/').last,
),
body: MediaPlayer(path: widget.path, isAudio: true, filename: _fileName),
);
Widget _buildTextView() => Scaffold(
appBar: _appbar(),
body: FutureBuilder<_TextPayload>(
// Cached: a future created in build re-read the file on every rebuild.
future: _textPayload ??= compute(
_loadTextPayload,
(widget.path, _textViewMaxBytes),
),
future: _textPayload ??= compute(_loadTextPayload, (
widget.path,
_textViewMaxBytes,
)),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: AppProgressIndicator.large());
@@ -428,7 +421,7 @@ class _FileViewerState extends State<FileViewer> {
UnknownPreviewBlock(remoteFile: widget.remoteFile),
const SizedBox(height: 16),
Text(
widget.path.split('/').last,
_fileName,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
+12
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../api/errors/error_mapper.dart';
import '../utils/clipboard_helper.dart';
class InfoDialog {
@@ -40,4 +41,15 @@ class InfoDialog {
},
);
}
/// Error dialog with the user-facing [message] (default: mapped from
/// [error]) plus the technical details when they add information.
static void showError(BuildContext context, Object error, {String? message}) {
final text = message ?? errorToUserMessage(error);
final details = errorToTechnicalDetails(error);
final body = details != null && details != text
? '$text\n\n$details'
: text;
show(context, body, copyable: true, title: 'Fehler');
}
}
+1 -2
View File
@@ -4,7 +4,6 @@ class ListViewUtil {
static ListView fromList<T>(List<T>? items, Widget Function(T item) map) =>
ListView.builder(
itemCount: items?.length ?? 0,
itemBuilder: (context, index) =>
items != null ? map(items[index]) : null,
itemBuilder: (context, index) => map(items![index]),
);
}
+8 -13
View File
@@ -123,19 +123,14 @@ class PmImageView extends StatelessWidget {
double? _resolveWidth(String? width, double available) {
if (width == null) return null;
final match = RegExp(r'^(\d+(?:\.\d+)?)(px|%|em|rem)$').firstMatch(width);
if (match == null) return null;
final value = double.parse(match.group(1)!);
final double resolved;
switch (match.group(2)) {
case '%':
resolved = available * value / 100;
case 'em':
case 'rem':
resolved = value * 16;
default:
resolved = value;
}
final length = parsePmCssLength(width);
if (length == null) return null;
final value = length.value;
final resolved = switch (length.unit) {
'%' => available * value / 100,
'em' || 'rem' => value * 16,
_ => value,
};
return resolved.clamp(1, available);
}
}
+9
View File
@@ -325,3 +325,12 @@ class PmUnknown extends PmNode {
const PmUnknown({required this.rawType, this.children = const []});
}
final RegExp _cssLength = RegExp(r'^(\d+(?:\.\d+)?)(px|%|em|rem)$');
/// Splits a CSS length like `12px` / `1.5em` / `50%` into value and unit.
({double value, String unit})? parsePmCssLength(String raw) {
final match = _cssLength.firstMatch(raw);
if (match == null) return null;
return (value: double.parse(match.group(1)!), unit: match.group(2)!);
}
+9 -16
View File
@@ -152,22 +152,15 @@ class _PmRichTextState extends State<PmRichText> {
double? _fontSize(dynamic raw, double base) {
if (raw is! String) return null;
final match = RegExp(r'^(\d+(?:\.\d+)?)(px|rem|em|%)$').firstMatch(raw);
if (match == null) return null;
final value = double.parse(match.group(1)!);
final double size;
switch (match.group(2)) {
case 'px':
size = value;
case 'rem':
size = value * 16;
case 'em':
size = base * value;
case '%':
size = base * value / 100;
default:
return null;
}
final length = parsePmCssLength(raw);
if (length == null) return null;
final value = length.value;
final size = switch (length.unit) {
'px' => value,
'rem' => value * 16,
'em' => base * value,
_ => base * value / 100,
};
return size.clamp(8, 72);
}
+1 -1
View File
@@ -195,7 +195,7 @@ class _PmTableViewState extends State<PmTableView> {
required bool left,
required bool visible,
}) {
final dark = Theme.of(context).brightness == Brightness.dark;
final dark = AppTheme.isDarkMode(context);
final color = Colors.black.withValues(alpha: dark ? 0.28 : 0.12);
return Positioned(
top: 0,
+37 -32
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -76,13 +77,16 @@ final Map<String, Future<AvatarPayload?>> _pendingAvatars = {};
final ValueNotifier<int> _avatarCacheGeneration = ValueNotifier<int>(0);
String avatarUrl({required String id, required bool isGroup, int size = 512}) {
final host = EndpointData().nextcloud().full();
if (isGroup) {
final host = EndpointData().nextcloud().full();
return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/$id/avatar';
}
return 'https://$host/avatar/$id/$size';
return '${_userAvatarUrlPrefix(id)}$size';
}
String _userAvatarUrlPrefix(String id) =>
'https://${EndpointData().nextcloud().full()}/avatar/$id/';
/// Resolves an avatar through the shared caches: in-memory first, then disk,
/// then the network — the fetch being deduped across every concurrent caller
/// and written back to both caches.
@@ -109,26 +113,32 @@ Future<AvatarPayload?> loadAvatarPayload({
return payload;
}
final payload = await _fetchDeduped(url);
_commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: payload);
return payload;
return _fetchDeduped(url, id: id, isGroup: isGroup, size: size);
}
/// Shares one in-flight request per URL across all callers.
Future<AvatarPayload?> _fetchDeduped(String url) =>
_pendingAvatars.putIfAbsent(url, () {
final future = _fetchAvatarPayload(url);
// Cleanup hangs off an error-neutralised copy: whenComplete on `future`
// itself returns a second future that forwards the error unawaited.
unawaited(
future.then<void>((_) {}, onError: (_) {}).whenComplete(() {
if (identical(_pendingAvatars[url], future)) {
_pendingAvatars.remove(url);
}
}),
);
return future;
});
/// Shares one in-flight request per URL across all callers; the result is
/// committed to the caches once, not once per waiting caller.
Future<AvatarPayload?> _fetchDeduped(
String url, {
required String id,
required bool isGroup,
required int size,
}) => _pendingAvatars.putIfAbsent(url, () {
final future = _fetchAvatarPayload(url).then((payload) {
_commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: payload);
return payload;
});
// Cleanup hangs off an error-neutralised copy: whenComplete on `future`
// itself returns a second future that forwards the error unawaited.
unawaited(
future.then<void>((_) {}, onError: (_) {}).whenComplete(() {
if (identical(_pendingAvatars[url], future)) {
_pendingAvatars.remove(url);
}
}),
);
return future;
});
/// Persists a resolved result to the in-memory and disk caches.
void _commitAvatar(
@@ -189,10 +199,7 @@ AvatarPayload _payloadFromBytes(Uint8List bytes) =>
bool _looksLikeSvg(Uint8List bytes) {
final head = utf8
.decode(
bytes.sublist(0, bytes.length < 256 ? bytes.length : 256),
allowMalformed: true,
)
.decode(bytes.sublist(0, min(bytes.length, 256)), allowMalformed: true)
.trimLeft();
return head.startsWith('<?xml') || head.startsWith('<svg');
}
@@ -220,8 +227,7 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
unawaited(PushAvatarStore.evict(id));
} else {
// User avatars include the rendered size in the URL — drop every variant.
final host = EndpointData().nextcloud().full();
final prefix = 'https://$host/avatar/$id/';
final prefix = _userAvatarUrlPrefix(id);
_resolvedAvatars.removeWhere((url, _) => url.startsWith(prefix));
_pendingAvatars.removeWhere((url, _) => url.startsWith(prefix));
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: false));
@@ -285,14 +291,15 @@ class _UserAvatarState extends State<UserAvatar> {
int _resolvedRequestSize() =>
widget.requestSize ?? (widget.size * 4).clamp(64, 1024);
String _url() => avatarUrl(
String _url([int? size]) => avatarUrl(
id: widget.id,
isGroup: widget.isGroup,
size: _resolvedRequestSize(),
size: size ?? _resolvedRequestSize(),
);
void _attach() {
final url = _url();
final size = _resolvedRequestSize();
final url = _url(size);
final cached = _readAvatarCache(url);
if (cached != null) {
_payload = cached.payload;
@@ -302,7 +309,6 @@ class _UserAvatarState extends State<UserAvatar> {
// the widget may have been recycled onto a different id by then.
final id = widget.id;
final isGroup = widget.isGroup;
final size = _resolvedRequestSize();
// Persistent disk cache: on a warm session (cache directory already known)
// this hits synchronously, so a cold app start paints the last-known
@@ -348,14 +354,13 @@ class _UserAvatarState extends State<UserAvatar> {
AvatarPayload? fresh;
try {
fresh = await _fetchDeduped(url);
fresh = await _fetchDeduped(url, id: id, isGroup: isGroup, size: size);
} on Object {
// Transient failure (offline, 5xx). Keep showing the cached picture; the
// next mount retries. Deliberately no null-cache so we don't mask it.
return;
}
_commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: fresh);
if (!mounted || _url() != url) return;
if (fresh == null) {
// HTTP 404 — the avatar was removed server-side. Fall back to the icon.