import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:dartssh2/dartssh2.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import '../core/app_controller.dart'; import '../core/failure.dart'; import '../l10n/strings.dart'; import '../models/server_profile.dart'; import '../services/documents.dart'; import '../services/editor/languages.dart'; import '../services/editor/recents.dart'; import '../services/editor/sources.dart'; import '../services/sftp_service.dart'; import '../services/vault.dart'; import 'adaptive.dart'; import 'common.dart'; import 'editor/viewers.dart'; enum FilesTab { remote, device, recent, favorites } /// Remote files over SFTP, files on the device, recent and favorite files. /// Files only move between the device and a server on an explicit action. class FilesPage extends StatefulWidget { final AppController controller; const FilesPage(this.controller, {super.key}); @override State createState() => _FilesPageState(); } class _FilesPageState extends State { late FilesTab tab = widget.controller.ssh.connected ? FilesTab.remote : FilesTab.device; SftpService? service; String path = '/'; List entries = []; bool loading = false; TransferToken? transfer; int transferred = 0, total = 0; // Device tab: files picked in this session and an opened folder. final picked = []; final folders = []; List folderEntries = []; AppController get c => widget.controller; @override void initState() { super.initState(); if (c.ssh.connected) unawaited(load(initial: true)); } @override void dispose() { transfer?.cancel(); super.dispose(); } Future load({bool initial = false, String? directory}) async { if (!c.ssh.connected) { if (mounted) setState(() => loading = false); return; } setState(() => loading = true); try { service ??= await c.sftp(); if (!mounted) return; final next = initial ? c.remoteDirectory ?? await service!.home() : directory ?? path; final result = await service!.list(next); if (mounted) { setState(() { path = next; entries = result; }); c.remoteDirectory = next; } } catch (e) { if (mounted) showFailure(context, e); } finally { if (mounted) setState(() => loading = false); } } Future act(Future Function() action, {bool reload = true}) async { try { await action(); if (mounted && reload && tab == FilesTab.remote) await load(); } catch (e) { if (mounted) showFailure(context, e); } } Future runTransfer( Future Function(TransferToken, void Function(int, int)) action, ) async { final token = TransferToken(); setState(() { transfer = token; transferred = 0; total = 0; }); try { await action(token, (n, size) { if (mounted) { setState(() { transferred = n; total = size; }); } }); } finally { if (mounted) setState(() => transfer = null); } } Rect get _origin { final o = (context.findRenderObject() as RenderBox?)?.localToGlobal( Offset.zero, ) ?? Offset.zero; return Rect.fromLTWH(o.dx, o.dy, 1, 1); } RemoteSource _remote(String file) => RemoteSource( service: () => c.sftpFor(c.ssh.profile!.id), profileId: c.ssh.profile!.id, profileName: c.ssh.profile!.name, path: file, ); Future _open(FileSource source) async { await openSource(context, c, source); if (mounted) setState(() {}); } // --------------------------------------------------------------------------- // Remote actions Future upload() => act(() async { final file = await c.withSystemDialog(() => FilePicker.pickFile()); if (file == null || !mounted || !c.vault.unlocked) return; final destination = childPath(path, file.name); await runTransfer( (token, progress) => service!.upload( file.readAsByteStream(), file.lengthSync() ?? 0, destination, token, progress, ), ); }); /// Downloads into a private temporary copy and offers the share sheet. Future share(SftpName entry) => act(() async { final tempRoot = await getTemporaryDirectory(); final dir = await Directory( '${tempRoot.path}/sm-transfer-${newId()}', ).create(); final local = File('${dir.path}/${safeName(entry.filename)}'); final origin = _origin; try { await runTransfer( (token, progress) => service!.download( childPath(path, entry.filename), local, token, progress, ), ); if (!mounted || !c.vault.unlocked) return; await c.withSystemDialog( () => SharePlus.instance.share( ShareParams(files: [XFile(local.path)], sharePositionOrigin: origin), ), ); } finally { if (await dir.exists()) await dir.delete(recursive: true); } }, reload: false); /// Copies a server file to a place the user picks on the device. Future saveToDevice(SftpName entry) => act(() async { Uint8List? bytes; await runTransfer((token, progress) async { final (b, _) = await service!.readFile( childPath(path, entry.filename), maxBytes: maxViewBytes, token: token, progress: progress, ); bytes = b; }); if (bytes == null || !mounted) return; final doc = await c.withSystemDialog( () => c.documents.saveAs( entry.filename, bytes!, mime: mimeOf(entry.filename), ), ); if (doc != null && mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(context.tr('savedToDevice')))); } }, reload: false); Future newRemoteFile() async { final name = await askName(context, 'newFile'); if (name == null || !mounted) return; await act(() async { final target = childPath(path, name); if (await service!.statOrNull(target) != null) { throw const Failure('fileExists'); } await service!.saveFile(target, Uint8List(0)); if (mounted) await _open(_remote(target)); }); } // --------------------------------------------------------------------------- // Device actions Future pickFiles() => act(() async { final docs = await c.withSystemDialog( () => c.documents.pick(multiple: true), ); if (docs.isEmpty || !mounted) return; setState(() { for (final d in docs) { picked.removeWhere((p) => p.id == d.id); picked.insert(0, d); } }); if (docs.length == 1) await _open(DeviceSource(c.documents, docs.single)); }, reload: false); Future pickFolder() => act(() async { final folder = await c.withSystemDialog(() => c.documents.pickFolder()); if (folder == null || !mounted) return; folders ..clear() ..add(folder); await _listFolder(); }, reload: false); Future _listFolder() async { final list = await c.documents.list(folders.last); list.sort( (a, b) => a.directory == b.directory ? a.name.toLowerCase().compareTo(b.name.toLowerCase()) : a.directory ? -1 : 1, ); if (mounted) setState(() => folderEntries = list); } Future newDeviceFile() async { final name = await askName(context, 'newFile', initial: 'untitled.txt'); if (name == null || !mounted) return; await act(() async { safeName(name); if (folders.isNotEmpty) { final doc = await c.documents.create( folders.last, name, Uint8List(0), mime: mimeOf(name), ); await _listFolder(); if (mounted) await _open(DeviceSource.saved(c.documents, doc, 0)); } else { // Saved to a place of the user's choice on the first save. await _open(NewSource(name)); } }, reload: false); } /// Copies a device file to the current server directory. Future uploadDocument(DeviceDocument doc) => act(() async { if (!c.ssh.connected) throw const Failure('connectToSave'); final s = service ??= await c.sftp(); final dir = c.remoteDirectory ?? await s.home(); final target = childPath(dir, doc.name); final bytes = await c.documents.read(doc); if (await s.statOrNull(target) != null) { if (!mounted) return; final ok = await confirm(context, 'overwriteExisting', detail: target); if (!ok) return; } await s.saveFile(target, bytes, force: true); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${context.tr('uploadedTo')} $target')), ); } }, reload: false); // --------------------------------------------------------------------------- // Recent and favorites RecentFiles get recents => RecentFiles(c.vault); RecentFile _entry(FileSource s) => RecentFile( key: s.key, kind: s.kind, name: s.name, location: s.location, source: s.toJson(), opened: DateTime.now(), ); Future toggleFavorite(FileSource s) => act(() async { await recents.setFavorite(_entry(s), !recents.isFavorite(s.key)); if (mounted) setState(() {}); }, reload: false); Future openRecent(RecentFile r) => act(() async { if (r.kind == 'remote' && (!c.ssh.connected || c.ssh.profile?.id != r.source['profileId'])) { throw const Failure('connectToOpen'); } await _open( sourceFromJson(r.source, documents: c.documents, sftpFor: c.sftpFor), ); }, reload: false); Future restoreDraft(Draft d) async { final source = sourceFromJson( Map.from(d.json['source'] as Map), documents: c.documents, sftpFor: c.sftpFor, fallbackName: d.json['name'] as String? ?? 'untitled.txt', ); // The restored draft lives on in the editor (and is stashed again if // the Vault locks before it is saved); the others stay recoverable. await act( () => c.vault.keepDrafts([...c.vault.drafts]..remove(d)), reload: false, ); if (mounted) await openSource(context, c, source, draft: d); if (mounted) setState(() {}); } List get drafts => c.vault.drafts; Future discardDrafts() async { final ok = await confirm(context, 'discardDrafts'); if (!ok) return; await act(() => c.vault.clearDrafts(), reload: false); if (mounted) setState(() {}); } // --------------------------------------------------------------------------- // UI @override Widget build(BuildContext context) => MinHeight( minHeight: 260, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: SegmentedButton( showSelectedIcon: false, segments: [ ButtonSegment( value: FilesTab.remote, icon: const Icon(Icons.dns_outlined, size: 18), label: Text(context.tr('remoteFiles')), ), ButtonSegment( value: FilesTab.device, icon: const Icon(Icons.phone_iphone, size: 18), label: Text(context.tr('deviceFiles')), ), ButtonSegment( value: FilesTab.recent, icon: const Icon(Icons.history, size: 18), label: Text(context.tr('recentFiles')), ), ButtonSegment( value: FilesTab.favorites, icon: const Icon(Icons.star_outline, size: 18), label: Text(context.tr('favoriteFiles')), ), ], selected: {tab}, onSelectionChanged: transfer != null ? null : (v) { setState(() => tab = v.first); if (tab == FilesTab.remote && service == null) { unawaited(load(initial: true)); } }, ), ), ), if (c.vault.unlocked && drafts.isNotEmpty) _draftsBanner(context), if (transfer != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Column( children: [ LinearProgressIndicator( value: total > 0 ? (transferred / total).clamp(0, 1) : null, ), Row( children: [ Expanded(child: Text('$transferred / $total B')), TextButton( onPressed: transfer!.cancel, child: Text(context.tr('cancel')), ), ], ), ], ), ), Expanded( child: switch (tab) { FilesTab.remote => _remoteView(context), FilesTab.device => _deviceView(context), FilesTab.recent => _recentView(context, favorites: false), FilesTab.favorites => _recentView(context, favorites: true), }, ), ], ), ); Widget _draftsBanner(BuildContext context) => Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), color: Theme.of(context).colorScheme.secondaryContainer, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ListTile( leading: const Icon(Icons.restore_page_outlined), title: Text(context.tr('draftsRecovered')), trailing: TextButton( onPressed: discardDrafts, child: Text(context.tr('discard')), ), ), for (final d in drafts) ListTile( dense: true, title: Text(d.json['name'] as String? ?? '?'), subtitle: Text(d.json['location'] as String? ?? ''), trailing: const Icon(Icons.chevron_right), onTap: () => restoreDraft(d), ), ], ), ); Widget _remoteView(BuildContext context) { if (!c.ssh.connected) { return const PageMessage('selectServer', icon: Icons.folder_outlined); } final busy = loading || transfer != null; return Column( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ IconButton( onPressed: busy ? null : () => load(directory: parentPath(path)), tooltip: context.tr('parent'), icon: const Icon(Icons.arrow_upward), ), // One line: a deep path must not grow the header; it scrolls. Expanded(child: SelectableText(path, maxLines: 1)), IconButton( onPressed: busy ? null : () => load(), tooltip: context.tr('refresh'), icon: const Icon(Icons.refresh), ), PopupMenuButton( enabled: !busy, onSelected: (action) async { if (action == 'upload') await upload(); if (action == 'newFile') await newRemoteFile(); if (action == 'newFolder' && context.mounted) { final name = await askName(context, 'newFolder'); if (name != null) { await act(() => service!.mkdir(childPath(path, name))); } } }, itemBuilder: (ctx) => ['upload', 'newFile', 'newFolder'] .map((a) => PopupMenuItem(value: a, child: Text(ctx.tr(a)))) .toList(), ), ], ), ), if (loading) const LinearProgressIndicator(), Expanded( child: entries.isEmpty && !loading ? const PageMessage('emptyDirectory') : ListView.builder( itemCount: entries.length, itemBuilder: (context, i) => _remoteTile(context, entries[i]), ), ), ], ); } Widget _remoteTile(BuildContext context, SftpName e) { final attrs = e.attr; final symlink = attrs.isSymbolicLink; final file = childPath(path, e.filename); final time = attrs.modifyTime == null ? '—' : DateTime.fromMillisecondsSinceEpoch( attrs.modifyTime! * 1000, ).toLocal().toString().substring(0, 16); final favorite = attrs.isFile && recents.isFavorite('remote:${c.ssh.profile!.id}:$file'); return ListTile( leading: Icon( symlink ? Icons.link_off : attrs.isDirectory ? Icons.folder_outlined : _icon(fileKind(e.filename)), ), title: Text(e.filename), subtitle: Text( symlink ? context.tr('symlink') : '${context.tr('size')}: ${attrs.size ?? '—'} B · ' '${context.tr('modified')}: $time', ), onTap: transfer != null || symlink ? null : attrs.isDirectory ? () => load(directory: file) : attrs.isFile ? () => _open(_remote(file)) : null, trailing: symlink ? null : PopupMenuButton( enabled: transfer == null && !loading, onSelected: (action) async { switch (action) { case 'open': await _open(_remote(file)); case 'saveToDevice': await saveToDevice(e); case 'download': await share(e); case 'favorite': await toggleFavorite(_remote(file)); case 'delete': if (context.mounted && await confirm(context, 'confirmDelete', detail: file)) { await act(() => service!.remove(file)); } case 'rename': if (!context.mounted) return; final name = await askName( context, 'rename', initial: e.filename, ); if (name != null && context.mounted && await confirm( context, 'confirmRename', detail: '${e.filename} → $name', )) { await act( () => service!.rename(file, childPath(path, name)), ); } } }, itemBuilder: (ctx) => [ if (attrs.isFile) ...[ PopupMenuItem(value: 'open', child: Text(ctx.tr('open'))), PopupMenuItem( value: 'saveToDevice', child: Text(ctx.tr('saveToDevice')), ), PopupMenuItem( value: 'download', child: Text(ctx.tr('shareFile')), ), PopupMenuItem( value: 'favorite', child: Text( ctx.tr(favorite ? 'removeFavorite' : 'addFavorite'), ), ), ], PopupMenuItem(value: 'rename', child: Text(ctx.tr('rename'))), PopupMenuItem(value: 'delete', child: Text(ctx.tr('delete'))), ], ), ); } static IconData _icon(FileKind k) => switch (k) { FileKind.image || FileKind.svg => Icons.image_outlined, FileKind.pdf => Icons.picture_as_pdf_outlined, FileKind.office => Icons.description_outlined, FileKind.archive => Icons.archive_outlined, FileKind.audio => Icons.audiotrack_outlined, FileKind.video => Icons.movie_outlined, FileKind.certificate => Icons.verified_user_outlined, FileKind.notebook => Icons.data_object, _ => Icons.insert_drive_file_outlined, }; Widget _deviceView(BuildContext context) => LayoutBuilder( builder: (context, constraints) => ListView( padding: readablePadding(constraints, maxWidth: 900, gutter: 12), children: [ Text( context.tr('deviceFilesNote'), style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ FilledButton.icon( onPressed: pickFiles, icon: const Icon(Icons.file_open_outlined), label: Text(context.tr('openFiles')), ), OutlinedButton.icon( onPressed: pickFolder, icon: const Icon(Icons.folder_open_outlined), label: Text(context.tr('openFolder')), ), OutlinedButton.icon( onPressed: newDeviceFile, icon: const Icon(Icons.note_add_outlined), label: Text(context.tr('newFile')), ), ], ), if (folders.isNotEmpty) ...[ const SizedBox(height: 16), Row( children: [ if (folders.length > 1) IconButton( tooltip: context.tr('parent'), onPressed: () async { folders.removeLast(); await act(_listFolder, reload: false); }, icon: const Icon(Icons.arrow_upward), ), Expanded( child: Text( folders.map((f) => f.name).join(' / '), style: Theme.of(context).textTheme.titleSmall, ), ), ], ), if (folderEntries.isEmpty) Padding( padding: const EdgeInsets.all(16), child: Text(context.tr('emptyDirectory')), ), for (final d in folderEntries) _deviceTile(context, d), ], if (picked.isNotEmpty) ...[ const SizedBox(height: 16), Text( context.tr('openedFiles'), style: Theme.of(context).textTheme.titleSmall, ), for (final d in picked) _deviceTile(context, d), ], ], ), ); Widget _deviceTile(BuildContext context, DeviceDocument d) { final source = DeviceSource(c.documents, d); final favorite = !d.directory && recents.isFavorite(source.key); return ListTile( contentPadding: EdgeInsets.zero, leading: Icon( d.directory ? Icons.folder_outlined : _icon(fileKind(d.name)), ), title: Text(d.name), subtitle: Text( [ if (d.size != null && !d.directory) '${d.size} B', if (!d.writable && !d.directory) context.tr('readOnly'), ].join(' · '), ), onTap: d.directory ? () async { folders.add(d); await act(_listFolder, reload: false); } : () => _open(source), trailing: d.directory ? null : PopupMenuButton( onSelected: (a) async { switch (a) { case 'open': await _open(source); case 'upload': await uploadDocument(d); case 'favorite': await toggleFavorite(source); } }, itemBuilder: (ctx) => [ PopupMenuItem(value: 'open', child: Text(ctx.tr('open'))), PopupMenuItem( value: 'upload', enabled: c.ssh.connected, child: Text(ctx.tr('uploadToServer')), ), PopupMenuItem( value: 'favorite', child: Text( ctx.tr(favorite ? 'removeFavorite' : 'addFavorite'), ), ), ], ), ); } Widget _recentView(BuildContext context, {required bool favorites}) { final list = favorites ? recents.favorites : recents.recent; if (list.isEmpty) { return PageMessage( favorites ? 'noFavorites' : 'noRecentFiles', icon: favorites ? Icons.star_outline : Icons.history, ); } return ListView.builder( itemCount: list.length, itemBuilder: (context, i) { final r = list[i]; return ListTile( leading: Icon( r.kind == 'remote' ? Icons.dns_outlined : Icons.phone_iphone, ), title: Text(r.name), subtitle: Text( r.location, maxLines: 1, overflow: TextOverflow.ellipsis, ), onTap: () => openRecent(r), trailing: IconButton( tooltip: context.tr( favorites ? 'removeFavorite' : 'removeFromList', ), icon: Icon(favorites ? Icons.star : Icons.close), onPressed: () => act(() async { if (favorites) { await recents.setFavorite(r, false); } else { await recents.remove(r.key); } if (mounted) setState(() {}); }, reload: false), ), ); }, ); } }