mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 16:18:31 +08:00
1041 lines
34 KiB
Dart
1041 lines
34 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.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/auth_notifier.dart';
|
|
import '../../state/app_update_notifier.dart';
|
|
import '../../state/tab_order_notifier.dart';
|
|
import '../../state/credential_list_notifier.dart';
|
|
import '../../state/host_list_notifier.dart';
|
|
import '../../state/locale_notifier.dart';
|
|
import '../../state/theme_mode_notifier.dart';
|
|
import '../../state/package_info_provider.dart';
|
|
import '../../state/plus_discount_notifier.dart';
|
|
import '../../state/plus_info_notifier.dart';
|
|
import '../../state/script_list_notifier.dart';
|
|
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';
|
|
import '../settings/widgets/settings_section.dart';
|
|
import 'tab_header.dart';
|
|
|
|
class SettingsTab extends ConsumerWidget {
|
|
const SettingsTab({super.key});
|
|
|
|
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
|
final l = AppLocalizations.of(context);
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l.tr('settings.logoutConfirmTitle')),
|
|
content: Text(l.tr('settings.logoutConfirmBody')),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: Text(l.tr('common.cancel')),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: Text(l.tr('settings.logout')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed == true) {
|
|
await ref.read(authProvider.notifier).signOut();
|
|
}
|
|
}
|
|
|
|
Future<void> _pickLanguage(BuildContext context, WidgetRef ref) async {
|
|
final l = AppLocalizations.of(context);
|
|
final current = ref.read(localeProvider);
|
|
final selected = await showDialog<_LangChoice>(
|
|
context: context,
|
|
builder: (context) {
|
|
return SimpleDialog(
|
|
title: Text(l.tr('settings.language')),
|
|
children: [
|
|
_LangOption(
|
|
label: l.tr('common.system'),
|
|
selected: current == null,
|
|
value: _LangChoice.system,
|
|
),
|
|
_LangOption(
|
|
label: l.tr('settings.languageChinese'),
|
|
selected: current?.languageCode == 'zh',
|
|
value: _LangChoice.zh,
|
|
),
|
|
_LangOption(
|
|
label: l.tr('settings.languageEnglish'),
|
|
selected: current?.languageCode == 'en',
|
|
value: _LangChoice.en,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
if (selected == null) return;
|
|
final notifier = ref.read(localeProvider.notifier);
|
|
switch (selected) {
|
|
case _LangChoice.system:
|
|
await notifier.setLocale(null);
|
|
case _LangChoice.zh:
|
|
await notifier.setLocale(const Locale('zh'));
|
|
case _LangChoice.en:
|
|
await notifier.setLocale(const Locale('en'));
|
|
}
|
|
}
|
|
|
|
String _languageSubtitle(BuildContext context, Locale? locale) {
|
|
final l = AppLocalizations.of(context);
|
|
if (locale == null) return l.tr('common.system');
|
|
return switch (locale.languageCode) {
|
|
'zh' => l.tr('settings.languageChinese'),
|
|
'en' => l.tr('settings.languageEnglish'),
|
|
_ => locale.languageCode,
|
|
};
|
|
}
|
|
|
|
void _push(BuildContext context, Widget page) {
|
|
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => page));
|
|
}
|
|
|
|
void _showNotifications(BuildContext context, PlusDiscount discount) {
|
|
final l = AppLocalizations.of(context);
|
|
final c = context.colors;
|
|
final hasDiscount = discount.discount && discount.content.isNotEmpty;
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: c.card,
|
|
constraints: const BoxConstraints(maxWidth: double.infinity),
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (sheetContext) {
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 36,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: c.border,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
l.tr('settings.notifications.tooltip'),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: c.text,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
if (hasDiscount)
|
|
_DiscountNotificationCard(
|
|
content: discount.content,
|
|
onTap: () {
|
|
Navigator.of(sheetContext).pop();
|
|
_push(context, const PlusSubscriptionPage());
|
|
},
|
|
)
|
|
else ...[
|
|
Icon(
|
|
Icons.notifications_off_outlined,
|
|
size: 36,
|
|
color: c.softMuted,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l.tr('settings.notifications.empty'),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: c.muted,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _showThemePicker(BuildContext context, WidgetRef ref) {
|
|
final l = AppLocalizations.of(context);
|
|
final current = ref.read(themeModeProvider);
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => SimpleDialog(
|
|
title: Text(l.tr('settings.theme.title')),
|
|
children: [
|
|
for (final entry in [
|
|
(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),
|
|
])
|
|
ListTile(
|
|
leading: Icon(entry.$3),
|
|
title: Text(entry.$2),
|
|
trailing: entry.$1 == current
|
|
? Icon(Icons.check, color: context.colors.primary)
|
|
: null,
|
|
onTap: () {
|
|
ref.read(themeModeProvider.notifier).setThemeMode(entry.$1);
|
|
Navigator.of(ctx).pop();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
static String _themeModeLabel(AppLocalizations l, ThemeMode mode) {
|
|
return switch (mode) {
|
|
ThemeMode.system => l.tr('settings.theme.system'),
|
|
ThemeMode.light => l.tr('settings.theme.light'),
|
|
ThemeMode.dark => l.tr('settings.theme.dark'),
|
|
};
|
|
}
|
|
|
|
void _showTabOrderDialog(BuildContext context, WidgetRef ref) {
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (_) => _TabOrderDialog(
|
|
initialOrder: ref.read(tabOrderProvider),
|
|
initialHomeTab: ref.read(homeTabProvider),
|
|
onSave: (order, homeTab) {
|
|
ref.read(tabOrderProvider.notifier).setOrder(order);
|
|
ref.read(homeTabProvider.notifier).setHomeTab(homeTab);
|
|
},
|
|
onReset: () {
|
|
ref.read(tabOrderProvider.notifier).resetToDefault();
|
|
ref.read(homeTabProvider.notifier).resetToDefault();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _checkForUpdates(BuildContext context, WidgetRef ref) async {
|
|
final l = AppLocalizations.of(context);
|
|
final result = await ref.read(appUpdateProvider.notifier).check();
|
|
if (!context.mounted) return;
|
|
|
|
if (result == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(l.tr('settings.update.failed')),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!result.hasUpdate) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(l.tr('settings.update.latest')),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
await showAppUpdateDialog(context, result);
|
|
}
|
|
|
|
Future<void> _refresh(BuildContext context, WidgetRef ref) async {
|
|
await runRefreshWithFeedback(context, () async {
|
|
final refreshes = <Future<void>>[
|
|
ref.read(hostListProvider.notifier).refresh(throwOnError: true),
|
|
ref.read(credentialListProvider.notifier).refresh(throwOnError: true),
|
|
ref.read(scriptListProvider.notifier).refresh(throwOnError: true),
|
|
ref.read(plusInfoProvider.notifier).refresh(throwOnError: true),
|
|
];
|
|
if (!isIosAppStoreCompliance) {
|
|
refreshes.add(
|
|
ref.read(plusDiscountProvider.notifier).refresh(throwOnError: true),
|
|
);
|
|
}
|
|
await Future.wait(refreshes);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final l = AppLocalizations.of(context);
|
|
final session = ref.watch(authProvider).session;
|
|
final locale = ref.watch(localeProvider);
|
|
final hostCount = ref.watch(hostListProvider).valueOrNull?.length ?? 0;
|
|
final credentialCount =
|
|
ref.watch(credentialListProvider).valueOrNull?.length ?? 0;
|
|
final scriptCount = ref.watch(scriptListProvider).valueOrNull?.length ?? 0;
|
|
final plusActive =
|
|
ref.watch(plusInfoProvider).valueOrNull?.isActive ?? false;
|
|
final discount =
|
|
ref.watch(plusDiscountProvider).valueOrNull ??
|
|
const PlusDiscount(discount: false, content: '');
|
|
final hasDiscount =
|
|
!isIosAppStoreCompliance &&
|
|
discount.discount &&
|
|
discount.content.isNotEmpty;
|
|
final packageInfo = ref.watch(packageInfoProvider).valueOrNull;
|
|
final updateState = ref.watch(appUpdateProvider);
|
|
final versionLabel = packageInfo == null
|
|
? ''
|
|
: 'v${packageInfo.version} (${packageInfo.buildNumber})';
|
|
|
|
return Scaffold(
|
|
backgroundColor: context.colors.canvas,
|
|
body: SafeArea(
|
|
bottom: false,
|
|
child: RefreshIndicator(
|
|
onRefresh: () => _refresh(context, ref),
|
|
child: CustomScrollView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
slivers: [
|
|
SliverToBoxAdapter(
|
|
child: TabHeader(
|
|
title: l.tr('settings.title'),
|
|
actions: [
|
|
if (versionLabel.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: Text(
|
|
versionLabel,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w500,
|
|
color: context.colors.softMuted,
|
|
letterSpacing: 0.2,
|
|
),
|
|
),
|
|
),
|
|
if (!plusActive && !isIosAppStoreCompliance)
|
|
_SettingsBellButton(
|
|
hasDiscount: hasDiscount,
|
|
onTap: () => _showNotifications(context, discount),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: _ProfileCard(
|
|
username: session?.username ?? '-',
|
|
serverAddress: session?.serverAddress ?? '',
|
|
hostCount: hostCount,
|
|
credentialCount: credentialCount,
|
|
scriptCount: scriptCount,
|
|
plusActive: plusActive,
|
|
onPlusTap: () => _push(context, const PlusSubscriptionPage()),
|
|
onLogoutTap: () => _confirmLogout(context, ref),
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsSection(
|
|
title: l.tr('settings.section.security'),
|
|
children: [
|
|
SettingsRow(
|
|
icon: Icons.lock_outline,
|
|
title: l.tr('settings.account.title'),
|
|
subtitle: l.tr('settings.account.subtitle'),
|
|
onTap: () => _push(context, const AccountSecurityPage()),
|
|
),
|
|
SettingsRow(
|
|
icon: Icons.devices_outlined,
|
|
title: l.tr('settings.sessions.title'),
|
|
subtitle: l.tr('settings.sessions.subtitle'),
|
|
onTap: () => _push(context, const SessionsPage()),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsSection(
|
|
title: l.tr('settings.section.connection'),
|
|
children: [
|
|
SettingsRow(
|
|
icon: Icons.vpn_key_outlined,
|
|
title: l.tr('settings.credentials.title'),
|
|
subtitle: l.tr('settings.credentials.subtitle'),
|
|
onTap: () => _push(context, const CredentialsPage()),
|
|
),
|
|
SettingsRow(
|
|
icon: Icons.cloud_outlined,
|
|
title: l.tr('settings.proxy.title'),
|
|
subtitle: l.tr('settings.proxy.subtitle'),
|
|
onTap: () => _push(context, const ProxyPage()),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsSection(
|
|
title: l.tr('settings.section.preferences'),
|
|
children: [
|
|
SettingsRow(
|
|
key: const Key('settings-language'),
|
|
icon: Icons.translate,
|
|
title: l.tr('settings.language'),
|
|
subtitle: _languageSubtitle(context, locale),
|
|
onTap: () => _pickLanguage(context, ref),
|
|
),
|
|
SettingsRow(
|
|
icon: Icons.swap_vert_outlined,
|
|
title: l.tr('settings.tabOrder.title'),
|
|
subtitle: l.tr('settings.tabOrder.subtitle'),
|
|
onTap: () => _showTabOrderDialog(context, ref),
|
|
),
|
|
SettingsRow(
|
|
icon: Icons.palette_outlined,
|
|
title: l.tr('settings.theme.title'),
|
|
subtitle: _themeModeLabel(l, ref.watch(themeModeProvider)),
|
|
onTap: () => _showThemePicker(context, ref),
|
|
),
|
|
SettingsRow(
|
|
key: const Key('settings-check-update'),
|
|
icon: Icons.system_update_alt_outlined,
|
|
title: l.tr('settings.update.title'),
|
|
subtitle: updateState.status == AppUpdateStatus.available
|
|
? l.trf('settings.update.availableSubtitle', [
|
|
updateState.result!.info.latestVersion,
|
|
])
|
|
: l.tr('settings.update.subtitle'),
|
|
trailing: updateState.isChecking
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: null,
|
|
onTap: updateState.isChecking
|
|
? null
|
|
: () => _checkForUpdates(context, ref),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SliverToBoxAdapter(child: SizedBox(height: 110)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SettingsBellButton extends StatelessWidget {
|
|
const _SettingsBellButton({required this.hasDiscount, required this.onTap});
|
|
|
|
final bool hasDiscount;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.colors;
|
|
return Material(
|
|
color: colors.card,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.border),
|
|
),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(14),
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.notifications_none_outlined,
|
|
size: 20,
|
|
color: colors.primary,
|
|
),
|
|
if (hasDiscount)
|
|
Positioned(
|
|
top: 10,
|
|
right: 10,
|
|
child: Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: colors.danger,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: colors.card, width: 1.5),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProfileCard extends StatelessWidget {
|
|
const _ProfileCard({
|
|
required this.username,
|
|
required this.serverAddress,
|
|
required this.hostCount,
|
|
required this.credentialCount,
|
|
required this.scriptCount,
|
|
required this.plusActive,
|
|
required this.onPlusTap,
|
|
required this.onLogoutTap,
|
|
});
|
|
|
|
final String username;
|
|
final String serverAddress;
|
|
final int hostCount;
|
|
final int credentialCount;
|
|
final int scriptCount;
|
|
final bool plusActive;
|
|
final VoidCallback onPlusTap;
|
|
final VoidCallback onLogoutTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
final colors = context.colors;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
|
child: Container(
|
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 16),
|
|
decoration: BoxDecoration(
|
|
color: colors.card,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: colors.border),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
color: colors.chip,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: colors.border),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Image.asset(
|
|
'assets/logo_v2_01.png',
|
|
width: 30,
|
|
height: 30,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
username,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w800,
|
|
color: colors.text,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_PlusBadge(active: plusActive, onTap: onPlusTap),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
serverAddress,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colors.softMuted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_LogoutIconButton(onTap: onLogoutTap),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: colors.chip,
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
_ProfileStat(
|
|
value: hostCount,
|
|
label: l.tr('settings.profile.hostsLabel'),
|
|
),
|
|
const _StatDivider(),
|
|
_ProfileStat(
|
|
value: credentialCount,
|
|
label: l.tr('settings.profile.credentialsLabel'),
|
|
),
|
|
const _StatDivider(),
|
|
_ProfileStat(
|
|
value: scriptCount,
|
|
label: l.tr('settings.profile.scriptsLabel'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProfileStat extends StatelessWidget {
|
|
const _ProfileStat({required this.value, required this.label});
|
|
|
|
final int value;
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.colors;
|
|
return Expanded(
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
'$value',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w800,
|
|
color: colors.primary,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: colors.muted,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.4,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatDivider extends StatelessWidget {
|
|
const _StatDivider();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(width: 1, height: 24, color: context.colors.border);
|
|
}
|
|
}
|
|
|
|
|
|
class _PlusBadge extends StatelessWidget {
|
|
const _PlusBadge({required this.active, required this.onTap});
|
|
|
|
final bool active;
|
|
final VoidCallback onTap;
|
|
|
|
static const _grayscale = ColorFilter.matrix(<double>[
|
|
0.2126,
|
|
0.7152,
|
|
0.0722,
|
|
0,
|
|
0,
|
|
0.2126,
|
|
0.7152,
|
|
0.0722,
|
|
0,
|
|
0,
|
|
0.2126,
|
|
0.7152,
|
|
0.0722,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
1,
|
|
0,
|
|
]);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
final image = Image.asset(
|
|
'assets/plus.png',
|
|
width: 36,
|
|
height: 22,
|
|
fit: BoxFit.contain,
|
|
);
|
|
return InkWell(
|
|
onTap: 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,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LogoutIconButton extends StatelessWidget {
|
|
const _LogoutIconButton({required this.onTap});
|
|
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
final colors = context.colors;
|
|
return Material(
|
|
color: colors.chip,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: InkWell(
|
|
key: const Key('settings-logout'),
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: onTap,
|
|
child: Tooltip(
|
|
message: l.tr('settings.logout'),
|
|
child: Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: colors.border),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Icon(Icons.logout, size: 18, color: colors.danger),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _LangChoice { system, zh, en }
|
|
|
|
class _LangOption extends StatelessWidget {
|
|
const _LangOption({
|
|
required this.label,
|
|
required this.selected,
|
|
required this.value,
|
|
});
|
|
|
|
final String label;
|
|
final bool selected;
|
|
final _LangChoice value;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SimpleDialogOption(
|
|
onPressed: () => Navigator.of(context).pop(value),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
selected ? Icons.radio_button_checked : Icons.radio_button_off,
|
|
color: selected ? Theme.of(context).colorScheme.primary : null,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: Text(label)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DiscountNotificationCard extends StatelessWidget {
|
|
const _DiscountNotificationCard({required this.content, required this.onTap});
|
|
|
|
final String content;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.colors;
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: colors.dangerSoft,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: colors.dangerBorder),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(
|
|
Icons.local_offer_outlined,
|
|
size: 18,
|
|
color: colors.danger,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
content,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
height: 1.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: colors.danger,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(
|
|
Icons.chevron_right_rounded,
|
|
size: 18,
|
|
color: colors.danger,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TabOrderDialog extends StatefulWidget {
|
|
const _TabOrderDialog({
|
|
required this.initialOrder,
|
|
required this.initialHomeTab,
|
|
required this.onSave,
|
|
required this.onReset,
|
|
});
|
|
|
|
final List<String> initialOrder;
|
|
final String initialHomeTab;
|
|
final void Function(List<String> order, String homeTab) onSave;
|
|
final VoidCallback onReset;
|
|
|
|
static const _tabMeta = <String, (IconData, String)>{
|
|
'settings': (Icons.settings_outlined, 'tabs.settings'),
|
|
'scripts': (Icons.article_outlined, 'tabs.scripts'),
|
|
'servers': (Icons.monitor_outlined, 'tabs.servers'),
|
|
'sftp': (Icons.folder_outlined, 'tabs.sftp'),
|
|
'docker': (Icons.view_in_ar_outlined, 'tabs.docker'),
|
|
};
|
|
|
|
@override
|
|
State<_TabOrderDialog> createState() => _TabOrderDialogState();
|
|
}
|
|
|
|
class _TabOrderDialogState extends State<_TabOrderDialog> {
|
|
late List<String> _order;
|
|
late String _homeTab;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_order = List.of(widget.initialOrder);
|
|
_homeTab = widget.initialHomeTab;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
final c = context.colors;
|
|
return Dialog(
|
|
backgroundColor: c.card,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
side: BorderSide(color: c.border),
|
|
),
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(0, 18, 0, 12),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 18),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.swap_vert_outlined, color: c.primary, size: 20),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l.tr('settings.tabOrder.title'),
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
TextButton(
|
|
onPressed: () {
|
|
widget.onReset();
|
|
Navigator.of(context).pop();
|
|
},
|
|
style: TextButton.styleFrom(foregroundColor: c.muted),
|
|
child: Text(
|
|
l.tr('settings.tabOrder.reset'),
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 0, 18, 8),
|
|
child: Text(
|
|
l.tr('settings.tabOrder.homeHint'),
|
|
style: TextStyle(color: c.softMuted, fontSize: 12),
|
|
),
|
|
),
|
|
ReorderableListView.builder(
|
|
shrinkWrap: true,
|
|
buildDefaultDragHandles: false,
|
|
proxyDecorator: (child, index, animation) {
|
|
return AnimatedBuilder(
|
|
animation: animation,
|
|
builder: (context, child) => Material(
|
|
elevation: 4,
|
|
color: c.card,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: child,
|
|
),
|
|
child: child,
|
|
);
|
|
},
|
|
itemCount: _order.length,
|
|
onReorderItem: (oldIndex, newIndex) {
|
|
setState(() {
|
|
final item = _order.removeAt(oldIndex);
|
|
_order.insert(newIndex, item);
|
|
});
|
|
},
|
|
itemBuilder: (context, index) {
|
|
final key = _order[index];
|
|
final meta = _TabOrderDialog._tabMeta[key]!;
|
|
final isHome = key == _homeTab;
|
|
return ListTile(
|
|
key: ValueKey(key),
|
|
onTap: () => setState(() => _homeTab = key),
|
|
leading: Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: c.chip,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Icon(meta.$1, size: 18, color: c.primary),
|
|
),
|
|
title: Row(
|
|
children: [
|
|
Text(
|
|
l.tr(meta.$2),
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
if (isHome) ...[
|
|
const SizedBox(width: 6),
|
|
Icon(Icons.home_rounded, size: 15, color: c.primary),
|
|
],
|
|
],
|
|
),
|
|
trailing: ReorderableDragStartListener(
|
|
index: index,
|
|
child: Icon(
|
|
Icons.drag_handle,
|
|
color: c.softMuted,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 18),
|
|
child: FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: c.primary,
|
|
foregroundColor: c.fontOnPrimary,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
minimumSize: const Size.fromHeight(46),
|
|
),
|
|
onPressed: () {
|
|
widget.onSave(_order, _homeTab);
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: Text(
|
|
l.tr('common.save'),
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|