claude refactorings, flutter best practices, platform dependent changes, general cleanup
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
|
||||
/// Shows a modal bottom sheet with technical metadata about a single file or
|
||||
/// folder: full path, MIME type, size, timestamps, ETag.
|
||||
Future<void> showFileDetailsSheet(BuildContext context, CacheableFile file) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32),
|
||||
title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '–')),
|
||||
),
|
||||
const Divider(),
|
||||
_DetailRow(label: 'Pfad', value: file.path, copyable: true),
|
||||
if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)),
|
||||
if (file.modifiedAt != null)
|
||||
_DetailRow(
|
||||
label: 'Geändert',
|
||||
value: '${Jiffy.parseFromDateTime(file.modifiedAt!).format(pattern: 'dd.MM.yyyy HH:mm')} '
|
||||
'(${Jiffy.parseFromDateTime(file.modifiedAt!).fromNow()})',
|
||||
),
|
||||
if (file.createdAt != null)
|
||||
_DetailRow(
|
||||
label: 'Erstellt',
|
||||
value: Jiffy.parseFromDateTime(file.createdAt!).format(pattern: 'dd.MM.yyyy HH:mm'),
|
||||
),
|
||||
if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
const _DetailRow({required this.label, required this.value, this.copyable = false});
|
||||
final String label;
|
||||
final String value;
|
||||
final bool copyable;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(label, style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
Expanded(child: SelectableText(value)),
|
||||
if (copyable)
|
||||
IconButton(
|
||||
tooltip: 'Kopieren',
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('In Zwischenablage kopiert')),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:flowder/flowder.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import '../../../../widget/info_dialog.dart';
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart';
|
||||
import '../../../../api/marianumcloud/webdav/webdavApi.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../utils/download_manager.dart';
|
||||
import '../../../../utils/file_clipboard.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/unimplemented_dialog.dart';
|
||||
import '../../../../widget/info_dialog.dart';
|
||||
import 'file_details_sheet.dart';
|
||||
|
||||
class FileElement extends StatefulWidget {
|
||||
final CacheableFile file;
|
||||
@@ -26,57 +20,117 @@ class FileElement extends StatefulWidget {
|
||||
final void Function() refetch;
|
||||
const FileElement(this.file, this.path, this.refetch, {super.key});
|
||||
|
||||
static Future<DownloaderCore> download(BuildContext context, String remotePath, String name, Function(double) onProgress, Function(OpenResult) onDone) async {
|
||||
var paths = await getTemporaryDirectory();
|
||||
|
||||
var encodedPath = Uri.encodeComponent(remotePath);
|
||||
encodedPath = encodedPath.replaceAll('%2F', '/');
|
||||
|
||||
var local = paths.path + Platform.pathSeparator + name;
|
||||
|
||||
var options = DownloaderUtils(
|
||||
progressCallback: (current, total) {
|
||||
final progress = (current / total) * 100;
|
||||
onProgress(progress);
|
||||
},
|
||||
file: File(local),
|
||||
progress: ProgressImplementation(),
|
||||
deleteOnCancel: true,
|
||||
client: Dio(BaseOptions(headers: AccountData().authHeaders())),
|
||||
onDone: () {
|
||||
AppRoutes.openFileViewer(context, local);
|
||||
onDone(OpenResult(message: 'File viewer opened', type: ResultType.done));
|
||||
},
|
||||
);
|
||||
|
||||
return await Flowder.download(
|
||||
'${WebdavApi.buildWebdavUrl()}$encodedPath',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<FileElement> createState() => _FileElementState();
|
||||
}
|
||||
|
||||
class _FileElementState extends State<FileElement> {
|
||||
double percent = 0;
|
||||
Future<DownloaderCore>? downloadCore;
|
||||
DownloadJob? _job;
|
||||
|
||||
Widget getSubtitle() {
|
||||
if(widget.file.currentlyDownloading) {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_attachJob(DownloadManager.instance.jobFor(widget.file.path));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FileElement oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.file.path != widget.file.path) {
|
||||
_detachJob();
|
||||
_attachJob(DownloadManager.instance.jobFor(widget.file.path));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_detachJob();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _attachJob(DownloadJob? job) {
|
||||
_job = job;
|
||||
if (job == null) return;
|
||||
job.status.addListener(_onStatusChange);
|
||||
if (job.isFinished) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _onStatusChange());
|
||||
}
|
||||
}
|
||||
|
||||
void _detachJob() {
|
||||
_job?.status.removeListener(_onStatusChange);
|
||||
_job = null;
|
||||
}
|
||||
|
||||
void _onStatusChange() {
|
||||
if (!mounted) return;
|
||||
final job = _job;
|
||||
if (job == null) return;
|
||||
final status = job.status.value;
|
||||
if (status is DownloadDone) {
|
||||
DownloadManager.instance.clear(widget.file.path);
|
||||
_detachJob();
|
||||
AppRoutes.openFileViewer(context, status.localPath);
|
||||
setState(() {});
|
||||
} else if (status is DownloadFailed) {
|
||||
final message = status.message;
|
||||
DownloadManager.instance.clear(widget.file.path);
|
||||
_detachJob();
|
||||
setState(() {});
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Download'),
|
||||
content: Text(message),
|
||||
),
|
||||
);
|
||||
} else if (status is DownloadCancelled) {
|
||||
DownloadManager.instance.clear(widget.file.path);
|
||||
_detachJob();
|
||||
setState(() {});
|
||||
} else {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startDownload() async {
|
||||
final job = await DownloadManager.instance.start(
|
||||
remotePath: widget.file.path,
|
||||
name: widget.file.name,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (_job == job) return;
|
||||
_detachJob();
|
||||
_attachJob(job);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _confirmCancel() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => ConfirmDialog(
|
||||
title: 'Download abbrechen?',
|
||||
content: 'Möchtest du den Download abbrechen?',
|
||||
cancelButton: 'Nein',
|
||||
confirmButton: 'Ja, Abbrechen',
|
||||
onConfirm: () => _job?.cancel(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _subtitle() {
|
||||
final status = _job?.status.value;
|
||||
if (status is DownloadInProgress) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 10),
|
||||
child: const Text('Download:'),
|
||||
),
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(value: percent/100),
|
||||
),
|
||||
Expanded(child: LinearProgressIndicator(value: status.percent / 100)),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 10),
|
||||
child: Text('${percent.round()}%'),
|
||||
child: Text('${status.percent.round()}%'),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -86,101 +140,173 @@ class _FileElementState extends State<FileElement> {
|
||||
: Text('${filesize(widget.file.size)}, ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}');
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
if (widget.file.isDirectory) {
|
||||
AppRoutes.openFolder(context, widget.path.toList()..add(widget.file.name));
|
||||
return;
|
||||
}
|
||||
if (EndpointData().getEndpointMode() == EndpointMode.stage) {
|
||||
InfoDialog.show(context, 'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!');
|
||||
return;
|
||||
}
|
||||
final status = _job?.status.value;
|
||||
if (status is DownloadInProgress) {
|
||||
_confirmCancel();
|
||||
return;
|
||||
}
|
||||
_startDownload();
|
||||
}
|
||||
|
||||
// All paths here are relative to the WebDAV root (matching CacheableFile.path).
|
||||
// Root parent is the empty string ''. Folders end with '/'.
|
||||
String _parentPathOf(String path) {
|
||||
final stripped = path.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
if (!stripped.contains('/')) return '';
|
||||
final parts = stripped.split('/')..removeLast();
|
||||
return parts.isEmpty ? '' : '${parts.join('/')}/';
|
||||
}
|
||||
|
||||
String _joinPath(String folder, String name, {required bool isDirectory}) =>
|
||||
isDirectory ? '$folder$name/' : '$folder$name';
|
||||
|
||||
Future<void> _rename() async {
|
||||
final controller = TextEditingController(text: widget.file.name);
|
||||
final newName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: const Text('Umbenennen'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(labelText: 'Neuer Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogCtx).pop(controller.text.trim()),
|
||||
child: const Text('Umbenennen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (newName == null || newName.isEmpty || newName == widget.file.name) return;
|
||||
|
||||
final parent = _parentPathOf(widget.file.path);
|
||||
final destination = _joinPath(parent, newName, isDirectory: widget.file.isDirectory);
|
||||
await _runWebdavOp(() async {
|
||||
final webdav = await WebdavApi.webdav;
|
||||
await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination));
|
||||
}, errorTitle: 'Umbenennen fehlgeschlagen');
|
||||
}
|
||||
|
||||
void _putOnClipboard({required bool copy}) {
|
||||
if (copy) {
|
||||
FileClipboard.instance.copy([widget.file]);
|
||||
} else {
|
||||
FileClipboard.instance.cut([widget.file]);
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('"${widget.file.name}" zum ${copy ? "Kopieren" : "Verschieben"} bereitgelegt'),
|
||||
duration: const Duration(seconds: 2),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
title: 'Element löschen?',
|
||||
content: 'Das Element wird unwiederruflich gelöscht.',
|
||||
confirmButton: 'Löschen',
|
||||
onConfirmAsync: () async {
|
||||
final webdav = await WebdavApi.webdav;
|
||||
await webdav.delete(PathUri.parse(widget.file.path));
|
||||
widget.refetch();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runWebdavOp(Future<void> Function() action, {required String errorTitle}) async {
|
||||
try {
|
||||
await action();
|
||||
widget.refetch();
|
||||
} on Object catch (e) {
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: Text(errorTitle),
|
||||
content: Text(e.toString()),
|
||||
actions: [TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('OK'))],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showActionSheet() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetCtx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.info_outline)),
|
||||
title: const Text('Info'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
showFileDetailsSheet(context, widget.file);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.drive_file_rename_outline)),
|
||||
title: const Text('Umbenennen'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
_rename();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.drive_file_move_outline)),
|
||||
title: const Text('Verschieben'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
_putOnClipboard(copy: false);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.copy_outlined)),
|
||||
title: const Text('Kopieren'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
_putOnClipboard(copy: true);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.delete_outline)),
|
||||
title: const Text('Löschen'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
_delete();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListTile(
|
||||
leading: CenteredLeading(
|
||||
Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined)
|
||||
),
|
||||
title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
subtitle: getSubtitle(),
|
||||
trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null),
|
||||
onTap: () {
|
||||
if(widget.file.isDirectory) {
|
||||
AppRoutes.openFolder(context, widget.path.toList()..add(widget.file.name));
|
||||
} else {
|
||||
if(EndpointData().getEndpointMode() == EndpointMode.stage) {
|
||||
InfoDialog.show(context, 'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!');
|
||||
return;
|
||||
}
|
||||
if(widget.file.currentlyDownloading) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
title: 'Download abbrechen?',
|
||||
content: 'Möchtest du den Download abbrechen?',
|
||||
cancelButton: 'Nein',
|
||||
confirmButton: 'Ja, Abbrechen',
|
||||
onConfirm: () {
|
||||
downloadCore?.then((value) {
|
||||
if(!value.isCancelled) value.cancel();
|
||||
});
|
||||
setState(() {
|
||||
widget.file.currentlyDownloading = false;
|
||||
percent = 0;
|
||||
downloadCore = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
widget.file.currentlyDownloading = true;
|
||||
});
|
||||
|
||||
downloadCore = FileElement.download(context, widget.file.path, widget.file.name, (progress) {
|
||||
setState(() => percent = progress);
|
||||
}, (result) {
|
||||
if(result.type != ResultType.done) {
|
||||
showDialog(context: context, builder: (context) => AlertDialog(
|
||||
title: const Text('Download'),
|
||||
content: Text(result.message),
|
||||
));
|
||||
}
|
||||
|
||||
setState(() {
|
||||
widget.file.currentlyDownloading = false;
|
||||
percent = 0;
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
showDialog(context: context, builder: (context) => SimpleDialog(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: const Text('Löschen'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
showDialog(context: context, builder: (context) => ConfirmDialog(
|
||||
title: 'Element löschen?',
|
||||
content: 'Das Element wird unwiederruflich gelöscht.',
|
||||
confirmButton: 'Löschen',
|
||||
onConfirmAsync: () async {
|
||||
final webdav = await WebdavApi.webdav;
|
||||
await webdav.delete(PathUri.parse(widget.file.path));
|
||||
widget.refetch();
|
||||
},
|
||||
));
|
||||
},
|
||||
),
|
||||
Visibility(
|
||||
visible: !kReleaseMode,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.share_outlined),
|
||||
title: const Text('Teilen'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
UnimplementedDialog.show(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
));
|
||||
},
|
||||
);
|
||||
leading: CenteredLeading(
|
||||
Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined),
|
||||
),
|
||||
title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
subtitle: _subtitle(),
|
||||
trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null),
|
||||
onTap: _onTap,
|
||||
onLongPress: _showActionSheet,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user