- SFTP download
- open downloaded files in other apps
This commit is contained in:
Junyuan Feng
2022-05-07 22:15:09 +08:00
parent 74a933eb6e
commit b824e06736
33 changed files with 1223 additions and 198 deletions

View File

@@ -0,0 +1,187 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart';
import 'package:toolbox/core/extension/colorx.dart';
import 'package:toolbox/core/extension/numx.dart';
import 'package:toolbox/core/extension/stringx.dart';
import 'package:toolbox/core/route.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/app/path_with_prefix.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/data/res/path.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/view/page/sftp/downloading.dart';
import 'package:toolbox/view/widget/fade_in.dart';
class SFTPDownloadedPage extends StatefulWidget {
const SFTPDownloadedPage({Key? key}) : super(key: key);
@override
State<SFTPDownloadedPage> createState() => _SFTPDownloadedPageState();
}
class _SFTPDownloadedPageState extends State<SFTPDownloadedPage> {
PathWithPrefix? _path;
String? _prefixPath;
late S s;
late ThemeData _theme;
@override
void initState() {
super.initState();
sftpDownloadDir.then((dir) {
_path = PathWithPrefix(dir.path);
_prefixPath = dir.path + '/';
setState(() {});
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
s = S.of(context);
_theme = Theme.of(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(s.download),
actions: [
IconButton(
icon: const Icon(Icons.downloading),
onPressed: () =>
AppRoute(const SFTPDownloadingPage(), 'sftp downloading')
.go(context),
)
],
),
body: FadeIn(
child: _buildBody(),
key: UniqueKey(),
),
bottomNavigationBar: SafeArea(
child: _buildPath(),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: FloatingActionButton(
onPressed: (() {
if (_path!.path == _prefixPath) {
showSnackBar(context, Text(s.alreadyLastDir));
return;
}
_path!.update('..');
setState(() {});
}),
child: const Icon(Icons.keyboard_arrow_left),
),
);
}
Widget _buildPath() {
return Container(
color: _theme.appBarTheme.foregroundColor,
padding: const EdgeInsets.fromLTRB(11, 7, 11, 11),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
(_path?.path ?? s.loadingFiles).omitStartStr(
style: TextStyle(
color:
primaryColor.isBrightColor ? Colors.black : Colors.white),
)
],
),
);
}
Widget _buildBody() {
if (_path == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
final dir = Directory(_path!.path);
final files = dir.listSync();
return ListView.builder(
itemCount: files.length,
itemBuilder: (context, index) {
var file = files[index];
var fileName = file.path.split('/').last;
var stat = file.statSync();
var isDir = stat.type == FileSystemEntityType.directory;
return ListTile(
leading: isDir
? const Icon(Icons.folder)
: const Icon(Icons.insert_drive_file),
title: Text(fileName),
subtitle: isDir ? null : Text(stat.size.convertBytes),
trailing: Text(
stat.modified
.toString()
.substring(0, stat.modified.toString().length - 4),
style: grey,
),
onTap: () {
if (!isDir) {
showFileActionDialog(file);
return;
}
_path!.update(fileName);
setState(() {});
},
);
},
);
}
void showFileActionDialog(FileSystemEntity file) {
final fileName = file.path.split('/').last;
showRoundDialog(
context,
s.choose,
Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.delete),
title: Text(s.delete),
onTap: () {
Navigator.of(context).pop();
showRoundDialog(
context, s.sureDelete(fileName), const SizedBox(), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () {
file.deleteSync();
setState(() {});
Navigator.of(context).pop();
},
child: Text(s.ok),
),
]);
},
),
ListTile(
leading: const Icon(Icons.open_in_new),
title: Text(s.open),
onTap: () {
Share.shareFiles([file.absolute.path],
text: '$fileName from ServerBox');
}),
],
),
[
TextButton(
onPressed: (() => Navigator.of(context).pop()),
child: Text(s.close))
]);
}
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:toolbox/core/extension/numx.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/sftp/download_status.dart';
import 'package:toolbox/data/provider/sftp_download.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/view/widget/center_loading.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
class SFTPDownloadingPage extends StatefulWidget {
const SFTPDownloadingPage({Key? key}) : super(key: key);
@override
_SFTPDownloadingPageState createState() => _SFTPDownloadingPageState();
}
class _SFTPDownloadingPageState extends State<SFTPDownloadingPage> {
late S s;
@override
void didChangeDependencies() {
super.didChangeDependencies();
s = S.of(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
s.download,
style: size18,
),
),
body: _buildBody(),
);
}
Widget _buildBody() {
return Consumer<SftpDownloadProvider>(builder: (__, pro, _) {
if (pro.status.isEmpty) {
return Center(
child: Text(s.sftpNoDownloadTask),
);
}
return ListView.builder(
padding: const EdgeInsets.all(13),
itemCount: pro.status.length,
itemBuilder: (context, index) {
final status = pro.status[index];
return _buildItem(status);
},
);
});
}
Widget _wrapInCard(SftpDownloadStatus status, String? subtitle,
{Widget? trailing}) {
return RoundRectCard(ListTile(
title: Text(status.fileName),
subtitle: subtitle == null
? null
: Text(
subtitle,
style: grey,
),
trailing: trailing,
));
}
Widget _buildItem(SftpDownloadStatus status) {
if (status.error != null) {
showSnackBar(context, Text(status.error.toString()));
}
switch (status.status) {
case SftpWorkerStatus.finished:
return _wrapInCard(status,
'${s.downloadFinished}, ${s.spentTime(status.spentTime ?? s.unknown)}',
trailing: IconButton(
onPressed: () => Share.shareFiles([status.item.localPath],
text: '${status.fileName} from ServerBox'),
icon: const Icon(Icons.open_in_new)));
case SftpWorkerStatus.downloading:
return _wrapInCard(
status,
s.downloadStatus((status.progress ?? 0.0).toStringAsFixed(2),
(status.size ?? 0).convertBytes),
trailing:
CircularProgressIndicator(value: (status.progress ?? 0) / 100));
case SftpWorkerStatus.preparing:
return _wrapInCard(status, s.sftpDlPrepare, trailing: centerLoading);
case SftpWorkerStatus.sshConnectted:
return _wrapInCard(status, s.sftpSSHConnected, trailing: centerLoading);
default:
return _wrapInCard(status, s.unknown,
trailing: const Icon(
Icons.error,
size: 40,
));
}
}
}

