diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index fc1b52ad..8981e9e4 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -354,7 +354,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 96; + CURRENT_PROJECT_VERSION = 97; DEVELOPMENT_TEAM = BA88US33G6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -362,7 +362,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.96; + MARKETING_VERSION = 1.0.97; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -484,7 +484,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 96; + CURRENT_PROJECT_VERSION = 97; DEVELOPMENT_TEAM = BA88US33G6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -492,7 +492,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.96; + MARKETING_VERSION = 1.0.97; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -508,7 +508,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 96; + CURRENT_PROJECT_VERSION = 97; DEVELOPMENT_TEAM = BA88US33G6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -516,7 +516,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.96; + MARKETING_VERSION = 1.0.97; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; diff --git a/lib/data/provider/server.dart b/lib/data/provider/server.dart index c58dff7b..92cd9e75 100644 --- a/lib/data/provider/server.dart +++ b/lib/data/provider/server.dart @@ -191,8 +191,9 @@ class ServerProvider extends BusyProvider { } catch (e) { _servers[idx].connectionState = ServerConnectionState.failed; servers[idx].status.failedInfo = e.toString(); - notifyListeners(); logger.warning(e); + } finally { + notifyListeners(); } } @@ -219,7 +220,6 @@ class ServerProvider extends BusyProvider { results.add(NetSpeedPart(device, bytesIn, bytesOut, time)); } info.status.netSpeed = info.status.netSpeed.update(results); - notifyListeners(); } void _getSysVer(ServerPrivateInfo spi, String raw) { @@ -228,8 +228,6 @@ class ServerProvider extends BusyProvider { if (s.length == 2) { info.status.sysVer = s[1].replaceAll('"', '').replaceFirst('\n', ''); } - - notifyListeners(); } String _getCPUTemp(String raw) { @@ -264,14 +262,11 @@ class ServerProvider extends BusyProvider { info.status.cpu2Status = info.status.cpu2Status.update(cpus, _getCPUTemp(temp)); } - - notifyListeners(); } void _getUpTime(ServerPrivateInfo spi, String raw) { _servers.firstWhere((e) => e.info == spi).status.uptime = raw.split('up ')[1].split(', ')[0]; - notifyListeners(); } void _getTcp(ServerPrivateInfo spi, String raw) { @@ -283,7 +278,6 @@ class ServerProvider extends BusyProvider { final vals = idx.split(RegExp(r'\s{1,}')); info.status.tcp = TcpStatus(vals[5].i, vals[6].i, vals[7].i, vals[8].i); } - notifyListeners(); } void _getDisk(ServerPrivateInfo spi, String raw) { @@ -299,7 +293,6 @@ class ServerProvider extends BusyProvider { int.parse(vals[4].replaceFirst('%', '')), vals[2], vals[1], vals[3])); } info.status.disk = list; - notifyListeners(); } void _getMem(ServerPrivateInfo spi, String raw) { @@ -318,7 +311,6 @@ class ServerProvider extends BusyProvider { avail: memList[5]); } } - notifyListeners(); } Future runSnippet(ServerPrivateInfo spi, Snippet snippet) async { diff --git a/lib/data/res/build_data.dart b/lib/data/res/build_data.dart index a49566b7..0f08b2e4 100644 --- a/lib/data/res/build_data.dart +++ b/lib/data/res/build_data.dart @@ -4,7 +4,7 @@ class BuildData { static const String name = "ServerBox"; static const int build = 97; static const String engine = - "Flutter 2.10.0 • channel stable • https://github.com/flutter/flutter.git\nFramework • revision 5f105a6ca7 (7 days ago) • 2022-02-01 14:15:42 -0800\nEngine • revision 776efd2034\nTools • Dart 2.16.0 • DevTools 2.9.2\n"; - static const String buildAt = "2022-02-09 10:58:45.586008"; - static const int modifications = 8; + "Flutter 2.8.1 • channel stable • https://github.com/flutter/flutter.git\nFramework • revision 77d935af4d (8 weeks ago) • 2021-12-16 08:37:33 -0800\nEngine • revision 890a5fca2e\nTools • Dart 2.15.1\n"; + static const String buildAt = "2022-02-10 19:30:23.388434"; + static const int modifications = 9; } diff --git a/lib/view/page/home.dart b/lib/view/page/home.dart index 4f367b94..7faf4957 100644 --- a/lib/view/page/home.dart +++ b/lib/view/page/home.dart @@ -101,7 +101,6 @@ class _MyHomePageState extends State animationDuration: const Duration(milliseconds: 300), animateChildDecoration: true, rtlOpening: false, - disabledGestures: true, childDecoration: const BoxDecoration( // NOTICE: Uncomment if you want to add shadow behind the page. // Keep in mind that it may cause animation jerks. diff --git a/lib/view/page/ping.dart b/lib/view/page/ping.dart index 591e32c8..458d39d0 100644 --- a/lib/view/page/ping.dart +++ b/lib/view/page/ping.dart @@ -49,7 +49,8 @@ class _PingPageState extends State RoundRectCard( SizedBox( width: double.infinity, - child: Padding(padding: const EdgeInsets.all(7), child: Text(_result)), + child: Padding( + padding: const EdgeInsets.all(7), child: Text(_result)), ), ), ])), diff --git a/lib/view/page/server/tab.dart b/lib/view/page/server/tab.dart index f497269b..39e8d3ba 100644 --- a/lib/view/page/server/tab.dart +++ b/lib/view/page/server/tab.dart @@ -19,6 +19,7 @@ import 'package:toolbox/data/store/setting.dart'; import 'package:toolbox/locator.dart'; import 'package:toolbox/view/page/server/detail.dart'; import 'package:toolbox/view/page/server/edit.dart'; +import 'package:toolbox/view/page/sftp.dart'; import 'package:toolbox/view/page/snippet/list.dart'; import 'package:toolbox/view/widget/round_rect_card.dart'; @@ -241,9 +242,16 @@ class _ServerPageState extends State switch (item) { case MenuItems.ssh: case MenuItems.apt: - case MenuItems.sftp: showSnackBar(context, const Text('Now is not supported')); break; + case MenuItems.sftp: + AppRoute( + SFTPPage( + spi: spi, + ), + 'SFTP') + .go(context); + break; case MenuItems.snippet: AppRoute( SnippetListPage( diff --git a/lib/view/page/sftp.dart b/lib/view/page/sftp.dart new file mode 100644 index 00000000..f8f90efe --- /dev/null +++ b/lib/view/page/sftp.dart @@ -0,0 +1,337 @@ +import 'package:dartssh2/dartssh2.dart'; +import 'package:flutter/material.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/provider/server.dart'; +import 'package:toolbox/locator.dart'; +import 'package:toolbox/view/widget/fade_in.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 { + /// Whether the Left/Right Destination is selected. + final List _selectedDest = List.filled(2, false); + final List _destSpi = + List.filled(2, null); + final List?> _files = List?>.filled(2, null); + final List _paths = List.filled(2, ''); + final List _clients = List.filled(2, null); + + final ScrollController _leftScrollController = ScrollController(); + final ScrollController _rightScrollController = ScrollController(); + + late MediaQueryData _media; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _media = MediaQuery.of(context); + } + + @override + void initState() { + super.initState(); + if (widget.spi != null) { + _destSpi[0] = widget.spi; + _selectedDest[0] = true; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text(_titleText), + ), + body: Row( + children: [ + _buildSingleColumn(true), + const VerticalDivider( + width: 2, + ), + _buildSingleColumn(false), + ], + ), + ); + } + + String get _titleText { + List titles = [ + '', + '', + ]; + if (_selectedDest[0]) { + titles[0] = _destSpi[0]?.name ?? ''; + } + if (_selectedDest[1]) { + titles[1] = _destSpi[1]?.name ?? ''; + } + return titles[0] == '' || titles[1] == '' ? 'SFTP' : titles.join(' - '); + } + + Widget _buildSingleColumn(bool left) { + Widget child; + if (!_selectedDest[left ? 0 : 1]) { + child = _buildDestSelector(left); + } else { + child = _buildFileView(left); + } + + return SizedBox( + width: (_media.size.width - 2) / 2, + child: child, + ); + } + + Widget get centerCircleLoading => Center( + child: Column( + children: [ + SizedBox( + height: _media.size.height * 0.4, + ), + const CircularProgressIndicator(), + ], + ), + ); + + Widget _buildFileView(bool left) { + final spi = _destSpi[left ? 0 : 1]; + final si = + locator().servers.firstWhere((s) => s.info == spi); + final client = si.client; + if (client == null || + si.connectionState != ServerConnectionState.connected) { + return centerCircleLoading; + } + + if (_files[left ? 0 : 1] == null) { + updatePath('/', left); + listDir(client, '/', left); + return centerCircleLoading; + } else { + return RefreshIndicator( + child: FadeIn( + child: ListView.builder( + itemCount: _files[left ? 0 : 1]!.length, + controller: left ? _leftScrollController : _rightScrollController, + itemBuilder: (context, index) { + final file = _files[left ? 0 : 1]![index]; + final isDir = file.attr.mode?.isDirectory ?? true; + return ListTile( + leading: + Icon(isDir ? Icons.folder : Icons.insert_drive_file), + title: Text(file.filename), + subtitle: isDir + ? null + : Text((convertBytes(file.attr.size ?? 0))), + onTap: () { + if (isDir) { + updatePath(file.filename, left); + listDir(client, _paths[left ? 0 : 1], left); + } else { + // downloadFile(client, file.name); + } + }, + onLongPress: () => onItemLongPress(context, left, file)); + }, + ), + key: Key(_paths[left ? 0 : 1]), + ), + onRefresh: () => listDir(client, _paths[left ? 0 : 1], left)); + } + } + + void onItemLongPress(BuildContext context, bool left, SftpName file) { + showRoundDialog( + context, + 'Action', + Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.delete), + title: const Text('Delete'), + onTap: () => showRoundDialog(context, 'Confirm', + Text('Are you sure to delete ${file.filename}?'), [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel')), + TextButton( + onPressed: () {}, + child: const Text( + 'Delete', + style: TextStyle(color: Colors.red), + )), + ]), + ), + ListTile( + leading: const Icon(Icons.folder), + title: const Text('Create Folder'), + onTap: () => mkdir(context, left)), + ListTile( + leading: Icon(left ? Icons.arrow_forward : Icons.arrow_back), + title: const Text('Copy'), + onTap: () {}, + ), + ListTile( + leading: const Icon(Icons.edit), + title: const Text('Rename'), + onTap: () => rename(context, left, file), + ), + ListTile( + leading: const Icon(Icons.file_download), + title: const Text('Download'), + onTap: () {}, + ), + ], + ), + [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel')) + ]); + } + + void mkdir(BuildContext context, bool left) { + final textController = TextEditingController(); + showRoundDialog( + context, + 'Create Folder', + TextField( + controller: textController, + decoration: const InputDecoration( + labelText: 'Folder Name', + ), + ), + [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel')), + TextButton( + onPressed: () { + if (textController.text == '') { + showRoundDialog(context, 'Attention', + const Text('You need input a name.'), [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK')), + ]); + return; + } + _clients[left ? 0 : 1]! + .mkdir(_paths[left ? 0 : 1] + '/' + textController.text); + }, + child: const Text( + 'Create', + style: TextStyle(color: Colors.red), + )), + ]); + } + + void rename(BuildContext context, bool left, SftpName file) { + final textController = TextEditingController(); + showRoundDialog( + context, + 'Create Folder', + TextField( + controller: textController, + decoration: const InputDecoration( + labelText: 'New Name', + ), + ), + [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel')), + TextButton( + onPressed: () async { + if (textController.text == '') { + showRoundDialog(context, 'Attention', + const Text('You need input a name.'), [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK')), + ]); + return; + } + await _clients[left ? 0 : 1]! + .rename(file.filename, textController.text); + }, + child: const Text( + 'Create', + style: TextStyle(color: Colors.red), + )), + ]); + } + + String convertBytes(int bytes) { + const suffix = ['B', 'KB', 'MB', 'GB', 'TB']; + double value = bytes.toDouble(); + int squareTimes = 0; + for (; value / 1024 > 1 && squareTimes < 3; squareTimes++) { + value /= 1024; + } + var finalValue = value.toStringAsFixed(1); + if (finalValue.endsWith('.0')) { + finalValue = finalValue.replaceFirst('.0', ''); + } + return '$finalValue ${suffix[squareTimes]}'; + } + + void updatePath(String filename, bool left) { + if (filename == '..') { + _paths[left ? 0 : 1] = _paths[left ? 0 : 1] + .substring(0, _paths[left ? 0 : 1].lastIndexOf('/')); + if (_paths[left ? 0 : 1] == '') { + _paths[left ? 0 : 1] = '/'; + } + return; + } + _paths[left ? 0 : 1] = _paths[left ? 0 : 1] + + (_paths[left ? 0 : 1].endsWith('/') ? '' : '/') + + filename; + } + + Future listDir(SSHClient client, String path, bool left) async { + final sftpc = await client.sftp(); + _clients[left ? 0 : 1] = sftpc; + final fs = await sftpc.listdir(path); + fs.sort((a, b) => a.filename.compareTo(b.filename)); + fs.removeAt(0); + if (mounted) { + setState(() { + _files[left ? 0 : 1] = fs; + }); + } + } + + Widget _buildDestSelector(bool left) { + return Column( + children: locator() + .servers + .map((e) => _buildDestSelectorItem(e.info, left)) + .toList(), + ); + } + + Widget _buildDestSelectorItem(ServerPrivateInfo spi, bool left) { + return ListTile( + title: Text(spi.name), + subtitle: Text('${spi.user}@${spi.ip}:${spi.port}'), + onTap: () { + setState(() { + _destSpi[left ? 0 : 1] = spi; + _selectedDest[left ? 0 : 1] = true; + }); + }, + ); + } +} diff --git a/lib/view/widget/fade_in.dart b/lib/view/widget/fade_in.dart new file mode 100644 index 00000000..eae255f0 --- /dev/null +++ b/lib/view/widget/fade_in.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +/// 渐隐渐显实现 +class FadeIn extends StatefulWidget { + final Widget child; + + const FadeIn({Key? key, required this.child}) : super(key: key); + + @override + _MyFadeInState createState() => _MyFadeInState(); +} + +class _MyFadeInState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 377), + ); + _animation = Tween( + begin: 0.0, + end: 1.0, + ).animate(_controller); + } + + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } + + @override + Widget build(BuildContext context) { + _controller.forward(); + return FadeTransition( + opacity: _animation, + child: widget.child, + ); + } +} diff --git a/make.dart b/make.dart index bad99210..c3255611 100755 --- a/make.dart +++ b/make.dart @@ -44,13 +44,12 @@ Future getFlutterVersion() async { } Future> getBuildData() async { - final modifiedCount = await getGitModificationCount(); final data = { 'name': appName, - 'build': await getGitCommitCount() + (modifiedCount == 0 ? 0 : 1), + 'build': await getGitCommitCount(), 'engine': await getFlutterVersion(), 'buildAt': DateTime.now().toString(), - 'modifications': modifiedCount, + 'modifications': await getGitModificationCount(), }; return data; } @@ -84,8 +83,7 @@ void flutterRun(String? mode) { Future flutterBuild(String source, String target, bool isAndroid) async { final startTime = DateTime.now(); - final build = await getGitCommitCount() + - (await getGitModificationCount() == 0 ? 0 : 1); + final build = await getGitCommitCount(); final args = [ 'build',