import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nextcloud/nextcloud.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../extensions/date_time.dart'; import '../../../../routing/app_routes.dart'; import '../../../../share_intent/remote_file_ref.dart'; import '../../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import '../../../../utils/downloads/download_job.dart'; import '../../../../utils/file_clipboard.dart'; import '../../../../utils/haptics.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/demo_restricted.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/downloads/download_trigger.dart'; import '../../../../widget/prompt_dialog.dart'; import '../../talk/widgets/highlighted_linkify.dart'; import '../sharing/share_sheet.dart'; import 'file_details_sheet.dart'; import 'file_leading.dart'; class FileElement extends StatefulWidget { final CacheableFile file; final List path; final void Function() refetch; /// When non-null, occurrences of this string in the file name are visually /// highlighted in the tile title. Used by the Files search delegate. final String? highlight; const FileElement( this.file, this.path, this.refetch, { this.highlight, super.key, }); @override State createState() => _FileElementState(); } class _FileElementState extends State with DownloadTrigger { @override String? get downloadRemotePath => widget.file.isDirectory ? null : widget.file.path; @override void initState() { super.initState(); initDownloadTrigger(); } @override void didUpdateWidget(covariant FileElement oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.file.path != widget.file.path) refreshDownloadTrigger(); } @override void dispose() { disposeDownloadTrigger(); super.dispose(); } Widget? _subtitle() { final status = downloadJob?.status.value; if (status is DownloadInProgress) { return Row( children: [ Container( margin: const EdgeInsets.only(right: 10), child: const Text('Download:'), ), Expanded(child: LinearProgressIndicator(value: status.percent / 100)), Container( margin: const EdgeInsets.only(left: 10), child: Text('${status.percent.round()}%'), ), ], ); } final modified = widget.file.modifiedAt; final size = widget.file.size; if (widget.file.isDirectory) { if (modified == null) return null; return Text('geändert ${modified.formatRelative()}'); } if (size == null && modified == null) return null; if (size == null) return Text(modified!.formatRelative()); if (modified == null) return Text(filesize(size)); return Text('${filesize(size)}, ${modified.formatRelative()}'); } void _onTap() { if (widget.file.isDirectory) { AppRoutes.openFolder( context, widget.path.toList()..add(widget.file.name), ); return; } if (guardDemoAction(context)) return; if (isDownloading) { confirmCancelDownload(); return; } startDownload( name: widget.file.name, remoteFile: RemoteFileRef.fromCacheable(widget.file), ); } // 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'; void _rename() { if (guardDemoAction(context)) return; showPromptDialog( context, title: 'Umbenennen', label: 'Neuer Name', confirmButton: 'Umbenennen', initialValue: widget.file.name, onConfirm: (newName) async { if (newName.isEmpty || newName == widget.file.name) return; final parent = _parentPathOf(widget.file.path); final destination = _joinPath( parent, newName, isDirectory: widget.file.isDirectory, ); final webdav = await WebdavApi.webdav; await webdav.move( PathUri.parse(widget.file.path), PathUri.parse(destination), ); widget.refetch(); }, ); } void _putOnClipboard({required bool copy}) { if (guardDemoAction(context)) return; 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), ), ); } void _delete() { if (guardDemoAction(context)) return; 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(); }, ).asDialog(context); } void _showActionSheet() { Haptics.longPress(); showDetailsBottomSheet( context, children: (sheetCtx) => [ 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); }, ), if (!widget.file.isDirectory) ListTile( leading: const CenteredLeading(Icon(Icons.chat_bubble_outline)), title: const Text('Im Talk-Chat versenden'), onTap: () { Navigator.of(sheetCtx).pop(); if (guardDemoAction(context)) return; AppRoutes.openInternalShareToChat( context, RemoteFileRef.fromCacheable(widget.file), ); }, ), if (context.read().canShareAtAll) ListTile( leading: const CenteredLeading(Icon(Icons.person_add_outlined)), title: const Text('Freigeben'), onTap: () { Navigator.of(sheetCtx).pop(); showShareSheet(context, widget.file); }, ), ListTile( leading: CenteredLeading( Icon( Icons.delete_outline, color: Theme.of(sheetCtx).colorScheme.error, ), ), title: Text( 'Löschen', style: TextStyle(color: Theme.of(sheetCtx).colorScheme.error), ), onTap: () { Navigator.of(sheetCtx).pop(); _delete(); }, ), ], ); } Widget _title(BuildContext context) { final base = Theme.of(context).textTheme.bodyLarge ?? DefaultTextStyle.of(context).style; if (widget.highlight == null || widget.highlight!.trim().isEmpty) { return Text( widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis, ); } return Text.rich( TextSpan( children: buildHighlightedSpans( text: widget.file.name, query: widget.highlight, baseStyle: base, ), ), maxLines: 2, overflow: TextOverflow.ellipsis, ); } @override Widget build(BuildContext context) => ListTile( leading: CenteredLeading(FileLeading(file: widget.file)), title: _title(context), subtitle: _subtitle(), trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), onTap: _onTap, onLongPress: _showActionSheet, ); }