View File

@@ -0,0 +1,362 @@
import 'package:dartssh2/dartssh2.dart';
import 'package:flutter/material.dart';
import 'package:toolbox/core/extension/numx.dart';
import 'package:toolbox/core/extension/stringx.dart';
import 'package:toolbox/core/route.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/server/server_connection_state.dart';
import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/model/sftp/absolute_path.dart';
import 'package:toolbox/data/model/sftp/download_worker.dart';
import 'package:toolbox/data/model/sftp/browser_status.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/provider/sftp_download.dart';
import 'package:toolbox/data/res/path.dart';
import 'package:toolbox/data/store/private_key.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/page/sftp/downloading.dart';
import 'package:toolbox/view/widget/fade_in.dart';
import 'package:toolbox/view/widget/two_line_text.dart';
class SFTPPage extends StatefulWidget {
final ServerPrivateInfo spi;
const SFTPPage(this.spi, {Key? key}) : super(key: key);
@override
_SFTPPageState createState() => _SFTPPageState();
}
class _SFTPPageState extends State<SFTPPage> {
final SftpBrowserStatus _status = SftpBrowserStatus();
final ScrollController _scrollController = ScrollController();
late MediaQueryData _media;
late S s;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_media = MediaQuery.of(context);
s = S.of(context);
}
@override
void initState() {
super.initState();
_status.spi = widget.spi;
_status.selected = true;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: TwoLineText(up: 'SFTP', down: widget.spi.name),
),
body: _buildFileView(),
);
}
Widget get centerCircleLoading => Center(
child: Column(
children: [
SizedBox(
height: _media.size.height * 0.4,
),
const CircularProgressIndicator(),
],
),
);
Widget _buildFileView() {
if (!_status.selected) {
return ListView(
children: [
_buildDestSelector(),
],
);
}
final spi = _status.spi;
final si =
locator<ServerProvider>().servers.firstWhere((s) => s.info == spi);
final client = si.client;
if (client == null ||
si.connectionState != ServerConnectionState.connected) {
return centerCircleLoading;
}
if (_status.files == null) {
_status.path = AbsolutePath('/');
listDir(path: '/', client: client);
return centerCircleLoading;
} else {
return RefreshIndicator(
child: FadeIn(
child: ListView.builder(
itemCount: _status.files!.length + 1,
controller: _scrollController,
itemBuilder: (context, index) {
if (index == 0) {
return _buildDestSelector();
}
final file = _status.files![index - 1];
final isDir = file.attr.isDirectory;
return ListTile(
leading: Icon(isDir ? Icons.folder : Icons.insert_drive_file),
title: Text(file.filename),
trailing: Text(
DateTime.fromMillisecondsSinceEpoch(
(file.attr.modifyTime ?? 0) * 1000)
.toString()
.replaceFirst('.000', ''),
style: const TextStyle(color: Colors.grey),
),
subtitle:
isDir ? null : Text((file.attr.size ?? 0).convertBytes),
onTap: () {
if (isDir) {
_status.path?.update(file.filename);
listDir(path: _status.path?.path);
} else {
onItemPress(context, file);
}
},
onLongPress: () => onItemPress(context, file),
);
},
),
key: Key(_status.spi!.name + _status.path!.path),
),
onRefresh: () => listDir(path: _status.path?.path));
}
}
void onItemPress(BuildContext context, SftpName file) {
showRoundDialog(
context,
'Action',
Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.delete),
title: Text(s.delete),
onTap: () => delete(context, file),
),
ListTile(
leading: const Icon(Icons.folder),
title: Text(s.createFolder),
onTap: () => mkdir(context)),
ListTile(
leading: const Icon(Icons.edit),
title: Text(s.rename),
onTap: () => rename(context, file),
),
ListTile(
leading: const Icon(Icons.download),
title: Text(s.download),
onTap: () => download(context, file),
)
],
),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel))
]);
}
void download(BuildContext context, SftpName name) {
showRoundDialog(
context, s.download, Text(s.dl2Local(name.filename)), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
final prePath = _status.path!.path;
final remotePath =
prePath + (prePath.endsWith('/') ? '' : '/') + name.filename;
final local = '${(await sftpDownloadDir).path}$remotePath';
final pubKeyId = _status.spi!.pubKeyId;
locator<SftpDownloadProvider>().add(
DownloadItem(_status.spi!, remotePath, local),
key: pubKeyId == null
? null
: locator<PrivateKeyStore>().get(pubKeyId).privateKey);
showRoundDialog(context, s.goSftpDlPage, const SizedBox(), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(onPressed: () => AppRoute(const SFTPDownloadingPage(), 'sftp downloading'), child: Text(s.ok))
]);
},
child: Text(s.download))
]);
}
void delete(BuildContext context, SftpName file) {
Navigator.of(context).pop();
showRoundDialog(
context, s.attention, Text(s.sureDelete(file.filename)), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel')),
TextButton(
onPressed: () {
_status.client!.remove(file.filename);
Navigator.of(context).pop();
listDir();
},
child: Text(
s.delete,
style: const TextStyle(color: Colors.red),
)),
]);
}
void mkdir(BuildContext context) {
Navigator.of(context).pop();
final textController = TextEditingController();
showRoundDialog(
context,
s.createFolder,
TextField(
controller: textController,
decoration: InputDecoration(
labelText: s.name,
),
),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () {
if (textController.text == '') {
showRoundDialog(context, s.attention,
Text(s.fieldMustNotEmpty), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.ok)),
]);
return;
}
_status.client!
.mkdir(_status.path!.path + '/' + textController.text);
Navigator.of(context).pop();
listDir();
},
child: Text(
s.ok,
style: const TextStyle(color: Colors.red),
)),
]);
}
void rename(BuildContext context, SftpName file) {
Navigator.of(context).pop();
final textController = TextEditingController();
showRoundDialog(
context,
s.rename,
TextField(
controller: textController,
decoration: InputDecoration(
labelText: s.name,
),
),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () async {
if (textController.text == '') {
showRoundDialog(context, s.attention,
Text(s.fieldMustNotEmpty), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.ok)),
]);
return;
}
await _status.client!
.rename(file.filename, textController.text);
Navigator.of(context).pop();
listDir();
},
child: Text(
s.rename,
style: const TextStyle(color: Colors.red),
)),
]);
}
Future<void> listDir({String? path, SSHClient? client}) async {
if (_status.isBusy) {
return;
}
_status.isBusy = true;
if (client != null) {
final sftpc = await client.sftp();
_status.client = sftpc;
}
try {
final fs =
await _status.client!.listdir(path ?? (_status.path?.path ?? '/'));
fs.sort((a, b) => a.filename.compareTo(b.filename));
fs.removeAt(0);
if (mounted) {
setState(() {
_status.files = fs;
_status.isBusy = false;
});
}
} catch (e) {
await showRoundDialog(context, s.error, Text(e.toString()), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.ok))
]);
if (_status.path!.undo()) {
await listDir();
}
}
}
Widget _buildDestSelector() {
final str = _status.path?.path;
return ExpansionTile(
title: Text(_status.spi?.name ?? s.chooseDestination),
subtitle: _status.selected
? str!.omitStartStr(style: const TextStyle(color: Colors.grey))
: null,
children: locator<ServerProvider>()
.servers
.map((e) => _buildDestSelectorItem(e.info))
.toList());
}
Widget _buildDestSelectorItem(ServerPrivateInfo spi) {
return ListTile(
title: Text(spi.name),
subtitle: Text('${spi.user}@${spi.ip}:${spi.port}'),
onTap: () {
_status.spi = spi;
_status.selected = true;
_status.path = AbsolutePath('/');
listDir(
client: locator<ServerProvider>()
.servers
.firstWhere((s) => s.info == spi)
.client,
path: '/');
},
);
}
}