opt.: redesigned settings page (#587)

This commit is contained in:
lollipopkit🏳️‍⚧️
2024-09-21 22:37:42 +08:00
committed by GitHub
parent d7669c94b8
commit c062c12a0e
21 changed files with 561 additions and 551 deletions

View File

@@ -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<BackupPage> createState() => _BackupPageState();
}
final class _BackupPageState extends State<BackupPage>
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;
}

View File

@@ -1,40 +1,20 @@
part of 'home.dart';
final class _AppBar extends CustomAppBar {
final ValueNotifier<int> selectIndex;
final ValueNotifier<bool> 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);
}

View File

@@ -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<HomePage>
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: <Widget>[
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<HomePage>
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<void> _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<String>(context);
if (result == null) {
return;
}
try {
final newSettings = json.decode(result) as Map<String, dynamic>;
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);
}
}
}

View File

@@ -22,9 +22,6 @@ class _PrivateKeyListState extends State<PrivateKeysListPage>
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: CustomAppBar(
title: Text(l10n.privateKey),
),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),

View File

@@ -118,10 +118,10 @@ class _ServerDetailPageState extends State<ServerDetailPage>
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),

View File

@@ -610,13 +610,12 @@ class _ServerEditPageState extends State<ServerEditPage> 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);

View File

@@ -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<ServerPage>
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<ServerPage>
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<ServerPage>
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<ServerPage>
// 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<String> _filterServers(List<String> 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;

View File

@@ -0,0 +1,43 @@
part of 'tab.dart';
final class _TopBar extends StatelessWidget implements PreferredSizeWidget {
final ValueNotifier<Set<String>> 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);
}

View File

@@ -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<SettingPage> 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<Widget> pages =
SettingsTabs.values.map((e) => e.page).toList();
}
class _SettingPageState extends State<SettingPage> {
final _setting = Stores.setting;
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
static const route = AppRouteNoArg(page: SettingsPage.new, path: '/settings');
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage>
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<AppAboutPage> createState() => _AppAboutPageState();
}
final class _AppAboutPageState extends State<AppAboutPage>
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: <Widget>[
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<AppSettingsPage> createState() => _AppSettingsPageState();
}
final class _AppSettingsPageState extends State<AppSettingsPage> {
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<SettingPage> {
_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<SettingPage> {
},
);
}
Future<void> _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<String>(context);
if (result == null) {
return;
}
try {
final newSettings = json.decode(result) as Map<String, dynamic>;
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);
}
}
}

View File

@@ -13,11 +13,13 @@ class SnippetListPage extends StatefulWidget {
State<SnippetListPage> createState() => _SnippetListPageState();
}
class _SnippetListPageState extends State<SnippetListPage> {
class _SnippetListPageState extends State<SnippetListPage>
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<SnippetListPage> {
}
Widget _buildSnippetList(List<Snippet> 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<SnippetListPage> {
);
}
@override
bool get wantKeepAlive => true;
// Future<void> _runSnippet(Snippet snippet) async {
// final servers = await context.showPickDialog<Server>(
// items: Pros.server.servers.toList(),

View File

@@ -300,7 +300,9 @@ class SSHPageState extends State<SSHPage>
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();

View File

@@ -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<LocalStoragePage> createState() => _LocalStoragePageState();
}
class _LocalStoragePageState extends State<LocalStoragePage> {
LocalPath? _path;
class LocalFilePage extends StatefulWidget {
final LocalFilePageArgs? args;
final _sortType = ValueNotifier(_SortType.name);
const LocalFilePage({super.key, this.args});
static const route = AppRoute<String, LocalFilePageArgs>(
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<LocalFilePage> createState() => _LocalFilePageState();
}
class _LocalFilePageState extends State<LocalFilePage>
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<List<(FileSystemEntity, FileStat)>> 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<LocalStoragePage> {
Future<void> _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<LocalStoragePage> {
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<LocalStoragePage> {
).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),
],
),
);
}
}

View File

@@ -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<SftpPage> with AfterLayoutMixin {
],
));
final path = switch (idx) {
0 =>
await AppRoutes.localStorage(isPickFile: true).go<String>(context),
0 => await LocalFilePage.route.go(
context,
args: const LocalFilePageArgs(isPickFile: true),
),
1 => await Pfs.pickFilePath(),
_ => null,
};

View File

@@ -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<SftpMissionPage> {
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),
),

View File

@@ -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();