diff --git a/native/lib/core/api/api_result.dart b/native/lib/core/api/api_result.dart index 45bce5d..fcecfbc 100644 --- a/native/lib/core/api/api_result.dart +++ b/native/lib/core/api/api_result.dart @@ -7,23 +7,11 @@ class ApiFailure implements Exception { final String message; final int? statusCode; - /// Raw `data` object from the server response body, if any. Used to surface - /// structured flags like `needRestart` from `/plus-conf`. + /// Raw `data` object from the server response body, if any. final Object? data; bool get isUnauthorized => statusCode == 401 || statusCode == 403; - /// `true` when the server signalled the client must restart the panel - /// service before re-activating Plus (kicked session). - bool get needRestart { - final d = data; - if (d is Map) { - final v = d['needRestart']; - return v == true || v == 1 || v == '1' || v == 'true'; - } - return false; - } - @override String toString() => message; } diff --git a/native/lib/core/ui/top_notice.dart b/native/lib/core/ui/top_notice.dart new file mode 100644 index 0000000..d4d2928 --- /dev/null +++ b/native/lib/core/ui/top_notice.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import 'app_color_theme.dart'; + +OverlayEntry? _activeTopNoticeEntry; + +void showTopNotice( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 2), +}) { + final previous = _activeTopNoticeEntry; + if (previous?.mounted ?? false) { + previous!.remove(); + } + + final overlay = Overlay.of(context, rootOverlay: true); + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (context) { + final top = MediaQuery.viewPaddingOf(context).top + 14; + return Positioned( + top: top, + left: 18, + right: 18, + child: IgnorePointer( + child: Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: context.colors.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: context.colors.strongBorder), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.14), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.info_outline_rounded, + size: 18, + color: context.colors.primary, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + message, + style: TextStyle( + color: context.colors.text, + fontSize: 13, + fontWeight: FontWeight.w700, + height: 1.35, + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + + _activeTopNoticeEntry = entry; + overlay.insert(entry); + Future.delayed(duration, () { + if (_activeTopNoticeEntry == entry && entry.mounted) { + entry.remove(); + _activeTopNoticeEntry = null; + } + }); +} diff --git a/native/lib/features/docker/docker_tab.dart b/native/lib/features/docker/docker_tab.dart index cc4b32d..5fad928 100644 --- a/native/lib/features/docker/docker_tab.dart +++ b/native/lib/features/docker/docker_tab.dart @@ -7,6 +7,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../core/api/api_result.dart'; import '../../core/ui/app_color_theme.dart'; import '../../core/ui/refresh_feedback.dart'; +import '../../core/ui/top_notice.dart'; import '../../l10n/app_localizations.dart'; import '../../state/auth_notifier.dart'; import '../../state/docker_providers.dart'; @@ -96,7 +97,7 @@ class _DockerPanelState extends ConsumerState { return; } if (!ref.read(isPlusActiveProvider)) { - _showSnack(l.tr('docker.plusRequired')); + showTopNotice(context, l.tr('plus.serverManagedTip')); return; } final authSession = ref.read(authProvider).session; @@ -199,7 +200,7 @@ class _DockerPanelState extends ConsumerState { _autoConnectInitialServer(servers); if (!isPlusActive) { return _DockerMessageList( - message: l.tr('docker.plusRequired'), + message: l.tr('plus.serverManagedTip'), icon: Icons.workspace_premium_outlined, ); } @@ -425,7 +426,7 @@ class _DockerContainerListState extends State<_DockerContainerList> { final session = widget.session; if (session.status == DockerConnectionStatus.error) { final message = session.errorMessage == 'docker_not_plus' - ? l.tr('docker.notPlus') + ? l.tr('plus.serverManagedTip') : session.errorMessage == 'docker_connect_fail' ? l.tr('docker.connectFailed') : session.errorMessage ?? l.tr('docker.connectFailed'); diff --git a/native/lib/features/scripts/script_groups_page.dart b/native/lib/features/scripts/script_groups_page.dart index 927397d..9474ca4 100644 --- a/native/lib/features/scripts/script_groups_page.dart +++ b/native/lib/features/scripts/script_groups_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_result.dart'; +import '../../core/ui/top_notice.dart'; import '../../l10n/app_localizations.dart'; import '../../state/api_providers.dart'; import '../../state/auth_notifier.dart'; @@ -33,9 +34,7 @@ class _ScriptGroupsPageState extends ConsumerState { bool _ensurePlusOrWarn() { if (ref.read(isPlusActiveProvider)) return true; final l = AppLocalizations.of(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.tr('scripts.groupsPlusTip'))), - ); + showTopNotice(context, l.tr('plus.serverManagedTip')); return false; } @@ -152,10 +151,7 @@ class _ScriptGroupsPageState extends ConsumerState { elevation: 0, title: Text( l.tr('scripts.groupsTitle'), - style: TextStyle( - color: c.text, - fontWeight: FontWeight.w800, - ), + style: TextStyle(color: c.text, fontWeight: FontWeight.w800), ), iconTheme: IconThemeData(color: c.text), actions: [ @@ -190,7 +186,7 @@ class _ScriptGroupsPageState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), children: [ if (!isPlus) ...[ - _PlusBanner(message: l.tr('scripts.groupsPlusTip')), + _PlusBanner(message: l.tr('plus.serverManagedTip')), const SizedBox(height: 10), ], _HintCard(body: l.tr('scripts.groupsHintBody')), @@ -237,21 +233,13 @@ class _HintCard extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.only(top: 2), - child: Icon( - Icons.info_outline, - size: 16, - color: c.primary, - ), + child: Icon(Icons.info_outline, size: 16, color: c.primary), ), const SizedBox(width: 8), Expanded( child: Text( body, - style: TextStyle( - color: c.muted, - fontSize: 12, - height: 1.4, - ), + style: TextStyle(color: c.muted, fontSize: 12, height: 1.4), ), ), ], @@ -368,10 +356,7 @@ class _GroupCard extends StatelessWidget { const SizedBox(width: 4), Text( l.trf('scripts.scriptCount', [scriptCount]), - style: TextStyle( - color: c.softMuted, - fontSize: 12, - ), + style: TextStyle(color: c.softMuted, fontSize: 12), ), ], ), @@ -399,9 +384,7 @@ class _GroupCard extends StatelessWidget { ), _ActionIcon( icon: Icons.delete_outline, - color: onDelete == null - ? c.softMuted - : c.danger, + color: onDelete == null ? c.softMuted : c.danger, onTap: onDelete, ), ], diff --git a/native/lib/features/servers/server_form_page.dart b/native/lib/features/servers/server_form_page.dart index 18616be..1c55c47 100644 --- a/native/lib/features/servers/server_form_page.dart +++ b/native/lib/features/servers/server_form_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/ui/app_color_theme.dart'; +import '../../core/ui/top_notice.dart'; import '../../l10n/app_localizations.dart'; import '../../state/api_providers.dart'; import '../../state/credential_list_notifier.dart'; @@ -45,7 +46,8 @@ class _ServerFormPageState extends ConsumerState { @override void initState() { super.initState(); - final hosts = ref.read(hostListProvider).valueOrNull ?? const []; + final hosts = + ref.read(hostListProvider).valueOrNull ?? const []; final maxIndex = hosts.fold( 0, (max, host) => host.index > max ? host.index : max, @@ -87,9 +89,12 @@ class _ServerFormPageState extends ConsumerState { @override Widget build(BuildContext context) { final l = AppLocalizations.of(context); - final groups = ref.watch(groupListProvider).valueOrNull ?? const []; - final hosts = ref.watch(hostListProvider).valueOrNull ?? const []; - final credentials = ref.watch(credentialListProvider).valueOrNull ?? + final groups = + ref.watch(groupListProvider).valueOrNull ?? const []; + final hosts = + ref.watch(hostListProvider).valueOrNull ?? const []; + final credentials = + ref.watch(credentialListProvider).valueOrNull ?? const []; final proxies = ref.watch(proxyListProvider).valueOrNull ?? const []; @@ -104,7 +109,9 @@ class _ServerFormPageState extends ConsumerState { backgroundColor: context.colors.canvas, surfaceTintColor: Colors.transparent, elevation: 0, - title: Text(_form.isEdit ? l.tr('servers.editServer') : l.tr('servers.addServer')), + title: Text( + _form.isEdit ? l.tr('servers.editServer') : l.tr('servers.addServer'), + ), ), bottomNavigationBar: _BottomSaveBar( saving: _saving, @@ -129,8 +136,9 @@ class _ServerFormPageState extends ConsumerState { value: _normalizeGroupValue(groups), groups: groups, onChanged: (value) => _form.group = value ?? 'default', - validator: (value) => - value == null || value.isEmpty ? l.tr('servers.validation.group') : null, + validator: (value) => value == null || value.isEmpty + ? l.tr('servers.validation.group') + : null, ), Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -139,7 +147,8 @@ class _ServerFormPageState extends ConsumerState { child: _TextField( controller: _nameCtrl, label: l.tr('servers.field.name'), - validator: (value) => _required(value, l.tr('servers.validation.name')), + validator: (value) => + _required(value, l.tr('servers.validation.name')), ), ), const SizedBox(width: 12), @@ -171,7 +180,8 @@ class _ServerFormPageState extends ConsumerState { controller: _hostCtrl, label: l.tr('servers.field.host'), mono: true, - validator: (value) => _required(value, l.tr('servers.validation.host')), + validator: (value) => + _required(value, l.tr('servers.validation.host')), ), ), const SizedBox(width: 12), @@ -198,15 +208,16 @@ class _ServerFormPageState extends ConsumerState { if (_form.isSsh) ...[ _AuthTypeSegment( value: _form.authType, - onChanged: (value) => setState(() => _form.authType = value), + onChanged: (value) => + setState(() => _form.authType = value), ), FormField( key: ValueKey('auth-${_form.authType}'), initialValue: _form.credential, validator: _form.authType == 'credential' ? (value) => value == null || value.isEmpty - ? l.tr('servers.validation.credential') - : null + ? l.tr('servers.validation.credential') + : null : null, builder: (field) => AnimatedSize( duration: const Duration(milliseconds: 180), @@ -231,7 +242,9 @@ class _ServerFormPageState extends ConsumerState { controller: _passwordCtrl, label: l.tr('servers.field.password'), obscureText: true, - helperText: _form.isEdit ? l.tr('servers.secretEditHint') : null, + helperText: _form.isEdit + ? l.tr('servers.secretEditHint') + : null, ), ], ), @@ -259,10 +272,12 @@ class _ServerFormPageState extends ConsumerState { validator: _form.proxyType.isEmpty ? null : (value) => value == null || value.isEmpty - ? l.tr(_form.proxyType == 'jumpHosts' - ? 'servers.validation.jumpHosts' - : 'servers.validation.proxyServer') - : null, + ? l.tr( + _form.proxyType == 'jumpHosts' + ? 'servers.validation.jumpHosts' + : 'servers.validation.proxyServer', + ) + : null, builder: (field) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -276,7 +291,7 @@ class _ServerFormPageState extends ConsumerState { Padding( padding: const EdgeInsets.only(top: 6), child: Text( - l.tr('servers.proxyPlusTip'), + l.tr('plus.serverManagedTip'), style: TextStyle( color: context.colors.softMuted, fontSize: 11, @@ -381,9 +396,7 @@ class _ServerFormPageState extends ConsumerState { void _showProxyPlusRequiredSnack() { final l = AppLocalizations.of(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.tr('servers.proxyPlusTip'))), - ); + showTopNotice(context, l.tr('plus.serverManagedTip')); } String? _normalizeGroupValue(List groups) { @@ -433,18 +446,22 @@ class _ServerFormPageState extends ConsumerState { ..command = _commandCtrl.text; try { final repo = ref.read(serverRepositoryProvider); - final message = _form.isEdit ? await repo.updateHost(_form) : await repo.createHost(_form); + final message = _form.isEdit + ? await repo.updateHost(_form) + : await repo.createHost(_form); await refreshServerSharedData(ref); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message == 'success' ? l.tr('common.saved') : message)), + SnackBar( + content: Text(message == 'success' ? l.tr('common.saved') : message), + ), ); Navigator.of(context).pop(true); } catch (error) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error.toString())), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.toString()))); } finally { if (mounted) setState(() => _saving = false); } @@ -513,10 +530,7 @@ class _CardSection extends StatelessWidget { } class _ConnectionTypeSegment extends StatelessWidget { - const _ConnectionTypeSegment({ - required this.value, - required this.onChanged, - }); + const _ConnectionTypeSegment({required this.value, required this.onChanged}); final String value; final ValueChanged onChanged; @@ -543,10 +557,7 @@ class _ConnectionTypeSegment extends StatelessWidget { } class _AuthTypeSegment extends StatelessWidget { - const _AuthTypeSegment({ - required this.value, - required this.onChanged, - }); + const _AuthTypeSegment({required this.value, required this.onChanged}); final String value; final ValueChanged onChanged; @@ -633,10 +644,7 @@ class _ProxyTypeSegment extends StatelessWidget { } class _SegmentShell extends StatelessWidget { - const _SegmentShell({ - required this.children, - this.compact = false, - }); + const _SegmentShell({required this.children, this.compact = false}); final List children; final bool compact; @@ -722,10 +730,7 @@ class _SegmentButton extends StatelessWidget { } class _LabeledBlock extends StatelessWidget { - const _LabeledBlock({ - required this.label, - required this.child, - }); + const _LabeledBlock({required this.label, required this.child}); final String label; final Widget child; @@ -878,8 +883,8 @@ class _AuthField extends StatelessWidget { meta: selected == null ? null : selected.authType == 'privateKey' - ? l.tr('servers.auth.privateKey') - : l.tr('servers.auth.password'), + ? l.tr('servers.auth.privateKey') + : l.tr('servers.auth.password'), placeholder: l.tr('servers.credentials.empty'), enabled: credentials.isNotEmpty, errorText: errorText, @@ -921,7 +926,6 @@ class _AuthField extends StatelessWidget { ], ); } - } class _ProxySelector extends StatelessWidget { @@ -1121,7 +1125,9 @@ class _PickerField extends StatelessWidget { Icon( leadingIcon, size: 18, - color: enabled ? context.colors.primary : context.colors.softMuted, + color: enabled + ? context.colors.primary + : context.colors.softMuted, ), const SizedBox(width: 10), ], @@ -1130,7 +1136,9 @@ class _PickerField extends StatelessWidget { value.isEmpty ? placeholder : value, overflow: TextOverflow.ellipsis, style: TextStyle( - color: value.isEmpty ? context.colors.softMuted : context.colors.text, + color: value.isEmpty + ? context.colors.softMuted + : context.colors.text, fontSize: 15, ), ), @@ -1228,13 +1236,14 @@ Future _showChoiceSheet({ child: ListView.separated( shrinkWrap: true, itemCount: options.length, - separatorBuilder: (_, _) => - const SizedBox(height: 8), + separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { final option = options[index]; final selected = option.value == value; return Material( - color: selected ? context.colors.chip : Colors.transparent, + color: selected + ? context.colors.chip + : Colors.transparent, borderRadius: BorderRadius.circular(10), child: InkWell( borderRadius: BorderRadius.circular(10), @@ -1257,9 +1266,7 @@ Future _showChoiceSheet({ child: Row( children: [ Icon( - selected - ? Icons.check_circle - : option.icon, + selected ? Icons.check_circle : option.icon, size: 20, color: context.colors.primary, ), @@ -1311,8 +1318,8 @@ class _DateField extends StatelessWidget { final display = value == null ? '' : '${value!.year.toString().padLeft(4, '0')}/' - '${value!.month.toString().padLeft(2, '0')}/' - '${value!.day.toString().padLeft(2, '0')}'; + '${value!.month.toString().padLeft(2, '0')}/' + '${value!.day.toString().padLeft(2, '0')}'; return _LabeledBlock( label: label, child: InkWell( @@ -1346,10 +1353,18 @@ class _DateField extends StatelessWidget { onTap: () => onChanged(null), child: Padding( padding: const EdgeInsets.all(4), - child: Icon(Icons.close, size: 18, color: context.colors.softMuted), + child: Icon( + Icons.close, + size: 18, + color: context.colors.softMuted, + ), ), ), - Icon(Icons.calendar_today_outlined, size: 18, color: context.colors.muted), + Icon( + Icons.calendar_today_outlined, + size: 18, + color: context.colors.muted, + ), ], ), ), @@ -1429,7 +1444,8 @@ class _JumpHostSelector extends StatelessWidget { borderRadius: BorderRadius.circular(10), onTap: options.isEmpty ? null : () => _showPicker(context), child: InputDecorator( - decoration: _fieldDecoration(context, + decoration: _fieldDecoration( + context, helperText: options.isEmpty ? l.tr('servers.jumpHosts.empty') : null, @@ -1437,7 +1453,10 @@ class _JumpHostSelector extends StatelessWidget { child: selectedHosts.isEmpty ? Text( l.tr('servers.jumpHosts.placeholder'), - style: TextStyle(color: context.colors.softMuted, fontSize: 14), + style: TextStyle( + color: context.colors.softMuted, + fontSize: 14, + ), ) : Column( children: [ @@ -1471,7 +1490,11 @@ class _JumpHostSelector extends StatelessWidget { ..remove(selectedHosts[i].id); onChanged(next); }, - child: Icon(Icons.close, size: 18, color: context.colors.softMuted), + child: Icon( + Icons.close, + size: 18, + color: context.colors.softMuted, + ), ), ], ), @@ -1545,7 +1568,9 @@ class _JumpHostSelector extends StatelessWidget { backgroundColor: context.colors.primary, child: Text( '$order', - style: TextStyle(color: context.colors.fontOnPrimary), + style: TextStyle( + color: context.colors.fontOnPrimary, + ), ), ) : null, diff --git a/native/lib/features/servers/servers_tab.dart b/native/lib/features/servers/servers_tab.dart index 9a4fb3f..fb76fd4 100644 --- a/native/lib/features/servers/servers_tab.dart +++ b/native/lib/features/servers/servers_tab.dart @@ -5,6 +5,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../../core/api/api_result.dart'; import '../../core/ui/app_color_theme.dart'; import '../../core/ui/refresh_feedback.dart'; +import '../../core/ui/top_notice.dart'; import '../../l10n/app_localizations.dart'; import '../../state/api_providers.dart'; import '../../state/auth_notifier.dart'; @@ -73,12 +74,9 @@ class _ServersTabState extends ConsumerState { if (!ref.read(isPlusActiveProvider) && (server.proxyType == 'proxyServer' || server.proxyType == 'jumpHosts')) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context).tr('servers.connectPlusRequired'), - ), - ), + showTopNotice( + context, + AppLocalizations.of(context).tr('plus.serverManagedTip'), ); return; } @@ -587,7 +585,9 @@ class _GroupPill extends StatelessWidget { @override Widget build(BuildContext context) { final background = selected ? context.colors.primary : Colors.transparent; - final foreground = selected ? context.colors.fontOnPrimary : context.colors.muted; + final foreground = selected + ? context.colors.fontOnPrimary + : context.colors.muted; return Material( color: background, shape: RoundedRectangleBorder( @@ -1232,7 +1232,10 @@ class _MessageState extends StatelessWidget { } } -InputDecoration _searchFieldDecoration(BuildContext context, {required String hintText}) { +InputDecoration _searchFieldDecoration( + BuildContext context, { + required String hintText, +}) { return InputDecoration( hintText: hintText, isDense: true, diff --git a/native/lib/features/settings/plus_subscription_page.dart b/native/lib/features/settings/plus_subscription_page.dart deleted file mode 100644 index 9c03ff5..0000000 --- a/native/lib/features/settings/plus_subscription_page.dart +++ /dev/null @@ -1,643 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/api/api_result.dart'; -import '../../core/ui/app_color_theme.dart'; -import '../../core/ui/refresh_feedback.dart'; -import '../../core/utils/app_store_compliance.dart'; -import '../../l10n/app_localizations.dart'; -import '../../state/api_providers.dart'; -import '../../state/plus_info_notifier.dart'; -import 'models/plus_info.dart'; - -class PlusSubscriptionPage extends ConsumerStatefulWidget { - const PlusSubscriptionPage({super.key}); - - @override - ConsumerState createState() => - _PlusSubscriptionPageState(); -} - -class _PlusSubscriptionPageState extends ConsumerState { - final _keyController = TextEditingController(); - final _formKey = GlobalKey(); - bool _activating = false; - bool _prefilled = false; - PlusDiscount _discount = const PlusDiscount(discount: false, content: ''); - - @override - void initState() { - super.initState(); - Future.microtask(_loadInitial); - } - - @override - void dispose() { - _keyController.dispose(); - super.dispose(); - } - - Future _loadInitial() async { - if (isIosAppStoreCompliance) return; - final repo = ref.read(settingsRepositoryProvider); - try { - final saved = await repo.getPlusKey(); - if (!mounted) return; - if (!_prefilled) { - _keyController.text = saved; - _prefilled = true; - } - } catch (_) { - // /plus-conf is optional pre-activation — silent fallback. - } - if (!isIosAppStoreCompliance) { - try { - final discount = await repo.getPlusDiscount(); - if (!mounted) return; - setState(() => _discount = discount); - } catch (_) { - // ignore - } - } - } - - Future _activate() async { - final l = AppLocalizations.of(context); - final key = _keyController.text.trim(); - if (key.length < 15) { - _showSnack(l.tr('plus.keyTooShort')); - return; - } - setState(() => _activating = true); - try { - await ref.read(settingsRepositoryProvider).updatePlusKey(key); - if (!mounted) return; - _showSnack(l.tr('plus.activateSuccess')); - } on ApiFailure catch (err) { - if (!mounted) return; - // Kicked sessions require a service restart before re-activation. - _showSnack(err.needRestart ? l.tr('plus.needRestart') : err.message); - } catch (_) { - if (!mounted) return; - _showSnack(l.tr('plus.activateFailed')); - } finally { - if (mounted) setState(() => _activating = false); - await ref.read(plusInfoProvider.notifier).refresh(); - } - } - - void _showSnack(String text) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(text), behavior: SnackBarBehavior.floating), - ); - } - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - final plusAsync = ref.watch(plusInfoProvider); - - return Scaffold( - backgroundColor: context.colors.canvas, - appBar: AppBar( - backgroundColor: context.colors.canvas, - elevation: 0, - scrolledUnderElevation: 0, - title: Text(l.tr('settings.plus.title')), - ), - body: RefreshIndicator( - onRefresh: () => runRefreshWithFeedback(context, () async { - await ref - .read(plusInfoProvider.notifier) - .refresh(throwOnError: true); - await _loadInitial(); - }), - child: plusAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (err, _) => _ErrorBody( - message: l.trf('plus.loadFailed', [err.toString()]), - onRetry: () => ref.read(plusInfoProvider.notifier).refresh(), - ), - data: (info) => _buildContent(info), - ), - ), - ); - } - - Widget _buildContent(PlusInfo info) { - final l = AppLocalizations.of(context); - final iosCompliance = isIosAppStoreCompliance; - return ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), - children: [ - _SectionHeader( - label: l.tr( - iosCompliance ? 'plus.section.status' : 'plus.section.activate', - ), - ), - if (iosCompliance) - _AuthorizationStatusCard(info: info) - else - _ActivateCard( - controller: _keyController, - formKey: _formKey, - discount: _discount, - loading: _activating, - onSubmit: _activate, - ), - const SizedBox(height: 18), - _SectionHeader(label: l.tr('plus.featuresSection')), - const _FeaturesCard(), - ], - ); - } -} - -class _AuthorizationStatusCard extends StatelessWidget { - const _AuthorizationStatusCard({required this.info}); - - final PlusInfo info; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - final statusText = info.isActive - ? l.tr('plus.status.active') - : l.tr('plus.status.inactive'); - final detail = info.error.isNotEmpty - ? info.error - : l.tr('plus.status.serverManagedHint'); - - return Container( - decoration: BoxDecoration( - color: context.colors.card, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: context.colors.border), - ), - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Icon( - info.isActive - ? Icons.verified_outlined - : Icons.info_outline_rounded, - size: 20, - color: info.isActive ? context.colors.success : context.colors.muted, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - statusText, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: context.colors.text, - ), - ), - ), - ], - ), - const SizedBox(height: 10), - Text( - detail, - style: TextStyle( - fontSize: 12, - height: 1.5, - fontWeight: FontWeight.w500, - color: context.colors.muted, - ), - ), - const SizedBox(height: 12), - _StatusLine(label: l.tr('plus.status.label'), value: info.status), - if (info.instanceId.isNotEmpty) ...[ - const SizedBox(height: 8), - _StatusLine( - label: l.tr('plus.status.instance'), - value: info.instanceId, - ), - ], - ], - ), - ); - } -} - -class _StatusLine extends StatelessWidget { - const _StatusLine({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 96, - child: Text( - label, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: context.colors.softMuted, - ), - ), - ), - Expanded( - child: Text( - value.isEmpty ? '-' : value, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: context.colors.text, - ), - ), - ), - ], - ); - } -} - -class _SectionHeader extends StatelessWidget { - const _SectionHeader({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(4, 2, 4, 8), - child: Text( - label.toUpperCase(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 1.5, - color: context.colors.muted, - ), - ), - ); - } -} - -class _ActivateCard extends StatefulWidget { - const _ActivateCard({ - required this.controller, - required this.formKey, - required this.discount, - required this.loading, - required this.onSubmit, - }); - - final TextEditingController controller; - final GlobalKey formKey; - final PlusDiscount discount; - final bool loading; - final VoidCallback onSubmit; - - @override - State<_ActivateCard> createState() => _ActivateCardState(); -} - -class _ActivateCardState extends State<_ActivateCard> { - bool _showKey = false; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - return Container( - decoration: BoxDecoration( - color: context.colors.card, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: context.colors.border), - ), - padding: const EdgeInsets.all(14), - child: Form( - key: widget.formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - l.tr('plus.keyLabel').toUpperCase(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 1.5, - color: context.colors.muted, - ), - ), - const SizedBox(height: 6), - TextFormField( - controller: widget.controller, - enabled: !widget.loading, - obscureText: !_showKey, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => widget.onSubmit(), - style: TextStyle( - fontSize: 14, - fontFamily: 'monospace', - color: context.colors.text, - ), - decoration: InputDecoration( - hintText: l.tr('plus.keyHint'), - hintStyle: TextStyle( - fontSize: 13, - color: context.colors.softMuted, - ), - isDense: true, - filled: true, - fillColor: context.colors.canvas, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - suffixIcon: IconButton( - icon: Icon( - _showKey - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - size: 18, - color: context.colors.muted, - ), - onPressed: () => setState(() => _showKey = !_showKey), - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: context.colors.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: context.colors.border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: context.colors.accent), - ), - ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: widget.loading ? null : widget.onSubmit, - style: FilledButton.styleFrom( - backgroundColor: context.colors.primary, - foregroundColor: context.colors.fontOnPrimary, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: widget.loading - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: context.colors.fontOnPrimary, - ), - ) - : Text( - l.tr('plus.activate'), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - if (widget.discount.discount && widget.discount.content.isNotEmpty) ...[ - const SizedBox(height: 10), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - decoration: BoxDecoration( - color: context.colors.dangerSoft, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: context.colors.dangerBorder), - ), - child: Row( - children: [ - Icon( - Icons.info_outline, - size: 14, - color: context.colors.danger, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - widget.discount.content, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: context.colors.danger, - ), - ), - ), - ], - ), - ), - ], - const SizedBox(height: 10), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: context.colors.chip, - borderRadius: BorderRadius.circular(10), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.info_outline, - size: 14, - color: context.colors.muted, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - l.tr('plus.activateHint'), - style: TextStyle( - fontSize: 11, - height: 1.5, - fontWeight: FontWeight.w500, - color: context.colors.muted, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _FeaturesCard extends StatelessWidget { - const _FeaturesCard(); - - static const _features = <(IconData, String, String)>[ - (Icons.smart_toy_outlined, 'plus.feature.ai.title', 'plus.feature.ai.desc'), - ( - Icons.alt_route_outlined, - 'plus.feature.proxy.title', - 'plus.feature.proxy.desc', - ), - ( - Icons.desktop_windows_outlined, - 'plus.feature.rdp.title', - 'plus.feature.rdp.desc', - ), - ( - Icons.swap_horiz_rounded, - 'plus.feature.transfer.title', - 'plus.feature.transfer.desc', - ), - ( - Icons.bolt_outlined, - 'plus.feature.advanced.title', - 'plus.feature.advanced.desc', - ), - ( - Icons.web_outlined, - 'plus.feature.web.title', - 'plus.feature.web.desc', - ), - ]; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - return Container( - decoration: BoxDecoration( - color: context.colors.card, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: context.colors.border), - ), - child: Column( - children: [ - for (var i = 0; i < _features.length; i++) ...[ - _FeatureRow( - icon: _features[i].$1, - title: l.tr(_features[i].$2), - description: l.tr(_features[i].$3), - ), - if (i < _features.length - 1) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Divider(height: 1, color: context.colors.border), - ), - ], - ], - ), - ); - } -} - -class _FeatureRow extends StatelessWidget { - const _FeatureRow({ - required this.icon, - required this.title, - required this.description, - }); - - final IconData icon; - final String title; - final String description; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(14), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: context.colors.chip, - borderRadius: BorderRadius.circular(10), - ), - alignment: Alignment.center, - child: Icon(icon, size: 16, color: context.colors.primary), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: context.colors.text, - ), - ), - const SizedBox(height: 2), - Text( - description, - style: TextStyle( - fontSize: 11, - height: 1.4, - fontWeight: FontWeight.w500, - color: context.colors.muted, - ), - ), - ], - ), - ), - Icon(Icons.check_rounded, size: 16, color: context.colors.success), - ], - ), - ); - } -} - -class _ErrorBody extends StatelessWidget { - const _ErrorBody({required this.message, required this.onRetry}); - - final String message; - final Future Function() onRetry; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - return ListView( - children: [ - const SizedBox(height: 80), - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Column( - children: [ - Icon( - Icons.error_outline, - size: 36, - color: context.colors.danger, - ), - const SizedBox(height: 12), - Text( - message, - textAlign: TextAlign.center, - style: TextStyle(fontSize: 13, color: context.colors.text), - ), - const SizedBox(height: 16), - FilledButton.tonal( - onPressed: () => onRetry(), - child: Text(l.tr('common.retry')), - ), - ], - ), - ), - ), - ], - ); - } -} diff --git a/native/lib/features/settings/proxy_page.dart b/native/lib/features/settings/proxy_page.dart index 8355bcf..804cbd2 100644 --- a/native/lib/features/settings/proxy_page.dart +++ b/native/lib/features/settings/proxy_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_result.dart'; import '../../core/ui/app_color_theme.dart'; import '../../core/ui/refresh_feedback.dart'; +import '../../core/ui/top_notice.dart'; import '../../l10n/app_localizations.dart'; import '../../state/api_providers.dart'; import '../../state/plus_info_notifier.dart'; @@ -124,7 +125,7 @@ class _ProxyPageState extends ConsumerState { void _showPlusGate() { final l = AppLocalizations.of(context); - _showSnack(l.tr('proxy.plusTipBody')); + showTopNotice(context, l.tr('plus.serverManagedTip')); } void _showSnack(String text) { @@ -136,12 +137,14 @@ class _ProxyPageState extends ConsumerState { List _filter(List list) { final q = _query.trim().toLowerCase(); if (q.isEmpty) return list; - return list.where((e) { - return e.name.toLowerCase().contains(q) || - e.host.toLowerCase().contains(q) || - e.username.toLowerCase().contains(q) || - e.type.toLowerCase().contains(q); - }).toList(growable: false); + return list + .where((e) { + return e.name.toLowerCase().contains(q) || + e.host.toLowerCase().contains(q) || + e.username.toLowerCase().contains(q) || + e.type.toLowerCase().contains(q); + }) + .toList(growable: false); } String _formatDate(int? ms) { @@ -155,7 +158,8 @@ class _ProxyPageState extends ConsumerState { Widget build(BuildContext context) { final l = AppLocalizations.of(context); final async = ref.watch(proxyListProvider); - final plusActive = ref.watch(plusInfoProvider).valueOrNull?.isActive ?? false; + final plusActive = + ref.watch(plusInfoProvider).valueOrNull?.isActive ?? false; return Scaffold( backgroundColor: context.colors.canvas, @@ -221,18 +225,21 @@ class _ProxyPageState extends ConsumerState { ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: - BorderSide(color: context.colors.border), + borderSide: BorderSide( + color: context.colors.border, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: - BorderSide(color: context.colors.border), + borderSide: BorderSide( + color: context.colors.border, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: - BorderSide(color: context.colors.accent), + borderSide: BorderSide( + color: context.colors.accent, + ), ), ), ), @@ -349,7 +356,7 @@ class _PlusTip extends StatelessWidget { ), const SizedBox(height: 2), Text( - l.tr('proxy.plusTipBody'), + l.tr('plus.serverManagedTip'), style: TextStyle( fontSize: 11, color: context.colors.muted, @@ -652,10 +659,7 @@ class _ActionButton extends StatelessWidget { ? SizedBox( width: 14, height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: fg, - ), + child: CircularProgressIndicator(strokeWidth: 2, color: fg), ) : Icon(icon, size: 14, color: fg), ), diff --git a/native/lib/features/settings/settings_repository.dart b/native/lib/features/settings/settings_repository.dart index 9219883..fadc539 100644 --- a/native/lib/features/settings/settings_repository.dart +++ b/native/lib/features/settings/settings_repository.dart @@ -18,8 +18,8 @@ class SettingsRepository { required this.publicKeyPem, CryptoJsAes? aes, RsaCrypto? rsa, - }) : _aes = aes ?? CryptoJsAes(), - _rsa = rsa ?? RsaCrypto(); + }) : _aes = aes ?? CryptoJsAes(), + _rsa = rsa ?? RsaCrypto(); final ApiClient apiClient; final String publicKeyPem; @@ -82,16 +82,6 @@ class SettingsRepository { return PlusInfo.fromJson(Map.from(raw)); } - Future getPlusKey() async { - final res = await apiClient.getJson('/plus-conf'); - final raw = res['data']; - return raw == null ? '' : raw.toString(); - } - - Future updatePlusKey(String key) async { - await apiClient.postJson('/plus-conf', {'key': key}); - } - Future getPlusDiscount() async { try { final res = await apiClient.getJson('/plus-discount'); @@ -141,13 +131,13 @@ class SettingsRepository { } Map _proxyBody(ServerProxyModel proxy) => { - 'type': proxy.type, - 'name': proxy.name, - 'host': proxy.host, - 'port': proxy.port, - 'username': proxy.username, - 'password': proxy.password, - }; + 'type': proxy.type, + 'name': proxy.name, + 'host': proxy.host, + 'port': proxy.port, + 'username': proxy.username, + 'password': proxy.password, + }; // ---- Credentials CRUD ---- diff --git a/native/lib/features/shell/settings_tab.dart b/native/lib/features/shell/settings_tab.dart index c19b615..3c8943b 100644 --- a/native/lib/features/shell/settings_tab.dart +++ b/native/lib/features/shell/settings_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/ui/app_color_theme.dart'; import '../../core/ui/refresh_feedback.dart'; +import '../../core/ui/top_notice.dart'; import '../../core/utils/app_store_compliance.dart'; import '../../l10n/app_localizations.dart'; import '../../state/auth_notifier.dart'; @@ -20,7 +21,6 @@ import '../settings/account_security_page.dart'; import '../settings/app_update_prompt.dart'; import '../settings/credentials_page.dart'; import '../settings/models/plus_info.dart'; -import '../settings/plus_subscription_page.dart'; import '../settings/proxy_page.dart'; import '../settings/sessions_page.dart'; import '../settings/widgets/settings_row.dart'; @@ -153,7 +153,7 @@ class SettingsTab extends ConsumerWidget { content: discount.content, onTap: () { Navigator.of(sheetContext).pop(); - _push(context, const PlusSubscriptionPage()); + _showPlusServerManagedTip(context); }, ) else ...[ @@ -166,10 +166,7 @@ class SettingsTab extends ConsumerWidget { Text( l.tr('settings.notifications.empty'), textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: c.muted, - ), + style: TextStyle(fontSize: 13, color: c.muted), ), ], const SizedBox(height: 12), @@ -181,6 +178,11 @@ class SettingsTab extends ConsumerWidget { ); } + void _showPlusServerManagedTip(BuildContext context) { + final l = AppLocalizations.of(context); + showTopNotice(context, l.tr('plus.serverManagedTip')); + } + void _showThemePicker(BuildContext context, WidgetRef ref) { final l = AppLocalizations.of(context); final current = ref.read(themeModeProvider); @@ -190,7 +192,11 @@ class SettingsTab extends ConsumerWidget { title: Text(l.tr('settings.theme.title')), children: [ for (final entry in [ - (ThemeMode.system, l.tr('settings.theme.system'), Icons.brightness_auto), + ( + ThemeMode.system, + l.tr('settings.theme.system'), + Icons.brightness_auto, + ), (ThemeMode.light, l.tr('settings.theme.light'), Icons.light_mode), (ThemeMode.dark, l.tr('settings.theme.dark'), Icons.dark_mode), ]) @@ -347,7 +353,7 @@ class SettingsTab extends ConsumerWidget { credentialCount: credentialCount, scriptCount: scriptCount, plusActive: plusActive, - onPlusTap: () => _push(context, const PlusSubscriptionPage()), + onPlusTap: () => _showPlusServerManagedTip(context), onLogoutTap: () => _confirmLogout(context, ref), ), ), @@ -409,7 +415,10 @@ class SettingsTab extends ConsumerWidget { SettingsRow( icon: Icons.palette_outlined, title: l.tr('settings.theme.title'), - subtitle: _themeModeLabel(l, ref.watch(themeModeProvider)), + subtitle: _themeModeLabel( + l, + ref.watch(themeModeProvider), + ), onTap: () => _showThemePicker(context, ref), ), SettingsRow( @@ -578,10 +587,7 @@ class _ProfileCard extends StatelessWidget { serverAddress, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - color: colors.softMuted, - ), + style: TextStyle(fontSize: 12, color: colors.softMuted), ), ], ), @@ -668,7 +674,6 @@ class _StatDivider extends StatelessWidget { } } - class _PlusBadge extends StatelessWidget { const _PlusBadge({required this.active, required this.onTap}); @@ -700,7 +705,6 @@ class _PlusBadge extends StatelessWidget { @override Widget build(BuildContext context) { - final l = AppLocalizations.of(context); final image = Image.asset( 'assets/plus.png', width: 36, @@ -708,30 +712,13 @@ class _PlusBadge extends StatelessWidget { fit: BoxFit.contain, ); return InkWell( - onTap: onTap, + onTap: active ? null : onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - active - ? image - : ColorFiltered(colorFilter: _grayscale, child: image), - if (!active) ...[ - const SizedBox(width: 6), - Text( - l.tr('settings.plus.goActivate'), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: context.colors.accent, - decoration: TextDecoration.underline, - ), - ), - ], - ], - ), + child: active + ? image + : ColorFiltered(colorFilter: _grayscale, child: image), ), ); } @@ -824,11 +811,7 @@ class _DiscountNotificationCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.local_offer_outlined, - size: 18, - color: colors.danger, - ), + Icon(Icons.local_offer_outlined, size: 18, color: colors.danger), const SizedBox(width: 10), Expanded( child: Text( @@ -842,11 +825,7 @@ class _DiscountNotificationCard extends StatelessWidget { ), ), const SizedBox(width: 8), - Icon( - Icons.chevron_right_rounded, - size: 18, - color: colors.danger, - ), + Icon(Icons.info_outline_rounded, size: 18, color: colors.danger), ], ), ), @@ -999,10 +978,7 @@ class _TabOrderDialogState extends State<_TabOrderDialog> { ), trailing: ReorderableDragStartListener( index: index, - child: Icon( - Icons.drag_handle, - color: c.softMuted, - ), + child: Icon(Icons.drag_handle, color: c.softMuted), ), ); }, diff --git a/native/lib/features/shell/sftp_tab.dart b/native/lib/features/shell/sftp_tab.dart index 998b0ef..498e4f3 100644 --- a/native/lib/features/shell/sftp_tab.dart +++ b/native/lib/features/shell/sftp_tab.dart @@ -11,6 +11,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../core/api/api_result.dart'; import '../../core/ui/app_color_theme.dart'; import '../../core/ui/refresh_feedback.dart'; +import '../../core/ui/top_notice.dart'; import '../../features/servers/server_model.dart'; import '../../l10n/app_localizations.dart'; import '../../state/api_providers.dart'; @@ -44,6 +45,7 @@ class SftpPanel extends ConsumerStatefulWidget { this.allowDisconnect = true, this.lockToHost = false, this.onExecCommand, + this.onCommandPanelClose, }); final bool showHeader; @@ -51,6 +53,7 @@ class SftpPanel extends ConsumerStatefulWidget { final bool allowDisconnect; final bool lockToHost; final void Function(String command)? onExecCommand; + final VoidCallback? onCommandPanelClose; @override ConsumerState createState() => _SftpPanelState(); @@ -245,6 +248,7 @@ class _SftpPanelState extends ConsumerState { session: session, manager: manager, onExecCommand: widget.onExecCommand, + onCommandPanelClose: widget.onCommandPanelClose, isPlusActive: ref.watch(isPlusActiveProvider), ); }, @@ -288,12 +292,14 @@ class _SftpConnectedView extends StatelessWidget { required this.session, required this.manager, this.onExecCommand, + this.onCommandPanelClose, this.isPlusActive = false, }); final SftpSessionState session; final SftpSessionManager manager; final void Function(String command)? onExecCommand; + final VoidCallback? onCommandPanelClose; final bool isPlusActive; @override @@ -409,8 +415,10 @@ class _SftpConnectedView extends StatelessWidget { } static bool _isDockerComposeFile(String filename) { - return RegExp(r'^docker-compose\.(yml|yaml)$', caseSensitive: false) - .hasMatch(filename); + return RegExp( + r'^docker-compose\.(yml|yaml)$', + caseSensitive: false, + ).hasMatch(filename); } void _showFileActionSheet( @@ -573,20 +581,41 @@ class _SftpConnectedView extends StatelessWidget { anchor.name, ); final composeActions = <(IconData, String, String)>[ - (Icons.play_arrow_rounded, l.tr('sftp.compose.up'), - 'docker compose -f $fullPath up -d\n'), - (Icons.restart_alt_rounded, l.tr('sftp.compose.restart'), - 'docker compose -f $fullPath restart\n'), - (Icons.stop_rounded, l.tr('sftp.compose.down'), - 'docker compose -f $fullPath down\n'), - (Icons.cloud_download_outlined, l.tr('sftp.compose.pull'), - 'docker compose -f $fullPath pull\n'), - (Icons.build_outlined, l.tr('sftp.compose.rebuild'), - 'docker compose -f $fullPath up -d --force-recreate\n'), - (Icons.upgrade_rounded, l.tr('sftp.compose.upgrade'), - 'docker compose -f $fullPath pull && docker compose -f $fullPath down && docker compose -f $fullPath up -d\n'), - (Icons.article_outlined, l.tr('sftp.compose.logs'), - 'docker compose -f $fullPath logs --tail=500 -f\n'), + ( + Icons.play_arrow_rounded, + l.tr('sftp.compose.up'), + 'docker compose -f $fullPath up -d\n', + ), + ( + Icons.restart_alt_rounded, + l.tr('sftp.compose.restart'), + 'docker compose -f $fullPath restart\n', + ), + ( + Icons.stop_rounded, + l.tr('sftp.compose.down'), + 'docker compose -f $fullPath down\n', + ), + ( + Icons.cloud_download_outlined, + l.tr('sftp.compose.pull'), + 'docker compose -f $fullPath pull\n', + ), + ( + Icons.build_outlined, + l.tr('sftp.compose.rebuild'), + 'docker compose -f $fullPath up -d --force-recreate\n', + ), + ( + Icons.upgrade_rounded, + l.tr('sftp.compose.upgrade'), + 'docker compose -f $fullPath pull && docker compose -f $fullPath down && docker compose -f $fullPath up -d\n', + ), + ( + Icons.article_outlined, + l.tr('sftp.compose.logs'), + 'docker compose -f $fullPath logs --tail=500 -f\n', + ), ]; showModalBottomSheet( context: context, @@ -639,15 +668,11 @@ class _SftpConnectedView extends StatelessWidget { onTap: () { Navigator.of(sheetContext).pop(); if (!isPlusActive) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l.tr('sftp.compose.plusRequired')), - ), - ); + showTopNotice(context, l.tr('plus.serverManagedTip')); return; } onExecCommand!(command); - Navigator.of(context).pop(); + onCommandPanelClose?.call(); }, ), ], @@ -1391,8 +1416,7 @@ class _SftpNameDialogState extends State<_SftpNameDialog> { ), const SizedBox(width: 8), FilledButton( - onPressed: () => - Navigator.of(context).pop(_controller.text), + onPressed: () => Navigator.of(context).pop(_controller.text), style: FilledButton.styleFrom( backgroundColor: c.primary, foregroundColor: c.fontOnPrimary, @@ -1614,11 +1638,13 @@ class _SftpCreateDialogState extends State<_SftpCreateDialog> { onTap: () => _selectSuggestion(item), borderRadius: index == 0 ? const BorderRadius.vertical( - top: Radius.circular(10)) + top: Radius.circular(10), + ) : index == _filtered.length - 1 - ? const BorderRadius.vertical( - bottom: Radius.circular(10)) - : BorderRadius.zero, + ? const BorderRadius.vertical( + bottom: Radius.circular(10), + ) + : BorderRadius.zero, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12, @@ -1644,8 +1670,7 @@ class _SftpCreateDialogState extends State<_SftpCreateDialog> { ), const SizedBox(width: 8), FilledButton( - onPressed: () => - Navigator.of(context).pop(_controller.text), + onPressed: () => Navigator.of(context).pop(_controller.text), style: FilledButton.styleFrom( backgroundColor: c.primary, foregroundColor: c.fontOnPrimary, diff --git a/native/lib/features/terminal/terminal_bottom_menu.dart b/native/lib/features/terminal/terminal_bottom_menu.dart index 6cf87bb..4bad0e7 100644 --- a/native/lib/features/terminal/terminal_bottom_menu.dart +++ b/native/lib/features/terminal/terminal_bottom_menu.dart @@ -264,14 +264,14 @@ class _TerminalBottomBarState extends ConsumerState { widget.onFocusTerminal?.call(); } - Future _showSftp(BuildContext context) { + Future _showSftp(BuildContext context) async { final l = AppLocalizations.of(context); - return showModalBottomSheet( + await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, barrierColor: Colors.black.withValues(alpha: 0.34), - builder: (_) => FractionallySizedBox( + builder: (sheetContext) => FractionallySizedBox( heightFactor: 0.90, child: _TerminalSheetFrame( title: l.tr('terminal.menu.sftp'), @@ -291,10 +291,12 @@ class _TerminalBottomBarState extends ConsumerState { ?.controller .writeInput(command); }, + onCommandPanelClose: () => Navigator.of(sheetContext).pop(), ), ), ), ); + widget.onFocusTerminal?.call(); } Future _showDocker(BuildContext context) async { diff --git a/native/lib/l10n/strings_en.dart b/native/lib/l10n/strings_en.dart index 5aea6c6..878ef99 100644 --- a/native/lib/l10n/strings_en.dart +++ b/native/lib/l10n/strings_en.dart @@ -71,10 +71,6 @@ const Map stringsEn = { 'servers.notConfigured': 'Not configured', 'servers.fetchSshFailed': 'Failed to get SSH config: {0}', 'servers.windowsUnsupported': 'Windows connections are not supported yet.', - 'servers.connectPlusRequired': - 'This connection capability is not enabled by the connected server.', - 'servers.proxyPlusTip': - 'This proxy capability is not enabled by the connected server.', 'servers.activeTerminalsOne': '1 active terminal', 'servers.activeTerminalsMany': '{0} active terminals', 'servers.closeAllTitle': 'Close all terminals?', @@ -200,9 +196,6 @@ const Map stringsEn = { 'sftp.compose.rebuild': 'Rebuild', 'sftp.compose.upgrade': 'Upgrade', 'sftp.compose.logs': 'Logs', - 'sftp.compose.plusRequired': - 'This capability is not enabled by the connected server.', - // Docker 'docker.emptyTitle': 'No server selected', 'docker.emptyBody': 'Choose a Linux server to manage Docker containers.', @@ -217,9 +210,7 @@ const Map stringsEn = { 'docker.statusDisconnected': 'Not connected', 'docker.connecting': 'Connecting Docker...', 'docker.noContainers': 'No containers found.', - 'docker.plusRequired': 'Docker management requires server authorization.', 'docker.connectFailed': 'Docker service connection failed.', - 'docker.notPlus': 'Docker management is not enabled on this server.', 'docker.refresh': 'Refresh', 'docker.logs': 'Logs', 'docker.start': 'Start', @@ -255,8 +246,6 @@ const Map stringsEn = { 'scripts.groupsHint': 'Groups organize your scripts.', 'scripts.groupsHintBody': 'Deleting a group moves its scripts to the default group.', - 'scripts.groupsPlusTip': - 'This capability is not enabled by the connected server.', 'scripts.addGroup': 'Add group', 'scripts.editGroup': 'Edit group', 'scripts.groupName': 'Group name', @@ -309,9 +298,8 @@ const Map stringsEn = { 'settings.section.connection': 'Credentials & Connection', 'settings.section.preferences': 'Preferences', - 'settings.plus.title': 'Plus features', - 'settings.plus.inactiveMeta': 'Server authorization is not active', - 'settings.plus.goActivate': 'View status', + 'plus.serverManagedTip': + 'This feature requires authorization from the connected server.', 'settings.account.title': 'Account & password', 'settings.account.subtitle': 'Username, password and MFA2', @@ -355,43 +343,6 @@ const Map stringsEn = { 'settings.notifications.tooltip': 'Notifications', 'settings.notifications.empty': 'No notifications yet', - // Plus subscription - 'plus.activate': 'Update authorization', - 'plus.keyLabel': 'Authorization Key', - 'plus.keyHint': 'Enter the server authorization key', - 'plus.keyTooShort': 'Please enter a valid authorization key', - 'plus.activateSuccess': 'Authorization updated', - 'plus.activateFailed': 'Authorization update failed', - 'plus.needRestart': - 'Authorization is occupied by another instance. Please restart the panel service and retry', - 'plus.fetchKey': 'Open authorization page', - 'plus.fetchKeyHint': 'Copied authorization URL to clipboard: {0}', - 'plus.featuresTitle': 'Plus features', - 'plus.featuresSection': 'Plus features', - 'plus.section.activate': 'Authorization', - 'plus.section.status': 'Authorization status', - 'plus.activateHint': 'Authorization is configured on your EasyNode server.', - 'plus.discountTip': 'Service notice', - 'plus.loadFailed': 'Failed to load Plus info: {0}', - 'plus.status.active': 'Server authorization active', - 'plus.status.inactive': 'Server authorization inactive', - 'plus.status.serverManagedHint': - 'Authorization is managed by the connected self-hosted server.', - 'plus.status.label': 'Status', - 'plus.status.instance': 'Instance', - 'plus.feature.ai.title': 'AI Chat', - 'plus.feature.ai.desc': 'Built-in AI assistant for terminals', - 'plus.feature.proxy.title': 'Proxy & jump hosts', - 'plus.feature.proxy.desc': 'Proxy servers and jump host hops', - 'plus.feature.rdp.title': 'RDP remote desktop', - 'plus.feature.rdp.desc': 'Windows RDP access', - 'plus.feature.transfer.title': 'File transfer', - 'plus.feature.transfer.desc': 'Direct file transfer between hosts', - 'plus.feature.advanced.title': 'Advanced terminal', - 'plus.feature.advanced.desc': 'Session suspend, batch tasks, Docker', - 'plus.feature.web.title': 'More on web', - 'plus.feature.web.desc': 'Explore the full Plus feature set on web', - // Sessions 'sessions.ipWhitelistTitle': 'IP whitelist', 'sessions.ipWhitelistHint': @@ -486,8 +437,6 @@ const Map stringsEn = { // Proxy 'proxy.plusTipTitle': 'Server authorization required', - 'proxy.plusTipBody': - 'This capability is not enabled by the connected server.', 'proxy.add': 'Add proxy', 'settings.proxy.add': 'Add', 'proxy.edit': 'Edit proxy', diff --git a/native/lib/l10n/strings_zh.dart b/native/lib/l10n/strings_zh.dart index 21d1ccf..ff53165 100644 --- a/native/lib/l10n/strings_zh.dart +++ b/native/lib/l10n/strings_zh.dart @@ -67,8 +67,6 @@ const Map stringsZh = { 'servers.notConfigured': '未配置', 'servers.fetchSshFailed': '获取 SSH 连接信息失败:{0}', 'servers.windowsUnsupported': 'Windows 连接暂不支持', - 'servers.connectPlusRequired': '当前连接能力未由服务端启用。', - 'servers.proxyPlusTip': '当前代理能力未由服务端启用。', 'servers.activeTerminalsOne': '1 个活动终端', 'servers.activeTerminalsMany': '{0} 个活动终端', 'servers.closeAllTitle': '关闭所有终端?', @@ -188,8 +186,6 @@ const Map stringsZh = { 'sftp.compose.rebuild': '重建(rebuild)', 'sftp.compose.upgrade': '升级(upgrade)', 'sftp.compose.logs': '日志(logs)', - 'sftp.compose.plusRequired': '当前能力未由服务端启用。', - // Docker 'docker.emptyTitle': '还没有选择服务器', 'docker.emptyBody': '请选择一台 Linux 服务器,开始管理 Docker 容器。', @@ -204,9 +200,7 @@ const Map stringsZh = { 'docker.statusDisconnected': '未连接', 'docker.connecting': '正在连接 Docker...', 'docker.noContainers': '暂无容器', - 'docker.plusRequired': 'Docker 管理需要服务端授权。', 'docker.connectFailed': 'Docker 服务连接失败。', - 'docker.notPlus': '当前服务端未启用 Docker 管理能力。', 'docker.refresh': '刷新', 'docker.logs': '日志', 'docker.start': '启动', @@ -240,7 +234,6 @@ const Map stringsZh = { 'scripts.groupsManage': '分组管理', 'scripts.groupsHint': '分组用于组织你的脚本', 'scripts.groupsHintBody': '删除分组时其下的脚本会自动移动到默认分组', - 'scripts.groupsPlusTip': '当前能力未由服务端启用。', 'scripts.addGroup': '添加分组', 'scripts.editGroup': '修改分组', 'scripts.groupName': '分组名称', @@ -292,9 +285,7 @@ const Map stringsZh = { 'settings.section.connection': '凭据与连接', 'settings.section.preferences': '偏好设置', - 'settings.plus.title': 'Plus 功能', - 'settings.plus.inactiveMeta': '服务端授权未启用', - 'settings.plus.goActivate': '查看状态', + 'plus.serverManagedTip': '此功能需当前服务端授权后使用。', 'settings.account.title': '账户与密码', 'settings.account.subtitle': '修改用户名 / 密码 / MFA2', @@ -337,41 +328,6 @@ const Map stringsZh = { 'settings.notifications.tooltip': '通知', 'settings.notifications.empty': '暂无新通知', - // Plus subscription page - 'plus.activate': '更新授权', - 'plus.keyLabel': '授权 Key', - 'plus.keyHint': '请输入服务端授权 Key', - 'plus.keyTooShort': '请输入有效的授权 Key', - 'plus.activateSuccess': '授权已更新', - 'plus.activateFailed': '授权更新失败', - 'plus.needRestart': '检测到授权已在其它实例被占用,请重启面板服务后重试', - 'plus.fetchKey': '打开授权页面', - 'plus.fetchKeyHint': '已复制授权地址到剪贴板:{0}', - 'plus.featuresTitle': 'Plus 专属功能', - 'plus.featuresSection': 'Plus 特性', - 'plus.section.activate': '授权', - 'plus.section.status': '授权状态', - 'plus.activateHint': '授权由你的 EasyNode 服务端配置。', - 'plus.discountTip': '服务通知', - 'plus.loadFailed': '加载 Plus 信息失败:{0}', - 'plus.status.active': '服务端授权已启用', - 'plus.status.inactive': '服务端授权未启用', - 'plus.status.serverManagedHint': '授权状态由当前连接的自托管服务端管理。', - 'plus.status.label': '状态', - 'plus.status.instance': '实例', - 'plus.feature.ai.title': 'AI Chat', - 'plus.feature.ai.desc': '内置 AI 对话联动终端', - 'plus.feature.proxy.title': '代理与跳板机', - 'plus.feature.proxy.desc': '服务器代理、跳板机连接', - 'plus.feature.rdp.title': 'RDP 远程桌面', - 'plus.feature.rdp.desc': 'Windows RDP 桌面接入', - 'plus.feature.transfer.title': '文件对传', - 'plus.feature.transfer.desc': '主机之间直接传输文件', - 'plus.feature.advanced.title': '高级终端能力', - 'plus.feature.advanced.desc': '会话挂起 / 批量任务 / Docker 管理', - 'plus.feature.web.title': 'Web 端更多特性', - 'plus.feature.web.desc': '前往 Web 端解锁完整 Plus 能力', - // Sessions page 'sessions.ipWhitelistTitle': 'IP 白名单', 'sessions.ipWhitelistHint': 'IP 包含匹配,如 192.168 则包含 192.168.*', @@ -459,7 +415,6 @@ const Map stringsZh = { // Proxy page 'proxy.plusTipTitle': '需要服务端授权', - 'proxy.plusTipBody': '当前能力未由服务端启用。', 'proxy.add': '添加代理', 'settings.proxy.add': '添加', 'proxy.edit': '编辑代理', diff --git a/native/pubspec.yaml b/native/pubspec.yaml index 1bc14c5..616eb05 100644 --- a/native/pubspec.yaml +++ b/native/pubspec.yaml @@ -1,7 +1,7 @@ name: easynode_native description: "EasyNode native app." publish_to: 'none' -version: 1.0.1+1 +version: 1.1.0+2 environment: sdk: ^3.11.0