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,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,
),
),
],
),
);
}
}