import 'dart:async'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import '../../about/internal/application/NotificationType.dart'; import '../../about/internal/application/ServerProfile.dart'; import '../../about/internal/application/TextType.dart'; import '../../comms/Comms.dart' show RemoteEntry, TransferProgress; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../../utils/CommonUtils.dart'; import 'ConnectFiles.dart'; import 'Files.dart'; import 'ViewFiles.dart'; class FilesState extends State implements ConnectFiles { ViewFiles? _model; StreamSubscription? _progressSub; String _path = '.'; List _entries = []; bool _loading = true; bool _busy = false; bool _dragging = false; String? _error; double? _progress; ServerProfile get _server => widget.server; @override void dispose() { _progressSub?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewFiles(context, this), onViewModelReady: _onReady, builder: (context, viewModel, child) => Scaffold( appBar: AppBar( title: text( '${_server.name} · files', 20, TextType.Bold, color: context.jp.text, ), actions: [ IconButton( tooltip: 'Refresh', onPressed: _busy ? null : _onRefresh, icon: const Icon(Icons.refresh), ), const SizedBox(width: 4), ], ), floatingActionButton: FloatingActionButton.extended( onPressed: _busy ? null : _onUploadPicked, icon: const Icon(Icons.upload), label: text('Upload', 14, TextType.Bold, color: colorWhite), ), body: DropTarget( onDragEntered: _onDragEntered, onDragExited: _onDragExited, onDragDone: _onDragDone, child: Column( children: [ _pathBar(), if (_progress != null) LinearProgressIndicator( value: _progress == 0 ? null : _progress, minHeight: 3, ), Expanded(child: _body()), ], ), ), ), ); } void _onReady(ViewFiles model) { _model = model; // onViewModelReady fires mid-build; a synchronous setState from the // first load would dirty this State while it is still being built. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _initiate(); }); } Future _initiate() async { _progressSub = _model?.transferProgress.listen(_onProgress); _model?.resolveHome(_server); } void _onProgress(TransferProgress? p) { if (!mounted) return; setState(() => _progress = p?.fraction); } // ── named handlers ───────────────────────────────────────────────────────── void _onRefresh() { setState(() { _loading = true; _error = null; }); _model?.listDirectory(_server, _path); } void _onEnter(RemoteEntry e) { if (!e.isDir) return; setState(() => _path = joinRemote(_path, e.name)); _onRefresh(); } void _onUp() { setState(() => _path = parentRemote(_path)); _onRefresh(); } void _onUploadPicked() { setState(() { _busy = true; _progress = 0; }); _model?.uploadPicked(_server, _path, joinRemote); } void _onDragEntered(DropEventDetails _) => setState(() => _dragging = true); void _onDragExited(DropEventDetails _) => setState(() => _dragging = false); Future _onDragDone(DropDoneDetails details) async { setState(() { _dragging = false; _busy = true; _progress = 0; }); for (final file in details.files) { final bytes = await file.readAsBytes(); _model?.uploadBytes( _server, joinRemote(_path, file.name), bytes, file.name, ); } } void _onDownload(RemoteEntry e) { setState(() { _busy = true; _progress = 0; }); _model?.downloadFile(_server, joinRemote(_path, e.name), e.name); } void _onDelete(RemoteEntry e) { _model?.showApplicationNotification( NotificationType.warning, 'Delete "${e.name}"?', e.isDir ? 'Removes the directory (it must be empty).' : 'Permanently deletes this file on the server.', true, true, null, action: 'Delete', positiveAction: () => _onConfirmDelete(e), ); } void _onConfirmDelete(RemoteEntry e) { Navigator.pop(context); _model?.deleteEntry(_server, joinRemote(_path, e.name), e.isDir); } void _onRowMenu(String value, RemoteEntry e) { if (value == 'download') _onDownload(e); if (value == 'delete') _onDelete(e); } void _notify(String message) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } // ── ConnectFiles ─────────────────────────────────────────────────────────── @override void onHomeResolved(String path) { if (!mounted) return; setState(() => _path = path); _model?.listDirectory(_server, path); } @override void onDirectoryLoaded(List entries) { if (!mounted) return; setState(() { _entries = entries; _loading = false; _error = null; }); } @override void onDirectoryFailed(String message) { if (!mounted) return; setState(() { _loading = false; _error = message; }); } @override void onTransferFinished(String message) { if (!mounted) return; setState(() { _busy = false; _progress = null; }); _notify(message); _onRefresh(); } @override void onEntryDeleted() { _onRefresh(); } // ── screen-only widgets ──────────────────────────────────────────────────── Widget _pathBar() { final jp = context.jp; return Container( color: _dragging ? PeachTheme.seed.withValues(alpha: 0.12) : jp.surface, padding: const EdgeInsets.fromLTRB(8, 8, 12, 8), child: Row( children: [ IconButton( tooltip: 'Up', onPressed: _busy || _path == '/' ? null : _onUp, icon: const Icon(Icons.arrow_upward), ), Expanded( child: text( _dragging ? 'Drop files to upload here' : _path, 13, _dragging ? TextType.Bold : TextType.Regular, color: _dragging ? PeachTheme.seed : jp.textDim, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ); } Widget _body() { if (_loading) return const Center(child: CircularProgressIndicator()); if (_error != null) return _errorView(); if (_entries.isEmpty) { return Center( child: text( 'Empty directory', 14, TextType.Regular, color: context.jp.textFaint, ), ); } return ListView.separated( itemCount: _entries.length, separatorBuilder: (_, i) => Divider(height: 1, color: context.jp.border), itemBuilder: (_, i) => _row(_entries[i]), ); } Widget _errorView() => Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, color: colorNegative, size: 40), const SizedBox(height: 12), text( _error!, 14, TextType.Regular, color: colorNegative, align: TextAlign.center, ), const SizedBox(height: 16), FilledButton.icon( onPressed: _onRefresh, icon: const Icon(Icons.refresh), label: text('Retry', 14, TextType.Bold, color: colorWhite), ), ], ), ), ); Widget _row(RemoteEntry e) { final jp = context.jp; return ListTile( leading: Icon( e.isDir ? Icons.folder : Icons.insert_drive_file_outlined, color: e.isDir ? PeachTheme.accent : jp.textDim, ), title: text( e.name, 14, TextType.Regular, color: jp.text, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: e.isDir ? null : text(formatBytes(e.size), 12, TextType.Light, color: jp.textFaint), onTap: e.isDir ? () => _onEnter(e) : null, trailing: PopupMenuButton( onSelected: (v) => _onRowMenu(v, e), itemBuilder: (_) => [ if (!e.isDir) const PopupMenuItem( value: 'download', child: ListTile( leading: Icon(Icons.download), title: Text('Download'), contentPadding: EdgeInsets.zero, ), ), const PopupMenuItem( value: 'delete', child: ListTile( leading: Icon(Icons.delete_outline), title: Text('Delete'), contentPadding: EdgeInsets.zero, ), ), ], ), ); } }