mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 16:37:58 +08:00
feat: 完善设置功能
This commit is contained in:
@@ -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 |
@@ -71,5 +71,10 @@
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>http</string>
|
||||
<string>https</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '没有活动的终端',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }`)
|
||||
|
||||
Reference in New Issue
Block a user