diff --git a/lib/core/utils/server_dedup.dart b/lib/core/utils/server_dedup.dart index ea561c34..14f07156 100644 --- a/lib/core/utils/server_dedup.dart +++ b/lib/core/utils/server_dedup.dart @@ -1,4 +1,8 @@ +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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/data/store/server.dart'; class ServerDeduplication { @@ -66,6 +70,45 @@ class ServerDeduplication { toImport: deduplicatedList.length, ); } + + /// Import servers with deduplication and show appropriate notifications + /// Returns the number of servers actually imported + /// Note: Caller must check mounted before calling this method + /// If resolvedServers is provided, it should be pre-filtered (non-empty) + /// [originalCount] should be provided when passing resolvedServers to show + /// the true pre-dedup count in messages + static Future importServersWithNotification({ + List? servers, + required WidgetRef ref, + required BuildContext context, + List? resolvedServers, + int? originalCount, + required String Function(String) allExistMessage, + required String Function(String) importedMessage, + }) async { + assert(servers != null || resolvedServers != null, + 'Either servers or resolvedServers must be provided'); + + final count = originalCount ?? servers?.length ?? resolvedServers!.length; + final resolved = resolvedServers ?? _resolveServers(servers!); + + if (resolved.isEmpty) { + context.showSnackBar(allExistMessage('$count')); + return 0; + } + + for (final server in resolved) { + ref.read(serversProvider.notifier).addServer(server); + } + context.showSnackBar(importedMessage('${resolved.length}')); + return resolved.length; + } + + static List _resolveServers(List servers) { + final deduplicated = deduplicateServers(servers); + final resolved = resolveNameConflicts(deduplicated); + return resolved; + } } class ImportSummary { diff --git a/lib/data/res/github_id.dart b/lib/data/res/github_id.dart index 703dfa45..72269dea 100644 --- a/lib/data/res/github_id.dart +++ b/lib/data/res/github_id.dart @@ -150,6 +150,7 @@ abstract final class GithubIds { 'yeluonight', 'Yinhono', 'kuvaldini', + 'aliferne', }; } diff --git a/lib/view/page/server/edit/actions.dart b/lib/view/page/server/edit/actions.dart index c734a299..7aabd7d5 100644 --- a/lib/view/page/server/edit/actions.dart +++ b/lib/view/page/server/edit/actions.dart @@ -13,226 +13,6 @@ extension _Actions on _ServerEditPageState { ); } - Future _onTapSSHDiscovery() async { - try { - final result = await SshDiscoveryPage.route.go(context); - - if (result != null && result.isNotEmpty) { - await _processDiscoveredServers(result); - } - } catch (e, s) { - context.showErrDialog(e, s); - } - } - - Future _processDiscoveredServers( - List discoveredServers, - ) async { - if (discoveredServers.length == 1) { - // Single server - populate the current form - final server = discoveredServers.first; - _ipController.text = server.ip; - _portController.text = server.port.toString(); - if (_nameController.text.isEmpty) { - _nameController.text = server.ip; - } - context.showSnackBar('${libL10n.found} 1 ${libL10n.server}'); - } else { - // Multiple servers - show import dialog - final shouldImport = await context.showRoundDialog( - title: libL10n.import, - child: Text( - libL10n.askContinue( - '${libL10n.found} ${discoveredServers.length} ${libL10n.servers}', - ), - ), - actions: Btnx.cancelOk, - ); - - if (shouldImport == true) { - // Prompt user to configure default values before importing - final defaultUsername = 'root'; - final defaultKeyId = _keyIdx.value?.toString() ?? ''; - final usernameController = TextEditingController(text: defaultUsername); - final keyIdController = TextEditingController(text: defaultKeyId); - - final shouldProceed = await context.showRoundDialog( - title: libL10n.import, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${libL10n.found} ${discoveredServers.length} ${libL10n.servers}.', - ), - const SizedBox(height: 8), - Text(libL10n.setting), - const SizedBox(height: 8), - TextField( - controller: usernameController, - decoration: InputDecoration(labelText: libL10n.user), - ), - TextField( - controller: keyIdController, - decoration: InputDecoration(labelText: l10n.privateKey), - ), - ], - ), - actions: Btnx.cancelOk, - ); - - if (shouldProceed == true) { - final username = usernameController.text.isNotEmpty - ? usernameController.text - : defaultUsername; - final keyId = keyIdController.text.isNotEmpty - ? keyIdController.text - : null; - final servers = discoveredServers - .map( - (result) => Spi( - name: result.ip, - ip: result.ip, - port: result.port, - user: username, - keyId: keyId, - pwd: _passwordController.text.isEmpty - ? null - : _passwordController.text, - ), - ) - .toList(); - - await _batchImportServers(servers); - } - - usernameController.dispose(); - keyIdController.dispose(); - } - } - } - - Future _batchImportServers(List servers) async { - final store = Stores.server; - int imported = 0; - for (final server in servers) { - try { - store.put(server); - imported++; - } catch (e) { - dprint('Failed to import server ${server.name}: $e'); - } - } - context.showSnackBar('${libL10n.success}: $imported ${libL10n.servers}'); - if (mounted) context.pop(true); - } - - 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(serversProvider.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, @@ -358,60 +138,60 @@ extension _Actions on _ServerEditPageState { } 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; + Future _checkSSHConfigImport() async { + final hasExistingServers = ref.read(serversProvider).servers.isNotEmpty; + if (hasExistingServers) { + Stores.setting.firstTimeReadSSHCfg.put(false); + return; + } try { - // Check if SSH config exists - final (_, configExists) = SSHConfig.configExists(); - if (!configExists) return; + final servers = await SSHConfig.parseConfig(); + if (!mounted) return; + if (servers.isEmpty) { + Stores.setting.firstTimeReadSSHCfg.put(false); + return; + } - // Ask for permission - final hasPermission = await context.showRoundDialog( + final shouldImport = await context.showRoundDialog( title: l10n.sshConfigImport, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.sshConfigFound), - UIs.height7, + const SizedBox(height: 8), Text(l10n.sshConfigImportPermission), - UIs.height7, - Text(l10n.sshConfigImportHelp, style: UIs.textGrey), ], ), actions: Btnx.cancelOk, ); - prop.put(false); + if (!mounted) return; - if (hasPermission == true) { - // Parse and import SSH config - final servers = await SSHConfig.parseConfig(); - if (servers.isEmpty) { - context.showSnackBar(l10n.sshConfigNoServers); - return; - } + Stores.setting.firstTimeReadSSHCfg.put(false); - 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(serversProvider.notifier).addServer(server); - } - context.showSnackBar(l10n.sshConfigImported('${resolved.length}')); + if (shouldImport == true) { + await ServerDeduplication.importServersWithNotification( + servers: servers, + ref: ref, + context: context, + allExistMessage: l10n.sshConfigAllExist, + importedMessage: l10n.sshConfigImported, + ); + } + } catch (e) { + if (!mounted) return; + if (e is PathAccessException || + e.toString().contains('Operation not permitted')) { + Stores.setting.firstTimeReadSSHCfg.put(false); + context.showSnackBar( + '${l10n.sshConfigPermissionDenied} ${l10n.sshConfigManualSelect}', + ); + } else { + dprint('Error checking SSH config: $e'); + Stores.setting.firstTimeReadSSHCfg.put(false); } - } catch (e, s) { - _handleImportSSHCfgPermissionIssue(e, s); } } diff --git a/lib/view/page/server/edit/edit.dart b/lib/view/page/server/edit/edit.dart index 90bb6ed6..22ab507e 100644 --- a/lib/view/page/server/edit/edit.dart +++ b/lib/view/page/server/edit/edit.dart @@ -1,8 +1,6 @@ -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'; @@ -14,7 +12,6 @@ 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/discovery_result.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'; @@ -23,7 +20,6 @@ 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'; -import 'package:server_box/view/page/server/discovery/discovery.dart'; part 'actions.dart'; part 'widget.dart'; @@ -138,9 +134,6 @@ class _ServerEditPageState extends ConsumerState Widget _buildForm() { final topItems = [ _buildWriteScriptTip(), - if (isMobile) _buildQrScan(), - if (isDesktop) _buildSSHImport(), - _buildSSHDiscovery(), ]; final children = [ SizedBox( @@ -217,8 +210,7 @@ class _ServerEditPageState extends ConsumerState void afterFirstLayout(BuildContext context) { if (spi != null) { _initWithSpi(spi!); - } else { - // Only for new servers, check SSH config import on first time + } else if (isDesktop && Stores.setting.firstTimeReadSSHCfg.fetch()) { _checkSSHConfigImport(); } } diff --git a/lib/view/page/server/edit/widget.dart b/lib/view/page/server/edit/widget.dart index 65855eee..571ab67d 100644 --- a/lib/view/page/server/edit/widget.dart +++ b/lib/view/page/server/edit/widget.dart @@ -408,49 +408,6 @@ extension _Widgets on _ServerEditPageState { ); } - 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 _buildSSHDiscovery() { - return Btn.tile( - text: l10n.discoverSshServers, - icon: const Icon(BoxIcons.bx_search, color: Colors.grey), - onTap: _onTapSSHDiscovery, - textStyle: UIs.textGrey, - mainAxisSize: MainAxisSize.min, - ); - } - Widget _buildDelBtn() { return IconButton( onPressed: () { diff --git a/lib/view/page/setting/entries/server.dart b/lib/view/page/setting/entries/server.dart index 3a1c5c4b..8b45b6db 100644 --- a/lib/view/page/setting/entries/server.dart +++ b/lib/view/page/setting/entries/server.dart @@ -180,7 +180,7 @@ extension _Server on _AppSettingsPageState { _buildDoubleColumnServersPage(), _buildUpdateInterval(), _buildMaxRetry(), - _buildSSHConfigImport(), + if (isDesktop) _buildSSHConfigAutoImportToggle(), ], ); } @@ -261,7 +261,7 @@ extension _Server on _AppSettingsPageState { ); } - Widget _buildSSHConfigImport() { + Widget _buildSSHConfigAutoImportToggle() { return ListTile( title: Text(l10n.sshConfigImport), subtitle: Text(l10n.sshConfigImportTip, style: UIs.textGrey), diff --git a/lib/view/page/setting/entries/ssh.dart b/lib/view/page/setting/entries/ssh.dart index d5f67528..38eeffa5 100644 --- a/lib/view/page/setting/entries/ssh.dart +++ b/lib/view/page/setting/entries/ssh.dart @@ -4,6 +4,9 @@ extension _SSH on _AppSettingsPageState { Widget _buildSSH() { return Column( children: [ + if (isDesktop) _buildSSHConfigImport(), + if (isMobile) _buildQrScan(), + _buildSSHDiscovery(), _buildLetterCache(), _buildSSHWakeLock(), _buildTermTheme(), @@ -17,6 +20,246 @@ extension _SSH on _AppSettingsPageState { ); } + Widget _buildSSHConfigImport() { + return ListTile( + leading: const Icon(MingCute.file_import_line), + title: Text(l10n.sshConfigImport), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: _onTapSSHConfigImport, + ); + } + + Widget _buildQrScan() { + return ListTile( + leading: const Icon(Icons.qr_code), + title: Text(libL10n.import), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: _onTapQrScan, + ); + } + + Future _onTapQrScan() async { + final ret = await BarcodeScannerPage.route.go( + context, + args: const BarcodeScannerPageArgs(), + ); + final code = ret?.text; + if (code == null) return; + if (!mounted) return; + + try { + final spi = Spi.fromJson(json.decode(code)); + final existingIds = ref.read(serversProvider).servers.keys; + if (existingIds.contains(spi.id)) { + context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}'); + return; + } + final resolvedList = ServerDeduplication.resolveNameConflicts([spi]); + final resolvedSpi = resolvedList.first; + ref.read(serversProvider.notifier).addServer(resolvedSpi); + context.showSnackBar(libL10n.success); + } catch (e, s) { + context.showErrDialog(e, s); + } + } + + Widget _buildSSHDiscovery() { + return ListTile( + leading: const Icon(BoxIcons.bx_search), + title: Text(l10n.discoverSshServers), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: _onTapSSHDiscovery, + ); + } + + Future _onTapSSHDiscovery() async { + try { + final result = await SshDiscoveryPage.route.go(context); + if (!mounted) return; + + if (result != null && result.isNotEmpty) { + await _processDiscoveredServers(result); + } + } catch (e, s) { + if (!mounted) return; + context.showErrDialog(e, s); + } + } + + Future _processDiscoveredServers( + List discoveredServers, + ) async { + final defaultUsername = 'root'; + final usernameController = TextEditingController(text: defaultUsername); + + try { + final shouldImport = await context.showRoundDialog( + title: libL10n.import, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.sshConfigFoundServers('${discoveredServers.length}')), + const SizedBox(height: 8), + Input( + controller: usernameController, + label: libL10n.user, + ), + ], + ), + actions: Btnx.cancelOk, + ); + + if (!mounted) return; + + if (shouldImport == true) { + final username = usernameController.text.isNotEmpty + ? usernameController.text + : defaultUsername; + final servers = discoveredServers + .map( + (result) => Spi( + id: ShortId.generate(), + name: result.ip, + ip: result.ip, + port: result.port, + user: username, + ), + ) + .toList(); + + await ServerDeduplication.importServersWithNotification( + servers: servers, + ref: ref, + context: context, + allExistMessage: l10n.sshConfigAllExist, + importedMessage: (count) => '${libL10n.success}: $count ${libL10n.servers}', + ); + } + } finally { + usernameController.dispose(); + } + } + + Future _onTapSSHConfigImport() async { + try { + final servers = await SSHConfig.parseConfig(); + if (!mounted) return; + if (servers.isEmpty) { + context.showSnackBar(l10n.sshConfigNoServers); + return; + } + + await _processSSHServers(servers); + } catch (e, s) { + if (!mounted) return; + await _handleImportSSHCfgPermissionIssue(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) { + if (!mounted) return; + 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 (!mounted) return; + + if (shouldImport == true) { + await ServerDeduplication.importServersWithNotification( + ref: ref, + context: context, + resolvedServers: resolved, + originalCount: summary.total, + allExistMessage: l10n.sshConfigAllExist, + importedMessage: l10n.sshConfigImported, + ); + } + } + + Future _handleImportSSHCfgPermissionIssue(Object e, StackTrace s) async { + dprint('Error importing SSH config: $e'); + 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 (!mounted) return; + + if (useFilePicker == true) { + await _onTapSSHImportWithFilePicker(); + } + } else { + if (!mounted) return; + context.showErrDialog(e, s); + } + } + + Future _onTapSSHImportWithFilePicker() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + allowMultiple: false, + dialogTitle: l10n.sshConfigImport, + ); + + if (!mounted) return; + + if (result?.files.single.path case final path?) { + final servers = await SSHConfig.parseConfig(path); + if (!mounted) return; + if (servers.isEmpty) { + context.showSnackBar(l10n.sshConfigNoServers); + return; + } + + await _processSSHServers(servers); + } + } catch (e, s) { + if (!mounted) return; + context.showErrDialog(e, s); + } + } + Widget _buildSSHVirtKeys() { return ListTile( leading: const Icon(BoxIcons.bxs_keyboard), diff --git a/lib/view/page/setting/entry.dart b/lib/view/page/setting/entry.dart index 99e6e921..93775387 100644 --- a/lib/view/page/setting/entry.dart +++ b/lib/view/page/setting/entry.dart @@ -2,13 +2,18 @@ import 'dart:convert'; import 'dart:io'; import 'package:dynamic_color/dynamic_color.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_highlight/theme_map.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/utils/server_dedup.dart'; +import 'package:server_box/core/utils/ssh_config.dart'; import 'package:server_box/data/model/app/net_view.dart'; +import 'package:server_box/data/model/server/discovery_result.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/data/res/build_data.dart'; import 'package:server_box/data/res/github_id.dart'; @@ -18,6 +23,7 @@ import 'package:server_box/data/store/setting.dart'; import 'package:server_box/generated/l10n/l10n.dart'; import 'package:server_box/view/page/backup.dart'; import 'package:server_box/view/page/private_key/list.dart'; +import 'package:server_box/view/page/server/discovery/discovery.dart'; import 'package:server_box/view/page/setting/entries/home_tabs.dart'; import 'package:server_box/view/page/setting/platform/ios.dart'; import 'package:server_box/view/page/setting/platform/platform_pub.dart';