feat: notice tips

This commit is contained in:
chaoszhu
2026-07-02 17:35:20 +08:00
parent 4e7151686f
commit 60d70e0f66
15 changed files with 312 additions and 974 deletions
+1 -13
View File
@@ -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;
}
+80
View File
@@ -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<void>.delayed(duration, () {
if (_activeTopNoticeEntry == entry && entry.mounted) {
entry.remove();
_activeTopNoticeEntry = null;
}
});
}
+4 -3
View File
@@ -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<DockerPanel> {
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<DockerPanel> {
_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');
@@ -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<ScriptGroupsPage> {
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<ScriptGroupsPage> {
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<ScriptGroupsPage> {
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,
),
],
@@ -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<ServerFormPage> {
@override
void initState() {
super.initState();
final hosts = ref.read(hostListProvider).valueOrNull ?? const <ServerModel>[];
final hosts =
ref.read(hostListProvider).valueOrNull ?? const <ServerModel>[];
final maxIndex = hosts.fold<int>(
0,
(max, host) => host.index > max ? host.index : max,
@@ -87,9 +89,12 @@ class _ServerFormPageState extends ConsumerState<ServerFormPage> {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final groups = ref.watch(groupListProvider).valueOrNull ?? const <ServerGroupModel>[];
final hosts = ref.watch(hostListProvider).valueOrNull ?? const <ServerModel>[];
final credentials = ref.watch(credentialListProvider).valueOrNull ??
final groups =
ref.watch(groupListProvider).valueOrNull ?? const <ServerGroupModel>[];
final hosts =
ref.watch(hostListProvider).valueOrNull ?? const <ServerModel>[];
final credentials =
ref.watch(credentialListProvider).valueOrNull ??
const <ServerCredentialModel>[];
final proxies =
ref.watch(proxyListProvider).valueOrNull ?? const <ServerProxyModel>[];
@@ -104,7 +109,9 @@ class _ServerFormPageState extends ConsumerState<ServerFormPage> {
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<ServerFormPage> {
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<ServerFormPage> {
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<ServerFormPage> {
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<ServerFormPage> {
if (_form.isSsh) ...[
_AuthTypeSegment(
value: _form.authType,
onChanged: (value) => setState(() => _form.authType = value),
onChanged: (value) =>
setState(() => _form.authType = value),
),
FormField<String>(
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<ServerFormPage> {
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<ServerFormPage> {
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<ServerFormPage> {
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<ServerFormPage> {
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<ServerGroupModel> groups) {
@@ -433,18 +446,22 @@ class _ServerFormPageState extends ConsumerState<ServerFormPage> {
..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<String> 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<String> 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<Widget> 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<T?> _showChoiceSheet<T>({
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<T?> _showChoiceSheet<T>({
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,
+11 -8
View File
@@ -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<ServersTab> {
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,
@@ -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<PlusSubscriptionPage> createState() =>
_PlusSubscriptionPageState();
}
class _PlusSubscriptionPageState extends ConsumerState<PlusSubscriptionPage> {
final _keyController = TextEditingController();
final _formKey = GlobalKey<FormState>();
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<void> _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<void> _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<FormState> 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<void> 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')),
),
],
),
),
),
],
);
}
}
+23 -19
View File
@@ -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<ProxyPage> {
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<ProxyPage> {
List<ServerProxyModel> _filter(List<ServerProxyModel> 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<ProxyPage> {
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<ProxyPage> {
),
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),
),
@@ -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<String, dynamic>.from(raw));
}
Future<String> getPlusKey() async {
final res = await apiClient.getJson('/plus-conf');
final raw = res['data'];
return raw == null ? '' : raw.toString();
}
Future<void> updatePlusKey(String key) async {
await apiClient.postJson('/plus-conf', {'key': key});
}
Future<PlusDiscount> getPlusDiscount() async {
try {
final res = await apiClient.getJson('/plus-discount');
@@ -141,13 +131,13 @@ class SettingsRepository {
}
Map<String, dynamic> _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 ----
+26 -50
View File
@@ -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),
),
);
},
+55 -30
View File
@@ -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<SftpPanel> createState() => _SftpPanelState();
@@ -245,6 +248,7 @@ class _SftpPanelState extends ConsumerState<SftpPanel> {
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<void>(
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,
@@ -264,14 +264,14 @@ class _TerminalBottomBarState extends ConsumerState<TerminalBottomBar> {
widget.onFocusTerminal?.call();
}
Future<void> _showSftp(BuildContext context) {
Future<void> _showSftp(BuildContext context) async {
final l = AppLocalizations.of(context);
return showModalBottomSheet<void>(
await showModalBottomSheet<void>(
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<TerminalBottomBar> {
?.controller
.writeInput(command);
},
onCommandPanelClose: () => Navigator.of(sheetContext).pop(),
),
),
),
);
widget.onFocusTerminal?.call();
}
Future<void> _showDocker(BuildContext context) async {
+2 -53
View File
@@ -71,10 +71,6 @@ const Map<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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',
+1 -46
View File
@@ -67,8 +67,6 @@ const Map<String, String> 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<String, String> 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<String, String> 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<String, String> stringsZh = {
'scripts.groupsManage': '分组管理',
'scripts.groupsHint': '分组用于组织你的脚本',
'scripts.groupsHintBody': '删除分组时其下的脚本会自动移动到默认分组',
'scripts.groupsPlusTip': '当前能力未由服务端启用。',
'scripts.addGroup': '添加分组',
'scripts.editGroup': '修改分组',
'scripts.groupName': '分组名称',
@@ -292,9 +285,7 @@ const Map<String, String> 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<String, String> 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<String, String> stringsZh = {
// Proxy page
'proxy.plusTipTitle': '需要服务端授权',
'proxy.plusTipBody': '当前能力未由服务端启用。',
'proxy.add': '添加代理',
'settings.proxy.add': '添加',
'proxy.edit': '编辑代理',
+1 -1
View File
@@ -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