diff --git a/lib/core/route.dart b/lib/core/route.dart index 42edd093..589a080e 100644 --- a/lib/core/route.dart +++ b/lib/core/route.dart @@ -3,13 +3,11 @@ import 'package:flutter/material.dart'; import 'package:server_box/data/model/server/private_key_info.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/res/store.dart'; -import 'package:server_box/view/page/backup.dart'; import 'package:server_box/view/page/container.dart'; import 'package:server_box/view/page/home/home.dart'; import 'package:server_box/view/page/iperf.dart'; import 'package:server_box/view/page/ping.dart'; import 'package:server_box/view/page/private_key/edit.dart'; -import 'package:server_box/view/page/private_key/list.dart'; import 'package:server_box/view/page/pve.dart'; import 'package:server_box/view/page/server/detail/view.dart'; import 'package:server_box/view/page/setting/platform/android.dart'; @@ -18,17 +16,13 @@ import 'package:server_box/view/page/setting/seq/srv_func_seq.dart'; import 'package:server_box/view/page/snippet/result.dart'; import 'package:server_box/view/page/ssh/page.dart'; import 'package:server_box/view/page/setting/seq/virt_key.dart'; -import 'package:server_box/view/page/storage/local.dart'; - import 'package:server_box/data/model/server/snippet.dart'; import 'package:server_box/view/page/editor.dart'; import 'package:server_box/view/page/process.dart'; import 'package:server_box/view/page/server/tab.dart'; -import 'package:server_box/view/page/setting/entry.dart'; import 'package:server_box/view/page/setting/seq/srv_detail_seq.dart'; import 'package:server_box/view/page/setting/seq/srv_seq.dart'; import 'package:server_box/view/page/snippet/edit.dart'; -import 'package:server_box/view/page/snippet/list.dart'; import 'package:server_box/view/page/storage/sftp.dart'; import 'package:server_box/view/page/storage/sftp_mission.dart'; @@ -72,10 +66,6 @@ class AppRoutes { ); } - static AppRoutes keyList({Key? key}) { - return AppRoutes(PrivateKeysListPage(key: key), 'key_detail'); - } - static AppRoutes snippetEdit({Key? key, Snippet? snippet}) { return AppRoutes( SnippetEditPage(snippet: snippet), @@ -83,10 +73,6 @@ class AppRoutes { ); } - static AppRoutes snippetList({Key? key}) { - return AppRoutes(SnippetListPage(key: key), 'snippet_detail'); - } - static AppRoutes ssh({ Key? key, required Spi spi, @@ -108,17 +94,6 @@ class AppRoutes { return AppRoutes(SSHVirtKeySettingPage(key: key), 'ssh_virt_key_setting'); } - static AppRoutes localStorage( - {Key? key, bool isPickFile = false, String? initDir}) { - return AppRoutes( - LocalStoragePage( - key: key, - isPickFile: isPickFile, - initDir: initDir, - ), - 'local_storage'); - } - static AppRoutes sftpMission({Key? key}) { return AppRoutes(SftpMissionPage(key: key), 'sftp_mission'); } @@ -135,10 +110,6 @@ class AppRoutes { 'sftp'); } - static AppRoutes backup({Key? key}) { - return AppRoutes(BackupPage(key: key), 'backup'); - } - static AppRoutes docker({Key? key, required Spi spi}) { return AppRoutes(ContainerPage(key: key, spi: spi), 'docker'); } @@ -179,10 +150,6 @@ class AppRoutes { return AppRoutes(ProcessPage(key: key, spi: spi), 'process'); } - static AppRoutes settings({Key? key}) { - return AppRoutes(SettingPage(key: key), 'setting'); - } - static AppRoutes serverOrder({Key? key}) { return AppRoutes(ServerOrderPage(key: key), 'server_order'); } diff --git a/lib/data/model/app/tab.dart b/lib/data/model/app/tab.dart index fcdea06f..9774a986 100644 --- a/lib/data/model/app/tab.dart +++ b/lib/data/model/app/tab.dart @@ -1,26 +1,62 @@ +import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; -import 'package:server_box/view/page/ping.dart'; +import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/view/page/server/tab.dart'; +import 'package:server_box/view/page/setting/entry.dart'; import 'package:server_box/view/page/snippet/list.dart'; import 'package:server_box/view/page/ssh/tab.dart'; +import 'package:icons_plus/icons_plus.dart'; +import 'package:server_box/view/page/storage/local.dart'; enum AppTab { server, ssh, + file, snippet, - ping, + settings, ; Widget get page { - switch (this) { - case server: - return const ServerPage(); - case snippet: - return const SnippetListPage(); - case ssh: - return const SSHTabPage(); - case ping: - return const PingPage(); - } + return switch (this) { + server => const ServerPage(), + settings => const SettingsPage(), + ssh => const SSHTabPage(), + file => const LocalFilePage(), + snippet => const SnippetListPage(), + }; + } + + NavigationDestination get navDestination { + return switch (this) { + server => NavigationDestination( + icon: const Icon(BoxIcons.bx_server), + label: l10n.server, + selectedIcon: const Icon(BoxIcons.bxs_server), + ), + settings => NavigationDestination( + icon: const Icon(Icons.settings), + label: libL10n.setting, + selectedIcon: const Icon(Icons.settings), + ), + ssh => const NavigationDestination( + icon: Icon(Icons.terminal_outlined), + label: 'SSH', + selectedIcon: Icon(Icons.terminal), + ), + snippet => NavigationDestination( + icon: const Icon(Icons.code), + label: l10n.snippet, + selectedIcon: const Icon(Icons.code), + ), + file => NavigationDestination( + icon: const Icon(Icons.folder_open), + label: libL10n.file, + selectedIcon: const Icon(Icons.folder), + ), + }; + } + + static List get navDestinations { + return AppTab.values.map((e) => e.navDestination).toList(); } } diff --git a/lib/data/model/server/server_private_info.dart b/lib/data/model/server/server_private_info.dart index 3268b1da..d57a863b 100644 --- a/lib/data/model/server/server_private_info.dart +++ b/lib/data/model/server/server_private_info.dart @@ -119,6 +119,9 @@ extension Spix on Spi { return (ip_, usr, port_); } + /// Just for showing the struct of the class. + /// + /// **NOT** the default value. static const example = Spi( name: 'name', ip: 'ip', diff --git a/lib/data/provider/server.dart b/lib/data/provider/server.dart index 1339c1bc..7e9e56b3 100644 --- a/lib/data/provider/server.dart +++ b/lib/data/provider/server.dart @@ -88,16 +88,15 @@ class ServerProvider extends Provider { } static void _updateTags() { + final tags = {}; for (final s in servers.values) { - final tags = s.value.spi.tags; - if (tags == null) continue; - for (final t in tags) { - if (!_tags.value.contains(t)) { - _tags.value.add(t); - } + final spiTags = s.value.spi.tags; + if (spiTags == null) continue; + for (final t in spiTags) { + tags.add(t); } } - _tags.value = (_tags.value.toList()..sort()).toSet(); + _tags.value = tags; } static Server genServer(Spi spi) { diff --git a/lib/view/page/backup.dart b/lib/view/page/backup.dart index 3ee858bb..74833d14 100644 --- a/lib/view/page/backup.dart +++ b/lib/view/page/backup.dart @@ -15,18 +15,23 @@ import 'package:server_box/data/res/store.dart'; import 'package:icons_plus/icons_plus.dart'; import 'package:server_box/data/store/no_backup.dart'; -final icloudLoading = false.vn; -final webdavLoading = false.vn; - -final _noBak = NoBackupStore.instance; - -class BackupPage extends StatelessWidget { +class BackupPage extends StatefulWidget { const BackupPage({super.key}); + @override + State createState() => _BackupPageState(); +} + +final class _BackupPageState extends State + with AutomaticKeepAliveClientMixin { + final _noBak = NoBackupStore.instance; + final icloudLoading = false.vn; + final webdavLoading = false.vn; + @override Widget build(BuildContext context) { + super.build(context); return Scaffold( - appBar: CustomAppBar(title: Text(libL10n.backup)), body: _buildBody(context), ); } @@ -477,4 +482,7 @@ class BackupPage extends StatelessWidget { Loggers.app.warning('Import servers failed', e, s); } } + + @override + bool get wantKeepAlive => true; } diff --git a/lib/view/page/home/appbar.dart b/lib/view/page/home/appbar.dart index eb1d0d69..e0b4a06b 100644 --- a/lib/view/page/home/appbar.dart +++ b/lib/view/page/home/appbar.dart @@ -1,40 +1,20 @@ part of 'home.dart'; -final class _AppBar extends CustomAppBar { - final ValueNotifier selectIndex; - final ValueNotifier landscape; +final class _AppBar extends StatelessWidget implements PreferredSizeWidget { + final double paddingTop; - const _AppBar({ - required this.selectIndex, - required this.landscape, - super.title, - super.actions, - super.centerTitle, - }); + const _AppBar(this.paddingTop); @override Widget build(BuildContext context) { - final placeholder = SizedBox( - height: CustomAppBar.sysStatusBarHeight ?? - 0 + MediaQuery.of(context).padding.top, - ); - return selectIndex.listenVal( - (idx) { - if (isDesktop) return super.build(context); - - if (idx == AppTab.ssh.index) { - return placeholder; - } - - return ValBuilder( - listenable: landscape, - builder: (ls) { - if (ls) return placeholder; - - return super.build(context); - }, - ); - }, + return SizedBox( + height: paddingTop, + child: isIOS + ? const Center(child: Text(BuildData.name, style: UIs.text15Bold)) + : null, ); } + + @override + Size get preferredSize => Size.fromHeight(paddingTop); } diff --git a/lib/view/page/home/home.dart b/lib/view/page/home/home.dart index 05e5c31c..7cde7ded 100644 --- a/lib/view/page/home/home.dart +++ b/lib/view/page/home/home.dart @@ -1,17 +1,10 @@ -import 'dart:convert'; - import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; -import 'package:icons_plus/icons_plus.dart'; import 'package:server_box/core/channel/home_widget.dart'; -import 'package:server_box/core/extension/context/locale.dart'; -import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/app/tab.dart'; import 'package:server_box/data/provider/app.dart'; import 'package:server_box/data/provider/server.dart'; import 'package:server_box/data/res/build_data.dart'; -import 'package:server_box/data/res/github_id.dart'; -import 'package:server_box/data/res/misc.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/url.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -104,39 +97,10 @@ class _HomePageState extends State Widget build(BuildContext context) { super.build(context); AppProvider.ctx = context; + final sysPadding = MediaQuery.of(context).padding; - final appBar = _AppBar( - selectIndex: _selectIndex, - landscape: _isLandscape, - centerTitle: false, - title: const Text(BuildData.name), - actions: [ - ValBuilder( - listenable: Stores.setting.serverStatusUpdateInterval.listenable(), - builder: (interval) { - if (interval != 0) return UIs.placeholder; - return IconButton( - icon: const Icon(Icons.refresh), - tooltip: 'Refresh', - onPressed: () async { - await ServerProvider.refresh(); - }, - ); - }, - ), - IconButton( - icon: const Icon(Icons.developer_mode, size: 21), - tooltip: 'Debug', - onPressed: () => DebugPage.route.go( - context, - args: const DebugPageArgs(title: 'Debug(${BuildData.build})'), - ), - ), - ], - ); return Scaffold( - drawer: _buildDrawer(), - appBar: appBar, + appBar: _AppBar(sysPadding.top), body: PageView.builder( controller: _pageController, itemCount: AppTab.values.length, @@ -184,133 +148,7 @@ class _HomePageState extends State labelBehavior: ls ? NavigationDestinationLabelBehavior.alwaysHide : NavigationDestinationLabelBehavior.onlyShowSelected, - destinations: [ - NavigationDestination( - icon: const Icon(BoxIcons.bx_server), - label: l10n.server, - selectedIcon: const Icon(BoxIcons.bxs_server), - ), - const NavigationDestination( - icon: Icon(Icons.terminal_outlined), - label: 'SSH', - selectedIcon: Icon(Icons.terminal), - ), - NavigationDestination( - icon: const Icon(MingCute.file_code_line), - label: l10n.snippet, - selectedIcon: const Icon(MingCute.file_code_fill), - ), - const NavigationDestination( - icon: Icon(MingCute.planet_line), - label: 'Ping', - selectedIcon: Icon(MingCute.planet_fill), - ), - ], - ); - } - - Widget _buildDrawer() { - return Drawer( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildIcon(), - const Text( - '${BuildData.name}\nv${BuildData.build}', - textAlign: TextAlign.center, - style: UIs.text15, - ), - const SizedBox(height: 37), - _buildTiles(), - ], - ), - ); - } - - Widget _buildTiles() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 17), - child: Column( - children: [ - ListTile( - leading: const Icon(Icons.settings), - title: Text(libL10n.setting), - onTap: () => AppRoutes.settings().go(context), - onLongPress: _onLongPressSetting, - ), - ListTile( - leading: const Icon(Icons.vpn_key), - title: Text(l10n.privateKey), - onTap: () => AppRoutes.keyList().go(context), - ), - ListTile( - leading: const Icon(BoxIcons.bxs_file_blank), - title: Text(libL10n.file), - onTap: () => AppRoutes.localStorage().go(context), - ), - ListTile( - leading: const Icon(MingCute.file_import_fill), - title: Text(libL10n.backup), - onTap: () => AppRoutes.backup().go(context), - ), - ListTile( - leading: const Icon(OctIcons.feed_discussion), - title: Text('${libL10n.about} & ${libL10n.feedback}'), - onTap: _showAboutDialog, - ) - ].map((e) => CardX(child: e)).toList(), - ), - ); - } - - void _showAboutDialog() { - context.showRoundDialog( - title: libL10n.about, - child: _buildAboutContent(), - actions: [ - TextButton( - onPressed: () => Urls.appWiki.launch(), - child: const Text('Wiki'), - ), - TextButton( - onPressed: () => Urls.appHelp.launch(), - child: Text(libL10n.feedback), - ), - TextButton( - onPressed: () => showLicensePage(context: context), - child: Text(l10n.license), - ), - ], - ); - } - - Widget _buildAboutContent() { - return SingleChildScrollView( - child: SizedBox( - width: MediaQuery.of(context).size.width * 0.8, - child: SimpleMarkdown( - data: ''' -${l10n.madeWithLove('[lollipopkit](${Urls.myGithub})')} - -#### Contributors -${GithubIds.contributors.map((e) => '[$e](${e.url})').join(' ')} - -#### Participants -${GithubIds.participants.map((e) => '[$e](${e.url})').join(' ')} - -#### My other apps -- [GPT Box](https://github.com/lollipopkit/flutter_gpt_box) -''', - ), - ), - ); - } - - Widget _buildIcon() { - return ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 57, maxWidth: 57), - child: UIs.appIcon, + destinations: AppTab.navDestinations, ); } @@ -366,35 +204,4 @@ ${GithubIds.participants.map((e) => '[$e](${e.url})').join(' ')} ); } } - - Future _onLongPressSetting() async { - final map = Stores.setting.box.toJson(includeInternal: false); - final keys = map.keys; - - /// Encode [map] to String with indent `\t` - final text = Miscs.jsonEncoder.convert(map); - final result = await AppRoutes.editor( - text: text, - langCode: 'json', - title: libL10n.setting, - ).go(context); - if (result == null) { - return; - } - try { - final newSettings = json.decode(result) as Map; - Stores.setting.box.putAll(newSettings); - final newKeys = newSettings.keys; - final removedKeys = keys.where((e) => !newKeys.contains(e)); - for (final key in removedKeys) { - Stores.setting.box.delete(key); - } - } catch (e, trace) { - context.showRoundDialog( - title: libL10n.error, - child: Text('${l10n.save}:\n$e'), - ); - Loggers.app.warning('Update json settings failed', e, trace); - } - } } diff --git a/lib/view/page/private_key/list.dart b/lib/view/page/private_key/list.dart index dc0b3706..61c6d9f1 100644 --- a/lib/view/page/private_key/list.dart +++ b/lib/view/page/private_key/list.dart @@ -22,9 +22,6 @@ class _PrivateKeyListState extends State @override Widget build(BuildContext context) { return Scaffold( - appBar: CustomAppBar( - title: Text(l10n.privateKey), - ), body: _buildBody(), floatingActionButton: FloatingActionButton( child: const Icon(Icons.add), diff --git a/lib/view/page/server/detail/view.dart b/lib/view/page/server/detail/view.dart index 8a6053ce..984b0045 100644 --- a/lib/view/page/server/detail/view.dart +++ b/lib/view/page/server/detail/view.dart @@ -118,10 +118,10 @@ class _ServerDetailPageState extends State return CustomAppBar( title: Text(si.spi.name), actions: [ - ShareBtn( + QrShareBtn( data: si.spi.toJsonString(), tip: si.spi.name, - tip2: '${libL10n.share} ${l10n.server} ~ ServerBox', + tip2: '${l10n.server} ~ ServerBox', ), IconButton( icon: const Icon(Icons.edit), diff --git a/lib/view/page/server/edit.dart b/lib/view/page/server/edit.dart index cee450b1..dd6dc77a 100644 --- a/lib/view/page/server/edit.dart +++ b/lib/view/page/server/edit.dart @@ -610,13 +610,12 @@ class _ServerEditPageState extends State with AfterLayoutMixin { envs: _env.value.isEmpty ? null : _env.value, ); - final existsIds = ServerStore.instance.box.keys; - if (existsIds.contains(spi.id)) { - context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}'); - return; - } - if (this.spi == null) { + final existsIds = ServerStore.instance.box.keys; + if (existsIds.contains(spi.id)) { + context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}'); + return; + } ServerProvider.addServer(spi); } else { ServerProvider.updateServer(this.spi!, spi); diff --git a/lib/view/page/server/tab.dart b/lib/view/page/server/tab.dart index d0811b71..55b3b5b9 100644 --- a/lib/view/page/server/tab.dart +++ b/lib/view/page/server/tab.dart @@ -8,8 +8,10 @@ import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/extension/ssh_client.dart'; import 'package:server_box/data/model/app/shell_func.dart'; import 'package:server_box/data/model/server/try_limiter.dart'; +import 'package:server_box/data/res/build_data.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/view/page/server/edit.dart'; +import 'package:server_box/view/page/setting/entry.dart'; import 'package:server_box/view/widget/percent_circle.dart'; import 'package:server_box/core/route.dart'; @@ -19,6 +21,8 @@ import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/provider/server.dart'; import 'package:server_box/view/widget/server_func_btns.dart'; +part 'top_bar.dart'; + class ServerPage extends StatefulWidget { const ServerPage({super.key}); @@ -85,7 +89,7 @@ class _ServerPageState extends State Widget _buildPortrait() { return Scaffold( - appBar: TagSwitcher( + appBar: _TopBar( tags: ServerProvider.tags, onTagChanged: (p0) => _tag.value = p0, initTag: _tag.value, @@ -130,7 +134,7 @@ class _ServerPageState extends State top: 0, left: 0, child: IconButton( - onPressed: () => AppRoutes.settings().go(context), + onPressed: () => SettingsPage.route.go(context), icon: const Icon(Icons.settings, color: Colors.grey), ), ), @@ -482,30 +486,18 @@ class _ServerPageState extends State null, ), ServerConn.failed => ( - const Icon( - Icons.refresh, - size: 21, - color: Colors.grey, - ), + const Icon(Icons.refresh, size: 21, color: Colors.grey), () { TryLimiter.reset(s.spi.id); ServerProvider.refresh(spi: s.spi); }, ), ServerConn.disconnected => ( - const Icon( - MingCute.link_3_line, - size: 19, - color: Colors.grey, - ), + const Icon(MingCute.link_3_line, size: 19, color: Colors.grey), () => ServerProvider.refresh(spi: s.spi) ), ServerConn.finished => ( - const Icon( - MingCute.unlink_2_line, - size: 17, - color: Colors.grey, - ), + const Icon(MingCute.unlink_2_line, size: 17, color: Colors.grey), () => ServerProvider.closeServer(id: s.spi.id), ), _ when Stores.setting.serverTabUseOldUI.fetch() => ( @@ -517,11 +509,7 @@ class _ServerPageState extends State // Or the loading icon will be rescaled. final wrapped = child is SizedBox ? child - : SizedBox( - height: _kCardHeightMin, - width: 27, - child: child, - ); + : SizedBox(height: _kCardHeightMin, width: 27, child: child); if (onTap == null) return wrapped.paddingOnly(left: 10); return InkWell( borderRadius: BorderRadius.circular(7), @@ -654,7 +642,7 @@ ${ss.err?.message ?? 'null'} List _filterServers(List order) { final tag = _tag.value; - if (tag == kDefaultTag) return order; + if (tag == TagSwitcher.kDefaultTag) return order; return order.where((e) { final tags = ServerProvider.pick(id: e)?.value.spi.tags; if (tags == null) return false; diff --git a/lib/view/page/server/top_bar.dart b/lib/view/page/server/top_bar.dart new file mode 100644 index 00000000..d9806b8c --- /dev/null +++ b/lib/view/page/server/top_bar.dart @@ -0,0 +1,43 @@ +part of 'tab.dart'; + +final class _TopBar extends StatelessWidget implements PreferredSizeWidget { + final ValueNotifier> tags; + final void Function(String) onTagChanged; + final String initTag; + + const _TopBar({ + required this.initTag, + required this.onTagChanged, + required this.tags, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 17), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Center( + child: Text( + BuildData.name, + style: TextStyle(fontSize: 20), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 30), + TagSwitcher( + tags: tags, + onTagChanged: onTagChanged, + initTag: initTag, + singleLine: true, + reversed: true, + ).expanded(), + ], + ), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(TagSwitcher.kTagBtnHeight); +} diff --git a/lib/view/page/setting/entry.dart b/lib/view/page/setting/entry.dart index e3933519..12e0eaed 100644 --- a/lib/view/page/setting/entry.dart +++ b/lib/view/page/setting/entry.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:fl_lib/fl_lib.dart'; @@ -6,6 +7,7 @@ import 'package:flutter_highlight/theme_map.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:icons_plus/icons_plus.dart'; import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/data/res/github_id.dart'; import 'package:server_box/data/res/rebuild.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/url.dart'; @@ -13,70 +15,196 @@ import 'package:server_box/data/res/url.dart'; import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/app/net_view.dart'; import 'package:server_box/data/res/build_data.dart'; +import 'package:server_box/view/page/backup.dart'; +import 'package:server_box/view/page/private_key/list.dart'; const _kIconSize = 23.0; -class SettingPage extends StatefulWidget { - const SettingPage({super.key}); +enum SettingsTabs { + app, + privateKey, + backup, + about, + ; - @override - State createState() => _SettingPageState(); + String get i18n => switch (this) { + SettingsTabs.app => libL10n.app, + SettingsTabs.privateKey => l10n.privateKey, + SettingsTabs.backup => libL10n.backup, + SettingsTabs.about => libL10n.about, + }; + + Widget get page => switch (this) { + SettingsTabs.app => const AppSettingsPage(), + SettingsTabs.privateKey => const PrivateKeysListPage(), + SettingsTabs.backup => const BackupPage(), + SettingsTabs.about => const AppAboutPage(), + }; + + static final List pages = + SettingsTabs.values.map((e) => e.page).toList(); } -class _SettingPageState extends State { - final _setting = Stores.setting; +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + static const route = AppRouteNoArg(page: SettingsPage.new, path: '/settings'); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State + with SingleTickerProviderStateMixin { + late final _tabCtrl = + TabController(length: SettingsTabs.values.length, vsync: this); @override Widget build(BuildContext context) { return Scaffold( - appBar: CustomAppBar( - title: Text(libL10n.setting), - actions: [ - IconButton( - icon: const Icon(Icons.delete), - onPressed: () => context.showRoundDialog( - title: libL10n.attention, - child: SimpleMarkdown( - data: libL10n.askContinue( - '${libL10n.delete} **${libL10n.all}** ${libL10n.setting}', - ), - ), - actions: Btn.ok( - onTap: () { - context.pop(); - _setting.box.deleteAll(_setting.box.keys); - context.showSnackBar(libL10n.success); - }, - red: true, - ).toList, - ), - ), - ], + appBar: TabBar( + controller: _tabCtrl, + dividerHeight: 0, + tabAlignment: TabAlignment.center, + isScrollable: true, + tabs: SettingsTabs.values + .map((e) => Tab(text: e.i18n)) + .toList(growable: false), ), - body: MultiList( - widthDivider: 2.3, - thumbVisibility: true, - children: [ - [const CenterGreyTitle('App'), _buildApp()], - [CenterGreyTitle(l10n.server), _buildServer()], - [ - const CenterGreyTitle('SSH'), - _buildSSH(), - const CenterGreyTitle('SFTP'), - _buildSFTP() - ], - [ - CenterGreyTitle(l10n.container), - _buildContainer(), - CenterGreyTitle(l10n.editor), - _buildEditor(), - ], + // actions: [ + // IconButton( + // icon: const Icon(Icons.delete), + // onPressed: () => context.showRoundDialog( + // title: libL10n.attention, + // child: SimpleMarkdown( + // data: libL10n.askContinue( + // '${libL10n.delete} **${libL10n.all}** ${libL10n.setting}', + // ), + // ), + // actions: [ + // CountDownBtn( + // onTap: () { + // context.pop(); + // _setting.box.deleteAll(_setting.box.keys); + // context.showSnackBar(libL10n.success); + // }, + // afterColor: Colors.red, + // ) + // ], + // ), + // ), + // ], + body: TabBarView(controller: _tabCtrl, children: SettingsTabs.pages), + ); + } +} - /// Fullscreen Mode is designed for old mobile phone which can be - /// used as a status screen. - if (isMobile) [CenterGreyTitle(l10n.fullScreen), _buildFullScreen()], +final class AppAboutPage extends StatefulWidget { + const AppAboutPage({super.key}); + + @override + State createState() => _AppAboutPageState(); +} + +final class _AppAboutPageState extends State + with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + return ListView( + padding: const EdgeInsets.all(13), + children: [ + UIs.height13, + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 47, maxWidth: 47), + child: UIs.appIcon, + ), + const Text( + '${BuildData.name}\nv${BuildData.build}', + textAlign: TextAlign.center, + style: UIs.text15, + ), + UIs.height13, + SizedBox( + height: 47, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + Btn.elevated( + icon: const Icon(Icons.edit_document), + text: 'Wiki', + onTap: Urls.appWiki.launch, + ), + Btn.elevated( + icon: const Icon(Icons.feedback), + text: libL10n.feedback, + onTap: Urls.appHelp.launch, + ), + Btn.elevated( + icon: const Icon(MingCute.question_fill), + text: l10n.license, + onTap: () => showLicensePage(context: context), + ), + ].joinWith(UIs.width13), + ), + ), + UIs.height13, + SimpleMarkdown( + data: ''' +#### Contributors +${GithubIds.contributors.map((e) => '[$e](${e.url})').join(' ')} + +#### Participants +${GithubIds.participants.map((e) => '[$e](${e.url})').join(' ')} + +#### My other apps +[GPT Box](https://github.com/lollipopkit/flutter_gpt_box) + +${l10n.madeWithLove('[lollipopkit](${Urls.myGithub})')} +''', + ).paddingAll(13).cardx, + ], + ); + } + + @override + bool get wantKeepAlive => true; +} + +final class AppSettingsPage extends StatefulWidget { + const AppSettingsPage({super.key}); + + @override + State createState() => _AppSettingsPageState(); +} + +final class _AppSettingsPageState extends State { + final _setting = Stores.setting; + + @override + Widget build(BuildContext context) { + return MultiList( + thumbVisibility: true, + children: [ + [const CenterGreyTitle('App'), _buildApp()], + [CenterGreyTitle(l10n.server), _buildServer()], + [ + const CenterGreyTitle('SSH'), + _buildSSH(), + const CenterGreyTitle('SFTP'), + _buildSFTP() ], - ), + [ + CenterGreyTitle(l10n.container), + _buildContainer(), + CenterGreyTitle(l10n.editor), + _buildEditor(), + ], + + /// Fullscreen Mode is designed for old mobile phone which can be + /// used as a status screen. + if (isMobile) [CenterGreyTitle(l10n.fullScreen), _buildFullScreen()], + ], ); } @@ -94,9 +222,7 @@ class _SettingPageState extends State { _buildAppMore(), ]; - return Column( - children: children.map((e) => CardX(child: e)).toList(), - ); + return Column(children: children.map((e) => e.cardx).toList()); } Widget _buildFullScreen() { @@ -1182,4 +1308,35 @@ class _SettingPageState extends State { }, ); } + + Future _onLongPressSetting() async { + final map = Stores.setting.box.toJson(includeInternal: false); + final keys = map.keys; + + /// Encode [map] to String with indent `\t` + final text = jsonIndentEncoder.convert(map); + final result = await AppRoutes.editor( + text: text, + langCode: 'json', + title: libL10n.setting, + ).go(context); + if (result == null) { + return; + } + try { + final newSettings = json.decode(result) as Map; + Stores.setting.box.putAll(newSettings); + final newKeys = newSettings.keys; + final removedKeys = keys.where((e) => !newKeys.contains(e)); + for (final key in removedKeys) { + Stores.setting.box.delete(key); + } + } catch (e, trace) { + context.showRoundDialog( + title: libL10n.error, + child: Text('${l10n.save}:\n$e'), + ); + Loggers.app.warning('Update json settings failed', e, trace); + } + } } diff --git a/lib/view/page/snippet/list.dart b/lib/view/page/snippet/list.dart index d6c6c509..47136e78 100644 --- a/lib/view/page/snippet/list.dart +++ b/lib/view/page/snippet/list.dart @@ -13,11 +13,13 @@ class SnippetListPage extends StatefulWidget { State createState() => _SnippetListPageState(); } -class _SnippetListPageState extends State { +class _SnippetListPageState extends State + with AutomaticKeepAliveClientMixin { final _tag = ''.vn; @override Widget build(BuildContext context) { + super.build(context); return Scaffold( appBar: TagSwitcher( tags: SnippetProvider.tags, @@ -43,7 +45,7 @@ class _SnippetListPageState extends State { } Widget _buildSnippetList(List snippets, String tag) { - final filtered = tag == kDefaultTag + final filtered = tag == TagSwitcher.kDefaultTag ? snippets : snippets.where((e) => e.tags?.contains(tag) ?? false).toList(); @@ -95,6 +97,9 @@ class _SnippetListPageState extends State { ); } + @override + bool get wantKeepAlive => true; + // Future _runSnippet(Snippet snippet) async { // final servers = await context.showPickDialog( // items: Pros.server.servers.toList(), diff --git a/lib/view/page/ssh/page.dart b/lib/view/page/ssh/page.dart index c3e81c9b..5b8304ea 100644 --- a/lib/view/page/ssh/page.dart +++ b/lib/view/page/ssh/page.dart @@ -300,7 +300,9 @@ class SSHPageState extends State title: l10n.snippet, tags: SnippetProvider.tags, itemsBuilder: (e) { - if (e == kDefaultTag) return SnippetProvider.snippets.value; + if (e == TagSwitcher.kDefaultTag) { + return SnippetProvider.snippets.value; + } return SnippetProvider.snippets.value .where((element) => element.tags?.contains(e) ?? false) .toList(); diff --git a/lib/view/page/storage/local.dart b/lib/view/page/storage/local.dart index 7c673be1..647383e0 100644 --- a/lib/view/page/storage/local.dart +++ b/lib/view/page/storage/local.dart @@ -8,187 +8,141 @@ import 'package:server_box/data/model/sftp/worker.dart'; import 'package:server_box/data/provider/server.dart'; import 'package:server_box/data/provider/sftp.dart'; import 'package:server_box/data/res/misc.dart'; -import 'package:server_box/view/widget/omit_start_text.dart'; import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/app/path_with_prefix.dart'; -class LocalStoragePage extends StatefulWidget { - final bool isPickFile; +final class LocalFilePageArgs { + final bool? isPickFile; final String? initDir; - const LocalStoragePage({ - super.key, - required this.isPickFile, + const LocalFilePageArgs({ + this.isPickFile, this.initDir, }); - - @override - State createState() => _LocalStoragePageState(); } -class _LocalStoragePageState extends State { - LocalPath? _path; +class LocalFilePage extends StatefulWidget { + final LocalFilePageArgs? args; - final _sortType = ValueNotifier(_SortType.name); + const LocalFilePage({super.key, this.args}); + + static const route = AppRoute( + page: LocalFilePage.new, + path: '/local_file', + ); @override - void initState() { - super.initState(); - if (widget.initDir != null) { - setState(() { - _path = LocalPath(widget.initDir!); - }); - } else { - setState(() { - _path = LocalPath(Paths.file); - }); - } - } + State createState() => _LocalFilePageState(); +} + +class _LocalFilePageState extends State + with AutomaticKeepAliveClientMixin { + late final _path = LocalPath(widget.args?.initDir ?? Paths.file); + final _sortType = _SortType.name.vn; + bool get isPickFile => widget.args?.isPickFile ?? false; @override Widget build(BuildContext context) { + super.build(context); + final title = _path.path.fileName ?? libL10n.file; return Scaffold( appBar: CustomAppBar( - leading: IconButton( - icon: const BackButtonIcon(), - onPressed: () { - if (_path != null) { - _path!.update('/'); - } - context.pop(); - }, + title: AnimatedSwitcher( + duration: Durations.short3, + child: Text(title, key: ValueKey(title)), ), - title: Text(libL10n.file), actions: [ - IconButton( - icon: const Icon(Icons.downloading), - onPressed: () => AppRoutes.sftpMission().go(context), - ), - ValBuilder<_SortType>( - listenable: _sortType, - builder: (value) { - return PopupMenuButton<_SortType>( - icon: const Icon(Icons.sort), - itemBuilder: (context) { - return [ - PopupMenuItem( - value: _SortType.name, - child: Text(libL10n.name), - ), - PopupMenuItem( - value: _SortType.size, - child: Text(l10n.size), - ), - PopupMenuItem( - value: _SortType.time, - child: Text(l10n.time), - ), - ]; - }, - onSelected: (value) { - _sortType.value = value; - }, - ); - }, - ), + if (!isPickFile) + IconButton( + onPressed: () async { + final path = await Pfs.pickFilePath(); + if (path == null) return; + final name = path.getFileName() ?? 'imported'; + await File(path).copy(_path.path.joinPath(name)); + setState(() {}); + }, + icon: const Icon(Icons.add), + ), + if (!isPickFile) _buildMissionBtn(), + _buildSortBtn(), ], ), - body: FadeIn( - key: UniqueKey(), - child: ValBuilder( - listenable: _sortType, - builder: (val) { - return _buildBody(); - }, - ), - ), - bottomNavigationBar: SafeArea(child: _buildPath()), - ); - } - - Widget _buildPath() { - return Container( - padding: const EdgeInsets.fromLTRB(11, 7, 11, 11), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - OmitStartText(_path?.path ?? '...'), - _buildBtns(), - ], - ), - ); - } - - Widget _buildBtns() { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - IconButton( - onPressed: () { - _path?.update('..'); - setState(() {}); - }, - icon: const Icon(Icons.arrow_back), - ), - IconButton( - onPressed: () async { - final path = await Pfs.pickFilePath(); - if (path == null) return; - final name = path.getFileName() ?? 'imported'; - await File(path).copy(_path!.path.joinPath(name)); - setState(() {}); - }, - icon: const Icon(Icons.add), - ), - ], + body: _sortType.listen(_buildBody), ); } Widget _buildBody() { - if (_path == null) { - return const Center( - child: CircularProgressIndicator(), + Future> getEntities() async { + final files = await Directory(_path.path).list().toList(); + final sorted = _sortType.value.sort(files); + final stats = await Future.wait( + sorted.map((e) async => (e, await e.stat())), ); + return stats; } - final dir = Directory(_path!.path); - final tempFiles = dir.listSync(); - final files = _sortType.value.sort(tempFiles); - return ListView.builder( - itemCount: files.length, - padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 7), - itemBuilder: (context, index) { - final file = files[index]; - final fileName = file.path.split('/').last; - final stat = file.statSync(); - final isDir = stat.type == FileSystemEntityType.directory; - return CardX( - child: ListTile( - leading: isDir - ? const Icon(Icons.folder_open) - : const Icon(Icons.insert_drive_file), - title: Text(fileName), - subtitle: - isDir ? null : Text(stat.size.bytes2Str, style: UIs.textGrey), - trailing: Text( - stat.modified - .toString() - .substring(0, stat.modified.toString().length - 4), - style: UIs.textGrey, - ), - onLongPress: () { - if (!isDir) return; - _showDirActionDialog(file); - }, - onTap: () async { - if (!isDir) { - await _showFileActionDialog(file); - return; - } - _path!.update(fileName); - setState(() {}); - }, - ), + return FutureWidget( + future: getEntities(), + loading: UIs.placeholder, + success: (items_) { + final items = items_ ?? []; + final len = _path.canBack ? items.length + 1 : items.length; + return ListView.builder( + itemCount: len, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 13), + itemBuilder: (context, index) { + if (index == 0 && _path.canBack) { + return CardX( + child: ListTile( + leading: const Icon(Icons.arrow_back), + title: const Text('..'), + onTap: () { + _path.update('..'); + setState(() {}); + }, + ), + ); + } + + if (_path.canBack) index--; + + final item = items[index]; + final file = item.$1; + final fileName = file.path.split('/').last; + final stat = item.$2; + final isDir = stat.type == FileSystemEntityType.directory; + + return CardX( + child: ListTile( + leading: isDir + ? const Icon(Icons.folder_open) + : const Icon(Icons.insert_drive_file), + title: Text(fileName), + subtitle: isDir + ? null + : Text(stat.size.bytes2Str, style: UIs.textGrey), + trailing: Text( + stat.modified.ymdhms(), + style: UIs.textGrey, + ), + onLongPress: () { + if (isDir) { + _showDirActionDialog(file); + return; + } + _showFileActionDialog(file); + }, + onTap: () { + if (!isDir) { + _showFileActionDialog(file); + return; + } + _path.update(fileName); + setState(() {}); + }, + ), + ); + }, ); }, ); @@ -222,7 +176,7 @@ class _LocalStoragePageState extends State { Future _showFileActionDialog(FileSystemEntity file) async { final fileName = file.path.split('/').last; - if (widget.isPickFile) { + if (isPickFile) { await context.showRoundDialog( title: libL10n.file, child: Text(fileName), @@ -324,25 +278,33 @@ class _LocalStoragePageState extends State { void _showRenameDialog(FileSystemEntity file) { final fileName = file.path.split('/').last; + final ctrl = TextEditingController(text: fileName); + void onSubmit() async { + final newName = ctrl.text; + if (newName.isEmpty) { + context.showSnackBar(libL10n.empty); + return; + } + + context.pop(); + final newPath = '${file.parent.path}/$newName'; + await context.showLoadingDialog(fn: () => file.rename(newPath)); + + setState(() {}); + } + context.showRoundDialog( title: libL10n.rename, child: Input( autoFocus: true, + icon: Icons.abc, + label: libL10n.name, controller: TextEditingController(text: fileName), suggestion: true, - onSubmitted: (p0) { - context.pop(); - final newPath = '${file.parent.path}/$p0'; - try { - file.renameSync(newPath); - } catch (e) { - context.showSnackBar('${libL10n.fail}:\n$e'); - return; - } - - setState(() {}); - }, + maxLines: 3, + onSubmitted: (p0) => onSubmit(), ), + actions: Btn.ok(onTap: onSubmit).toList, ); } @@ -365,6 +327,30 @@ class _LocalStoragePageState extends State { ).toList, ); } + + Widget _buildMissionBtn() { + return IconButton( + icon: const Icon(Icons.downloading), + onPressed: () => AppRoutes.sftpMission().go(context), + ); + } + + Widget _buildSortBtn() { + return _sortType.listenVal( + (value) { + return PopupMenuButton<_SortType>( + icon: const Icon(Icons.sort), + itemBuilder: (_) => _SortType.values.map((e) => e.menuItem).toList(), + onSelected: (value) { + _sortType.value = value; + }, + ); + }, + ); + } + + @override + bool get wantKeepAlive => true; } enum _SortType { @@ -388,4 +374,29 @@ enum _SortType { } return files; } + + String get i18n => switch (this) { + name => libL10n.name, + size => l10n.size, + time => l10n.time, + }; + + IconData get icon => switch (this) { + name => Icons.sort_by_alpha, + size => Icons.sort, + time => Icons.access_time, + }; + + PopupMenuItem<_SortType> get menuItem { + return PopupMenuItem( + value: this, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Icon(icon), + Text(i18n), + ], + ), + ); + } } diff --git a/lib/view/page/storage/sftp.dart b/lib/view/page/storage/sftp.dart index df548ce2..e16dde39 100644 --- a/lib/view/page/storage/sftp.dart +++ b/lib/view/page/storage/sftp.dart @@ -14,6 +14,7 @@ import 'package:server_box/data/model/sftp/worker.dart'; import 'package:server_box/data/provider/sftp.dart'; import 'package:server_box/data/res/misc.dart'; import 'package:server_box/data/res/store.dart'; +import 'package:server_box/view/page/storage/local.dart'; import 'package:server_box/view/widget/omit_start_text.dart'; import 'package:server_box/view/widget/two_line_text.dart'; import 'package:server_box/view/widget/unix_perm.dart'; @@ -691,8 +692,10 @@ class _SftpPageState extends State with AfterLayoutMixin { ], )); final path = switch (idx) { - 0 => - await AppRoutes.localStorage(isPickFile: true).go(context), + 0 => await LocalFilePage.route.go( + context, + args: const LocalFilePageArgs(isPickFile: true), + ), 1 => await Pfs.pickFilePath(), _ => null, }; diff --git a/lib/view/page/storage/sftp_mission.dart b/lib/view/page/storage/sftp_mission.dart index e1d40830..f4994cb0 100644 --- a/lib/view/page/storage/sftp_mission.dart +++ b/lib/view/page/storage/sftp_mission.dart @@ -1,9 +1,9 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:server_box/core/extension/context/locale.dart'; -import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/sftp/worker.dart'; import 'package:server_box/data/provider/sftp.dart'; +import 'package:server_box/view/page/storage/local.dart'; class SftpMissionPage extends StatefulWidget { const SftpMissionPage({super.key}); @@ -115,7 +115,10 @@ class _SftpMissionPageState extends State { onPressed: () { final idx = status.req.localPath.lastIndexOf('/'); final dir = status.req.localPath.substring(0, idx); - AppRoutes.localStorage(initDir: dir).go(context); + LocalFilePage.route.go( + context, + args: LocalFilePageArgs(initDir: dir), + ); }, icon: const Icon(Icons.file_open), ), diff --git a/lib/view/widget/server_func_btns.dart b/lib/view/widget/server_func_btns.dart index 27bda211..cc6e3c84 100644 --- a/lib/view/widget/server_func_btns.dart +++ b/lib/view/widget/server_func_btns.dart @@ -113,7 +113,9 @@ void _onTapMoreBtns( title: l10n.snippet, tags: SnippetProvider.tags, itemsBuilder: (e) { - if (e == kDefaultTag) return SnippetProvider.snippets.value; + if (e == TagSwitcher.kDefaultTag) { + return SnippetProvider.snippets.value; + } return SnippetProvider.snippets.value .where((element) => element.tags?.contains(e) ?? false) .toList(); diff --git a/pubspec.lock b/pubspec.lock index 9b0fa186..58ee8258 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -470,8 +470,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v1.0.183" - resolved-ref: "1333b6269a4caa54c58324c66558e09a0525784b" + ref: "v1.0.187" + resolved-ref: "9c69e55fd227428935de8de938c265ab5c099451" url: "https://github.com/lppcg/fl_lib" source: git version: "0.0.1" diff --git a/pubspec.yaml b/pubspec.yaml index 0249ee14..e1b56d9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,7 +60,7 @@ dependencies: fl_lib: git: url: https://github.com/lppcg/fl_lib - ref: v1.0.183 + ref: v1.0.187 dependency_overrides: # dartssh2: