358 lines
9.5 KiB
Dart
358 lines
9.5 KiB
Dart
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<Files> implements ConnectFiles {
|
|
ViewFiles? _model;
|
|
|
|
StreamSubscription<TransferProgress?>? _progressSub;
|
|
|
|
String _path = '.';
|
|
List<RemoteEntry> _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<ViewFiles>.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<void> _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<void> _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<RemoteEntry> 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<String>(
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|