mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-06 14:23:19 +08:00
feat: notice tips
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ----
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user