simplified code across the app and consolidated duplicated helpers
This commit is contained in:
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)!);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user