Add app source and tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alvocool
2026-10-11 10:15:30 +03:00
parent 7e246255c2
commit 38881d1fe1
135 changed files with 21057 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
import '../../comms/Comms.dart' show RemoteEntry;
abstract class ConnectFiles {
/// The user's home directory, resolved on open — the starting location.
void onHomeResolved(String path);
/// The contents of the directory currently being shown.
void onDirectoryLoaded(List<RemoteEntry> entries);
/// The listing failed; [message] explains why.
void onDirectoryFailed(String message);
/// A transfer finished (or failed) with a message to surface.
void onTransferFinished(String message);
/// A remote entry was deleted.
void onEntryDeleted();
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/cupertino.dart';
import '../../about/internal/application/ServerProfile.dart';
import 'FilesState.dart';
/// Browse the server over SFTP: navigate directories, upload local files into
/// the current directory, and download remote files to local disk.
class Files extends StatefulWidget {
final ServerProfile server;
const Files({super.key, required this.server});
@override
State<Files> createState() => FilesState();
}

View File

@@ -0,0 +1,357 @@
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,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,118 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../comms/Comms.dart' show TransferProgress;
import '../parent/ParentViewModel.dart';
import 'ConnectFiles.dart';
class ViewFiles extends ParentViewModel {
ConnectFiles connection;
ViewFiles(super.context, this.connection);
/// Upload/download progress, subscribed to by the screen so the bar can
/// track a transfer as it runs.
Stream<TransferProgress?> get transferProgress =>
getDataManager().transferProgress;
/// Resolves the login directory so the browser opens somewhere useful.
void resolveHome(ServerProfile server) async {
if (!await canReach(server, () => resolveHome(server))) return;
try {
final home = await getDataManager().resolveHome(server);
connection.onHomeResolved(home);
} catch (error) {
connection.onDirectoryFailed(error.toString());
}
}
void listDirectory(ServerProfile server, String path) async {
try {
final entries = await getDataManager().listDir(server, path);
connection.onDirectoryLoaded(entries);
} catch (error) {
connection.onDirectoryFailed(error.toString());
}
}
/// Uploads bytes already in memory (a drag-and-drop payload).
void uploadBytes(
ServerProfile server,
String remotePath,
Uint8List bytes,
String label,
) async {
if (!await canReach(
server,
() => uploadBytes(server, remotePath, bytes, label),
)) {
return;
}
try {
await getDataManager().uploadFile(server, bytes, remotePath);
connection.onTransferFinished('Uploaded $label');
} catch (error) {
connection.onTransferFinished('Upload failed: $error');
}
}
/// Prompts for a local file, then uploads it into [directory].
void uploadPicked(
ServerProfile server,
String directory,
String Function(String, String) join,
) async {
try {
final picked = await FilePicker.pickFiles(withData: true);
if (picked == null || picked.files.isEmpty) return;
final file = picked.files.first;
final bytes = file.bytes ?? await File(file.path!).readAsBytes();
uploadBytes(server, join(directory, file.name), bytes, file.name);
} catch (error) {
handleError(
error,
() => uploadPicked(server, directory, join),
() {},
"Retry",
);
}
}
/// Downloads [remotePath] and writes it wherever the user chooses.
void downloadFile(
ServerProfile server,
String remotePath,
String fileName,
) async {
if (!await canReach(
server,
() => downloadFile(server, remotePath, fileName),
)) {
return;
}
try {
final bytes = await getDataManager().downloadFile(server, remotePath);
final saved = await FilePicker.saveFile(fileName: fileName, bytes: bytes);
connection.onTransferFinished(
saved == null ? 'Download cancelled' : 'Saved to $saved',
);
} catch (error) {
connection.onTransferFinished('Download failed: $error');
}
}
void deleteEntry(ServerProfile server, String remotePath, bool isDir) async {
if (!await canReach(server, () => deleteEntry(server, remotePath, isDir))) {
return;
}
try {
await getDataManager().deleteRemote(server, remotePath, isDir);
connection.onEntryDeleted();
} catch (error) {
connection.onTransferFinished('Delete failed: $error');
}
}
}