feat: 完善设置功能

This commit is contained in:
chaos-zhu
2026-05-24 14:40:27 +08:00
parent 9294c3e744
commit c90e869d63
27 changed files with 6074 additions and 135 deletions
@@ -44,5 +44,15 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
</intent>
</queries>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

+5
View File
@@ -71,5 +71,10 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>http</string>
<string>https</string>
</array>
</dict>
</plist>
+90
View File
@@ -0,0 +1,90 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:pointycastle/export.dart';
/// CryptoJS-compatible AES-CBC encryption.
///
/// When CryptoJS.AES.encrypt is called with a string passphrase (not a
/// WordArray), it derives a 32-byte key + 16-byte IV from `passphrase` and an
/// 8-byte random salt using OpenSSL's EVP_BytesToKey with MD5 (1 iteration).
/// The output format is base64 of:
///
/// "Salted__" (8 bytes ASCII) | salt (8 bytes) | ciphertext (AES-256-CBC, PKCS#7)
///
/// The EasyNode server side uses `crypto-js` to decrypt — matching this exact
/// format is required for `/add-ssh` / `/update-ssh` / `/pwd` / `/proxy` POSTs
/// that carry secrets encrypted with a one-time tempKey.
class CryptoJsAes {
CryptoJsAes({Random? random}) : _random = random ?? Random.secure();
final Random _random;
/// Encrypts [plaintext] with the given UTF-8 passphrase and returns the
/// base64 envelope CryptoJS produces.
String encrypt(String plaintext, String passphrase) {
final salt = Uint8List.fromList(
List<int>.generate(8, (_) => _random.nextInt(256)),
);
final passphraseBytes = Uint8List.fromList(utf8.encode(passphrase));
final (key, iv) = _evpKdf(passphraseBytes, salt, keyLen: 32, ivLen: 16);
final cipher = PaddedBlockCipher('AES/CBC/PKCS7')
..init(
true,
PaddedBlockCipherParameters<CipherParameters, CipherParameters>(
ParametersWithIV<KeyParameter>(KeyParameter(key), iv),
null,
),
);
final ciphertext = cipher.process(
Uint8List.fromList(utf8.encode(plaintext)),
);
final out = BytesBuilder()
..add(utf8.encode('Salted__'))
..add(salt)
..add(ciphertext);
return base64Encode(out.toBytes());
}
/// OpenSSL EVP_BytesToKey with MD5, 1 iteration.
(Uint8List key, Uint8List iv) _evpKdf(
Uint8List password,
Uint8List salt, {
required int keyLen,
required int ivLen,
}) {
final total = keyLen + ivLen;
final out = BytesBuilder();
Uint8List prev = Uint8List(0);
while (out.length < total) {
final md5 = MD5Digest();
md5.update(prev, 0, prev.length);
md5.update(password, 0, password.length);
md5.update(salt, 0, salt.length);
prev = Uint8List(md5.digestSize);
md5.doFinal(prev, 0);
out.add(prev);
}
final keyAndIv = out.toBytes();
return (
Uint8List.sublistView(keyAndIv, 0, keyLen),
Uint8List.sublistView(keyAndIv, keyLen, keyLen + ivLen),
);
}
/// Random alphanumeric string suitable as the one-time tempKey passphrase
/// (matches `Math.random().toString(36)` style keys used in the web).
String generateTempKey({int length = 16}) {
const alphabet =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
return String.fromCharCodes(
Iterable.generate(
length,
(_) => alphabet.codeUnitAt(_random.nextInt(alphabet.length)),
),
);
}
}
@@ -1,21 +1,38 @@
/// Server `ssh.js` returns credential records with secrets stripped:
/// `{ id, name, authType, privateKey:'', password:'', openSSHKeyPassword:'', date }`.
/// For mobile we only display the metadata; secrets are write-only via the
/// add/update flow.
class ServerCredentialModel {
const ServerCredentialModel({
required this.id,
required this.name,
required this.authType,
this.date,
});
final String id;
final String name;
final String authType;
final int? date;
factory ServerCredentialModel.fromJson(Map<String, dynamic> json) {
final dateRaw = json['date'];
int? date;
if (dateRaw is int) {
date = dateRaw;
} else if (dateRaw is num) {
date = dateRaw.toInt();
} else if (dateRaw is String) {
date = int.tryParse(dateRaw);
}
return ServerCredentialModel(
id: (json['id'] ?? json['_id'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
authType: (json['authType'] ?? '').toString(),
date: date,
);
}
String get displayName => name.isEmpty ? id : name;
bool get isPrivateKey => authType == 'privateKey';
}
@@ -1,22 +1,60 @@
/// Proxy record returned by `GET /proxy`. Web returns passwords plaintext but
/// we never display them — UI masks with bullets. Add/update bodies POST the
/// full object to `/proxy` (POST for create, PUT `/proxy/:id` for update).
class ServerProxyModel {
const ServerProxyModel({
required this.id,
required this.name,
required this.type,
required this.host,
required this.port,
required this.username,
required this.password,
this.createTime,
this.updateTime,
});
final String id;
final String name;
final String type;
final String host;
final int port;
final String username;
final String password;
final int? createTime;
final int? updateTime;
factory ServerProxyModel.fromJson(Map<String, dynamic> json) {
final portRaw = json['port'];
int port = 0;
if (portRaw is int) {
port = portRaw;
} else if (portRaw is num) {
port = portRaw.toInt();
} else if (portRaw is String) {
port = int.tryParse(portRaw) ?? 0;
}
int? parseTime(Object? raw) {
if (raw is int) return raw;
if (raw is num) return raw.toInt();
if (raw is String) return int.tryParse(raw);
return null;
}
return ServerProxyModel(
id: (json['id'] ?? json['_id'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
type: (json['type'] ?? '').toString(),
host: (json['host'] ?? '').toString(),
port: port,
username: (json['username'] ?? '').toString(),
password: (json['password'] ?? '').toString(),
createTime: parseTime(json['createTime']),
updateTime: parseTime(json['updateTime']),
);
}
String get displayName => name.isEmpty ? id : name;
String get typeLabel => type.isEmpty ? 'SOCKS' : type.toUpperCase();
String get endpoint => host.isEmpty ? '' : '$host:$port';
}
@@ -1,12 +1,252 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_result.dart';
import '../../core/ui/palette.dart';
import '../../l10n/app_localizations.dart';
import 'widgets/coming_soon_placeholder.dart';
import '../../state/api_providers.dart';
import '../../state/auth_notifier.dart';
import 'settings_repository.dart';
class AccountSecurityPage extends StatelessWidget {
class AccountSecurityPage extends ConsumerStatefulWidget {
const AccountSecurityPage({super.key});
@override
ConsumerState<AccountSecurityPage> createState() =>
_AccountSecurityPageState();
}
class _AccountSecurityPageState extends ConsumerState<AccountSecurityPage> {
final _formKey = GlobalKey<FormState>();
final _oldNameCtrl = TextEditingController();
final _oldPwdCtrl = TextEditingController();
final _newNameCtrl = TextEditingController();
final _newPwdCtrl = TextEditingController();
final _newPwdConfirmCtrl = TextEditingController();
bool _showOldPwd = false;
bool _showNewPwd = false;
bool _showNewPwdConfirm = false;
bool _saving = false;
bool _mfaEnabled = false;
bool _mfaLoading = true;
Mfa2Setup? _setup;
bool _enableLoading = false;
bool _disableLoading = false;
@override
void initState() {
super.initState();
Future.microtask(_loadMfa);
}
@override
void dispose() {
_oldNameCtrl.dispose();
_oldPwdCtrl.dispose();
_newNameCtrl.dispose();
_newPwdCtrl.dispose();
_newPwdConfirmCtrl.dispose();
super.dispose();
}
Future<void> _loadMfa() async {
try {
final enabled =
await ref.read(settingsRepositoryProvider).getMfa2Status();
if (!mounted) return;
setState(() {
_mfaEnabled = enabled;
_mfaLoading = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _mfaLoading = false);
}
}
Future<void> _saveAccount() async {
final l = AppLocalizations.of(context);
if (!(_formKey.currentState?.validate() ?? false)) return;
final oldName = _oldNameCtrl.text.trim();
final newName = _newNameCtrl.text.trim();
final oldPwd = _oldPwdCtrl.text;
final newPwd = _newPwdCtrl.text;
if (oldName == newName && oldPwd == newPwd) {
_showSnack(l.tr('account.usernameSame'));
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.tr('account.confirmTitle')),
content: Text(l.tr('account.confirmBody')),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(l.tr('common.cancel')),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(l.tr('common.continue')),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _saving = true);
try {
await ref.read(settingsRepositoryProvider).updateAccount(
oldLoginName: oldName,
oldPwd: oldPwd,
newLoginName: newName,
newPwd: newPwd,
);
if (!mounted) return;
_showSnack(l.tr('account.changed'));
await ref.read(authProvider.notifier).signOut();
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(err.message);
} catch (err) {
if (!mounted) return;
_showSnack(err.toString());
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _startEnableMfa() async {
setState(() => _enableLoading = true);
try {
final setup =
await ref.read(settingsRepositoryProvider).getMfa2QrInfo();
if (!mounted) return;
setState(() => _setup = setup);
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(err.message);
} finally {
if (mounted) setState(() => _enableLoading = false);
}
}
Future<void> _submitEnableMfa(String token) async {
final l = AppLocalizations.of(context);
if (token.length != 6 || int.tryParse(token) == null) {
_showSnack(l.tr('account.mfa.codeInvalid'));
return;
}
setState(() => _enableLoading = true);
try {
await ref.read(settingsRepositoryProvider).enableMfa2(token);
if (!mounted) return;
_showSnack(l.tr('account.mfa.enabled'));
setState(() {
_setup = null;
_mfaEnabled = true;
});
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(err.message);
} finally {
if (mounted) setState(() => _enableLoading = false);
}
}
Future<void> _disableMfa() async {
final l = AppLocalizations.of(context);
final token = await _promptForCode(
title: l.tr('account.mfa.disable'),
label: l.tr('account.mfa.codeLabel'),
confirmText: l.tr('account.mfa.disableConfirm'),
);
if (token == null || !mounted) return;
if (token.length != 6 || int.tryParse(token) == null) {
_showSnack(l.tr('account.mfa.codeInvalid'));
return;
}
setState(() => _disableLoading = true);
try {
await ref.read(settingsRepositoryProvider).disableMfa2(token);
if (!mounted) return;
_showSnack(l.tr('account.mfa.disabled'));
setState(() {
_mfaEnabled = false;
_setup = null;
});
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(err.message);
} finally {
if (mounted) setState(() => _disableLoading = false);
}
}
Future<String?> _promptForCode({
required String title,
required String label,
required String confirmText,
}) async {
final controller = TextEditingController();
final l = AppLocalizations.of(context);
final result = await showDialog<String>(
context: context,
builder: (ctx) {
return AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
autofocus: true,
decoration: InputDecoration(
labelText: label,
hintText: '6 digits',
border: const OutlineInputBorder(),
),
onSubmitted: (v) => Navigator.of(ctx).pop(v.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l.tr('common.cancel')),
),
FilledButton(
onPressed: () =>
Navigator.of(ctx).pop(controller.text.trim()),
child: Text(confirmText),
),
],
);
},
);
controller.dispose();
return result;
}
void _showSnack(String text) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(text), behavior: SnackBarBehavior.floating),
);
}
void _copySecret() {
final setup = _setup;
if (setup == null) return;
final l = AppLocalizations.of(context);
Clipboard.setData(ClipboardData(text: setup.secret));
_showSnack(l.tr('common.saved'));
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
@@ -14,10 +254,716 @@ class AccountSecurityPage extends StatelessWidget {
backgroundColor: AppPalette.canvas,
appBar: AppBar(
backgroundColor: AppPalette.canvas,
centerTitle: true,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(l.tr('settings.account.title')),
),
body: const ComingSoonPlaceholder(icon: Icons.lock_outline),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
_SectionHeader(label: l.tr('account.section.credentials')),
const SizedBox(height: 10),
_CredentialsCard(
formKey: _formKey,
oldNameCtrl: _oldNameCtrl,
oldPwdCtrl: _oldPwdCtrl,
newNameCtrl: _newNameCtrl,
newPwdCtrl: _newPwdCtrl,
newPwdConfirmCtrl: _newPwdConfirmCtrl,
showOldPwd: _showOldPwd,
showNewPwd: _showNewPwd,
showNewPwdConfirm: _showNewPwdConfirm,
saving: _saving,
onToggleOldPwd: () =>
setState(() => _showOldPwd = !_showOldPwd),
onToggleNewPwd: () =>
setState(() => _showNewPwd = !_showNewPwd),
onToggleNewPwdConfirm: () => setState(
() => _showNewPwdConfirm = !_showNewPwdConfirm),
onSubmit: _saveAccount,
),
const SizedBox(height: 22),
_SectionHeader(label: l.tr('account.section.mfa')),
const SizedBox(height: 10),
_MfaCard(
loading: _mfaLoading,
enabled: _mfaEnabled,
setup: _setup,
enableLoading: _enableLoading,
disableLoading: _disableLoading,
onStartEnable: _enableLoading ? null : _startEnableMfa,
onSubmitEnable: _submitEnableMfa,
onCancelEnable: () => setState(() => _setup = null),
onDisable: _disableLoading ? null : _disableMfa,
onCopySecret: _copySecret,
),
],
),
);
}
}
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, 0),
child: Text(
label.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppPalette.muted,
),
),
);
}
}
class _CredentialsCard extends StatelessWidget {
const _CredentialsCard({
required this.formKey,
required this.oldNameCtrl,
required this.oldPwdCtrl,
required this.newNameCtrl,
required this.newPwdCtrl,
required this.newPwdConfirmCtrl,
required this.showOldPwd,
required this.showNewPwd,
required this.showNewPwdConfirm,
required this.saving,
required this.onToggleOldPwd,
required this.onToggleNewPwd,
required this.onToggleNewPwdConfirm,
required this.onSubmit,
});
final GlobalKey<FormState> formKey;
final TextEditingController oldNameCtrl;
final TextEditingController oldPwdCtrl;
final TextEditingController newNameCtrl;
final TextEditingController newPwdCtrl;
final TextEditingController newPwdConfirmCtrl;
final bool showOldPwd;
final bool showNewPwd;
final bool showNewPwdConfirm;
final bool saving;
final VoidCallback onToggleOldPwd;
final VoidCallback onToggleNewPwd;
final VoidCallback onToggleNewPwdConfirm;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Container(
decoration: BoxDecoration(
color: AppPalette.card,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.border),
),
padding: const EdgeInsets.all(16),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_FieldGroup(
label: l.tr('account.oldLoginName'),
child: _TextInput(
controller: oldNameCtrl,
hintText: l.tr('account.oldLoginName'),
validator: (v) => (v == null || v.trim().isEmpty)
? l.tr('account.usernameRequired')
: null,
),
),
const SizedBox(height: 14),
_FieldGroup(
label: l.tr('account.oldPwd'),
child: _TextInput(
controller: oldPwdCtrl,
hintText: l.tr('account.oldPwd'),
obscure: !showOldPwd,
suffix: _EyeButton(
visible: showOldPwd,
onTap: onToggleOldPwd,
),
validator: (v) => (v == null || v.isEmpty)
? l.tr('account.pwdRequired')
: null,
),
),
const SizedBox(height: 14),
_FieldGroup(
label: l.tr('account.newLoginName'),
child: _TextInput(
controller: newNameCtrl,
hintText: l.tr('account.newLoginName'),
validator: (v) => (v == null || v.trim().isEmpty)
? l.tr('account.usernameRequired')
: null,
),
),
const SizedBox(height: 14),
_FieldGroup(
label: l.tr('account.newPwd'),
child: _TextInput(
controller: newPwdCtrl,
hintText: l.tr('account.newPwd'),
obscure: !showNewPwd,
suffix: _EyeButton(
visible: showNewPwd,
onTap: onToggleNewPwd,
),
validator: (v) => (v == null || v.isEmpty)
? l.tr('account.pwdRequired')
: null,
),
),
const SizedBox(height: 14),
_FieldGroup(
label: l.tr('account.newPwdConfirm'),
child: _TextInput(
controller: newPwdConfirmCtrl,
hintText: l.tr('account.newPwdConfirm'),
obscure: !showNewPwdConfirm,
suffix: _EyeButton(
visible: showNewPwdConfirm,
onTap: onToggleNewPwdConfirm,
),
validator: (v) {
if (v == null || v.isEmpty) {
return l.tr('account.pwdRequired');
}
if (v != newPwdCtrl.text) {
return l.tr('account.pwdMismatch');
}
return null;
},
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: saving ? null : onSubmit,
style: FilledButton.styleFrom(
backgroundColor: AppPalette.primary,
foregroundColor: AppPalette.fontOnPrimary,
padding: const EdgeInsets.symmetric(vertical: 14),
disabledBackgroundColor: AppPalette.chip,
disabledForegroundColor: AppPalette.softMuted,
),
icon: saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.fontOnPrimary,
),
)
: const Icon(Icons.save_outlined, size: 16),
label: Text(
l.tr('account.submit'),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
),
),
const SizedBox(height: 10),
Text(
l.tr('account.confirmBody'),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: AppPalette.muted,
),
),
],
),
),
);
}
}
class _FieldGroup extends StatelessWidget {
const _FieldGroup({required this.label, required this.child});
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppPalette.muted,
),
),
const SizedBox(height: 6),
child,
],
);
}
}
class _TextInput extends StatelessWidget {
const _TextInput({
required this.controller,
required this.hintText,
this.validator,
this.obscure = false,
this.suffix,
});
final TextEditingController controller;
final String hintText;
final String? Function(String?)? validator;
final bool obscure;
final Widget? suffix;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
obscureText: obscure,
validator: validator,
style: const TextStyle(
fontSize: 14,
color: AppPalette.text,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: const TextStyle(
fontSize: 13,
color: AppPalette.softMuted,
),
isDense: true,
filled: true,
fillColor: AppPalette.canvas,
suffixIcon: suffix,
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.accent),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.danger),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.danger),
),
),
);
}
}
class _EyeButton extends StatelessWidget {
const _EyeButton({required this.visible, required this.onTap});
final bool visible;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onTap,
icon: Icon(
visible ? Icons.visibility_off_outlined : Icons.visibility_outlined,
size: 18,
color: AppPalette.muted,
),
);
}
}
class _MfaCard extends StatefulWidget {
const _MfaCard({
required this.loading,
required this.enabled,
required this.setup,
required this.enableLoading,
required this.disableLoading,
required this.onStartEnable,
required this.onSubmitEnable,
required this.onCancelEnable,
required this.onDisable,
required this.onCopySecret,
});
final bool loading;
final bool enabled;
final Mfa2Setup? setup;
final bool enableLoading;
final bool disableLoading;
final VoidCallback? onStartEnable;
final Future<void> Function(String token) onSubmitEnable;
final VoidCallback onCancelEnable;
final VoidCallback? onDisable;
final VoidCallback onCopySecret;
@override
State<_MfaCard> createState() => _MfaCardState();
}
class _MfaCardState extends State<_MfaCard> {
final _codeCtrl = TextEditingController();
@override
void dispose() {
_codeCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Container(
decoration: BoxDecoration(
color: AppPalette.card,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.border),
),
padding: const EdgeInsets.all(16),
child: widget.loading
? const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator()),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
widget.enabled
? l.tr('account.mfa.statusOn')
: l.tr('account.mfa.statusOff'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppPalette.text,
),
),
),
_MfaStatusChip(enabled: widget.enabled),
],
),
const SizedBox(height: 10),
if (widget.enabled) ...[
Text(
l.tr('account.mfa.scanHint'),
style: const TextStyle(
fontSize: 12,
color: AppPalette.muted,
height: 1.5,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: widget.onDisable,
style: OutlinedButton.styleFrom(
foregroundColor: AppPalette.danger,
side: const BorderSide(color: AppPalette.danger),
padding: const EdgeInsets.symmetric(vertical: 12),
),
icon: widget.disableLoading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.danger,
),
)
: const Icon(Icons.lock_open_outlined, size: 16),
label: Text(l.tr('account.mfa.disable')),
),
),
] else if (widget.setup != null) ...[
Text(
l.tr('account.mfa.scanHint'),
style: const TextStyle(
fontSize: 12,
color: AppPalette.muted,
height: 1.5,
),
),
const SizedBox(height: 12),
Center(child: _QrPreview(setup: widget.setup!)),
const SizedBox(height: 12),
_SecretRow(
secret: widget.setup!.secret,
onCopy: widget.onCopySecret,
),
const SizedBox(height: 14),
Text(
l.tr('account.mfa.codeLabel'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppPalette.muted,
),
),
const SizedBox(height: 8),
TextField(
controller: _codeCtrl,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
style: const TextStyle(
fontSize: 18,
fontFamily: 'monospace',
letterSpacing: 6,
color: AppPalette.text,
),
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: '------',
hintStyle: const TextStyle(
fontSize: 18,
letterSpacing: 6,
color: AppPalette.softMuted,
),
isDense: true,
filled: true,
fillColor: AppPalette.canvas,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.accent),
),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: widget.enableLoading
? null
: widget.onCancelEnable,
style: OutlinedButton.styleFrom(
foregroundColor: AppPalette.muted,
side: const BorderSide(color: AppPalette.border),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Text(l.tr('common.cancel')),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: widget.enableLoading
? null
: () => widget.onSubmitEnable(
_codeCtrl.text.trim(),
),
style: FilledButton.styleFrom(
backgroundColor: AppPalette.primary,
foregroundColor: AppPalette.fontOnPrimary,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: widget.enableLoading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.fontOnPrimary,
),
)
: Text(l.tr('account.mfa.enableConfirm')),
),
),
],
),
] else ...[
Text(
l.tr('account.mfa.scanHint'),
style: const TextStyle(
fontSize: 12,
color: AppPalette.muted,
height: 1.5,
),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: widget.onStartEnable,
style: FilledButton.styleFrom(
backgroundColor: AppPalette.primary,
foregroundColor: AppPalette.fontOnPrimary,
padding: const EdgeInsets.symmetric(vertical: 12),
),
icon: widget.enableLoading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.fontOnPrimary,
),
)
: const Icon(Icons.shield_outlined, size: 16),
label: Text(l.tr('account.mfa.enable')),
),
),
],
],
),
);
}
}
class _MfaStatusChip extends StatelessWidget {
const _MfaStatusChip({required this.enabled});
final bool enabled;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final bg = enabled
? AppPalette.success.withValues(alpha: 0.16)
: AppPalette.chip;
final fg = enabled ? AppPalette.success : AppPalette.muted;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
),
child: Text(
enabled
? l.tr('account.mfa.statusOn')
: l.tr('account.mfa.statusOff'),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: fg,
),
),
);
}
}
class _QrPreview extends StatelessWidget {
const _QrPreview({required this.setup});
final Mfa2Setup setup;
@override
Widget build(BuildContext context) {
final bytes = setup.qrImageBytes;
return Container(
width: 160,
height: 160,
decoration: BoxDecoration(
color: AppPalette.canvas,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppPalette.strongBorder),
),
alignment: Alignment.center,
child: bytes != null
? Padding(
padding: const EdgeInsets.all(8),
child: Image.memory(bytes, fit: BoxFit.contain),
)
: const Icon(
Icons.qr_code_2_rounded,
size: 96,
color: AppPalette.softMuted,
),
);
}
}
class _SecretRow extends StatelessWidget {
const _SecretRow({required this.secret, required this.onCopy});
final String secret;
final VoidCallback onCopy;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Container(
decoration: BoxDecoration(
color: AppPalette.canvas,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppPalette.border),
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Expanded(
child: Text(
secret,
style: const TextStyle(
fontSize: 13,
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppPalette.text,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
tooltip: l.tr('account.mfa.copySecret'),
onPressed: onCopy,
icon: const Icon(
Icons.copy_outlined,
size: 18,
color: AppPalette.muted,
),
),
],
),
);
}
}
@@ -0,0 +1,496 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_result.dart';
import '../../core/ui/palette.dart';
import '../../l10n/app_localizations.dart';
import '../../state/api_providers.dart';
import '../../state/credential_list_notifier.dart';
import '../servers/server_credential_model.dart';
class CredentialEditPage extends ConsumerStatefulWidget {
const CredentialEditPage({super.key, this.credential});
final ServerCredentialModel? credential;
@override
ConsumerState<CredentialEditPage> createState() => _CredentialEditPageState();
}
class _CredentialEditPageState extends ConsumerState<CredentialEditPage> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late final TextEditingController _passwordCtrl;
late final TextEditingController _privateKeyCtrl;
late final TextEditingController _passphraseCtrl;
String _authType = 'privateKey';
bool _showPassword = false;
bool _showPassphrase = false;
bool _saving = false;
bool get _isEdit => widget.credential != null;
@override
void initState() {
super.initState();
final c = widget.credential;
_nameCtrl = TextEditingController(text: c?.name ?? '');
_passwordCtrl = TextEditingController();
_privateKeyCtrl = TextEditingController();
_passphraseCtrl = TextEditingController();
if (c != null) _authType = c.authType.isEmpty ? 'privateKey' : c.authType;
}
@override
void dispose() {
_nameCtrl.dispose();
_passwordCtrl.dispose();
_privateKeyCtrl.dispose();
_passphraseCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
final l = AppLocalizations.of(context);
if (!_formKey.currentState!.validate()) return;
final name = _nameCtrl.text.trim();
final password = _passwordCtrl.text;
final privateKey = _privateKeyCtrl.text.trim();
final passphrase = _passphraseCtrl.text;
setState(() => _saving = true);
try {
final repo = ref.read(settingsRepositoryProvider);
if (_isEdit) {
await repo.updateCredential(
id: widget.credential!.id,
name: name,
authType: _authType,
password: password,
privateKey: privateKey,
openSSHKeyPassword: passphrase,
);
} else {
await repo.createCredential(
name: name,
authType: _authType,
password: password,
privateKey: privateKey,
openSSHKeyPassword: passphrase,
);
}
if (!mounted) return;
_showSnack(l.tr('credentials.saved'));
await ref.read(credentialListProvider.notifier).refresh();
if (!mounted) return;
Navigator.of(context).pop(true);
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(l.trf('credentials.saveFailed', [err.message]));
} catch (err) {
if (!mounted) return;
_showSnack(l.trf('credentials.saveFailed', [err.toString()]));
} finally {
if (mounted) setState(() => _saving = false);
}
}
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 isKey = _authType == 'privateKey';
return Scaffold(
backgroundColor: AppPalette.canvas,
appBar: AppBar(
backgroundColor: AppPalette.canvas,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(_isEdit ? l.tr('credentials.edit') : l.tr('credentials.add')),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: _AppBarSaveButton(
label: l.tr('common.save'),
loading: _saving,
onTap: _save,
),
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 80),
children: [
_FieldLabel(text: l.tr('credentials.field.name')),
const SizedBox(height: 8),
_FormField(
controller: _nameCtrl,
hint: l.tr('credentials.field.nameHint'),
validator: (v) {
if ((v ?? '').trim().isEmpty) {
return l.tr('credentials.validation.name');
}
return null;
},
),
const SizedBox(height: 16),
_FieldLabel(text: l.tr('credentials.field.authType')),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _TypeOption(
label: l.tr('servers.auth.password'),
sublabel: l.tr('credentials.field.password'),
icon: Icons.lock_outline,
selected: !isKey,
onTap: () => setState(() => _authType = 'password'),
),
),
const SizedBox(width: 12),
Expanded(
child: _TypeOption(
label: l.tr('servers.auth.privateKey'),
sublabel: l.tr('credentials.field.privateKey'),
icon: Icons.vpn_key_outlined,
selected: isKey,
onTap: () => setState(() => _authType = 'privateKey'),
),
),
],
),
const SizedBox(height: 16),
if (!isKey) ...[
_FieldLabel(
text: l.tr('credentials.field.password'),
trailing: _isEdit ? '(${l.tr('credentials.editHint')})' : null,
),
const SizedBox(height: 8),
_FormField(
controller: _passwordCtrl,
hint: l.tr('credentials.field.passwordHint'),
obscure: !_showPassword,
suffix: _EyeButton(
open: _showPassword,
onTap: () => setState(() => _showPassword = !_showPassword),
),
validator: (v) {
if (!_isEdit && (v ?? '').isEmpty) {
return l.tr('credentials.validation.password');
}
return null;
},
),
],
if (isKey) ...[
_FieldLabel(
text: l.tr('credentials.field.privateKey'),
trailing: _isEdit ? '(${l.tr('credentials.editHint')})' : null,
),
const SizedBox(height: 8),
_FormField(
controller: _privateKeyCtrl,
hint: l.tr('credentials.field.privateKeyHint'),
maxLines: 8,
monospace: true,
validator: (v) {
if (!_isEdit && (v ?? '').trim().isEmpty) {
return l.tr('credentials.validation.privateKey');
}
return null;
},
),
const SizedBox(height: 16),
_FieldLabel(
text: l.tr('credentials.field.passphrase'),
trailing: l.tr('common.optional'),
),
const SizedBox(height: 8),
_FormField(
controller: _passphraseCtrl,
hint: l.tr('credentials.field.passphraseHint'),
obscure: !_showPassphrase,
suffix: _EyeButton(
open: _showPassphrase,
onTap: () => setState(() => _showPassphrase = !_showPassphrase),
),
),
],
const SizedBox(height: 28),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppPalette.primary,
foregroundColor: AppPalette.fontOnPrimary,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.fontOnPrimary,
),
)
: Text(
l.tr('common.save'),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
);
}
}
class _AppBarSaveButton extends StatelessWidget {
const _AppBarSaveButton({
required this.label,
required this.loading,
required this.onTap,
});
final String label;
final bool loading;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppPalette.primary,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: loading ? null : onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (loading)
const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.fontOnPrimary,
),
)
else
const Icon(Icons.check, size: 14, color: AppPalette.fontOnPrimary),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppPalette.fontOnPrimary,
),
),
],
),
),
),
);
}
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel({required this.text, this.trailing});
final String text;
final String? trailing;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
text.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppPalette.muted,
),
),
if (trailing != null)
Text(
trailing!,
style: const TextStyle(
fontSize: 10,
fontStyle: FontStyle.italic,
color: AppPalette.softMuted,
),
),
],
);
}
}
class _FormField extends StatelessWidget {
const _FormField({
required this.controller,
this.hint,
this.obscure = false,
this.suffix,
this.validator,
this.maxLines = 1,
this.monospace = false,
});
final TextEditingController controller;
final String? hint;
final bool obscure;
final Widget? suffix;
final String? Function(String?)? validator;
final int maxLines;
final bool monospace;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
obscureText: obscure,
validator: validator,
maxLines: obscure ? 1 : maxLines,
style: TextStyle(
fontSize: monospace ? 12 : 14,
fontFamily: monospace ? 'monospace' : null,
color: AppPalette.text,
height: monospace ? 1.5 : null,
),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: monospace ? AppPalette.chip : AppPalette.card,
hintText: hint,
hintStyle: const TextStyle(
fontSize: 13,
color: AppPalette.softMuted,
),
suffixIcon: suffix,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppPalette.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppPalette.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppPalette.accent),
),
),
);
}
}
class _EyeButton extends StatelessWidget {
const _EyeButton({required this.open, required this.onTap});
final bool open;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(
open ? Icons.visibility_outlined : Icons.visibility_off_outlined,
size: 18,
color: AppPalette.muted,
),
onPressed: onTap,
);
}
}
class _TypeOption extends StatelessWidget {
const _TypeOption({
required this.label,
required this.sublabel,
required this.icon,
required this.selected,
required this.onTap,
});
final String label;
final String sublabel;
final IconData icon;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final bg = selected ? AppPalette.accentSoft : AppPalette.card;
final border = selected ? AppPalette.primary : AppPalette.border;
final borderWidth = selected ? 2.0 : 1.0;
return Material(
color: bg,
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: border, width: borderWidth),
),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 20,
color: selected ? AppPalette.primary : AppPalette.muted,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: selected ? AppPalette.primary : AppPalette.text,
),
),
const SizedBox(height: 2),
Text(
sublabel,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppPalette.softMuted,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}
}
@@ -1,23 +1,564 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_result.dart';
import '../../core/ui/palette.dart';
import '../../l10n/app_localizations.dart';
import 'widgets/coming_soon_placeholder.dart';
import '../../state/api_providers.dart';
import '../../state/credential_list_notifier.dart';
import '../servers/server_credential_model.dart';
import 'credential_edit_page.dart';
class CredentialsPage extends StatelessWidget {
class CredentialsPage extends ConsumerStatefulWidget {
const CredentialsPage({super.key});
@override
ConsumerState<CredentialsPage> createState() => _CredentialsPageState();
}
class _CredentialsPageState extends ConsumerState<CredentialsPage> {
String _query = '';
bool _searchOpen = false;
late final TextEditingController _searchCtrl;
String? _deletingId;
@override
void initState() {
super.initState();
_searchCtrl = TextEditingController();
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
void _toggleSearch() {
setState(() {
_searchOpen = !_searchOpen;
if (!_searchOpen) {
_searchCtrl.clear();
_query = '';
}
});
}
Future<void> _openEdit({ServerCredentialModel? credential}) async {
await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (_) => CredentialEditPage(credential: credential)),
);
}
Future<void> _delete(ServerCredentialModel c) async {
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.tr('credentials.deleteConfirmTitle')),
content: Text(l.trf('credentials.deleteConfirmBody', [c.displayName])),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(l.tr('common.cancel')),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppPalette.danger,
foregroundColor: AppPalette.fontOnPrimary,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(l.tr('common.delete')),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _deletingId = c.id);
try {
await ref.read(settingsRepositoryProvider).deleteCredential(c.id);
if (!mounted) return;
_showSnack(l.tr('credentials.deleted'));
await ref.read(credentialListProvider.notifier).refresh();
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(l.trf('credentials.deleteFailed', [err.message]));
} catch (err) {
if (!mounted) return;
_showSnack(l.trf('credentials.deleteFailed', [err.toString()]));
} finally {
if (mounted) setState(() => _deletingId = null);
}
}
void _showSnack(String text) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(text), behavior: SnackBarBehavior.floating),
);
}
List<ServerCredentialModel> _filter(List<ServerCredentialModel> list) {
final q = _query.trim().toLowerCase();
if (q.isEmpty) return list;
return list.where((e) {
return e.name.toLowerCase().contains(q) ||
e.authType.toLowerCase().contains(q);
}).toList(growable: false);
}
String _formatDate(int? ms) {
if (ms == null || ms <= 0) return '';
final dt = DateTime.fromMillisecondsSinceEpoch(ms).toLocal();
String two(int v) => v.toString().padLeft(2, '0');
return '${dt.year}-${two(dt.month)}-${two(dt.day)}';
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final async = ref.watch(credentialListProvider);
return Scaffold(
backgroundColor: AppPalette.canvas,
appBar: AppBar(
backgroundColor: AppPalette.canvas,
centerTitle: true,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(l.tr('settings.credentials.title')),
actions: [
IconButton(
tooltip: _searchOpen
? l.tr('common.closeSearch')
: l.tr('common.search'),
onPressed: _toggleSearch,
icon: Icon(_searchOpen ? Icons.close_rounded : Icons.search_rounded),
color: AppPalette.primary,
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: _AddButton(onTap: () => _openEdit()),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, anim) =>
FadeTransition(opacity: anim, child: child),
child: _searchOpen
? Padding(
key: const ValueKey('search'),
padding: const EdgeInsets.only(top: 4, bottom: 8),
child: TextField(
controller: _searchCtrl,
autofocus: true,
onChanged: (v) => setState(() => _query = v),
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: AppPalette.card,
hintText: l.tr('credentials.searchHint'),
hintStyle: const TextStyle(
fontSize: 13,
color: AppPalette.softMuted,
),
prefixIcon: const Icon(
Icons.search_rounded,
size: 18,
color: AppPalette.softMuted,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide:
const BorderSide(color: AppPalette.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide:
const BorderSide(color: AppPalette.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide:
const BorderSide(color: AppPalette.accent),
),
),
),
)
: const SizedBox(
key: ValueKey('search-empty'),
width: double.infinity,
),
),
),
),
Expanded(
child: RefreshIndicator(
onRefresh: () =>
ref.read(credentialListProvider.notifier).refresh(),
child: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => _ErrorBody(
message: l.trf('credentials.saveFailed', [err.toString()]),
onRetry: () =>
ref.read(credentialListProvider.notifier).refresh(),
),
data: (list) {
final visible = _filter(list);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
children: [
_SectionHeader(
label: l.tr('settings.credentials.title'),
count: list.length,
),
const SizedBox(height: 8),
if (visible.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Text(
list.isEmpty
? l.tr('credentials.empty')
: l.tr('credentials.emptyFiltered'),
style: const TextStyle(
color: AppPalette.softMuted,
fontSize: 13,
),
),
),
)
else
for (final cred in visible)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _CredentialCard(
credential: cred,
dateLabel: _formatDate(cred.date),
deleting: _deletingId == cred.id,
onTap: () => _openEdit(credential: cred),
onEdit: () => _openEdit(credential: cred),
onDelete: () => _delete(cred),
),
),
],
);
},
),
),
),
],
),
body: const ComingSoonPlaceholder(icon: Icons.vpn_key_outlined),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.label, required this.count});
final String label;
final int count;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppPalette.muted,
),
),
Text(
'· $count',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppPalette.muted,
fontStyle: FontStyle.italic,
),
),
],
);
}
}
class _AddButton extends StatelessWidget {
const _AddButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppPalette.primary,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: const SizedBox(
width: 36,
height: 36,
child: Icon(
Icons.add_rounded,
size: 20,
color: AppPalette.fontOnPrimary,
),
),
),
);
}
}
class _CredentialCard extends StatelessWidget {
const _CredentialCard({
required this.credential,
required this.dateLabel,
required this.deleting,
required this.onTap,
required this.onEdit,
required this.onDelete,
});
final ServerCredentialModel credential;
final String dateLabel;
final bool deleting;
final VoidCallback onTap;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final isKey = credential.isPrivateKey;
return Material(
color: AppPalette.card,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.border),
),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: AppPalette.chip,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Icon(
isKey ? Icons.vpn_key_outlined : Icons.lock_outline,
size: 16,
color: AppPalette.primary,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
credential.displayName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
fontFamily: 'monospace',
color: AppPalette.text,
),
),
),
const SizedBox(width: 8),
_AuthTypeChip(
label: isKey
? l.tr('servers.auth.privateKey')
: l.tr('servers.auth.password'),
isKey: isKey,
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: dateLabel.isEmpty
? const SizedBox.shrink()
: Text(
dateLabel,
style: const TextStyle(
fontSize: 10,
color: AppPalette.softMuted,
),
),
),
_ActionButton(
icon: Icons.edit_outlined,
bg: AppPalette.accentSoft,
fg: AppPalette.primary,
onTap: onEdit,
),
const SizedBox(width: 6),
_ActionButton(
icon: Icons.delete_outline,
bg: AppPalette.dangerSoft,
fg: AppPalette.danger,
loading: deleting,
onTap: onDelete,
),
],
),
],
),
),
),
);
}
}
class _AuthTypeChip extends StatelessWidget {
const _AuthTypeChip({required this.label, required this.isKey});
final String label;
final bool isKey;
@override
Widget build(BuildContext context) {
final bg = isKey
? AppPalette.success.withValues(alpha: 0.16)
: AppPalette.accentSoft;
final fg = isKey ? AppPalette.success : AppPalette.primary;
final icon = isKey ? Icons.vpn_key : Icons.lock;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: fg),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w800,
color: fg,
letterSpacing: 0.4,
),
),
],
),
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.icon,
required this.bg,
required this.fg,
required this.onTap,
this.loading = false,
});
final IconData icon;
final Color bg;
final Color fg;
final VoidCallback onTap;
final bool loading;
@override
Widget build(BuildContext context) {
return Material(
color: bg,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: loading ? null : onTap,
child: SizedBox(
width: 30,
height: 30,
child: Center(
child: loading
? SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: fg,
),
)
: Icon(icon, size: 14, color: fg),
),
),
),
);
}
}
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: [
const Icon(
Icons.error_outline,
size: 36,
color: AppPalette.danger,
),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: AppPalette.text),
),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () => onRetry(),
child: Text(l.tr('common.retry')),
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,119 @@
/// One entry of `GET /log` -> `data.list`. The server sends a parsed
/// user-agent in `agentInfo`, plus geolocation strings (country / city).
/// `revoked == 1` is the truthy marker.
class LoginSession {
const LoginSession({
required this.id,
required this.deviceId,
required this.tokenHash,
required this.userId,
required this.revoked,
required this.ip,
required this.country,
required this.city,
required this.browser,
required this.os,
required this.createAt,
required this.expireAt,
});
final String id;
final String deviceId;
final String tokenHash;
final String userId;
final bool revoked;
final String ip;
final String country;
final String city;
final String browser;
final String os;
final int createAt;
final int expireAt;
bool get isMobile =>
browser.contains('EasyNode-Mobile') || os.contains('EasyNode-Mobile');
String get location {
final parts = <String>[
if (country.isNotEmpty) country,
if (city.isNotEmpty) city,
];
return parts.join(' · ');
}
String get agentLabel {
final parts = <String>[];
if (browser.isNotEmpty) parts.add(browser);
if (os.isNotEmpty) parts.add(os);
return parts.join(' · ');
}
factory LoginSession.fromJson(Map<String, dynamic> json) {
int parseTime(Object? raw) {
if (raw is int) return raw;
if (raw is num) return raw.toInt();
if (raw is String) return int.tryParse(raw) ?? 0;
return 0;
}
final agent = json['agentInfo'];
String browser = '';
String os = '';
if (agent is Map) {
final browserInfo = agent['browser'];
final osInfo = agent['os'];
if (browserInfo is Map) {
final name = (browserInfo['name'] ?? '').toString();
final version = (browserInfo['version'] ?? '').toString();
browser = version.isEmpty ? name : '$name $version';
}
if (osInfo is Map) {
final name = (osInfo['name'] ?? '').toString();
final version = (osInfo['version'] ?? '').toString();
os = version.isEmpty ? name : '$name $version';
}
}
final revokedRaw = json['revoked'];
final revoked = revokedRaw == 1 ||
revokedRaw == true ||
revokedRaw == '1' ||
revokedRaw == 'true';
return LoginSession(
id: (json['_id'] ?? json['id'] ?? '').toString(),
deviceId: (json['deviceId'] ?? '').toString(),
tokenHash: (json['tokenHash'] ?? '').toString(),
userId: (json['userId'] ?? '').toString(),
revoked: revoked,
ip: (json['ip'] ?? '').toString(),
country: (json['country'] ?? '').toString(),
city: (json['city'] ?? '').toString(),
browser: browser,
os: os,
createAt: parseTime(json['create']),
expireAt: parseTime(json['expireAt']),
);
}
}
/// `GET /log` -> `data` shape: `{ list, ipWhiteList }`.
class LoginLogData {
const LoginLogData({required this.sessions, required this.ipWhiteList});
final List<LoginSession> sessions;
final List<String> ipWhiteList;
factory LoginLogData.fromJson(Map<String, dynamic> json) {
final list = (json['list'] as List?)
?.whereType<Map<String, dynamic>>()
.map(LoginSession.fromJson)
.toList(growable: false) ??
const <LoginSession>[];
final whitelist = (json['ipWhiteList'] as List?)
?.map((e) => e.toString())
.toList(growable: false) ??
const <String>[];
return LoginLogData(sessions: list, ipWhiteList: whitelist);
}
}
@@ -0,0 +1,65 @@
/// Server `GET /plus-info` response shape — `{ data: PlusInfo | null }`.
/// `null` or empty `data` means inactive. Active records carry expiry + IP
/// usage statistics from the license server.
class PlusInfo {
const PlusInfo({
required this.key,
required this.expiryDate,
required this.usedIPCount,
required this.maxIPs,
required this.usedIPs,
});
final String key;
final String expiryDate;
final int usedIPCount;
final int maxIPs;
final List<String> usedIPs;
bool get isActive => key.isNotEmpty;
factory PlusInfo.empty() => const PlusInfo(
key: '',
expiryDate: '',
usedIPCount: 0,
maxIPs: 0,
usedIPs: [],
);
factory PlusInfo.fromJson(Map<String, dynamic> json) {
int parseInt(Object? raw) {
if (raw is int) return raw;
if (raw is num) return raw.toInt();
if (raw is String) return int.tryParse(raw) ?? 0;
return 0;
}
final ips = (json['usedIPs'] as List?)
?.map((e) => e.toString())
.toList(growable: false) ??
const <String>[];
return PlusInfo(
key: (json['key'] ?? '').toString(),
expiryDate: (json['expiryDate'] ?? '').toString(),
usedIPCount: parseInt(json['usedIPCount']),
maxIPs: parseInt(json['maxIPs']),
usedIPs: ips,
);
}
}
class PlusDiscount {
const PlusDiscount({required this.discount, required this.content});
final bool discount;
final String content;
factory PlusDiscount.fromJson(Map<String, dynamic>? json) {
if (json == null) return const PlusDiscount(discount: false, content: '');
return PlusDiscount(
discount: json['discount'] == true,
content: (json['content'] ?? '').toString(),
);
}
}
@@ -1,23 +1,593 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/api/api_result.dart';
import '../../core/ui/palette.dart';
import '../../l10n/app_localizations.dart';
import 'widgets/coming_soon_placeholder.dart';
import '../../state/api_providers.dart';
import '../../state/plus_info_notifier.dart';
import 'models/plus_info.dart';
class PlusSubscriptionPage extends StatelessWidget {
const String _plusPurchaseUrl = 'https://en.221022.xyz/buy-plus';
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 {
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.
}
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;
_showSnack(err.message);
} catch (_) {
if (!mounted) return;
_showSnack(l.tr('plus.activateFailed'));
} finally {
if (mounted) setState(() => _activating = false);
await ref.read(plusInfoProvider.notifier).refresh();
}
}
Future<void> _openPurchaseUrl() async {
final l = AppLocalizations.of(context);
final uri = Uri.parse(_plusPurchaseUrl);
try {
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched) throw Exception('launch returned false');
} catch (_) {
await Clipboard.setData(const ClipboardData(text: _plusPurchaseUrl));
if (!mounted) return;
_showSnack(l.trf('plus.fetchKeyHint', const [_plusPurchaseUrl]));
}
}
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: AppPalette.canvas,
appBar: AppBar(
backgroundColor: AppPalette.canvas,
centerTitle: true,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(l.tr('settings.plus.title')),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: _FetchKeyButton(onTap: _openPurchaseUrl),
),
],
),
body: const ComingSoonPlaceholder(icon: Icons.workspace_premium_outlined),
body: RefreshIndicator(
onRefresh: () async {
await ref.read(plusInfoProvider.notifier).refresh();
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);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
_SectionHeader(label: l.tr('plus.section.activate')),
_ActivateCard(
controller: _keyController,
formKey: _formKey,
discount: _discount,
loading: _activating,
onSubmit: _activate,
onDiscountTap: _openPurchaseUrl,
),
const SizedBox(height: 18),
_SectionHeader(label: l.tr('plus.featuresSection')),
const _FeaturesCard(),
],
);
}
}
class _FetchKeyButton extends StatelessWidget {
const _FetchKeyButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Material(
color: AppPalette.accentSoft,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.open_in_new,
size: 14,
color: AppPalette.accent,
),
const SizedBox(width: 6),
Text(
l.tr('plus.fetchKey'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppPalette.accent,
),
),
],
),
),
),
);
}
}
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: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppPalette.muted,
),
),
);
}
}
class _ActivateCard extends StatefulWidget {
const _ActivateCard({
required this.controller,
required this.formKey,
required this.discount,
required this.loading,
required this.onSubmit,
required this.onDiscountTap,
});
final TextEditingController controller;
final GlobalKey<FormState> formKey;
final PlusDiscount discount;
final bool loading;
final VoidCallback onSubmit;
final VoidCallback onDiscountTap;
@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: AppPalette.card,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.border),
),
padding: const EdgeInsets.all(14),
child: Form(
key: widget.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l.tr('plus.keyLabel').toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 1.5,
color: AppPalette.muted,
),
),
const SizedBox(height: 6),
TextFormField(
controller: widget.controller,
enabled: !widget.loading,
obscureText: !_showKey,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => widget.onSubmit(),
style: const TextStyle(
fontSize: 14,
fontFamily: 'monospace',
color: AppPalette.text,
),
decoration: InputDecoration(
hintText: l.tr('plus.keyHint'),
hintStyle: const TextStyle(
fontSize: 13,
color: AppPalette.softMuted,
),
isDense: true,
filled: true,
fillColor: AppPalette.canvas,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
suffixIcon: IconButton(
icon: Icon(
_showKey
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
size: 18,
color: AppPalette.muted,
),
onPressed: () => setState(() => _showKey = !_showKey),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.accent),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: widget.loading ? null : widget.onSubmit,
style: FilledButton.styleFrom(
backgroundColor: AppPalette.primary,
foregroundColor: AppPalette.fontOnPrimary,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: widget.loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.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),
InkWell(
onTap: widget.onDiscountTap,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: AppPalette.dangerSoft,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppPalette.dangerBorder),
),
child: Row(
children: [
const Icon(
Icons.local_offer_outlined,
size: 14,
color: AppPalette.danger,
),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.discount.content,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppPalette.danger,
decoration: TextDecoration.underline,
),
),
),
],
),
),
),
],
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppPalette.chip,
borderRadius: BorderRadius.circular(10),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.info_outline,
size: 14,
color: AppPalette.muted,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l.tr('plus.activateHint'),
style: const TextStyle(
fontSize: 11,
height: 1.5,
fontWeight: FontWeight.w500,
color: AppPalette.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: AppPalette.card,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.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)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 14),
child: Divider(height: 1, color: AppPalette.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: AppPalette.chip,
borderRadius: BorderRadius.circular(10),
),
alignment: Alignment.center,
child: Icon(icon, size: 16, color: AppPalette.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppPalette.text,
),
),
const SizedBox(height: 2),
Text(
description,
style: const TextStyle(
fontSize: 11,
height: 1.4,
fontWeight: FontWeight.w500,
color: AppPalette.muted,
),
),
],
),
),
const Icon(Icons.check_rounded, size: 16, color: AppPalette.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: [
const Icon(
Icons.error_outline,
size: 36,
color: AppPalette.danger,
),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: AppPalette.text),
),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () => onRetry(),
child: Text(l.tr('common.retry')),
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,677 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_result.dart';
import '../../core/ui/palette.dart';
import '../../l10n/app_localizations.dart';
import '../../state/api_providers.dart';
import '../../state/proxy_list_notifier.dart';
import '../servers/server_proxy_model.dart';
class ProxyEditPage extends ConsumerStatefulWidget {
const ProxyEditPage({super.key, this.proxy});
final ServerProxyModel? proxy;
@override
ConsumerState<ProxyEditPage> createState() => _ProxyEditPageState();
}
class _ProxyEditPageState extends ConsumerState<ProxyEditPage> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _hostCtrl = TextEditingController();
final _portCtrl = TextEditingController();
final _userCtrl = TextEditingController();
final _passCtrl = TextEditingController();
String _type = 'socks5';
bool _saving = false;
bool _deleting = false;
bool _showPassword = false;
bool get _isEdit => widget.proxy != null;
@override
void initState() {
super.initState();
final p = widget.proxy;
if (p != null) {
_type = p.type.isEmpty ? 'socks5' : p.type;
_nameCtrl.text = p.name;
_hostCtrl.text = p.host;
_portCtrl.text = p.port == 0 ? '' : '${p.port}';
_userCtrl.text = p.username;
_passCtrl.text = p.password;
}
}
@override
void dispose() {
_nameCtrl.dispose();
_hostCtrl.dispose();
_portCtrl.dispose();
_userCtrl.dispose();
_passCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
final l = AppLocalizations.of(context);
if (!(_formKey.currentState?.validate() ?? false)) return;
final port = int.tryParse(_portCtrl.text.trim()) ?? 0;
final next = ServerProxyModel(
id: widget.proxy?.id ?? '',
name: _nameCtrl.text.trim(),
type: _type,
host: _hostCtrl.text.trim(),
port: port,
username: _userCtrl.text.trim(),
password: _passCtrl.text,
);
setState(() => _saving = true);
try {
final repo = ref.read(settingsRepositoryProvider);
if (_isEdit) {
await repo.updateProxy(next);
} else {
await repo.createProxy(next);
}
if (!mounted) return;
_showSnack(l.tr('proxy.saved'));
await ref.read(proxyListProvider.notifier).refresh();
if (!mounted) return;
Navigator.of(context).pop(true);
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(l.trf('proxy.saveFailed', [err.message]));
} catch (err) {
if (!mounted) return;
_showSnack(l.trf('proxy.saveFailed', [err.toString()]));
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _delete() async {
final p = widget.proxy;
if (p == null) return;
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.tr('proxy.deleteConfirmTitle')),
content: Text(l.trf('proxy.deleteConfirmBody', [p.displayName])),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(l.tr('common.cancel')),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppPalette.danger,
foregroundColor: AppPalette.fontOnPrimary,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(l.tr('common.delete')),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _deleting = true);
try {
await ref.read(settingsRepositoryProvider).deleteProxy(p.id);
if (!mounted) return;
_showSnack(l.tr('proxy.deleted'));
await ref.read(proxyListProvider.notifier).refresh();
if (!mounted) return;
Navigator.of(context).pop(true);
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(l.trf('proxy.deleteFailed', [err.message]));
} catch (err) {
if (!mounted) return;
_showSnack(l.trf('proxy.deleteFailed', [err.toString()]));
} finally {
if (mounted) setState(() => _deleting = false);
}
}
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);
return Scaffold(
backgroundColor: AppPalette.canvas,
appBar: AppBar(
backgroundColor: AppPalette.canvas,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(_isEdit ? l.tr('proxy.edit') : l.tr('proxy.add')),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: _AppBarSaveButton(
loading: _saving,
label: l.tr('common.save'),
onTap: _saving ? null : _save,
),
),
],
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
children: [
_FieldLabel(label: l.tr('proxy.field.type')),
const SizedBox(height: 8),
_TypeSelector(
value: _type,
onChanged: (next) => setState(() => _type = next),
),
const SizedBox(height: 16),
_FieldLabel(label: l.tr('proxy.field.name')),
const SizedBox(height: 8),
_FormField(
controller: _nameCtrl,
hintText: l.tr('proxy.field.nameHint'),
validator: (v) => (v == null || v.trim().isEmpty)
? l.tr('proxy.validation.name')
: null,
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_FieldLabel(label: l.tr('proxy.field.host')),
const SizedBox(height: 8),
_FormField(
controller: _hostCtrl,
hintText: l.tr('proxy.field.hostHint'),
monospace: true,
validator: (v) =>
(v == null || v.trim().isEmpty)
? l.tr('proxy.validation.host')
: null,
),
],
),
),
const SizedBox(width: 10),
SizedBox(
width: 110,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_FieldLabel(label: l.tr('proxy.field.port')),
const SizedBox(height: 8),
_FormField(
controller: _portCtrl,
hintText: l.tr('proxy.field.portHint'),
monospace: true,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
validator: (v) {
final n = int.tryParse(
(v ?? '').trim(),
);
if (n == null || n < 1 || n > 65535) {
return l.tr('proxy.validation.port');
}
return null;
},
),
],
),
),
],
),
const SizedBox(height: 16),
_FieldLabel(
label: l.tr('proxy.field.username'),
trailing: l.tr('common.optional'),
),
const SizedBox(height: 8),
_FormField(
controller: _userCtrl,
hintText: l.tr('proxy.field.username'),
),
const SizedBox(height: 16),
_FieldLabel(
label: l.tr('proxy.field.password'),
trailing: l.tr('common.optional'),
),
const SizedBox(height: 8),
_FormField(
controller: _passCtrl,
hintText: l.tr('proxy.field.password'),
obscure: !_showPassword,
suffix: IconButton(
onPressed: () =>
setState(() => _showPassword = !_showPassword),
icon: Icon(
_showPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
size: 18,
color: AppPalette.muted,
),
),
),
],
),
),
),
_BottomBar(
isEdit: _isEdit,
saving: _saving,
deleting: _deleting,
saveLabel: l.tr('common.save'),
deleteLabel: l.tr('common.delete'),
onSave: _saving || _deleting ? null : _save,
onDelete: _saving || _deleting ? null : _delete,
),
],
),
),
);
}
}
class _AppBarSaveButton extends StatelessWidget {
const _AppBarSaveButton({
required this.loading,
required this.label,
required this.onTap,
});
final bool loading;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppPalette.primary,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (loading)
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.fontOnPrimary,
),
)
else
const Icon(
Icons.check_rounded,
size: 16,
color: AppPalette.fontOnPrimary,
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppPalette.fontOnPrimary,
),
),
],
),
),
),
);
}
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel({required this.label, this.trailing});
final String label;
final String? trailing;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 1.5,
color: AppPalette.muted,
),
),
if (trailing != null && trailing!.isNotEmpty)
Text(
trailing!,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppPalette.muted,
),
),
],
);
}
}
class _FormField extends StatelessWidget {
const _FormField({
required this.controller,
required this.hintText,
this.validator,
this.keyboardType,
this.inputFormatters,
this.monospace = false,
this.obscure = false,
this.suffix,
});
final TextEditingController controller;
final String hintText;
final String? Function(String?)? validator;
final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters;
final bool monospace;
final bool obscure;
final Widget? suffix;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
validator: validator,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
obscureText: obscure,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppPalette.text,
fontFamily: monospace ? 'monospace' : null,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: const TextStyle(
fontSize: 13,
color: AppPalette.softMuted,
),
isDense: true,
filled: true,
fillColor: AppPalette.card,
suffixIcon: suffix,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.accent),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.danger),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.danger),
),
),
);
}
}
class _TypeSelector extends StatelessWidget {
const _TypeSelector({required this.value, required this.onChanged});
final String value;
final void Function(String value) onChanged;
@override
Widget build(BuildContext context) {
return Column(
children: [
_TypeOption(
label: 'SOCKS5',
description: 'SOCKS5 协议代理',
icon: Icons.shuffle_rounded,
selected: value == 'socks5',
onTap: () => onChanged('socks5'),
),
const SizedBox(height: 10),
_TypeOption(
label: 'HTTP',
description: 'HTTP / HTTPS 代理',
icon: Icons.public_rounded,
selected: value == 'http',
onTap: () => onChanged('http'),
),
],
);
}
}
class _TypeOption extends StatelessWidget {
const _TypeOption({
required this.label,
required this.description,
required this.icon,
required this.selected,
required this.onTap,
});
final String label;
final String description;
final IconData icon;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final bg = selected ? AppPalette.accentSoft : AppPalette.card;
final border = selected ? AppPalette.primary : AppPalette.border;
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: border, width: selected ? 2 : 1),
),
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: selected ? AppPalette.primary : AppPalette.chip,
borderRadius: BorderRadius.circular(10),
),
alignment: Alignment.center,
child: Icon(
icon,
size: 16,
color: selected
? AppPalette.fontOnPrimary
: AppPalette.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: AppPalette.text,
),
),
const SizedBox(height: 2),
Text(
description,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppPalette.muted,
),
),
],
),
),
Icon(
selected
? Icons.radio_button_checked
: Icons.radio_button_off,
size: 18,
color: selected ? AppPalette.primary : AppPalette.softMuted,
),
],
),
),
);
}
}
class _BottomBar extends StatelessWidget {
const _BottomBar({
required this.isEdit,
required this.saving,
required this.deleting,
required this.saveLabel,
required this.deleteLabel,
required this.onSave,
required this.onDelete,
});
final bool isEdit;
final bool saving;
final bool deleting;
final String saveLabel;
final String deleteLabel;
final VoidCallback? onSave;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: AppPalette.canvas,
border: Border(
top: BorderSide(color: AppPalette.strongBorder, width: 1),
),
),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isEdit)
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: onDelete,
style: TextButton.styleFrom(
foregroundColor: AppPalette.danger,
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
),
icon: deleting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.danger,
),
)
: const Icon(Icons.delete_outline, size: 16),
label: Text(
deleteLabel,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
),
if (isEdit) const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onSave,
style: FilledButton.styleFrom(
backgroundColor: AppPalette.primary,
foregroundColor: AppPalette.fontOnPrimary,
padding: const EdgeInsets.symmetric(vertical: 14),
disabledBackgroundColor: AppPalette.chip,
disabledForegroundColor: AppPalette.softMuted,
),
icon: saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.fontOnPrimary,
),
)
: const Icon(Icons.check_rounded, size: 18),
label: Text(
saveLabel,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
);
}
}
+680 -4
View File
@@ -1,23 +1,699 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_result.dart';
import '../../core/ui/palette.dart';
import '../../l10n/app_localizations.dart';
import 'widgets/coming_soon_placeholder.dart';
import '../../state/api_providers.dart';
import '../../state/plus_info_notifier.dart';
import '../../state/proxy_list_notifier.dart';
import '../servers/server_proxy_model.dart';
import 'proxy_edit_page.dart';
class ProxyPage extends StatelessWidget {
class ProxyPage extends ConsumerStatefulWidget {
const ProxyPage({super.key});
@override
ConsumerState<ProxyPage> createState() => _ProxyPageState();
}
class _ProxyPageState extends ConsumerState<ProxyPage> {
String _query = '';
bool _searchOpen = false;
late final TextEditingController _searchCtrl;
String? _deletingId;
@override
void initState() {
super.initState();
_searchCtrl = TextEditingController();
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
void _toggleSearch() {
setState(() {
_searchOpen = !_searchOpen;
if (!_searchOpen) {
_searchCtrl.clear();
_query = '';
}
});
}
Future<void> _openEdit({ServerProxyModel? proxy, required bool plus}) async {
if (!plus) {
_showPlusGate();
return;
}
await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (_) => ProxyEditPage(proxy: proxy)),
);
}
Future<void> _delete(ServerProxyModel p) async {
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.tr('proxy.deleteConfirmTitle')),
content: Text(l.trf('proxy.deleteConfirmBody', [p.displayName])),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(l.tr('common.cancel')),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppPalette.danger,
foregroundColor: AppPalette.fontOnPrimary,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(l.tr('common.delete')),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _deletingId = p.id);
try {
await ref.read(settingsRepositoryProvider).deleteProxy(p.id);
if (!mounted) return;
_showSnack(l.tr('proxy.deleted'));
await ref.read(proxyListProvider.notifier).refresh();
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(l.trf('proxy.deleteFailed', [err.message]));
} catch (err) {
if (!mounted) return;
_showSnack(l.trf('proxy.deleteFailed', [err.toString()]));
} finally {
if (mounted) setState(() => _deletingId = null);
}
}
Future<void> _clone(ServerProxyModel p) async {
final l = AppLocalizations.of(context);
final clone = ServerProxyModel(
id: '',
name: '${p.name}_copy',
type: p.type,
host: p.host,
port: p.port,
username: p.username,
password: p.password,
);
try {
await ref.read(settingsRepositoryProvider).createProxy(clone);
if (!mounted) return;
_showSnack(l.tr('proxy.saved'));
await ref.read(proxyListProvider.notifier).refresh();
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(l.trf('proxy.saveFailed', [err.message]));
} catch (err) {
if (!mounted) return;
_showSnack(l.trf('proxy.saveFailed', [err.toString()]));
}
}
void _showPlusGate() {
final l = AppLocalizations.of(context);
_showSnack(l.tr('proxy.plusTipBody'));
}
void _showSnack(String text) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(text), behavior: SnackBarBehavior.floating),
);
}
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);
}
String _formatDate(int? ms) {
if (ms == null || ms <= 0) return '';
final dt = DateTime.fromMillisecondsSinceEpoch(ms).toLocal();
String two(int v) => v.toString().padLeft(2, '0');
return '${dt.year}-${two(dt.month)}-${two(dt.day)}';
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final async = ref.watch(proxyListProvider);
final plusActive = ref.watch(plusInfoProvider).valueOrNull?.isActive ?? false;
return Scaffold(
backgroundColor: AppPalette.canvas,
appBar: AppBar(
backgroundColor: AppPalette.canvas,
centerTitle: true,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(l.tr('settings.proxy.title')),
actions: [
IconButton(
tooltip: _searchOpen
? l.tr('common.closeSearch')
: l.tr('common.search'),
onPressed: _toggleSearch,
icon: Icon(_searchOpen ? Icons.close_rounded : Icons.search_rounded),
color: AppPalette.primary,
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: _AddButton(
onTap: () => _openEdit(plus: plusActive),
enabled: plusActive,
),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, anim) =>
FadeTransition(opacity: anim, child: child),
child: _searchOpen
? Padding(
key: const ValueKey('search'),
padding: const EdgeInsets.only(top: 4, bottom: 8),
child: TextField(
controller: _searchCtrl,
autofocus: true,
onChanged: (v) => setState(() => _query = v),
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: AppPalette.card,
hintText: l.tr('credentials.searchHint'),
hintStyle: const TextStyle(
fontSize: 13,
color: AppPalette.softMuted,
),
prefixIcon: const Icon(
Icons.search_rounded,
size: 18,
color: AppPalette.softMuted,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide:
const BorderSide(color: AppPalette.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide:
const BorderSide(color: AppPalette.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide:
const BorderSide(color: AppPalette.accent),
),
),
),
)
: const SizedBox(
key: ValueKey('search-empty'),
width: double.infinity,
),
),
),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => ref.read(proxyListProvider.notifier).refresh(),
child: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => _ErrorBody(
message: l.trf('proxy.saveFailed', [err.toString()]),
onRetry: () => ref.read(proxyListProvider.notifier).refresh(),
),
data: (list) {
final visible = _filter(list);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
children: [
if (!plusActive) const _PlusTip(),
if (!plusActive) const SizedBox(height: 12),
_SectionHeader(
label: l.tr('settings.proxy.title'),
count: list.length,
),
const SizedBox(height: 8),
if (visible.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Text(
l.tr('proxy.empty'),
style: const TextStyle(
color: AppPalette.softMuted,
fontSize: 13,
),
),
),
)
else
for (final proxy in visible)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _ProxyCard(
proxy: proxy,
dateLabel: _formatDate(
proxy.updateTime ?? proxy.createTime,
),
deleting: _deletingId == proxy.id,
onTap: () =>
_openEdit(proxy: proxy, plus: plusActive),
onEdit: () =>
_openEdit(proxy: proxy, plus: plusActive),
onClone: () => _clone(proxy),
onDelete: () => _delete(proxy),
),
),
],
);
},
),
),
),
],
),
body: const ComingSoonPlaceholder(icon: Icons.cloud_outlined),
);
}
}
class _PlusTip extends StatelessWidget {
const _PlusTip();
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Container(
decoration: BoxDecoration(
color: AppPalette.accent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppPalette.accent),
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.auto_awesome_outlined,
size: 16,
color: AppPalette.accent,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.tr('proxy.plusTipTitle'),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppPalette.text,
),
),
const SizedBox(height: 2),
Text(
l.tr('proxy.plusTipBody'),
style: const TextStyle(
fontSize: 11,
color: AppPalette.muted,
height: 1.4,
),
),
],
),
),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.label, required this.count});
final String label;
final int count;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppPalette.muted,
),
),
Text(
'· $count',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppPalette.muted,
fontStyle: FontStyle.italic,
),
),
],
);
}
}
class _AddButton extends StatelessWidget {
const _AddButton({required this.onTap, required this.enabled});
final VoidCallback onTap;
final bool enabled;
@override
Widget build(BuildContext context) {
final bg = enabled ? AppPalette.primary : AppPalette.chip;
final fg = enabled ? AppPalette.fontOnPrimary : AppPalette.softMuted;
return Material(
color: bg,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: SizedBox(
width: 36,
height: 36,
child: Icon(Icons.add_rounded, size: 20, color: fg),
),
),
);
}
}
class _ProxyCard extends StatelessWidget {
const _ProxyCard({
required this.proxy,
required this.dateLabel,
required this.deleting,
required this.onTap,
required this.onEdit,
required this.onClone,
required this.onDelete,
});
final ServerProxyModel proxy;
final String dateLabel;
final bool deleting;
final VoidCallback onTap;
final VoidCallback onEdit;
final VoidCallback onClone;
final VoidCallback onDelete;
bool get _isSocks => proxy.type.toLowerCase() == 'socks5';
@override
Widget build(BuildContext context) {
return Material(
color: AppPalette.card,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.border),
),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Row(
children: [
_TypeChip(label: proxy.typeLabel, primary: !_isSocks),
const SizedBox(width: 8),
Flexible(
child: Text(
proxy.displayName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppPalette.text,
),
),
),
],
),
),
if (dateLabel.isNotEmpty)
Text(
dateLabel,
style: const TextStyle(
fontSize: 10,
color: AppPalette.softMuted,
),
),
],
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: AppPalette.chip,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppPalette.border),
),
child: Row(
children: [
const Icon(
Icons.lan_outlined,
size: 14,
color: AppPalette.muted,
),
const SizedBox(width: 8),
Expanded(
child: Text(
proxy.endpoint.isEmpty ? '-' : proxy.endpoint,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
color: AppPalette.text,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: Row(
children: [
if (proxy.username.isNotEmpty) ...[
const Icon(
Icons.person_outline,
size: 13,
color: AppPalette.softMuted,
),
const SizedBox(width: 4),
Flexible(
child: Text(
proxy.username,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: AppPalette.muted,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
),
_ActionButton(
icon: Icons.edit_outlined,
bg: AppPalette.accentSoft,
fg: AppPalette.primary,
onTap: onEdit,
),
const SizedBox(width: 6),
_ActionButton(
icon: Icons.copy_all_outlined,
bg: AppPalette.success.withValues(alpha: 0.16),
fg: AppPalette.success,
onTap: onClone,
),
const SizedBox(width: 6),
_ActionButton(
icon: Icons.delete_outline,
bg: AppPalette.dangerSoft,
fg: AppPalette.danger,
loading: deleting,
onTap: onDelete,
),
],
),
],
),
),
),
);
}
}
class _TypeChip extends StatelessWidget {
const _TypeChip({required this.label, required this.primary});
final String label;
final bool primary;
@override
Widget build(BuildContext context) {
final bg = primary ? AppPalette.accentSoft : const Color(0xFFCFFAFE);
final fg = primary ? AppPalette.primary : const Color(0xFF0E7490);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w800,
color: fg,
letterSpacing: 0.4,
),
),
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.icon,
required this.bg,
required this.fg,
required this.onTap,
this.loading = false,
});
final IconData icon;
final Color bg;
final Color fg;
final VoidCallback onTap;
final bool loading;
@override
Widget build(BuildContext context) {
return Material(
color: bg,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: loading ? null : onTap,
child: SizedBox(
width: 30,
height: 30,
child: Center(
child: loading
? SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: fg,
),
)
: Icon(icon, size: 14, color: fg),
),
),
),
);
}
}
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: [
const Icon(
Icons.error_outline,
size: 36,
color: AppPalette.danger,
),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: AppPalette.text),
),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () => onRetry(),
child: Text(l.tr('common.retry')),
),
],
),
),
),
],
);
}
}
+839 -4
View File
@@ -1,23 +1,858 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_result.dart';
import '../../core/ui/palette.dart';
import '../../l10n/app_localizations.dart';
import 'widgets/coming_soon_placeholder.dart';
import '../../state/api_providers.dart';
import '../../state/auth_notifier.dart';
import '../../state/login_log_notifier.dart';
import 'models/login_session.dart';
class SessionsPage extends StatelessWidget {
class SessionsPage extends ConsumerStatefulWidget {
const SessionsPage({super.key});
@override
ConsumerState<SessionsPage> createState() => _SessionsPageState();
}
class _SessionsPageState extends ConsumerState<SessionsPage> {
final _addController = TextEditingController();
late List<String> _whitelist;
bool _whitelistDirty = false;
bool _whitelistSaving = false;
bool _purging = false;
String? _revokingId;
@override
void initState() {
super.initState();
_whitelist = const [];
}
@override
void dispose() {
_addController.dispose();
super.dispose();
}
void _syncWhitelistFrom(LoginLogData data) {
if (_whitelistDirty) return;
final next = data.ipWhiteList.where((e) => e.isNotEmpty).toList();
if (next.length == _whitelist.length &&
next.every((e) => _whitelist.contains(e))) {
return;
}
_whitelist = next;
}
void _addIp() {
final raw = _addController.text.trim();
if (raw.isEmpty) return;
if (!RegExp(r'[\d\.]').hasMatch(raw)) return;
if (_whitelist.contains(raw)) {
_addController.clear();
return;
}
setState(() {
_whitelist = [..._whitelist, raw];
_whitelistDirty = true;
_addController.clear();
});
}
void _removeIp(String ip) {
setState(() {
_whitelist = _whitelist.where((e) => e != ip).toList();
_whitelistDirty = true;
});
}
Future<void> _saveWhitelist() async {
final l = AppLocalizations.of(context);
setState(() => _whitelistSaving = true);
try {
await ref
.read(settingsRepositoryProvider)
.saveIpWhiteList(List.of(_whitelist));
if (!mounted) return;
_whitelistDirty = false;
_showSnack(l.tr('sessions.ipSaved'));
await ref.read(loginLogProvider.notifier).refresh();
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(err.message);
} catch (err) {
if (!mounted) return;
_showSnack(err.toString());
} finally {
if (mounted) setState(() => _whitelistSaving = false);
}
}
Future<void> _purgeOld() async {
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.tr('sessions.purgeConfirmTitle')),
content: Text(l.tr('sessions.purgeConfirmBody')),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(l.tr('common.cancel')),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(l.tr('common.continue')),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _purging = true);
try {
await ref.read(settingsRepositoryProvider).purgeOldSessions();
if (!mounted) return;
_showSnack(l.tr('sessions.purgeDone'));
await ref.read(loginLogProvider.notifier).refresh();
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(err.message);
} finally {
if (mounted) setState(() => _purging = false);
}
}
Future<void> _revoke(LoginSession session) async {
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.tr('sessions.revokeConfirmTitle')),
content: Text(l.tr('sessions.revokeConfirmBody')),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(l.tr('common.cancel')),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppPalette.danger,
foregroundColor: AppPalette.fontOnPrimary,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(l.tr('sessions.revoke')),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _revokingId = session.id);
try {
await ref
.read(settingsRepositoryProvider)
.revokeSession(session.id);
if (!mounted) return;
_showSnack(l.tr('sessions.revokeDone'));
await ref.read(loginLogProvider.notifier).refresh();
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(err.message);
} finally {
if (mounted) setState(() => _revokingId = null);
}
}
void _showSnack(String text) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(text), behavior: SnackBarBehavior.floating),
);
}
String _formatTimestamp(int ms) {
if (ms <= 0) return '-';
final dt = DateTime.fromMillisecondsSinceEpoch(ms).toLocal();
String two(int v) => v.toString().padLeft(2, '0');
return '${dt.year}-${two(dt.month)}-${two(dt.day)} '
'${two(dt.hour)}:${two(dt.minute)}';
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final logAsync = ref.watch(loginLogProvider);
return Scaffold(
backgroundColor: AppPalette.canvas,
appBar: AppBar(
backgroundColor: AppPalette.canvas,
centerTitle: true,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(l.tr('settings.sessions.title')),
actions: [
IconButton(
tooltip: l.tr('sessions.purgeTooltip'),
onPressed: _purging || logAsync.isLoading ? null : _purgeOld,
icon: _purging
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.danger,
),
)
: const Icon(
Icons.delete_outline,
color: AppPalette.danger,
),
),
const SizedBox(width: 6),
],
),
body: const ComingSoonPlaceholder(icon: Icons.devices_outlined),
body: RefreshIndicator(
onRefresh: () => ref.read(loginLogProvider.notifier).refresh(),
child: logAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => _ErrorBody(
message: l.trf('sessions.loadFailed', [err.toString()]),
onRetry: () => ref.read(loginLogProvider.notifier).refresh(),
),
data: (data) {
_syncWhitelistFrom(data);
return _buildBody(data);
},
),
),
);
}
Widget _buildBody(LoginLogData data) {
final l = AppLocalizations.of(context);
final currentDeviceId =
ref.watch(authProvider).session?.deviceId ?? '';
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
_IpWhitelistCard(
whitelist: _whitelist,
controller: _addController,
saving: _whitelistSaving,
dirty: _whitelistDirty,
onAdd: _addIp,
onRemove: _removeIp,
onSave: _whitelistDirty && !_whitelistSaving
? _saveWhitelist
: null,
),
const SizedBox(height: 18),
_SectionHeader(
label: l.tr('sessions.loginRecordsTitle'),
count: data.sessions.length,
),
if (data.sessions.isEmpty)
_EmptyState(label: l.tr('sessions.empty'))
else
for (final session in data.sessions)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _SessionCard(
session: session,
isCurrent: session.deviceId.isNotEmpty &&
session.deviceId == currentDeviceId,
createLabel: _formatTimestamp(session.createAt),
expireLabel: _formatTimestamp(session.expireAt),
revoking: _revokingId == session.id,
onRevoke: session.revoked
? null
: () => _revoke(session),
),
),
],
);
}
}
class _IpWhitelistCard extends StatelessWidget {
const _IpWhitelistCard({
required this.whitelist,
required this.controller,
required this.saving,
required this.dirty,
required this.onAdd,
required this.onRemove,
required this.onSave,
});
final List<String> whitelist;
final TextEditingController controller;
final bool saving;
final bool dirty;
final VoidCallback onAdd;
final void Function(String ip) onRemove;
final VoidCallback? onSave;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Container(
decoration: BoxDecoration(
color: AppPalette.card,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.border),
),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
l.tr('sessions.ipWhitelistTitle'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppPalette.muted,
),
),
const SizedBox(width: 6),
const Icon(
Icons.info_outline,
size: 12,
color: AppPalette.softMuted,
),
],
),
const SizedBox(height: 6),
Text(
l.tr('sessions.ipWhitelistHint'),
style: const TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: AppPalette.muted,
),
),
const SizedBox(height: 10),
if (whitelist.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
l.tr('sessions.ipEmpty'),
style: const TextStyle(
fontSize: 12,
color: AppPalette.softMuted,
),
),
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final ip in whitelist)
_IpChip(label: ip, onRemove: () => onRemove(ip)),
],
),
const SizedBox(height: 10),
TextField(
controller: controller,
onSubmitted: (_) => onAdd(),
textInputAction: TextInputAction.done,
style: const TextStyle(
fontSize: 13,
fontFamily: 'monospace',
color: AppPalette.text,
),
decoration: InputDecoration(
hintText: l.tr('sessions.ipAddHint'),
hintStyle: const TextStyle(
fontSize: 12,
color: AppPalette.softMuted,
),
isDense: true,
filled: true,
fillColor: AppPalette.canvas,
prefixIcon: const Icon(
Icons.add_rounded,
size: 18,
color: AppPalette.softMuted,
),
prefixIconConstraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppPalette.accent),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onSave,
style: FilledButton.styleFrom(
backgroundColor: AppPalette.primary,
foregroundColor: AppPalette.fontOnPrimary,
disabledBackgroundColor: AppPalette.chip,
disabledForegroundColor: AppPalette.softMuted,
padding: const EdgeInsets.symmetric(vertical: 12),
),
icon: saving
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.fontOnPrimary,
),
)
: const Icon(Icons.verified_user_outlined, size: 16),
label: Text(
l.tr('sessions.ipSave'),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
}
}
class _IpChip extends StatelessWidget {
const _IpChip({required this.label, required this.onRemove});
final String label;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppPalette.accentSoft,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppPalette.strongBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppPalette.primary,
),
),
const SizedBox(width: 6),
InkWell(
onTap: onRemove,
child: const Icon(
Icons.close_rounded,
size: 14,
color: AppPalette.primary,
),
),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.label, required this.count});
final String label;
final int count;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 2, 4, 8),
child: Row(
children: [
Text(
label.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppPalette.muted,
),
),
const SizedBox(width: 6),
Text(
'· $count',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppPalette.muted,
),
),
],
),
);
}
}
class _SessionCard extends StatelessWidget {
const _SessionCard({
required this.session,
required this.isCurrent,
required this.createLabel,
required this.expireLabel,
required this.revoking,
required this.onRevoke,
});
final LoginSession session;
final bool isCurrent;
final String createLabel;
final String expireLabel;
final bool revoking;
final VoidCallback? onRevoke;
bool get _expired {
if (session.expireAt <= 0) return false;
return DateTime.now().millisecondsSinceEpoch > session.expireAt;
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final mobile = session.isMobile;
final bg = isCurrent ? AppPalette.accentSoft : AppPalette.card;
final borderColor = isCurrent ? AppPalette.primary : AppPalette.border;
final borderWidth = isCurrent ? 2.0 : 1.0;
final ipLabel =
session.ip.isEmpty ? '-' : session.ip;
final location = session.location;
return Container(
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: borderColor, width: borderWidth),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
mobile
? Icons.smartphone_rounded
: Icons.monitor_rounded,
size: 18,
color: AppPalette.primary,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ipLabel,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: AppPalette.text,
),
),
if (location.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
location,
style: const TextStyle(
fontSize: 11,
color: AppPalette.muted,
),
),
],
],
),
),
_StatusBadge(
label: _statusLabel(l),
tone: _statusTone(),
),
],
),
const SizedBox(height: 10),
Container(height: 1, color: AppPalette.border),
const SizedBox(height: 10),
Row(
children: [
Icon(
mobile
? Icons.smartphone_outlined
: Icons.monitor_outlined,
size: 14,
color: AppPalette.muted,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_agentLabel(l),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppPalette.muted,
),
),
),
],
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MetaLine(
label: l.tr('sessions.createTime'),
value: createLabel,
),
const SizedBox(height: 3),
_MetaLine(
label: l.tr('sessions.expireAt'),
value: expireLabel,
),
],
),
),
if (onRevoke != null && !_expired)
_RevokeButton(
label: l.tr('sessions.revoke'),
loading: revoking,
onTap: onRevoke!,
),
],
),
],
),
);
}
String _statusLabel(AppLocalizations l) {
if (isCurrent) return l.tr('sessions.current');
if (session.revoked) return l.tr('sessions.revoked');
if (_expired) return l.tr('terminal.status.disconnected');
return l.tr('sessions.mobile');
}
_StatusTone _statusTone() {
if (isCurrent) return _StatusTone.success;
if (session.revoked) return _StatusTone.muted;
if (_expired) return _StatusTone.muted;
return _StatusTone.primary;
}
String _agentLabel(AppLocalizations l) {
final raw = session.agentLabel;
if (raw.isEmpty) return '-';
return raw;
}
}
enum _StatusTone { primary, success, muted }
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.label, required this.tone});
final String label;
final _StatusTone tone;
@override
Widget build(BuildContext context) {
final (bg, fg) = switch (tone) {
_StatusTone.success => (AppPalette.success, AppPalette.fontOnPrimary),
_StatusTone.primary => (AppPalette.accentSoft, AppPalette.primary),
_StatusTone.muted => (AppPalette.chip, AppPalette.muted),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: fg,
letterSpacing: 0.3,
),
),
);
}
}
class _MetaLine extends StatelessWidget {
const _MetaLine({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'$label ',
style: const TextStyle(
fontSize: 11,
color: AppPalette.softMuted,
fontWeight: FontWeight.w600,
),
),
Expanded(
child: Text(
value,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: AppPalette.muted,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
}
class _RevokeButton extends StatelessWidget {
const _RevokeButton({
required this.label,
required this.loading,
required this.onTap,
});
final String label;
final bool loading;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: loading ? null : onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppPalette.warning),
),
child: loading
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppPalette.warning,
),
)
: Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppPalette.warning,
),
),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 36),
alignment: Alignment.center,
child: Text(
label,
style: const TextStyle(fontSize: 13, color: AppPalette.softMuted),
),
);
}
}
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: [
const Icon(
Icons.error_outline,
size: 36,
color: AppPalette.danger,
),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: AppPalette.text),
),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () => onRetry(),
child: Text(l.tr('common.retry')),
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,241 @@
import 'dart:convert';
import 'dart:typed_data';
import '../../core/api/api_client.dart';
import '../../core/crypto/cryptojs_aes.dart';
import '../../core/crypto/rsa_crypto.dart';
import '../servers/server_proxy_model.dart';
import 'models/login_session.dart';
import 'models/plus_info.dart';
/// Repository for the Account / Plus / Sessions / Proxy CRUD / Credential
/// CRUD endpoints. Plus / Sessions / Proxy / Credentials list reads still
/// live in their existing dedicated notifiers; this class only adds the
/// write paths + extras the notifiers don't cover.
class SettingsRepository {
SettingsRepository({
required this.apiClient,
required this.publicKeyPem,
CryptoJsAes? aes,
RsaCrypto? rsa,
}) : _aes = aes ?? CryptoJsAes(),
_rsa = rsa ?? RsaCrypto();
final ApiClient apiClient;
final String publicKeyPem;
final CryptoJsAes _aes;
final RsaCrypto _rsa;
// ---- Account / password ----
/// `PUT /pwd` with RSA-encrypted old + new password.
Future<void> updateAccount({
required String oldLoginName,
required String oldPwd,
required String newLoginName,
required String newPwd,
}) async {
final body = <String, dynamic>{
'oldLoginName': oldLoginName,
'newLoginName': newLoginName,
'oldPwd': _rsa.encryptPassword(publicKeyPem, oldPwd),
'newPwd': _rsa.encryptPassword(publicKeyPem, newPwd),
};
await apiClient.putJson('/pwd', body);
}
// ---- MFA2 ----
Future<bool> getMfa2Status() async {
final res = await apiClient.getJson('/mfa2-status');
final raw = res['data'];
return raw == true || raw == 1 || raw == '1';
}
/// `POST /mfa2-code` -> `{ data: { qrImage, secret } }`.
Future<Mfa2Setup> getMfa2QrInfo() async {
final res = await apiClient.postJson('/mfa2-code', const {});
final raw = res['data'];
if (raw is! Map) {
throw StateError('mfa2-code returned unexpected payload');
}
return Mfa2Setup(
qrImage: (raw['qrImage'] ?? '').toString(),
secret: (raw['secret'] ?? '').toString(),
);
}
Future<void> enableMfa2(String token) async {
await apiClient.postJson('/mfa2-enable', {'token': token});
}
Future<void> disableMfa2(String token) async {
await apiClient.postJson('/mfa2-disable', {'token': token});
}
// ---- Plus ----
Future<PlusInfo> getPlusInfo() async {
final res = await apiClient.getJson('/plus-info');
final raw = res['data'];
if (raw is! Map || raw.isEmpty) return PlusInfo.empty();
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');
final raw = res['data'];
if (raw is Map) {
return PlusDiscount.fromJson(Map<String, dynamic>.from(raw));
}
} catch (_) {
// /plus-discount is optional; treat any error as "no discount".
}
return const PlusDiscount(discount: false, content: '');
}
// ---- Sessions ----
Future<LoginLogData> getLoginLog() async {
final res = await apiClient.getJson('/log');
final raw = res['data'];
if (raw is! Map) return const LoginLogData(sessions: [], ipWhiteList: []);
return LoginLogData.fromJson(Map<String, dynamic>.from(raw));
}
Future<void> saveIpWhiteList(List<String> whitelist) async {
await apiClient.postJson('/ip-white-list', {'ipWhiteList': whitelist});
}
Future<void> revokeSession(String idOrDeviceId) async {
await apiClient.deleteJson('/revoke-login/$idOrDeviceId');
}
Future<void> purgeOldSessions() async {
await apiClient.deleteJson('/remove-some-login-records');
}
// ---- Proxies CRUD ----
Future<void> createProxy(ServerProxyModel proxy) async {
await apiClient.postJson('/proxy', _proxyBody(proxy));
}
Future<void> updateProxy(ServerProxyModel proxy) async {
await apiClient.putJson('/proxy/${proxy.id}', _proxyBody(proxy));
}
Future<void> deleteProxy(String id) async {
await apiClient.deleteJson('/proxy/$id');
}
Map<String, dynamic> _proxyBody(ServerProxyModel proxy) => {
'type': proxy.type,
'name': proxy.name,
'host': proxy.host,
'port': proxy.port,
'username': proxy.username,
'password': proxy.password,
};
// ---- Credentials CRUD ----
/// `POST /add-ssh`. Secrets are AES-encrypted with a one-time tempKey,
/// tempKey itself is RSA-encrypted with the server public key.
Future<void> createCredential({
required String name,
required String authType,
String password = '',
String privateKey = '',
String openSSHKeyPassword = '',
}) async {
final body = _credentialBody(
name: name,
authType: authType,
password: password,
privateKey: privateKey,
openSSHKeyPassword: openSSHKeyPassword,
);
await apiClient.postJson('/add-ssh', body);
}
/// `POST /update-ssh`.
Future<void> updateCredential({
required String id,
required String name,
required String authType,
String password = '',
String privateKey = '',
String openSSHKeyPassword = '',
}) async {
final body = _credentialBody(
name: name,
authType: authType,
password: password,
privateKey: privateKey,
openSSHKeyPassword: openSSHKeyPassword,
)..['id'] = id;
await apiClient.postJson('/update-ssh', body);
}
Future<void> deleteCredential(String id) async {
await apiClient.deleteJson('/remove-ssh/$id');
}
Map<String, dynamic> _credentialBody({
required String name,
required String authType,
required String password,
required String privateKey,
required String openSSHKeyPassword,
}) {
final tempKey = _aes.generateTempKey();
String encPassword = '';
String encPrivateKey = '';
String encOpenSSHKeyPassword = '';
if (authType == 'password' && password.isNotEmpty) {
encPassword = _aes.encrypt(password, tempKey);
}
if (authType == 'privateKey' && privateKey.isNotEmpty) {
encPrivateKey = _aes.encrypt(privateKey, tempKey);
}
if (openSSHKeyPassword.isNotEmpty) {
encOpenSSHKeyPassword = _aes.encrypt(openSSHKeyPassword, tempKey);
}
final rsaTempKey = _rsa.encryptPassword(publicKeyPem, tempKey);
return <String, dynamic>{
'name': name,
'authType': authType,
'password': encPassword,
'privateKey': encPrivateKey,
'openSSHKeyPassword': encOpenSSHKeyPassword,
'tempKey': rsaTempKey,
};
}
}
class Mfa2Setup {
const Mfa2Setup({required this.qrImage, required this.secret});
final String qrImage; // data:image/png;base64,...
final String secret;
/// Decode the data-URL payload to raw PNG bytes for Image.memory.
Uint8List? get qrImageBytes {
final marker = 'base64,';
final idx = qrImage.indexOf(marker);
if (idx < 0) return null;
return base64Decode(qrImage.substring(idx + marker.length));
}
}
+262 -110
View File
@@ -7,9 +7,12 @@ import '../../state/auth_notifier.dart';
import '../../state/credential_list_notifier.dart';
import '../../state/host_list_notifier.dart';
import '../../state/locale_notifier.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/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';
@@ -97,32 +100,38 @@ class SettingsTab extends ConsumerWidget {
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => page));
}
void _showNotifications(BuildContext context) {
void _showNotifications(BuildContext context, PlusDiscount discount) {
final l = AppLocalizations.of(context);
final hasDiscount = discount.discount && discount.content.isNotEmpty;
showModalBottomSheet<void>(
context: context,
backgroundColor: AppPalette.card,
constraints: const BoxConstraints(maxWidth: double.infinity),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
builder: (sheetContext) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: AppPalette.border,
borderRadius: BorderRadius.circular(4),
Center(
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: AppPalette.border,
borderRadius: BorderRadius.circular(4),
),
),
),
const SizedBox(height: 16),
Text(
l.tr('settings.notifications.tooltip'),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
@@ -130,16 +139,30 @@ class SettingsTab extends ConsumerWidget {
),
),
const SizedBox(height: 20),
Icon(
Icons.notifications_off_outlined,
size: 36,
color: AppPalette.softMuted,
),
const SizedBox(height: 8),
Text(
l.tr('settings.notifications.empty'),
style: const TextStyle(fontSize: 13, color: AppPalette.muted),
),
if (hasDiscount)
_DiscountNotificationCard(
content: discount.content,
onTap: () {
Navigator.of(sheetContext).pop();
_push(context, const PlusSubscriptionPage());
},
)
else ...[
Icon(
Icons.notifications_off_outlined,
size: 36,
color: AppPalette.softMuted,
),
const SizedBox(height: 8),
Text(
l.tr('settings.notifications.empty'),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
color: AppPalette.muted,
),
),
],
const SizedBox(height: 12),
],
),
@@ -159,6 +182,16 @@ class SettingsTab extends ConsumerWidget {
);
}
Future<void> _refresh(WidgetRef ref) async {
await Future.wait([
ref.read(hostListProvider.notifier).refresh(),
ref.read(credentialListProvider.notifier).refresh(),
ref.read(scriptListProvider.notifier).refresh(),
ref.read(plusInfoProvider.notifier).refresh(),
ref.read(plusDiscountProvider.notifier).refresh(),
]);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
@@ -168,17 +201,27 @@ class SettingsTab extends ConsumerWidget {
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 = discount.discount && discount.content.isNotEmpty;
return Scaffold(
backgroundColor: AppPalette.canvas,
body: SafeArea(
bottom: false,
child: CustomScrollView(
slivers: [
child: RefreshIndicator(
onRefresh: () => _refresh(ref),
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: _Header(
title: l.tr('settings.title'),
onBellTap: () => _showNotifications(context),
showBell: !plusActive,
hasDiscount: hasDiscount,
onBellTap: () => _showNotifications(context, discount),
),
),
SliverToBoxAdapter(
@@ -188,23 +231,9 @@ class SettingsTab extends ConsumerWidget {
hostCount: hostCount,
credentialCount: credentialCount,
scriptCount: scriptCount,
),
),
SliverToBoxAdapter(
child: SettingsSection(
title: l.tr('settings.section.subscription'),
children: [
SettingsRow(
icon: Icons.workspace_premium_outlined,
title: l.tr('settings.plus.title'),
subtitle: l.tr('settings.plus.inactiveMeta'),
trailing: _StatusChip(
label: l.tr('settings.plus.inactive'),
tone: _ChipTone.muted,
),
onTap: () => _push(context, const PlusSubscriptionPage()),
),
],
plusActive: plusActive,
onPlusTap: () => _push(context, const PlusSubscriptionPage()),
onLogoutTap: () => _confirmLogout(context, ref),
),
),
SliverToBoxAdapter(
@@ -268,16 +297,9 @@ class SettingsTab extends ConsumerWidget {
],
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
child: _LogoutButton(
label: l.tr('settings.logout'),
onTap: () => _confirmLogout(context, ref),
),
),
),
],
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
),
),
);
@@ -285,10 +307,17 @@ class SettingsTab extends ConsumerWidget {
}
class _Header extends StatelessWidget {
const _Header({required this.title, required this.onBellTap});
const _Header({
required this.title,
required this.onBellTap,
required this.hasDiscount,
required this.showBell,
});
final String title;
final VoidCallback onBellTap;
final bool hasDiscount;
final bool showBell;
@override
Widget build(BuildContext context) {
@@ -307,26 +336,49 @@ class _Header extends StatelessWidget {
),
),
),
Material(
color: AppPalette.card,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: AppPalette.border),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onBellTap,
child: const SizedBox(
width: 40,
height: 40,
child: Icon(
Icons.notifications_none_outlined,
size: 20,
color: AppPalette.primary,
if (showBell)
Material(
color: AppPalette.card,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: AppPalette.border),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onBellTap,
child: SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
const Icon(
Icons.notifications_none_outlined,
size: 20,
color: AppPalette.primary,
),
if (hasDiscount)
Positioned(
top: 10,
right: 10,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: AppPalette.danger,
shape: BoxShape.circle,
border: Border.all(
color: AppPalette.card,
width: 1.5,
),
),
),
),
],
),
),
),
),
),
],
),
);
@@ -340,6 +392,9 @@ class _ProfileCard extends StatelessWidget {
required this.hostCount,
required this.credentialCount,
required this.scriptCount,
required this.plusActive,
required this.onPlusTap,
required this.onLogoutTap,
});
final String username;
@@ -347,11 +402,13 @@ class _ProfileCard extends StatelessWidget {
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 initial = username.isNotEmpty ? username[0].toUpperCase() : '?';
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Container(
@@ -367,21 +424,19 @@ class _ProfileCard extends StatelessWidget {
Row(
children: [
Container(
width: 52,
height: 52,
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppPalette.accentSoft,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.accent, width: 1.5),
color: AppPalette.chip,
shape: BoxShape.circle,
border: Border.all(color: AppPalette.border),
),
alignment: Alignment.center,
child: Text(
initial,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: AppPalette.primary,
),
child: Image.asset(
'assets/logo_v2_01.png',
width: 30,
height: 30,
fit: BoxFit.contain,
),
),
const SizedBox(width: 14),
@@ -404,10 +459,7 @@ class _ProfileCard extends StatelessWidget {
),
),
const SizedBox(width: 8),
_StatusChip(
label: l.tr('settings.plus.inactive'),
tone: _ChipTone.muted,
),
_PlusBadge(active: plusActive, onTap: onPlusTap),
],
),
const SizedBox(height: 4),
@@ -423,6 +475,8 @@ class _ProfileCard extends StatelessWidget {
],
),
),
const SizedBox(width: 8),
_LogoutIconButton(onTap: onLogoutTap),
],
),
const SizedBox(height: 14),
@@ -504,6 +558,56 @@ class _StatDivider extends StatelessWidget {
enum _ChipTone { muted, accent, success }
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: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppPalette.accent,
decoration: TextDecoration.underline,
),
),
],
],
),
),
);
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.label, required this.tone});
@@ -539,42 +643,36 @@ class _StatusChip extends StatelessWidget {
}
}
class _LogoutButton extends StatelessWidget {
const _LogoutButton({required this.label, required this.onTap});
class _LogoutIconButton extends StatelessWidget {
const _LogoutIconButton({required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Material(
color: AppPalette.dangerSoft,
borderRadius: BorderRadius.circular(16),
color: AppPalette.chip,
borderRadius: BorderRadius.circular(12),
child: InkWell(
key: const Key('settings-logout'),
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.dangerBorder),
),
padding: const EdgeInsets.symmetric(vertical: 14),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.logout, size: 18, color: AppPalette.danger),
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppPalette.danger,
),
),
],
child: Tooltip(
message: l.tr('settings.logout'),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppPalette.border),
),
alignment: Alignment.center,
child: const Icon(
Icons.logout,
size: 18,
color: AppPalette.danger,
),
),
),
),
@@ -612,3 +710,57 @@ class _LangOption extends StatelessWidget {
);
}
}
class _DiscountNotificationCard extends StatelessWidget {
const _DiscountNotificationCard({
required this.content,
required this.onTap,
});
final String content;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppPalette.dangerSoft,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppPalette.dangerBorder),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.local_offer_outlined,
size: 18,
color: AppPalette.danger,
),
const SizedBox(width: 10),
Expanded(
child: Text(
content,
style: const TextStyle(
fontSize: 13,
height: 1.5,
fontWeight: FontWeight.w600,
color: AppPalette.danger,
),
),
),
const SizedBox(width: 8),
const Icon(
Icons.chevron_right_rounded,
size: 18,
color: AppPalette.danger,
),
],
),
),
);
}
}
+162 -2
View File
@@ -20,6 +20,7 @@ const Map<String, String> stringsEn = {
'common.closeSearch': 'Close search',
'common.system': 'System default',
'common.delete': 'Delete',
'common.optional': 'Optional',
// Login page
'login.serverAddress': 'Server address',
@@ -239,16 +240,16 @@ const Map<String, String> stringsEn = {
'settings.languageEnglish': 'English',
'settings.languageChinese': '中文',
'settings.section.subscription': 'Subscription',
'settings.section.security': 'Account & Security',
'settings.section.connection': 'Credentials & Connection',
'settings.section.preferences': 'Preferences',
'settings.plus.title': 'Plus subscription',
'settings.plus.title': 'Plus features',
'settings.plus.active': 'Active',
'settings.plus.inactive': 'Inactive',
'settings.plus.expiryMeta': 'Expires {0}',
'settings.plus.inactiveMeta': 'Activate to unlock proxy, jump host & batch ops',
'settings.plus.goActivate': 'Activate',
'settings.account.title': 'Account & password',
'settings.account.subtitle': 'Username, password and MFA2',
@@ -274,6 +275,165 @@ const Map<String, String> stringsEn = {
'settings.notifications.tooltip': 'Notifications',
'settings.notifications.empty': 'No notifications yet',
// Plus subscription
'plus.statusActive': 'Plus is active',
'plus.statusInactive': 'Plus is inactive',
'plus.maxIPs': 'IP quota',
'plus.usedIPCount': 'Authorized IPs',
'plus.expiry': 'Expires',
'plus.usedIPs': 'Authorized IPs',
'plus.viewAllIPs': 'View all ({0})',
'plus.activate': 'Activate now',
'plus.keyLabel': 'Plus Key',
'plus.keyHint': 'Enter your Plus Key',
'plus.keyTooShort': 'Please enter a valid Plus Key',
'plus.activateSuccess': 'Activated — thanks for the support',
'plus.activateFailed': 'Activation failed, please verify the key',
'plus.fetchKey': 'Get Plus Key',
'plus.fetchKeyHint': 'Copied purchase URL to clipboard: {0}',
'plus.appendWhitelist': 'Append all IPs to whitelist',
'plus.appendConfirmTitle': 'Append to login whitelist',
'plus.appendConfirmBody': 'Append {0} authorized IPs to login whitelist?',
'plus.appendSuccess': 'Added {0} IPs to whitelist',
'plus.appendFailed': 'Failed to update whitelist: {0}',
'plus.featuresTitle': 'Plus features',
'plus.featuresSection': 'Plus features',
'plus.section.activate': 'Activate',
'plus.activateHint': 'If you find it helpful, consider supporting the author and keep the project going ❤️',
'plus.statusBannerActive': 'Plus activated',
'plus.statusBannerInactive': 'Plus inactive',
'plus.discountTip': 'Limited offer',
'plus.loadFailed': 'Failed to load Plus info: {0}',
'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':
'Substring match — "192.168" matches all 192.168.* addresses.',
'sessions.ipAddHint': 'Enter an IP and press return',
'sessions.ipEmpty': 'No IPs configured — access is unrestricted',
'sessions.ipSave': 'Save whitelist',
'sessions.ipSaved': 'Whitelist saved',
'sessions.loginRecordsTitle': 'Login records',
'sessions.purgeTooltip': 'Delete records older than 7 days',
'sessions.purgeConfirmTitle': 'Purge old records?',
'sessions.purgeConfirmBody':
'All login records older than 7 days will be deleted.',
'sessions.purgeDone': 'Old records cleared',
'sessions.current': 'Current session',
'sessions.mobile': 'Mobile',
'sessions.revoked': 'Revoked',
'sessions.revoke': 'Revoke',
'sessions.revokeConfirmTitle': 'Revoke session?',
'sessions.revokeConfirmBody':
'The device on this session will be signed out immediately.',
'sessions.revokeDone': 'Revoked',
'sessions.createTime': 'Signed in',
'sessions.expireAt': 'Expires',
'sessions.empty': 'No login records yet',
'sessions.loadFailed': 'Failed to load sessions: {0}',
// Account security
'account.section.credentials': 'Login credentials',
'account.section.mfa': 'Two-factor (MFA2)',
'account.oldLoginName': 'Current username',
'account.oldPwd': 'Current password',
'account.newLoginName': 'New username',
'account.newPwd': 'New password',
'account.newPwdConfirm': 'Confirm new password',
'account.submit': 'Save changes',
'account.confirmTitle': 'Save changes?',
'account.confirmBody':
'All devices will be signed out after username or password changes.',
'account.changed': 'Updated — please sign in again',
'account.pwdMismatch': 'New passwords do not match',
'account.pwdRequired': 'Please enter the password',
'account.usernameRequired': 'Please enter the username',
'account.usernameSame': 'New username is the same as current',
'account.pwdSame': 'New password is the same as current',
'account.mfa.statusOn': 'MFA2 is enabled',
'account.mfa.statusOff': 'MFA2 is disabled',
'account.mfa.enable': 'Enable MFA2',
'account.mfa.disable': 'Disable MFA2',
'account.mfa.scanHint': 'Scan with Google Authenticator or similar',
'account.mfa.secret': 'Secret',
'account.mfa.copySecret': 'Copy secret',
'account.mfa.codeLabel': 'Enter the 6-digit code',
'account.mfa.enableConfirm': 'Enable',
'account.mfa.disableConfirm': 'Disable',
'account.mfa.enabled': 'MFA2 enabled',
'account.mfa.disabled': 'MFA2 disabled',
'account.mfa.codeRequired': 'Please enter the verification code',
'account.mfa.codeInvalid': 'Please enter a 6-digit code',
// Credentials
'credentials.searchHint': 'Search credential names',
'credentials.empty': 'No credentials yet. Tap the + above to add one.',
'credentials.emptyFiltered': 'No matching credentials.',
'credentials.add': 'Add credential',
'credentials.edit': 'Edit credential',
'credentials.deleteConfirmTitle': 'Delete credential?',
'credentials.deleteConfirmBody':
'Delete "{0}"? Servers referencing it will be cleared.',
'credentials.deleted': 'Deleted',
'credentials.deleteFailed': 'Delete failed: {0}',
'credentials.saveFailed': 'Save failed: {0}',
'credentials.field.name': 'Credential name',
'credentials.field.nameHint': 'A label for easy recognition',
'credentials.field.authType': 'Auth type',
'credentials.field.username': 'Username',
'credentials.field.usernameHint': 'Remote host username',
'credentials.field.password': 'Password',
'credentials.field.passwordHint': 'Remote host password',
'credentials.field.privateKey': 'Private key',
'credentials.field.privateKeyHint': 'Paste the full PEM private key',
'credentials.field.passphrase': 'Passphrase',
'credentials.field.passphraseHint':
'Leave blank if the private key is not encrypted',
'credentials.editHint':
'Leave the secret field blank to keep the existing value.',
'credentials.validation.name': 'Please enter a credential name',
'credentials.validation.privateKey': 'Please enter the private key',
'credentials.validation.password': 'Please enter the password',
'credentials.saved': 'Saved',
// Proxy
'proxy.plusTipTitle': 'Plus feature',
'proxy.plusTipBody': 'Proxy services require an active Plus license.',
'proxy.add': 'Add proxy',
'proxy.edit': 'Edit proxy',
'proxy.deleteConfirmTitle': 'Delete proxy?',
'proxy.deleteConfirmBody':
'Delete "{0}"? Servers referencing it will be cleared.',
'proxy.deleted': 'Deleted',
'proxy.deleteFailed': 'Delete failed: {0}',
'proxy.saveFailed': 'Save failed: {0}',
'proxy.field.type': 'Type',
'proxy.field.name': 'Name',
'proxy.field.nameHint': 'A label for easy recognition',
'proxy.field.host': 'Host',
'proxy.field.hostHint': 'IP or domain',
'proxy.field.port': 'Port',
'proxy.field.portHint': '1 - 65535',
'proxy.field.username': 'Username',
'proxy.field.password': 'Password',
'proxy.empty': 'No proxies yet. Tap + above to add one.',
'proxy.validation.name': 'Please enter a proxy name',
'proxy.validation.host': 'Please enter a host',
'proxy.validation.port': 'Please enter a valid port',
'proxy.saved': 'Saved',
// Terminal shell
'terminal.title': 'Terminal',
'terminal.noActive': 'No active terminal',
+154 -2
View File
@@ -19,6 +19,7 @@ const Map<String, String> stringsZh = {
'common.closeSearch': '关闭搜索',
'common.system': '跟随系统',
'common.delete': '删除',
'common.optional': '可选',
// Login
'login.serverAddress': '服务端地址',
@@ -226,16 +227,16 @@ const Map<String, String> stringsZh = {
'settings.languageEnglish': 'English',
'settings.languageChinese': '中文',
'settings.section.subscription': '订阅与授权',
'settings.section.security': '账户安全',
'settings.section.connection': '凭据与连接',
'settings.section.preferences': '偏好设置',
'settings.plus.title': 'Plus 订阅',
'settings.plus.title': 'Plus 功能',
'settings.plus.active': '已激活',
'settings.plus.inactive': '未激活',
'settings.plus.expiryMeta': '到期 {0}',
'settings.plus.inactiveMeta': '激活后解锁代理 / 跳板机 / 批量任务',
'settings.plus.goActivate': '去激活',
'settings.account.title': '账户与密码',
'settings.account.subtitle': '修改用户名 / 密码 / MFA2',
@@ -261,6 +262,157 @@ const Map<String, String> stringsZh = {
'settings.notifications.tooltip': '通知',
'settings.notifications.empty': '暂无新通知',
// Plus subscription page
'plus.statusActive': 'Plus 已激活',
'plus.statusInactive': '未激活 Plus',
'plus.maxIPs': '授权 IP 数',
'plus.usedIPCount': '已授权 IP',
'plus.expiry': '到期时间',
'plus.usedIPs': '已授权 IP',
'plus.viewAllIPs': '查看全部 ({0})',
'plus.activate': '立即激活',
'plus.keyLabel': 'Plus Key',
'plus.keyHint': '请输入 Plus Key',
'plus.keyTooShort': '请输入正确的 Plus Key',
'plus.activateSuccess': '激活成功,感谢支持',
'plus.activateFailed': '激活失败,请确认 key 是否正确',
'plus.fetchKey': '获取 Plus Key',
'plus.fetchKeyHint': '已复制购买地址到剪贴板:{0}',
'plus.appendWhitelist': '追加所有 IP 到白名单',
'plus.appendConfirmTitle': '追加授权 IP 到登录白名单',
'plus.appendConfirmBody': '确定将 {0} 个授权 IP 追加到登录白名单?',
'plus.appendSuccess': '已追加 {0} 个 IP 到白名单',
'plus.appendFailed': '追加白名单失败:{0}',
'plus.featuresTitle': 'Plus 专属功能',
'plus.featuresSection': 'Plus 特性',
'plus.section.activate': '激活',
'plus.activateHint': '如果你觉得好用,欢迎加入支持作者,让项目走得更远 ❤️',
'plus.statusBannerActive': 'Plus 已激活',
'plus.statusBannerInactive': '尚未激活 Plus',
'plus.discountTip': '限时优惠',
'plus.loadFailed': '加载 Plus 信息失败:{0}',
'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.*',
'sessions.ipAddHint': '添加 IP 后回车',
'sessions.ipEmpty': '尚未设置任何 IP,默认不限制访问',
'sessions.ipSave': '保存白名单',
'sessions.ipSaved': '白名单已保存',
'sessions.loginRecordsTitle': '登录记录',
'sessions.purgeTooltip': '清理 7 天前的记录',
'sessions.purgeConfirmTitle': '清理历史记录?',
'sessions.purgeConfirmBody': '将删除 7 天前的所有登录记录,确定继续?',
'sessions.purgeDone': '已清理过期记录',
'sessions.current': '当前会话',
'sessions.mobile': '移动端',
'sessions.revoked': '已撤销',
'sessions.revoke': '撤销',
'sessions.revokeConfirmTitle': '撤销会话?',
'sessions.revokeConfirmBody': '撤销该会话后该设备将立即登出',
'sessions.revokeDone': '已撤销',
'sessions.createTime': '登录时间',
'sessions.expireAt': '过期时间',
'sessions.empty': '暂无登录记录',
'sessions.loadFailed': '加载会话失败:{0}',
// Account security page
'account.section.credentials': '修改登录凭据',
'account.section.mfa': '两步验证 MFA2',
'account.oldLoginName': '当前用户名',
'account.oldPwd': '当前密码',
'account.newLoginName': '新用户名',
'account.newPwd': '新密码',
'account.newPwdConfirm': '确认新密码',
'account.submit': '保存修改',
'account.confirmTitle': '保存修改?',
'account.confirmBody': '修改用户名或密码后,所有设备将被登出',
'account.changed': '修改成功,请重新登录',
'account.pwdMismatch': '两次输入的新密码不一致',
'account.pwdRequired': '请输入密码',
'account.usernameRequired': '请输入用户名',
'account.usernameSame': '新用户名与当前一致',
'account.pwdSame': '新密码与当前一致',
'account.mfa.statusOn': 'MFA2 已启用',
'account.mfa.statusOff': 'MFA2 未启用',
'account.mfa.enable': '启用 MFA2',
'account.mfa.disable': '关闭 MFA2',
'account.mfa.scanHint': '请使用 Google Authenticator 等 App 扫码',
'account.mfa.secret': '密钥',
'account.mfa.copySecret': '复制密钥',
'account.mfa.codeLabel': '请输入 6 位验证码',
'account.mfa.enableConfirm': '启用',
'account.mfa.disableConfirm': '关闭',
'account.mfa.enabled': 'MFA2 已启用',
'account.mfa.disabled': 'MFA2 已关闭',
'account.mfa.codeRequired': '请输入验证码',
'account.mfa.codeInvalid': '请输入 6 位数字',
// Credentials page
'credentials.searchHint': '搜索凭据名称',
'credentials.empty': '还没有凭据,点击右上角添加',
'credentials.emptyFiltered': '没有匹配的凭据',
'credentials.add': '添加凭据',
'credentials.edit': '编辑凭据',
'credentials.deleteConfirmTitle': '删除凭据?',
'credentials.deleteConfirmBody': '确认删除"{0}"?引用了该凭据的服务器将被清空',
'credentials.deleted': '已删除',
'credentials.deleteFailed': '删除失败:{0}',
'credentials.saveFailed': '保存失败:{0}',
'credentials.field.name': '凭据名称',
'credentials.field.nameHint': '便于识别的名称',
'credentials.field.authType': '认证类型',
'credentials.field.username': '用户名',
'credentials.field.usernameHint': '远程主机用户名',
'credentials.field.password': '密码',
'credentials.field.passwordHint': '远程主机密码',
'credentials.field.privateKey': '私钥',
'credentials.field.privateKeyHint': '完整粘贴 PEM 私钥',
'credentials.field.passphrase': '密码短语 (PASSPHRASE)',
'credentials.field.passphraseHint': '如果私钥未加密可以不填',
'credentials.editHint': '留空将保留已保存的凭据',
'credentials.validation.name': '请输入凭据名称',
'credentials.validation.privateKey': '请输入私钥',
'credentials.validation.password': '请输入密码',
'credentials.saved': '已保存',
// Proxy page
'proxy.plusTipTitle': 'Plus 功能',
'proxy.plusTipBody': '代理服务需要 Plus 激活后方可使用',
'proxy.add': '添加代理',
'proxy.edit': '编辑代理',
'proxy.deleteConfirmTitle': '删除代理?',
'proxy.deleteConfirmBody': '确认删除"{0}"?引用了该代理的服务器将被清空',
'proxy.deleted': '已删除',
'proxy.deleteFailed': '删除失败:{0}',
'proxy.saveFailed': '保存失败:{0}',
'proxy.field.type': '代理类型',
'proxy.field.name': '代理名称',
'proxy.field.nameHint': '便于识别的名称',
'proxy.field.host': '主机地址',
'proxy.field.hostHint': 'IP 或域名',
'proxy.field.port': '端口',
'proxy.field.portHint': '1 - 65535',
'proxy.field.username': '用户名',
'proxy.field.password': '密码',
'proxy.empty': '还没有代理,点右上角添加',
'proxy.validation.name': '请输入代理名称',
'proxy.validation.host': '请输入主机地址',
'proxy.validation.port': '请输入正确端口',
'proxy.saved': '已保存',
// Terminal
'terminal.title': '终端',
'terminal.noActive': '没有活动的终端',
+14
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/api_client.dart';
import '../features/scripts/script_repository.dart';
import '../features/servers/server_repository.dart';
import '../features/settings/settings_repository.dart';
import 'auth_notifier.dart';
/// Resolves the active [ApiClient] from the auth state. Pages should not
@@ -38,3 +39,16 @@ final scriptRepositoryProvider = Provider<ScriptRepository>((ref) {
}
return ApiScriptRepository(apiClient: api);
});
/// Repository for the Settings sub-pages — account, plus, sessions, proxy
/// CRUD and credential CRUD. Holds the public key needed for RSA-wrapping
/// passwords and credential tempKeys.
final settingsRepositoryProvider = Provider<SettingsRepository>((ref) {
final auth = ref.watch(authProvider);
final api = auth.apiClient;
final pubKey = auth.publicKeyPem;
if (api == null || pubKey == null) {
throw StateError('settingsRepositoryProvider read while signed out');
}
return SettingsRepository(apiClient: api, publicKeyPem: pubKey);
});
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/api_result.dart';
import '../features/settings/models/login_session.dart';
import 'api_providers.dart';
import 'auth_notifier.dart';
class LoginLogNotifier extends AsyncNotifier<LoginLogData> {
@override
Future<LoginLogData> build() async {
try {
return await ref.watch(settingsRepositoryProvider).getLoginLog();
} on UnauthorizedFailure {
await ref.read(authProvider.notifier).signOut();
rethrow;
}
}
Future<void> refresh() async {
final previous = state.valueOrNull;
if (previous == null) state = const AsyncLoading();
state = await AsyncValue.guard(() async {
try {
return await ref.read(settingsRepositoryProvider).getLoginLog();
} on UnauthorizedFailure {
await ref.read(authProvider.notifier).signOut();
rethrow;
}
});
if (state.hasError && previous != null) state = AsyncData(previous);
}
}
final loginLogProvider =
AsyncNotifierProvider<LoginLogNotifier, LoginLogData>(LoginLogNotifier.new);
@@ -0,0 +1,26 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/settings/models/plus_info.dart';
import 'api_providers.dart';
/// Mirrors web's `/plus-discount` polling — optional banner/announcement.
/// Errors silently degrade to "no discount" so the bell can still open.
class PlusDiscountNotifier extends AsyncNotifier<PlusDiscount> {
@override
Future<PlusDiscount> build() async {
return ref.watch(settingsRepositoryProvider).getPlusDiscount();
}
Future<void> refresh() async {
final previous = state.valueOrNull;
state = await AsyncValue.guard(
() => ref.read(settingsRepositoryProvider).getPlusDiscount(),
);
if (state.hasError && previous != null) state = AsyncData(previous);
}
}
final plusDiscountProvider =
AsyncNotifierProvider<PlusDiscountNotifier, PlusDiscount>(
PlusDiscountNotifier.new,
);
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/api_result.dart';
import '../features/settings/models/plus_info.dart';
import 'api_providers.dart';
import 'auth_notifier.dart';
/// Mirrors web's `store.plusInfo` + `store.getPlusInfo()`.
class PlusInfoNotifier extends AsyncNotifier<PlusInfo> {
@override
Future<PlusInfo> build() async {
try {
return await ref.watch(settingsRepositoryProvider).getPlusInfo();
} on UnauthorizedFailure {
await ref.read(authProvider.notifier).signOut();
rethrow;
}
}
Future<void> refresh() async {
final previous = state.valueOrNull;
if (previous == null) state = const AsyncLoading();
state = await AsyncValue.guard(
() async {
try {
return await ref.read(settingsRepositoryProvider).getPlusInfo();
} on UnauthorizedFailure {
await ref.read(authProvider.notifier).signOut();
rethrow;
}
},
);
if (state.hasError && previous != null) state = AsyncData(previous);
}
}
final plusInfoProvider =
AsyncNotifierProvider<PlusInfoNotifier, PlusInfo>(PlusInfoNotifier.new);
@@ -11,6 +11,7 @@ import package_info_plus
import path_provider_foundation
import share_plus
import shared_preferences_foundation
import url_launcher_macos
import video_player_avfoundation
import wakelock_plus
@@ -21,6 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
}
+32
View File
@@ -810,6 +810,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.1"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
@@ -818,6 +842,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
+2
View File
@@ -33,6 +33,7 @@ dependencies:
photo_view: ^0.15.0
video_player: ^2.9.2
chewie: ^1.8.5
url_launcher: ^6.3.1
dependency_overrides:
# video_player_android 2.9.x has a known Pigeon channel-error regression on
@@ -53,5 +54,6 @@ flutter:
uses-material-design: true
assets:
- assets/logo_v2_01.png
- assets/plus.png
- assets/linux.svg
- assets/windows.svg
+1 -1
View File
@@ -213,7 +213,7 @@ const getPlusInfo = async ({ res }) => {
}
const getPlusDiscount = async ({ res } = {}) => {
if (process.env.EXEC_ENV === 'local') return res.success({ discount: false })
// if (process.env.EXEC_ENV === 'local') return res.success({ discount: false })
try {
const response = await requestWithFailover(`/api/announcement/public?version=${ version }`)