From ee22cdb55f46ea1f46d119ba8e45f120e4e6a96b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:05:54 +0800 Subject: [PATCH] fix: private key can't be selected in edit page (#879) --- lib/data/provider/private_key.g.dart | 2 +- lib/data/provider/server/all.g.dart | 2 +- lib/data/provider/snippet.g.dart | 2 +- lib/view/page/server/detail/view.dart | 2 +- lib/view/page/server/edit.dart | 964 ------------------------- lib/view/page/server/edit/actions.dart | 382 ++++++++++ lib/view/page/server/edit/edit.dart | 221 ++++++ lib/view/page/server/edit/widget.dart | 465 ++++++++++++ lib/view/page/server/tab/tab.dart | 2 +- lib/view/page/ssh/tab.dart | 2 +- pubspec.lock | 32 +- 11 files changed, 1090 insertions(+), 986 deletions(-) delete mode 100644 lib/view/page/server/edit.dart create mode 100644 lib/view/page/server/edit/actions.dart create mode 100644 lib/view/page/server/edit/edit.dart create mode 100644 lib/view/page/server/edit/widget.dart diff --git a/lib/data/provider/private_key.g.dart b/lib/data/provider/private_key.g.dart index 974da627..6ee8e41e 100644 --- a/lib/data/provider/private_key.g.dart +++ b/lib/data/provider/private_key.g.dart @@ -7,7 +7,7 @@ part of 'private_key.dart'; // ************************************************************************** String _$privateKeyNotifierHash() => - r'404836a4409f64d305c1e22f4a57b52985a57b68'; + r'12edd05dca29d1cbc9e2a3e047c3d417d22f7bb7'; /// See also [PrivateKeyNotifier]. @ProviderFor(PrivateKeyNotifier) diff --git a/lib/data/provider/server/all.g.dart b/lib/data/provider/server/all.g.dart index c49070bb..d427f43b 100644 --- a/lib/data/provider/server/all.g.dart +++ b/lib/data/provider/server/all.g.dart @@ -6,7 +6,7 @@ part of 'all.dart'; // RiverpodGenerator // ************************************************************************** -String _$serversNotifierHash() => r'2ae641188f772794a32e8700c008f51ba0cc1ec9'; +String _$serversNotifierHash() => r'2b29ad3027a203c7a20bfd0142d384a503cbbcaa'; /// See also [ServersNotifier]. @ProviderFor(ServersNotifier) diff --git a/lib/data/provider/snippet.g.dart b/lib/data/provider/snippet.g.dart index 96d07c9e..bad57609 100644 --- a/lib/data/provider/snippet.g.dart +++ b/lib/data/provider/snippet.g.dart @@ -6,7 +6,7 @@ part of 'snippet.dart'; // RiverpodGenerator // ************************************************************************** -String _$snippetNotifierHash() => r'caf0361f9a0346fb99cb90f032f1ceb29446dd71'; +String _$snippetNotifierHash() => r'8285c7edf905a4aaa41cd8b65b0a6755c8b97fc9'; /// See also [SnippetNotifier]. @ProviderFor(SnippetNotifier) diff --git a/lib/view/page/server/detail/view.dart b/lib/view/page/server/detail/view.dart index 856f24fb..7ea2de8d 100644 --- a/lib/view/page/server/detail/view.dart +++ b/lib/view/page/server/detail/view.dart @@ -24,7 +24,7 @@ import 'package:server_box/data/model/server/system.dart'; import 'package:server_box/data/provider/server/single.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/view/page/pve.dart'; -import 'package:server_box/view/page/server/edit.dart'; +import 'package:server_box/view/page/server/edit/edit.dart'; import 'package:server_box/view/widget/server_func_btns.dart'; part 'misc.dart'; diff --git a/lib/view/page/server/edit.dart b/lib/view/page/server/edit.dart deleted file mode 100644 index 439d9e95..00000000 --- a/lib/view/page/server/edit.dart +++ /dev/null @@ -1,964 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:choice/choice.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:fl_lib/fl_lib.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icons_plus/icons_plus.dart'; -import 'package:server_box/core/extension/context/locale.dart'; -import 'package:server_box/core/route.dart'; -import 'package:server_box/core/utils/server_dedup.dart'; -import 'package:server_box/core/utils/ssh_config.dart'; -import 'package:server_box/data/model/app/scripts/cmd_types.dart'; -import 'package:server_box/data/model/server/custom.dart'; -import 'package:server_box/data/model/server/server_private_info.dart'; -import 'package:server_box/data/model/server/system.dart'; -import 'package:server_box/data/model/server/wol_cfg.dart'; -import 'package:server_box/data/provider/private_key.dart'; -import 'package:server_box/data/provider/server/all.dart'; -import 'package:server_box/data/res/store.dart'; -import 'package:server_box/data/store/server.dart'; -import 'package:server_box/view/page/private_key/edit.dart'; - -class ServerEditPage extends ConsumerStatefulWidget { - final SpiRequiredArgs? args; - - const ServerEditPage({super.key, this.args}); - - static const route = AppRoute(page: ServerEditPage.new, path: '/servers/edit'); - - @override - ConsumerState createState() => _ServerEditPageState(); -} - -class _ServerEditPageState extends ConsumerState with AfterLayoutMixin { - late final spi = widget.args?.spi; - final _nameController = TextEditingController(); - final _ipController = TextEditingController(); - final _altUrlController = TextEditingController(); - final _portController = TextEditingController(); - final _usernameController = TextEditingController(); - final _passwordController = TextEditingController(); - final _pveAddrCtrl = TextEditingController(); - final _preferTempDevCtrl = TextEditingController(); - final _logoUrlCtrl = TextEditingController(); - final _wolMacCtrl = TextEditingController(); - final _wolIpCtrl = TextEditingController(); - final _wolPwdCtrl = TextEditingController(); - final _netDevCtrl = TextEditingController(); - final _scriptDirCtrl = TextEditingController(); - - final _nameFocus = FocusNode(); - final _ipFocus = FocusNode(); - final _alterUrlFocus = FocusNode(); - final _portFocus = FocusNode(); - final _usernameFocus = FocusNode(); - - late FocusScopeNode _focusScope; - - /// -1: non selected, null: password, others: index of private key - final _keyIdx = ValueNotifier(null); - final _autoConnect = ValueNotifier(true); - final _jumpServer = nvn(); - final _pveIgnoreCert = ValueNotifier(false); - final _env = {}.vn; - final _customCmds = {}.vn; - final _tags = {}.vn; - final _systemType = ValueNotifier(null); - final _disabledCmdTypes = {}.vn; - - @override - void dispose() { - super.dispose(); - _nameController.dispose(); - _ipController.dispose(); - _altUrlController.dispose(); - _portController.dispose(); - _usernameController.dispose(); - _passwordController.dispose(); - _preferTempDevCtrl.dispose(); - _logoUrlCtrl.dispose(); - _wolMacCtrl.dispose(); - _wolIpCtrl.dispose(); - _wolPwdCtrl.dispose(); - _netDevCtrl.dispose(); - _scriptDirCtrl.dispose(); - - _nameFocus.dispose(); - _ipFocus.dispose(); - _alterUrlFocus.dispose(); - _portFocus.dispose(); - _usernameFocus.dispose(); - _pveAddrCtrl.dispose(); - - _keyIdx.dispose(); - _autoConnect.dispose(); - _jumpServer.dispose(); - _pveIgnoreCert.dispose(); - _env.dispose(); - _customCmds.dispose(); - _tags.dispose(); - _systemType.dispose(); - _disabledCmdTypes.dispose(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _focusScope = FocusScope.of(context); - } - - @override - Widget build(BuildContext context) { - final actions = []; - if (spi != null) actions.add(_buildDelBtn()); - - return GestureDetector( - onTap: () => _focusScope.unfocus(), - child: Scaffold( - appBar: CustomAppBar(title: Text(libL10n.edit), actions: actions), - body: _buildForm(), - floatingActionButton: _buildFAB(), - ), - ); - } - - Widget _buildForm() { - final topItems = [_buildWriteScriptTip(), if (isMobile) _buildQrScan(), if (isDesktop) _buildSSHImport()]; - final children = [ - Row(mainAxisAlignment: MainAxisAlignment.center, children: topItems.joinWith(UIs.width13).toList()), - Input( - autoFocus: true, - controller: _nameController, - type: TextInputType.text, - node: _nameFocus, - onSubmitted: (_) => _focusScope.requestFocus(_ipFocus), - hint: libL10n.example, - label: libL10n.name, - icon: BoxIcons.bx_rename, - obscureText: false, - autoCorrect: true, - suggestion: true, - ), - Input( - controller: _ipController, - type: TextInputType.url, - onSubmitted: (_) => _focusScope.requestFocus(_portFocus), - node: _ipFocus, - label: l10n.host, - icon: BoxIcons.bx_server, - hint: 'example.com', - suggestion: false, - ), - Input( - controller: _portController, - type: TextInputType.number, - node: _portFocus, - onSubmitted: (_) => _focusScope.requestFocus(_usernameFocus), - label: l10n.port, - icon: Bootstrap.number_123, - hint: '22', - suggestion: false, - ), - Input( - controller: _usernameController, - type: TextInputType.text, - node: _usernameFocus, - onSubmitted: (_) => _focusScope.requestFocus(_alterUrlFocus), - label: libL10n.user, - icon: Icons.account_box, - hint: 'root', - suggestion: false, - ), - TagTile(tags: _tags, allTags: ref.watch(serversNotifierProvider).tags).cardx, - ListTile( - title: Text(l10n.autoConnect), - trailing: _autoConnect.listenVal( - (val) => Switch( - value: val, - onChanged: (val) { - _autoConnect.value = val; - }, - ), - ), - ), - _buildAuth(), - _buildSystemType(), - _buildJumpServer(), - _buildMore(), - ]; - return AutoMultiList(children: children); - } - - Widget _buildAuth() { - final switch_ = ListTile( - title: Text(l10n.keyAuth), - trailing: _keyIdx.listenVal( - (v) => Switch( - value: v != null, - onChanged: (val) { - if (val) { - _keyIdx.value = -1; - } else { - _keyIdx.value = null; - } - }, - ), - ), - ); - - /// Put [switch_] out of [ValueBuilder] to avoid rebuild - return _keyIdx.listenVal((v) { - final children = [switch_]; - if (v != null) { - children.add(_buildKeyAuth()); - } else { - children.add( - Input( - controller: _passwordController, - obscureText: true, - type: TextInputType.text, - label: libL10n.pwd, - icon: Icons.password, - suggestion: false, - onSubmitted: (_) => _onSave(), - ), - ); - } - return Column(children: children); - }); - } - - Widget _buildKeyAuth() { - final privateKeyState = ref.watch(privateKeyNotifierProvider); - final pkis = privateKeyState.keys; - - final tiles = List.generate(pkis.length, (index) { - final e = pkis[index]; - return ListTile( - contentPadding: const EdgeInsets.only(left: 10, right: 15), - leading: Radio(value: index), - title: Text(e.id, textAlign: TextAlign.start), - subtitle: Text(e.type ?? l10n.unknown, textAlign: TextAlign.start, style: UIs.textGrey), - trailing: Btn.icon( - icon: const Icon(Icons.edit), - onTap: () => PrivateKeyEditPage.route.go(context, args: PrivateKeyEditPageArgs(pki: e)), - ), - onTap: () => _keyIdx.value = index, - ); - }); - tiles.add( - ListTile( - title: Text(libL10n.add), - contentPadding: const EdgeInsets.only(left: 23, right: 23), - trailing: const Icon(Icons.add), - onTap: () => PrivateKeyEditPage.route.go(context), - ), - ); - return RadioGroup( - onChanged: (val) => _keyIdx.value = val, - child: _keyIdx.listenVal((_) => Column(children: tiles)).cardx, - ); - } - - Widget _buildEnvs() { - return _env.listenVal((val) { - final subtitle = val.isEmpty ? null : Text(val.keys.join(','), style: UIs.textGrey); - return ListTile( - leading: const Icon(HeroIcons.variable), - subtitle: subtitle, - title: Text(l10n.envVars), - trailing: const Icon(Icons.keyboard_arrow_right), - onTap: () async { - final res = await KvEditor.route.go(context, KvEditorArgs(data: spi?.envs ?? {})); - if (res == null) return; - _env.value = res; - }, - ).cardx; - }); - } - - Widget _buildMore() { - return ExpandTile( - title: Text(l10n.more), - children: [ - Input( - controller: _logoUrlCtrl, - type: TextInputType.url, - icon: Icons.image, - label: 'Logo URL', - hint: 'https://example.com/logo.png', - suggestion: false, - ), - _buildAltUrl(), - _buildScriptDir(), - _buildEnvs(), - _buildPVEs(), - _buildCustomCmds(), - _buildDisabledCmdTypes(), - _buildCustomDev(), - _buildWOLs(), - ], - ); - } - - Widget _buildScriptDir() { - return Input( - controller: _scriptDirCtrl, - type: TextInputType.text, - label: '${l10n.remotePath} (Shell ${l10n.install})', - icon: Icons.folder, - hint: '~/.config/server_box', - suggestion: false, - ); - } - - Widget _buildCustomDev() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - CenterGreyTitle(l10n.specifyDev), - ListTile( - leading: const Icon(MingCute.question_line), - title: TipText(libL10n.note, l10n.specifyDevTip), - ).cardx, - Input( - controller: _preferTempDevCtrl, - type: TextInputType.text, - label: l10n.temperature, - icon: MingCute.low_temperature_line, - hint: 'nvme-pci-0400', - suggestion: false, - ), - Input( - controller: _netDevCtrl, - type: TextInputType.text, - label: l10n.net, - icon: ZondIcons.network, - hint: 'eth0', - suggestion: false, - ), - ], - ); - } - - Widget _buildSystemType() { - return _systemType.listenVal((val) { - return ListTile( - leading: Icon(MingCute.laptop_2_line), - title: Text(l10n.system), - trailing: PopupMenu( - initialValue: val, - items: [ - PopupMenuItem(value: null, child: Text(libL10n.auto)), - PopupMenuItem(value: SystemType.linux, child: Text('Linux')), - PopupMenuItem(value: SystemType.bsd, child: Text('BSD')), - PopupMenuItem(value: SystemType.windows, child: Text('Windows')), - ], - onSelected: (value) => _systemType.value = value, - child: Text(val?.name ?? libL10n.auto, style: TextStyle(color: val == null ? Colors.grey : null)), - ), - ).cardx; - }); - } - - Widget _buildAltUrl() { - return Input( - controller: _altUrlController, - type: TextInputType.url, - node: _alterUrlFocus, - label: l10n.fallbackSshDest, - icon: MingCute.link_line, - hint: 'user@ip:port', - suggestion: false, - ); - } - - Widget _buildPVEs() { - const addr = 'https://127.0.0.1:8006'; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CenterGreyTitle('PVE'), - Input( - controller: _pveAddrCtrl, - type: TextInputType.url, - icon: MingCute.web_line, - label: 'URL', - hint: addr, - suggestion: false, - ), - ListTile( - leading: const Icon(MingCute.certificate_line), - title: TipText('PVE ${l10n.ignoreCert}', l10n.pveIgnoreCertTip), - trailing: _pveIgnoreCert.listenVal( - (v) => Switch( - value: v, - onChanged: (val) { - _pveIgnoreCert.value = val; - }, - ), - ), - ).cardx, - ], - ); - } - - Widget _buildCustomCmds() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - CenterGreyTitle(l10n.customCmd), - _customCmds.listenVal((vals) { - return ListTile( - leading: const Icon(BoxIcons.bxs_file_json), - title: const Text('JSON'), - subtitle: vals.isEmpty ? null : Text(vals.keys.join(','), style: UIs.textGrey), - trailing: const Icon(Icons.keyboard_arrow_right), - onTap: _onTapCustomItem, - ); - }).cardx, - ListTile( - leading: const Icon(MingCute.doc_line), - title: Text(libL10n.doc), - trailing: const Icon(Icons.open_in_new, size: 17), - onTap: l10n.customCmdDocUrl.launchUrl, - ).cardx, - ], - ); - } - - Widget _buildDisabledCmdTypes() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - CenterGreyTitle('${libL10n.disabled} ${l10n.cmd}'), - _disabledCmdTypes.listenVal((disabled) { - return ListTile( - leading: const Icon(Icons.disabled_by_default), - title: Text('${libL10n.disabled} ${l10n.cmd}'), - subtitle: disabled.isEmpty ? null : Text(disabled.join(', '), style: UIs.textGrey), - trailing: const Icon(Icons.keyboard_arrow_right), - onTap: _onTapDisabledCmdTypes, - ); - }).cardx, - ], - ); - } - - Widget _buildWOLs() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CenterGreyTitle('Wake On LAN (beta)'), - ListTile( - leading: const Icon(BoxIcons.bxs_help_circle), - title: TipText(libL10n.about, l10n.wolTip), - ).cardx, - Input( - controller: _wolMacCtrl, - type: TextInputType.text, - label: 'MAC ${l10n.addr}', - icon: Icons.computer, - hint: '00:11:22:33:44:55', - suggestion: false, - ), - Input( - controller: _wolIpCtrl, - type: TextInputType.text, - label: 'IP ${l10n.addr}', - icon: ZondIcons.network, - hint: '192.168.1.x', - suggestion: false, - ), - Input( - controller: _wolPwdCtrl, - type: TextInputType.text, - obscureText: true, - label: libL10n.pwd, - icon: Icons.password, - suggestion: false, - ), - ], - ); - } - - Widget _buildFAB() { - return FloatingActionButton(onPressed: _onSave, child: const Icon(Icons.save)); - } - - Widget _buildJumpServer() { - const padding = EdgeInsets.only(left: 13, right: 13, bottom: 7); - final srvs = ref - .watch(serversNotifierProvider) - .servers - .values - .where((e) => e.jumpId == null) - .where((e) => e.id != spi?.id) - .toList(); - final choice = _jumpServer.listenVal((val) { - final srv = srvs.firstWhereOrNull((e) => e.id == _jumpServer.value); - return Choice( - multiple: false, - clearable: true, - value: srv != null ? [srv] : [], - builder: (state, _) => Wrap( - children: List.generate(srvs.length, (index) { - final item = srvs[index]; - return ChoiceChipX( - label: item.name, - state: state, - value: item, - onSelected: (srv, on) { - if (on) { - _jumpServer.value = srv.id; - } else { - _jumpServer.value = null; - } - }, - ); - }), - ), - ); - }); - return ExpandTile( - leading: const Icon(Icons.map), - initiallyExpanded: _jumpServer.value != null, - childrenPadding: padding, - title: Text(l10n.jumpServer), - children: [choice], - ).cardx; - } - - Widget _buildWriteScriptTip() { - return Btn.tile( - text: libL10n.attention, - icon: const Icon(Icons.tips_and_updates, color: Colors.grey), - onTap: () { - context.showRoundDialog( - title: libL10n.attention, - child: SimpleMarkdown(data: l10n.writeScriptTip), - actions: Btnx.oks, - ); - }, - textStyle: UIs.textGrey, - mainAxisSize: MainAxisSize.min, - ); - } - - Widget _buildQrScan() { - return Btn.tile( - text: libL10n.import, - icon: const Icon(Icons.qr_code, color: Colors.grey), - onTap: () async { - final ret = await BarcodeScannerPage.route.go(context, args: const BarcodeScannerPageArgs()); - final code = ret?.text; - if (code == null) return; - try { - final spi = Spi.fromJson(json.decode(code)); - _initWithSpi(spi); - } catch (e, s) { - context.showErrDialog(e, s); - } - }, - textStyle: UIs.textGrey, - mainAxisSize: MainAxisSize.min, - ); - } - - Widget _buildSSHImport() { - return Btn.tile( - text: l10n.sshConfigImport, - icon: const Icon(Icons.settings, color: Colors.grey), - onTap: _onTapSSHImport, - textStyle: UIs.textGrey, - mainAxisSize: MainAxisSize.min, - ); - } - - Widget _buildDelBtn() { - return IconButton( - onPressed: () { - context.showRoundDialog( - title: libL10n.attention, - child: Text(libL10n.askContinue('${libL10n.delete} ${l10n.server}(${spi!.name})')), - actions: Btn.ok( - onTap: () async { - context.pop(); - ref.read(serversNotifierProvider.notifier).delServer(spi!.id); - context.pop(true); - }, - red: true, - ).toList, - ); - }, - icon: const Icon(Icons.delete), - ); - } - - @override - void afterFirstLayout(BuildContext context) { - if (spi != null) { - _initWithSpi(spi!); - } else { - // Only for new servers, check SSH config import on first time - _checkSSHConfigImport(); - } - } -} - -extension _Actions on _ServerEditPageState { - void _onTapSSHImport() async { - try { - final servers = await SSHConfig.parseConfig(); - if (servers.isEmpty) { - context.showSnackBar(l10n.sshConfigNoServers); - return; - } - - dprint('Parsed ${servers.length} servers from SSH config'); - await _processSSHServers(servers); - dprint('Finished processing SSH config servers'); - } catch (e, s) { - _handleImportSSHCfgPermissionIssue(e, s); - } - } - - void _handleImportSSHCfgPermissionIssue(Object e, StackTrace s) async { - dprint('Error importing SSH config: $e'); - // Check if it's a permission error and offer file picker as fallback - if (e is PathAccessException || e.toString().contains('Operation not permitted')) { - final useFilePicker = await context.showRoundDialog( - title: l10n.sshConfigImport, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.sshConfigPermissionDenied), - const SizedBox(height: 8), - Text(l10n.sshConfigManualSelect), - ], - ), - actions: Btnx.cancelOk, - ); - - if (useFilePicker == true) { - await _onTapSSHImportWithFilePicker(); - } - } else { - context.showErrDialog(e, s); - } - } - - Future _processSSHServers(List servers) async { - final deduplicated = ServerDeduplication.deduplicateServers(servers); - final resolved = ServerDeduplication.resolveNameConflicts(deduplicated); - final summary = ServerDeduplication.getImportSummary(servers, resolved); - - if (!summary.hasItemsToImport) { - context.showSnackBar(l10n.sshConfigAllExist('${summary.duplicates}')); - return; - } - - final shouldImport = await context.showRoundDialog( - title: l10n.sshConfigImport, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.sshConfigFoundServers('${summary.total}')), - if (summary.hasDuplicates) - Text(l10n.sshConfigDuplicatesSkipped('${summary.duplicates}'), style: UIs.textGrey), - Text(l10n.sshConfigServersToImport('${summary.toImport}')), - const SizedBox(height: 16), - ...resolved.map((s) => Text('• ${s.name} (${s.user}@${s.ip}:${s.port})')), - ], - ), - ), - actions: Btnx.cancelOk, - ); - - if (shouldImport == true) { - for (final server in resolved) { - ref.read(serversNotifierProvider.notifier).addServer(server); - } - context.showSnackBar(l10n.sshConfigImported('${resolved.length}')); - } - } - - Future _onTapSSHImportWithFilePicker() async { - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.any, - allowMultiple: false, - dialogTitle: 'SSH ${libL10n.select}', - ); - - if (result?.files.single.path case final path?) { - final servers = await SSHConfig.parseConfig(path); - if (servers.isEmpty) { - context.showSnackBar(l10n.sshConfigNoServers); - return; - } - - await _processSSHServers(servers); - } - } catch (e, s) { - context.showErrDialog(e, s); - } - } - - void _onTapCustomItem() async { - final res = await KvEditor.route.go(context, KvEditorArgs(data: _customCmds.value)); - if (res == null) return; - _customCmds.value = res; - } - - void _onTapDisabledCmdTypes() async { - final allCmdTypes = ShellCmdType.all; - - // [TimeSeq] depends on the `time` cmd type, so it should be removed from the list - allCmdTypes.remove(StatusCmdType.time); - - await _showCmdTypesDialog(allCmdTypes); - } - - void _onSave() async { - if (_ipController.text.isEmpty) { - context.showSnackBar('${libL10n.empty} ${l10n.host}'); - return; - } - - if (_keyIdx.value == null && _passwordController.text.isEmpty) { - final ok = await context.showRoundDialog( - title: libL10n.attention, - child: Text(libL10n.askContinue(l10n.useNoPwd)), - actions: Btnx.cancelRedOk, - ); - if (ok != true) return; - } - - // If [_pubKeyIndex] is -1, it means that the user has not selected - if (_keyIdx.value == -1) { - context.showSnackBar(libL10n.empty); - return; - } - if (_usernameController.text.isEmpty) { - _usernameController.text = 'root'; - } - if (_portController.text.isEmpty) { - _portController.text = '22'; - } - final customCmds = _customCmds.value; - final custom = ServerCustom( - pveAddr: _pveAddrCtrl.text.selfNotEmptyOrNull, - pveIgnoreCert: _pveIgnoreCert.value, - cmds: customCmds.isEmpty ? null : customCmds, - preferTempDev: _preferTempDevCtrl.text.selfNotEmptyOrNull, - logoUrl: _logoUrlCtrl.text.selfNotEmptyOrNull, - netDev: _netDevCtrl.text.selfNotEmptyOrNull, - scriptDir: _scriptDirCtrl.text.selfNotEmptyOrNull, - ); - - final wolEmpty = _wolMacCtrl.text.isEmpty && _wolIpCtrl.text.isEmpty && _wolPwdCtrl.text.isEmpty; - final wol = wolEmpty - ? null - : WakeOnLanCfg(mac: _wolMacCtrl.text, ip: _wolIpCtrl.text, pwd: _wolPwdCtrl.text.selfNotEmptyOrNull); - if (wol != null) { - final wolValidation = wol.validate(); - if (!wolValidation.$2) { - context.showSnackBar('${libL10n.fail}: ${wolValidation.$1}'); - return; - } - } - - final spi = Spi( - name: _nameController.text.isEmpty ? _ipController.text : _nameController.text, - ip: _ipController.text, - port: int.parse(_portController.text), - user: _usernameController.text, - pwd: _passwordController.text.selfNotEmptyOrNull, - keyId: _keyIdx.value != null - ? ref.read(privateKeyNotifierProvider).keys.elementAt(_keyIdx.value!).id - : null, - tags: _tags.value.isEmpty ? null : _tags.value.toList(), - alterUrl: _altUrlController.text.selfNotEmptyOrNull, - autoConnect: _autoConnect.value, - jumpId: _jumpServer.value, - custom: custom, - wolCfg: wol, - envs: _env.value.isEmpty ? null : _env.value, - id: widget.args?.spi.id ?? ShortId.generate(), - customSystemType: _systemType.value, - disabledCmdTypes: _disabledCmdTypes.value.isEmpty ? null : _disabledCmdTypes.value.toList(), - ); - - if (this.spi == null) { - final existsIds = ServerStore.instance.box.keys; - if (existsIds.contains(spi.id)) { - context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}'); - return; - } - ref.read(serversNotifierProvider.notifier).addServer(spi); - } else { - ref.read(serversNotifierProvider.notifier).updateServer(this.spi!, spi); - } - - context.pop(); - } -} - -extension _Utils on _ServerEditPageState { - void _checkSSHConfigImport() async { - final prop = Stores.setting.firstTimeReadSSHCfg; - // Only check if it's first time and user hasn't disabled it - if (!prop.fetch()) return; - - try { - // Check if SSH config exists - final (_, configExists) = SSHConfig.configExists(); - if (!configExists) return; - - // Ask for permission - final hasPermission = await context.showRoundDialog( - title: l10n.sshConfigImport, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.sshConfigFound), - UIs.height7, - Text(l10n.sshConfigImportPermission), - UIs.height7, - Text(l10n.sshConfigImportHelp, style: UIs.textGrey), - ], - ), - actions: Btnx.cancelOk, - ); - - prop.put(false); - - if (hasPermission == true) { - // Parse and import SSH config - final servers = await SSHConfig.parseConfig(); - if (servers.isEmpty) { - context.showSnackBar(l10n.sshConfigNoServers); - return; - } - - final deduplicated = ServerDeduplication.deduplicateServers(servers); - final resolved = ServerDeduplication.resolveNameConflicts(deduplicated); - final summary = ServerDeduplication.getImportSummary(servers, resolved); - - if (!summary.hasItemsToImport) { - context.showSnackBar(l10n.sshConfigAllExist('${summary.duplicates}')); - return; - } - - // Import without asking again since user already gave permission - for (final server in resolved) { - ref.read(serversNotifierProvider.notifier).addServer(server); - } - context.showSnackBar(l10n.sshConfigImported('${resolved.length}')); - } - } catch (e, s) { - _handleImportSSHCfgPermissionIssue(e, s); - } - } - - Future _showCmdTypesDialog(Set allCmdTypes) { - return context.showRoundDialog( - title: '${libL10n.disabled} ${l10n.cmd}', - child: SizedBox( - width: 270, - child: _disabledCmdTypes.listenVal((disabled) { - return ListView.builder( - itemCount: allCmdTypes.length, - itemExtent: 50, - itemBuilder: (context, index) { - final cmdType = allCmdTypes.elementAtOrNull(index); - if (cmdType == null) return UIs.placeholder; - final display = cmdType.displayName; - return ListTile( - leading: Icon(cmdType.sysType.icon, size: 20), - title: Text(cmdType.name, style: const TextStyle(fontSize: 16)), - trailing: Checkbox( - value: disabled.contains(display), - onChanged: (value) { - if (value == null) return; - if (value) { - _disabledCmdTypes.value.add(display); - } else { - _disabledCmdTypes.value.remove(display); - } - _disabledCmdTypes.notify(); - }, - ), - onTap: () { - final isDisabled = disabled.contains(display); - if (isDisabled) { - _disabledCmdTypes.value.remove(display); - } else { - _disabledCmdTypes.value.add(display); - } - _disabledCmdTypes.notify(); - }, - ); - }, - ); - }), - ), - actions: Btnx.oks, - ); - } - - void _initWithSpi(Spi spi) { - _nameController.text = spi.name; - _ipController.text = spi.ip; - _portController.text = spi.port.toString(); - _usernameController.text = spi.user; - if (spi.keyId == null) { - _passwordController.text = spi.pwd ?? ''; - } else { - _keyIdx.value = ref.read(privateKeyNotifierProvider).keys.indexWhere((e) => e.id == spi.keyId); - } - - /// List in dart is passed by pointer, so you need to copy it here - _tags.value = spi.tags?.toSet() ?? {}; - - _altUrlController.text = spi.alterUrl ?? ''; - _autoConnect.value = spi.autoConnect; - _jumpServer.value = spi.jumpId; - - final custom = spi.custom; - if (custom != null) { - _pveAddrCtrl.text = custom.pveAddr ?? ''; - _pveIgnoreCert.value = custom.pveIgnoreCert; - _customCmds.value = custom.cmds ?? {}; - _preferTempDevCtrl.text = custom.preferTempDev ?? ''; - _logoUrlCtrl.text = custom.logoUrl ?? ''; - } - - final wol = spi.wolCfg; - if (wol != null) { - _wolMacCtrl.text = wol.mac; - _wolIpCtrl.text = wol.ip; - _wolPwdCtrl.text = wol.pwd ?? ''; - } - - _env.value = spi.envs ?? {}; - - _netDevCtrl.text = spi.custom?.netDev ?? ''; - _scriptDirCtrl.text = spi.custom?.scriptDir ?? ''; - - _systemType.value = spi.customSystemType; - - final disabledCmdTypes = spi.disabledCmdTypes?.toSet() ?? {}; - final allAvailableCmdTypes = ShellCmdType.all.map((e) => e.displayName); - disabledCmdTypes.removeWhere((e) => !allAvailableCmdTypes.contains(e)); - _disabledCmdTypes.value = disabledCmdTypes; - } -} diff --git a/lib/view/page/server/edit/actions.dart b/lib/view/page/server/edit/actions.dart new file mode 100644 index 00000000..b5926693 --- /dev/null +++ b/lib/view/page/server/edit/actions.dart @@ -0,0 +1,382 @@ +part of 'edit.dart'; + +extension _Actions on _ServerEditPageState { + void _onTapSSHImport() async { + try { + final servers = await SSHConfig.parseConfig(); + if (servers.isEmpty) { + context.showSnackBar(l10n.sshConfigNoServers); + return; + } + + dprint('Parsed ${servers.length} servers from SSH config'); + await _processSSHServers(servers); + dprint('Finished processing SSH config servers'); + } catch (e, s) { + _handleImportSSHCfgPermissionIssue(e, s); + } + } + + void _handleImportSSHCfgPermissionIssue(Object e, StackTrace s) async { + dprint('Error importing SSH config: $e'); + // Check if it's a permission error and offer file picker as fallback + if (e is PathAccessException || + e.toString().contains('Operation not permitted')) { + final useFilePicker = await context.showRoundDialog( + title: l10n.sshConfigImport, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.sshConfigPermissionDenied), + const SizedBox(height: 8), + Text(l10n.sshConfigManualSelect), + ], + ), + actions: Btnx.cancelOk, + ); + + if (useFilePicker == true) { + await _onTapSSHImportWithFilePicker(); + } + } else { + context.showErrDialog(e, s); + } + } + + Future _processSSHServers(List servers) async { + final deduplicated = ServerDeduplication.deduplicateServers(servers); + final resolved = ServerDeduplication.resolveNameConflicts(deduplicated); + final summary = ServerDeduplication.getImportSummary(servers, resolved); + + if (!summary.hasItemsToImport) { + context.showSnackBar(l10n.sshConfigAllExist('${summary.duplicates}')); + return; + } + + final shouldImport = await context.showRoundDialog( + title: l10n.sshConfigImport, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.sshConfigFoundServers('${summary.total}')), + if (summary.hasDuplicates) + Text( + l10n.sshConfigDuplicatesSkipped('${summary.duplicates}'), + style: UIs.textGrey, + ), + Text(l10n.sshConfigServersToImport('${summary.toImport}')), + const SizedBox(height: 16), + ...resolved.map( + (s) => Text('• ${s.name} (${s.user}@${s.ip}:${s.port})'), + ), + ], + ), + ), + actions: Btnx.cancelOk, + ); + + if (shouldImport == true) { + for (final server in resolved) { + ref.read(serversNotifierProvider.notifier).addServer(server); + } + context.showSnackBar(l10n.sshConfigImported('${resolved.length}')); + } + } + + Future _onTapSSHImportWithFilePicker() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + allowMultiple: false, + dialogTitle: 'SSH ${libL10n.select}', + ); + + if (result?.files.single.path case final path?) { + final servers = await SSHConfig.parseConfig(path); + if (servers.isEmpty) { + context.showSnackBar(l10n.sshConfigNoServers); + return; + } + + await _processSSHServers(servers); + } + } catch (e, s) { + context.showErrDialog(e, s); + } + } + + void _onTapCustomItem() async { + final res = await KvEditor.route.go( + context, + KvEditorArgs(data: _customCmds.value), + ); + if (res == null) return; + _customCmds.value = res; + } + + void _onTapDisabledCmdTypes() async { + final allCmdTypes = ShellCmdType.all; + + // [TimeSeq] depends on the `time` cmd type, so it should be removed from the list + allCmdTypes.remove(StatusCmdType.time); + + await _showCmdTypesDialog(allCmdTypes); + } + + void _onSave() async { + if (_ipController.text.isEmpty) { + context.showSnackBar('${libL10n.empty} ${l10n.host}'); + return; + } + + if (_keyIdx.value == null && _passwordController.text.isEmpty) { + final ok = await context.showRoundDialog( + title: libL10n.attention, + child: Text(libL10n.askContinue(l10n.useNoPwd)), + actions: Btnx.cancelRedOk, + ); + if (ok != true) return; + } + + // If [_pubKeyIndex] is -1, it means that the user has not selected + if (_keyIdx.value == -1) { + context.showSnackBar(libL10n.empty); + return; + } + if (_usernameController.text.isEmpty) { + _usernameController.text = 'root'; + } + if (_portController.text.isEmpty) { + _portController.text = '22'; + } + final customCmds = _customCmds.value; + final custom = ServerCustom( + pveAddr: _pveAddrCtrl.text.selfNotEmptyOrNull, + pveIgnoreCert: _pveIgnoreCert.value, + cmds: customCmds.isEmpty ? null : customCmds, + preferTempDev: _preferTempDevCtrl.text.selfNotEmptyOrNull, + logoUrl: _logoUrlCtrl.text.selfNotEmptyOrNull, + netDev: _netDevCtrl.text.selfNotEmptyOrNull, + scriptDir: _scriptDirCtrl.text.selfNotEmptyOrNull, + ); + + final wolEmpty = + _wolMacCtrl.text.isEmpty && + _wolIpCtrl.text.isEmpty && + _wolPwdCtrl.text.isEmpty; + final wol = wolEmpty + ? null + : WakeOnLanCfg( + mac: _wolMacCtrl.text, + ip: _wolIpCtrl.text, + pwd: _wolPwdCtrl.text.selfNotEmptyOrNull, + ); + if (wol != null) { + final wolValidation = wol.validate(); + if (!wolValidation.$2) { + context.showSnackBar('${libL10n.fail}: ${wolValidation.$1}'); + return; + } + } + + final spi = Spi( + name: _nameController.text.isEmpty + ? _ipController.text + : _nameController.text, + ip: _ipController.text, + port: int.parse(_portController.text), + user: _usernameController.text, + pwd: _passwordController.text.selfNotEmptyOrNull, + keyId: _keyIdx.value != null + ? ref + .read(privateKeyNotifierProvider) + .keys + .elementAt(_keyIdx.value!) + .id + : null, + tags: _tags.value.isEmpty ? null : _tags.value.toList(), + alterUrl: _altUrlController.text.selfNotEmptyOrNull, + autoConnect: _autoConnect.value, + jumpId: _jumpServer.value, + custom: custom, + wolCfg: wol, + envs: _env.value.isEmpty ? null : _env.value, + id: widget.args?.spi.id ?? ShortId.generate(), + customSystemType: _systemType.value, + disabledCmdTypes: _disabledCmdTypes.value.isEmpty + ? null + : _disabledCmdTypes.value.toList(), + ); + + if (this.spi == null) { + final existsIds = ServerStore.instance.box.keys; + if (existsIds.contains(spi.id)) { + context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}'); + return; + } + ref.read(serversNotifierProvider.notifier).addServer(spi); + } else { + ref.read(serversNotifierProvider.notifier).updateServer(this.spi!, spi); + } + + context.pop(); + } +} + +extension _Utils on _ServerEditPageState { + void _checkSSHConfigImport() async { + final prop = Stores.setting.firstTimeReadSSHCfg; + // Only check if it's first time and user hasn't disabled it + if (!prop.fetch()) return; + + try { + // Check if SSH config exists + final (_, configExists) = SSHConfig.configExists(); + if (!configExists) return; + + // Ask for permission + final hasPermission = await context.showRoundDialog( + title: l10n.sshConfigImport, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.sshConfigFound), + UIs.height7, + Text(l10n.sshConfigImportPermission), + UIs.height7, + Text(l10n.sshConfigImportHelp, style: UIs.textGrey), + ], + ), + actions: Btnx.cancelOk, + ); + + prop.put(false); + + if (hasPermission == true) { + // Parse and import SSH config + final servers = await SSHConfig.parseConfig(); + if (servers.isEmpty) { + context.showSnackBar(l10n.sshConfigNoServers); + return; + } + + final deduplicated = ServerDeduplication.deduplicateServers(servers); + final resolved = ServerDeduplication.resolveNameConflicts(deduplicated); + final summary = ServerDeduplication.getImportSummary(servers, resolved); + + if (!summary.hasItemsToImport) { + context.showSnackBar(l10n.sshConfigAllExist('${summary.duplicates}')); + return; + } + + // Import without asking again since user already gave permission + for (final server in resolved) { + ref.read(serversNotifierProvider.notifier).addServer(server); + } + context.showSnackBar(l10n.sshConfigImported('${resolved.length}')); + } + } catch (e, s) { + _handleImportSSHCfgPermissionIssue(e, s); + } + } + + Future _showCmdTypesDialog(Set allCmdTypes) { + return context.showRoundDialog( + title: '${libL10n.disabled} ${l10n.cmd}', + child: SizedBox( + width: 270, + child: _disabledCmdTypes.listenVal((disabled) { + return ListView.builder( + itemCount: allCmdTypes.length, + itemExtent: 50, + itemBuilder: (context, index) { + final cmdType = allCmdTypes.elementAtOrNull(index); + if (cmdType == null) return UIs.placeholder; + final display = cmdType.displayName; + return ListTile( + leading: Icon(cmdType.sysType.icon, size: 20), + title: Text(cmdType.name, style: const TextStyle(fontSize: 16)), + trailing: Checkbox( + value: disabled.contains(display), + onChanged: (value) { + if (value == null) return; + if (value) { + _disabledCmdTypes.value.add(display); + } else { + _disabledCmdTypes.value.remove(display); + } + _disabledCmdTypes.notify(); + }, + ), + onTap: () { + final isDisabled = disabled.contains(display); + if (isDisabled) { + _disabledCmdTypes.value.remove(display); + } else { + _disabledCmdTypes.value.add(display); + } + _disabledCmdTypes.notify(); + }, + ); + }, + ); + }), + ), + actions: Btnx.oks, + ); + } + + void _initWithSpi(Spi spi) { + _nameController.text = spi.name; + _ipController.text = spi.ip; + _portController.text = spi.port.toString(); + _usernameController.text = spi.user; + if (spi.keyId == null) { + _passwordController.text = spi.pwd ?? ''; + } else { + _keyIdx.value = ref + .read(privateKeyNotifierProvider) + .keys + .indexWhere((e) => e.id == spi.keyId); + } + + /// List in dart is passed by pointer, so you need to copy it here + _tags.value = spi.tags?.toSet() ?? {}; + + _altUrlController.text = spi.alterUrl ?? ''; + _autoConnect.value = spi.autoConnect; + _jumpServer.value = spi.jumpId; + + final custom = spi.custom; + if (custom != null) { + _pveAddrCtrl.text = custom.pveAddr ?? ''; + _pveIgnoreCert.value = custom.pveIgnoreCert; + _customCmds.value = custom.cmds ?? {}; + _preferTempDevCtrl.text = custom.preferTempDev ?? ''; + _logoUrlCtrl.text = custom.logoUrl ?? ''; + } + + final wol = spi.wolCfg; + if (wol != null) { + _wolMacCtrl.text = wol.mac; + _wolIpCtrl.text = wol.ip; + _wolPwdCtrl.text = wol.pwd ?? ''; + } + + _env.value = spi.envs ?? {}; + + _netDevCtrl.text = spi.custom?.netDev ?? ''; + _scriptDirCtrl.text = spi.custom?.scriptDir ?? ''; + + _systemType.value = spi.customSystemType; + + final disabledCmdTypes = spi.disabledCmdTypes?.toSet() ?? {}; + final allAvailableCmdTypes = ShellCmdType.all.map((e) => e.displayName); + disabledCmdTypes.removeWhere((e) => !allAvailableCmdTypes.contains(e)); + _disabledCmdTypes.value = disabledCmdTypes; + } +} diff --git a/lib/view/page/server/edit/edit.dart b/lib/view/page/server/edit/edit.dart new file mode 100644 index 00000000..04440cb3 --- /dev/null +++ b/lib/view/page/server/edit/edit.dart @@ -0,0 +1,221 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:choice/choice.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icons_plus/icons_plus.dart'; +import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/core/route.dart'; +import 'package:server_box/core/utils/server_dedup.dart'; +import 'package:server_box/core/utils/ssh_config.dart'; +import 'package:server_box/data/model/app/scripts/cmd_types.dart'; +import 'package:server_box/data/model/server/custom.dart'; +import 'package:server_box/data/model/server/server_private_info.dart'; +import 'package:server_box/data/model/server/system.dart'; +import 'package:server_box/data/model/server/wol_cfg.dart'; +import 'package:server_box/data/provider/private_key.dart'; +import 'package:server_box/data/provider/server/all.dart'; +import 'package:server_box/data/res/store.dart'; +import 'package:server_box/data/store/server.dart'; +import 'package:server_box/view/page/private_key/edit.dart'; + +part 'actions.dart'; +part 'widget.dart'; + +class ServerEditPage extends ConsumerStatefulWidget { + final SpiRequiredArgs? args; + + const ServerEditPage({super.key, this.args}); + + static const route = AppRoute( + page: ServerEditPage.new, + path: '/servers/edit', + ); + + @override + ConsumerState createState() => _ServerEditPageState(); +} + +class _ServerEditPageState extends ConsumerState + with AfterLayoutMixin { + late final spi = widget.args?.spi; + final _nameController = TextEditingController(); + final _ipController = TextEditingController(); + final _altUrlController = TextEditingController(); + final _portController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _pveAddrCtrl = TextEditingController(); + final _preferTempDevCtrl = TextEditingController(); + final _logoUrlCtrl = TextEditingController(); + final _wolMacCtrl = TextEditingController(); + final _wolIpCtrl = TextEditingController(); + final _wolPwdCtrl = TextEditingController(); + final _netDevCtrl = TextEditingController(); + final _scriptDirCtrl = TextEditingController(); + + final _nameFocus = FocusNode(); + final _ipFocus = FocusNode(); + final _alterUrlFocus = FocusNode(); + final _portFocus = FocusNode(); + final _usernameFocus = FocusNode(); + + late FocusScopeNode _focusScope; + + /// -1: non selected, null: password, others: index of private key + final _keyIdx = ValueNotifier(null); + final _autoConnect = ValueNotifier(true); + final _jumpServer = nvn(); + final _pveIgnoreCert = ValueNotifier(false); + final _env = {}.vn; + final _customCmds = {}.vn; + final _tags = {}.vn; + final _systemType = ValueNotifier(null); + final _disabledCmdTypes = {}.vn; + + @override + void dispose() { + super.dispose(); + _nameController.dispose(); + _ipController.dispose(); + _altUrlController.dispose(); + _portController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _preferTempDevCtrl.dispose(); + _logoUrlCtrl.dispose(); + _wolMacCtrl.dispose(); + _wolIpCtrl.dispose(); + _wolPwdCtrl.dispose(); + _netDevCtrl.dispose(); + _scriptDirCtrl.dispose(); + + _nameFocus.dispose(); + _ipFocus.dispose(); + _alterUrlFocus.dispose(); + _portFocus.dispose(); + _usernameFocus.dispose(); + _pveAddrCtrl.dispose(); + + _keyIdx.dispose(); + _autoConnect.dispose(); + _jumpServer.dispose(); + _pveIgnoreCert.dispose(); + _env.dispose(); + _customCmds.dispose(); + _tags.dispose(); + _systemType.dispose(); + _disabledCmdTypes.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _focusScope = FocusScope.of(context); + } + + @override + Widget build(BuildContext context) { + final actions = []; + if (spi != null) actions.add(_buildDelBtn()); + + return GestureDetector( + onTap: () => _focusScope.unfocus(), + child: Scaffold( + appBar: CustomAppBar(title: Text(libL10n.edit), actions: actions), + body: _buildForm(), + floatingActionButton: _buildFAB(), + ), + ); + } + + Widget _buildForm() { + final topItems = [ + _buildWriteScriptTip(), + if (isMobile) _buildQrScan(), + if (isDesktop) _buildSSHImport(), + ]; + final children = [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: topItems.joinWith(UIs.width13).toList(), + ), + Input( + autoFocus: true, + controller: _nameController, + type: TextInputType.text, + node: _nameFocus, + onSubmitted: (_) => _focusScope.requestFocus(_ipFocus), + hint: libL10n.example, + label: libL10n.name, + icon: BoxIcons.bx_rename, + obscureText: false, + autoCorrect: true, + suggestion: true, + ), + Input( + controller: _ipController, + type: TextInputType.url, + onSubmitted: (_) => _focusScope.requestFocus(_portFocus), + node: _ipFocus, + label: l10n.host, + icon: BoxIcons.bx_server, + hint: 'example.com', + suggestion: false, + ), + Input( + controller: _portController, + type: TextInputType.number, + node: _portFocus, + onSubmitted: (_) => _focusScope.requestFocus(_usernameFocus), + label: l10n.port, + icon: Bootstrap.number_123, + hint: '22', + suggestion: false, + ), + Input( + controller: _usernameController, + type: TextInputType.text, + node: _usernameFocus, + onSubmitted: (_) => _focusScope.requestFocus(_alterUrlFocus), + label: libL10n.user, + icon: Icons.account_box, + hint: 'root', + suggestion: false, + ), + TagTile( + tags: _tags, + allTags: ref.watch(serversNotifierProvider).tags, + ).cardx, + ListTile( + title: Text(l10n.autoConnect), + trailing: _autoConnect.listenVal( + (val) => Switch( + value: val, + onChanged: (val) { + _autoConnect.value = val; + }, + ), + ), + ), + _buildAuth(), + _buildSystemType(), + _buildJumpServer(), + _buildMore(), + ]; + return AutoMultiList(children: children); + } + + @override + void afterFirstLayout(BuildContext context) { + if (spi != null) { + _initWithSpi(spi!); + } else { + // Only for new servers, check SSH config import on first time + _checkSSHConfigImport(); + } + } +} diff --git a/lib/view/page/server/edit/widget.dart b/lib/view/page/server/edit/widget.dart new file mode 100644 index 00000000..a8bb5d45 --- /dev/null +++ b/lib/view/page/server/edit/widget.dart @@ -0,0 +1,465 @@ +part of 'edit.dart'; + +extension _Widgets on _ServerEditPageState { + Widget _buildAuth() { + final switch_ = ListTile( + title: Text(l10n.keyAuth), + trailing: _keyIdx.listenVal( + (v) => Switch( + value: v != null, + onChanged: (val) { + if (val) { + _keyIdx.value = -1; + } else { + _keyIdx.value = null; + } + }, + ), + ), + ); + + /// Put [switch_] out of [ValueBuilder] to avoid rebuild + return _keyIdx.listenVal((v) { + final children = [switch_]; + if (v != null) { + children.add(_buildKeyAuth()); + } else { + children.add( + Input( + controller: _passwordController, + obscureText: true, + type: TextInputType.text, + label: libL10n.pwd, + icon: Icons.password, + suggestion: false, + onSubmitted: (_) => _onSave(), + ), + ); + } + return Column(children: children); + }); + } + + Widget _buildKeyAuth() { + const padding = EdgeInsets.only(left: 13, right: 13, bottom: 7); + final privateKeyState = ref.watch(privateKeyNotifierProvider); + final pkis = privateKeyState.keys; + + final choice = _keyIdx.listenVal((val) { + final selectedPki = val != null && val >= 0 && val < pkis.length + ? pkis[val] + : null; + return Choice( + multiple: false, + clearable: true, + value: selectedPki != null ? [val!] : [], + builder: (state, _) => Column( + children: [ + Wrap( + children: List.generate(pkis.length, (index) { + final item = pkis[index]; + return ChoiceChipX( + label: item.id, + state: state, + value: index, + onSelected: (idx, on) { + if (on) { + _keyIdx.value = idx; + } else { + _keyIdx.value = -1; + } + }, + ); + }), + ), + UIs.height7, + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (selectedPki != null) + Btn.icon( + icon: const Icon(Icons.edit, size: 20), + text: libL10n.edit, + onTap: () => PrivateKeyEditPage.route.go( + context, + args: PrivateKeyEditPageArgs(pki: selectedPki), + ), + ), + Btn.icon( + icon: const Icon(Icons.add, size: 20), + text: libL10n.add, + onTap: () => PrivateKeyEditPage.route.go(context), + ), + ], + ), + ], + ), + ); + }); + + return ExpandTile( + leading: const Icon(Icons.key), + initiallyExpanded: _keyIdx.value != null && _keyIdx.value! >= 0, + childrenPadding: padding, + title: Text(l10n.privateKey), + children: [choice], + ).cardx; + } + + Widget _buildEnvs() { + return _env.listenVal((val) { + final subtitle = val.isEmpty + ? null + : Text(val.keys.join(','), style: UIs.textGrey); + return ListTile( + leading: const Icon(HeroIcons.variable), + subtitle: subtitle, + title: Text(l10n.envVars), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: () async { + final res = await KvEditor.route.go( + context, + KvEditorArgs(data: spi?.envs ?? {}), + ); + if (res == null) return; + _env.value = res; + }, + ).cardx; + }); + } + + Widget _buildMore() { + return ExpandTile( + title: Text(l10n.more), + children: [ + Input( + controller: _logoUrlCtrl, + type: TextInputType.url, + icon: Icons.image, + label: 'Logo URL', + hint: 'https://example.com/logo.png', + suggestion: false, + ), + _buildAltUrl(), + _buildScriptDir(), + _buildEnvs(), + _buildPVEs(), + _buildCustomCmds(), + _buildDisabledCmdTypes(), + _buildCustomDev(), + _buildWOLs(), + ], + ); + } + + Widget _buildScriptDir() { + return Input( + controller: _scriptDirCtrl, + type: TextInputType.text, + label: '${l10n.remotePath} (Shell ${l10n.install})', + icon: Icons.folder, + hint: '~/.config/server_box', + suggestion: false, + ); + } + + Widget _buildCustomDev() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CenterGreyTitle(l10n.specifyDev), + ListTile( + leading: const Icon(MingCute.question_line), + title: TipText(libL10n.note, l10n.specifyDevTip), + ).cardx, + Input( + controller: _preferTempDevCtrl, + type: TextInputType.text, + label: l10n.temperature, + icon: MingCute.low_temperature_line, + hint: 'nvme-pci-0400', + suggestion: false, + ), + Input( + controller: _netDevCtrl, + type: TextInputType.text, + label: l10n.net, + icon: ZondIcons.network, + hint: 'eth0', + suggestion: false, + ), + ], + ); + } + + Widget _buildSystemType() { + return _systemType.listenVal((val) { + return ListTile( + leading: Icon(MingCute.laptop_2_line), + title: Text(l10n.system), + trailing: PopupMenu( + initialValue: val, + items: [ + PopupMenuItem(value: null, child: Text(libL10n.auto)), + PopupMenuItem(value: SystemType.linux, child: Text('Linux')), + PopupMenuItem(value: SystemType.bsd, child: Text('BSD')), + PopupMenuItem(value: SystemType.windows, child: Text('Windows')), + ], + onSelected: (value) => _systemType.value = value, + child: Text( + val?.name ?? libL10n.auto, + style: TextStyle(color: val == null ? Colors.grey : null), + ), + ), + ).cardx; + }); + } + + Widget _buildAltUrl() { + return Input( + controller: _altUrlController, + type: TextInputType.url, + node: _alterUrlFocus, + label: l10n.fallbackSshDest, + icon: MingCute.link_line, + hint: 'user@ip:port', + suggestion: false, + ); + } + + Widget _buildPVEs() { + const addr = 'https://127.0.0.1:8006'; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CenterGreyTitle('PVE'), + Input( + controller: _pveAddrCtrl, + type: TextInputType.url, + icon: MingCute.web_line, + label: 'URL', + hint: addr, + suggestion: false, + ), + ListTile( + leading: const Icon(MingCute.certificate_line), + title: TipText('PVE ${l10n.ignoreCert}', l10n.pveIgnoreCertTip), + trailing: _pveIgnoreCert.listenVal( + (v) => Switch( + value: v, + onChanged: (val) { + _pveIgnoreCert.value = val; + }, + ), + ), + ).cardx, + ], + ); + } + + Widget _buildCustomCmds() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CenterGreyTitle(l10n.customCmd), + _customCmds.listenVal((vals) { + return ListTile( + leading: const Icon(BoxIcons.bxs_file_json), + title: const Text('JSON'), + subtitle: vals.isEmpty + ? null + : Text(vals.keys.join(','), style: UIs.textGrey), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: _onTapCustomItem, + ); + }).cardx, + ListTile( + leading: const Icon(MingCute.doc_line), + title: Text(libL10n.doc), + trailing: const Icon(Icons.open_in_new, size: 17), + onTap: l10n.customCmdDocUrl.launchUrl, + ).cardx, + ], + ); + } + + Widget _buildDisabledCmdTypes() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CenterGreyTitle('${libL10n.disabled} ${l10n.cmd}'), + _disabledCmdTypes.listenVal((disabled) { + return ListTile( + leading: const Icon(Icons.disabled_by_default), + title: Text('${libL10n.disabled} ${l10n.cmd}'), + subtitle: disabled.isEmpty + ? null + : Text(disabled.join(', '), style: UIs.textGrey), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: _onTapDisabledCmdTypes, + ); + }).cardx, + ], + ); + } + + Widget _buildWOLs() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CenterGreyTitle('Wake On LAN (beta)'), + ListTile( + leading: const Icon(BoxIcons.bxs_help_circle), + title: TipText(libL10n.about, l10n.wolTip), + ).cardx, + Input( + controller: _wolMacCtrl, + type: TextInputType.text, + label: 'MAC ${l10n.addr}', + icon: Icons.computer, + hint: '00:11:22:33:44:55', + suggestion: false, + ), + Input( + controller: _wolIpCtrl, + type: TextInputType.text, + label: 'IP ${l10n.addr}', + icon: ZondIcons.network, + hint: '192.168.1.x', + suggestion: false, + ), + Input( + controller: _wolPwdCtrl, + type: TextInputType.text, + obscureText: true, + label: libL10n.pwd, + icon: Icons.password, + suggestion: false, + ), + ], + ); + } + + Widget _buildFAB() { + return FloatingActionButton( + onPressed: _onSave, + child: const Icon(Icons.save), + ); + } + + Widget _buildJumpServer() { + const padding = EdgeInsets.only(left: 13, right: 13, bottom: 7); + final srvs = ref + .watch(serversNotifierProvider) + .servers + .values + .where((e) => e.jumpId == null) + .where((e) => e.id != spi?.id) + .toList(); + final choice = _jumpServer.listenVal((val) { + final srv = srvs.firstWhereOrNull((e) => e.id == _jumpServer.value); + return Choice( + multiple: false, + clearable: true, + value: srv != null ? [srv] : [], + builder: (state, _) => Wrap( + children: List.generate(srvs.length, (index) { + final item = srvs[index]; + return ChoiceChipX( + label: item.name, + state: state, + value: item, + onSelected: (srv, on) { + if (on) { + _jumpServer.value = srv.id; + } else { + _jumpServer.value = null; + } + }, + ); + }), + ), + ); + }); + return ExpandTile( + leading: const Icon(Icons.map), + initiallyExpanded: _jumpServer.value != null, + childrenPadding: padding, + title: Text(l10n.jumpServer), + children: [choice], + ).cardx; + } + + Widget _buildWriteScriptTip() { + return Btn.tile( + text: libL10n.attention, + icon: const Icon(Icons.tips_and_updates, color: Colors.grey), + onTap: () { + context.showRoundDialog( + title: libL10n.attention, + child: SimpleMarkdown(data: l10n.writeScriptTip), + actions: Btnx.oks, + ); + }, + textStyle: UIs.textGrey, + mainAxisSize: MainAxisSize.min, + ); + } + + Widget _buildQrScan() { + return Btn.tile( + text: libL10n.import, + icon: const Icon(Icons.qr_code, color: Colors.grey), + onTap: () async { + final ret = await BarcodeScannerPage.route.go( + context, + args: const BarcodeScannerPageArgs(), + ); + final code = ret?.text; + if (code == null) return; + try { + final spi = Spi.fromJson(json.decode(code)); + _initWithSpi(spi); + } catch (e, s) { + context.showErrDialog(e, s); + } + }, + textStyle: UIs.textGrey, + mainAxisSize: MainAxisSize.min, + ); + } + + Widget _buildSSHImport() { + return Btn.tile( + text: l10n.sshConfigImport, + icon: const Icon(Icons.settings, color: Colors.grey), + onTap: _onTapSSHImport, + textStyle: UIs.textGrey, + mainAxisSize: MainAxisSize.min, + ); + } + + Widget _buildDelBtn() { + return IconButton( + onPressed: () { + context.showRoundDialog( + title: libL10n.attention, + child: Text( + libL10n.askContinue( + '${libL10n.delete} ${l10n.server}(${spi!.name})', + ), + ), + actions: Btn.ok( + onTap: () async { + context.pop(); + ref.read(serversNotifierProvider.notifier).delServer(spi!.id); + context.pop(true); + }, + red: true, + ).toList, + ); + }, + icon: const Icon(Icons.delete), + ); + } +} diff --git a/lib/view/page/server/tab/tab.dart b/lib/view/page/server/tab/tab.dart index 34cf09c8..681fd2ab 100644 --- a/lib/view/page/server/tab/tab.dart +++ b/lib/view/page/server/tab/tab.dart @@ -22,7 +22,7 @@ import 'package:server_box/data/provider/server/single.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/detail/view.dart'; -import 'package:server_box/view/page/server/edit.dart'; +import 'package:server_box/view/page/server/edit/edit.dart'; import 'package:server_box/view/page/setting/entry.dart'; import 'package:server_box/view/widget/percent_circle.dart'; import 'package:server_box/view/widget/server_func_btns.dart'; diff --git a/lib/view/page/ssh/tab.dart b/lib/view/page/ssh/tab.dart index 70d19621..78b6d627 100644 --- a/lib/view/page/ssh/tab.dart +++ b/lib/view/page/ssh/tab.dart @@ -8,7 +8,7 @@ import 'package:icons_plus/icons_plus.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/provider/server/all.dart'; -import 'package:server_box/view/page/server/edit.dart'; +import 'package:server_box/view/page/server/edit/edit.dart'; import 'package:server_box/view/page/ssh/page/page.dart'; class SSHTabPage extends ConsumerStatefulWidget { diff --git a/pubspec.lock b/pubspec.lock index b0c52f82..20601a03 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -189,18 +189,18 @@ packages: dependency: transitive description: name: camera_android_camerax - sha256: "58b8fe843a3c83fd1273c00cb35f5a8ae507f6cc9b2029bcf7e2abba499e28d8" + sha256: "2d438248554f44766bf9ea34c117a5bb0074e241342ef7c22c768fb431335234" url: "https://pub.dev" source: hosted - version: "0.6.19+1" + version: "0.6.21" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: e4aca5bccaf897b70cac87e5fdd789393310985202442837922fd40325e2733b + sha256: "951ef122d01ebba68b7a54bfe294e8b25585635a90465c311b2f875ae72c412f" url: "https://pub.dev" source: hosted - version: "0.9.21+1" + version: "0.9.21+2" camera_platform_interface: dependency: transitive description: @@ -644,10 +644,10 @@ packages: dependency: transitive description: name: flutter_svg - sha256: de82e6bf958cec7190fbc1c5298282c851228e35ae2b14e2b103e7f777818c64 + sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 url: "https://pub.dev" source: hosted - version: "2.0.13" + version: "2.2.0" flutter_test: dependency: "direct dev" description: flutter @@ -911,10 +911,10 @@ packages: dependency: transitive description: name: local_auth_android - sha256: "316503f6772dea9c0c038bb7aac4f68ab00112d707d258c770f7fc3c250a2d88" + sha256: "48924f4a8b3cc45994ad5993e2e232d3b00788a305c1bf1c7db32cef281ce9a3" url: "https://pub.dev" source: hosted - version: "1.0.51" + version: "1.0.52" local_auth_darwin: dependency: transitive description: @@ -1184,10 +1184,10 @@ packages: dependency: transitive description: name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -1613,10 +1613,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "0aedad096a85b49df2e4725fa32118f9fa580f3b14af7a2d2221896a02cd5656" + sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7" url: "https://pub.dev" source: hosted - version: "6.3.17" + version: "6.3.18" url_launcher_ios: dependency: transitive description: @@ -1685,18 +1685,18 @@ packages: dependency: transitive description: name: vector_graphics_codec - sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted - version: "1.1.11+1" + version: "1.1.13" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc url: "https://pub.dev" source: hosted - version: "1.1.11+1" + version: "1.1.19" vector_math: dependency: transitive description: