Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
18
lib/Peach/see/files/ConnectFiles.dart
Normal file
18
lib/Peach/see/files/ConnectFiles.dart
Normal 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();
|
||||
}
|
||||
15
lib/Peach/see/files/Files.dart
Normal file
15
lib/Peach/see/files/Files.dart
Normal 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();
|
||||
}
|
||||
357
lib/Peach/see/files/FilesState.dart
Normal file
357
lib/Peach/see/files/FilesState.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
118
lib/Peach/see/files/ViewFiles.dart
Normal file
118
lib/Peach/see/files/ViewFiles.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user