Android Feature Optimization and Updates
Build Android Release / Build Android (push) Failing after 7s

This commit is contained in:
2026-05-21 10:31:43 +08:00
parent 31acfd4e13
commit 1d9e0a831f
19 changed files with 2506 additions and 562 deletions
@@ -0,0 +1,496 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class CaptchaWebConfig {
final String type;
final String displayName;
final String? siteKey;
final String? instanceUrl;
final String? assetServer;
const CaptchaWebConfig._({
required this.type,
required this.displayName,
this.siteKey,
this.instanceUrl,
this.assetServer,
});
const CaptchaWebConfig.recaptchaV2({
required String siteKey,
String displayName = 'reCAPTCHA V2',
}) : this._(type: 'recaptcha', displayName: displayName, siteKey: siteKey);
const CaptchaWebConfig.turnstile({
required String siteKey,
String displayName = 'Cloudflare Turnstile',
}) : this._(type: 'turnstile', displayName: displayName, siteKey: siteKey);
const CaptchaWebConfig.cap({
required String instanceUrl,
required String siteKey,
String? assetServer,
String displayName = 'Cap',
}) : this._(
type: 'cap',
displayName: displayName,
instanceUrl: instanceUrl,
siteKey: siteKey,
assetServer: assetServer,
);
}
class CaptchaChallengePage extends StatefulWidget {
final CaptchaWebConfig config;
final String baseUrl;
const CaptchaChallengePage({
super.key,
required this.config,
required this.baseUrl,
});
@override
State<CaptchaChallengePage> createState() => _CaptchaChallengePageState();
}
class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
late final WebViewController _controller;
bool _isLoading = true;
int _progress = 0;
String? _errorMessage;
String? _statusText;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..addJavaScriptChannel(
'CaptchaBridge',
onMessageReceived: (message) {
_handleBridgeMessage(message.message);
},
)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (progress) {
if (mounted) setState(() => _progress = progress);
},
onPageFinished: (_) {
if (mounted) setState(() => _isLoading = false);
},
onWebResourceError: (error) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = '${error.errorCode}: ${error.description}'
.trim();
});
}
},
),
);
_loadCaptcha();
}
Future<void> _loadCaptcha() async {
setState(() {
_isLoading = true;
_errorMessage = null;
_statusText = null;
_progress = 0;
});
final html = _buildHtml(widget.config);
await _controller.loadHtmlString(html, baseUrl: widget.baseUrl);
}
void _handleBridgeMessage(String rawMessage) {
try {
final decoded = jsonDecode(rawMessage);
if (decoded is! Map) return;
final type = decoded['type']?.toString();
if (type == 'success') {
final token = decoded['token']?.toString() ?? '';
if (token.isNotEmpty && mounted) {
Navigator.of(context).pop(token);
}
return;
}
if (type == 'progress') {
final progress = decoded['progress']?.toString();
if (mounted) {
setState(() {
_statusText = progress == null ? '正在验证...' : '正在验证... $progress';
});
}
return;
}
if (type == 'error') {
if (mounted) {
setState(() {
_errorMessage = decoded['message']?.toString() ?? '验证码加载失败';
});
}
return;
}
if (type == 'expired') {
if (mounted) {
setState(() {
_statusText = '验证码已过期,请重新验证';
});
}
return;
}
} catch (_) {
// 忽略非 JSON 消息。
}
}
String _buildHtml(CaptchaWebConfig config) {
switch (config.type) {
case 'turnstile':
return _buildTurnstileHtml(config.siteKey!);
case 'recaptcha':
return _buildRecaptchaHtml(config.siteKey!);
case 'cap':
return _buildCapHtml(
instanceUrl: config.instanceUrl!,
siteKey: config.siteKey!,
assetServer: config.assetServer,
);
default:
return _buildErrorHtml('不支持的验证码类型: ${config.type}');
}
}
String _baseHtml({
required String title,
required String body,
required String script,
}) {
final safeTitle = const HtmlEscape().convert(title);
return '''
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>$safeTitle</title>
<style>
html, body {
margin: 0;
padding: 0;
min-height: 100%;
background: #ffffff;
color: #0f172a;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
}
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.card {
width: 100%;
max-width: 380px;
border: 1px solid #e5e7eb;
border-radius: 18px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
padding: 24px;
box-sizing: border-box;
}
h1 {
font-size: 18px;
margin: 0 0 8px;
text-align: center;
color: #111827;
}
p {
font-size: 13px;
color: #64748b;
text-align: center;
margin: 0 0 20px;
line-height: 1.5;
}
#widget {
display: flex;
justify-content: center;
min-height: 78px;
align-items: center;
}
.status {
margin-top: 14px;
font-size: 12px;
text-align: center;
color: #64748b;
word-break: break-word;
}
.error {
color: #dc2626;
}
cap-widget {
display: block;
margin: 0 auto;
}
</style>
</head>
<body>
<div class="page">
<div class="card">
<h1>$safeTitle</h1>
<p>完成验证后会自动返回登录页。</p>
$body
<div id="status" class="status">正在加载验证码...</div>
</div>
</div>
<script>
function sendBridge(payload) {
try {
CaptchaBridge.postMessage(JSON.stringify(payload));
} catch (e) {}
}
function markStatus(text, isError) {
var el = document.getElementById('status');
if (!el) return;
el.textContent = text || '';
el.className = isError ? 'status error' : 'status';
}
function solved(token) {
markStatus('验证完成,正在返回...', false);
sendBridge({ type: 'success', token: token });
}
function failed(message) {
markStatus(message || '验证码加载失败', true);
sendBridge({ type: 'error', message: message || '验证码加载失败' });
}
function expired() {
markStatus('验证码已过期,请重新验证', true);
sendBridge({ type: 'expired' });
}
$script
</script>
</body>
</html>
''';
}
String _buildTurnstileHtml(String siteKey) {
return _baseHtml(
title: 'Cloudflare Turnstile',
body: '<div id="widget"></div>',
script:
'''
function onTurnstileLoad() {
try {
turnstile.render('#widget', {
sitekey: '${_js(siteKey)}',
callback: function(token) { solved(token); },
'error-callback': function() { failed('Turnstile 验证失败,请重试'); },
'expired-callback': function() { expired(); }
});
markStatus('请完成人机验证', false);
} catch (e) {
failed(e && e.message ? e.message : String(e));
}
}
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit" async defer></script>
<script>
''',
);
}
String _buildRecaptchaHtml(String siteKey) {
return _baseHtml(
title: 'reCAPTCHA V2',
body: '<div id="widget"></div>',
script:
'''
function onRecaptchaLoad() {
try {
grecaptcha.render('widget', {
sitekey: '${_js(siteKey)}',
callback: function(token) { solved(token); },
'expired-callback': function() { expired(); },
'error-callback': function() { failed('reCAPTCHA 加载或验证失败,请重试'); }
});
markStatus('请完成人机验证', false);
} catch (e) {
failed(e && e.message ? e.message : String(e));
}
}
</script>
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
<script>
''',
);
}
String _buildCapHtml({
required String instanceUrl,
required String siteKey,
String? assetServer,
}) {
final endpoint = _capEndpoint(instanceUrl, siteKey);
final scriptUrl = _capWidgetScript(assetServer);
final safeEndpoint = const HtmlEscape().convert(endpoint);
final safeScriptUrl = const HtmlEscape().convert(scriptUrl);
return _baseHtml(
title: 'Cap',
body:
'<div id="widget"><cap-widget id="cap" required data-cap-api-endpoint="$safeEndpoint" data-cap-disable-haptics></cap-widget></div>',
script:
'''
window.CAP_DISABLE_HAPTICS = true;
const cap = document.getElementById('cap');
if (cap) {
cap.addEventListener('solve', function(e) {
solved(e.detail && e.detail.token ? e.detail.token : '');
});
cap.addEventListener('progress', function(e) {
const progress = e.detail && e.detail.progress != null ? e.detail.progress : '';
markStatus('正在验证... ' + progress, false);
sendBridge({ type: 'progress', progress: progress });
});
cap.addEventListener('error', function(e) {
const message = e.detail && e.detail.message ? e.detail.message : 'Cap 验证失败';
failed(message);
});
markStatus('请完成人机验证', false);
}
</script>
<script type="module" src="$safeScriptUrl"></script>
<script>
''',
);
}
String _buildErrorHtml(String message) {
return _baseHtml(
title: '验证码错误',
body:
'<div id="widget" class="error">${const HtmlEscape().convert(message)}</div>',
script: 'failed(${jsonEncode(message)});',
);
}
String _capEndpoint(String instanceUrl, String siteKey) {
final trimmedInstance = instanceUrl.trim().replaceAll(RegExp(r'/+$'), '');
final trimmedSiteKey = siteKey.trim().replaceAll(RegExp(r'^/+|/+$'), '');
return '$trimmedInstance/$trimmedSiteKey/';
}
String _capWidgetScript(String? assetServer) {
final asset = assetServer?.trim();
if (asset != null && asset.isNotEmpty) {
if (asset.startsWith('http://') || asset.startsWith('https://')) {
return asset;
}
if (asset.toLowerCase() == 'jsdelivr') {
return 'https://cdn.jsdelivr.net/npm/cap-widget';
}
if (asset.toLowerCase() == 'unpkg') {
return 'https://unpkg.com/cap-widget';
}
}
return 'https://cdn.jsdelivr.net/npm/cap-widget';
}
String _js(String input) {
return input
.replaceAll(r'\\', r'\\\\')
.replaceAll("'", r"\\'")
.replaceAll('\\n', r'\\n')
.replaceAll('\\r', r'\\r');
}
@override
Widget build(BuildContext context) {
final title = widget.config.displayName;
return Scaffold(
appBar: AppBar(
title: Text(title),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '刷新验证码',
onPressed: _loadCaptcha,
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(3),
child: _progress > 0 && _progress < 100
? LinearProgressIndicator(value: _progress / 100)
: const SizedBox(height: 3),
),
),
body: Stack(
children: [
WebViewWidget(controller: _controller),
if (_isLoading) const Center(child: CircularProgressIndicator()),
if (_errorMessage != null)
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
child: Container(
width: double.infinity,
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
),
)
else if (_statusText != null)
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
child: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(12),
),
child: Text(_statusText!, textAlign: TextAlign.center),
),
),
),
],
),
);
}
}
@@ -1,12 +1,20 @@
import 'package:cloudreve4_flutter/data/models/login_config_model.dart';
import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart';
import 'package:cloudreve4_flutter/services/captcha_service.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../core/validators/string_validator.dart';
import '../../../services/auth_service.dart';
import '../../../services/server_service.dart';
import '../../widgets/toast_helper.dart';
class ForgotPasswordPage extends StatefulWidget {
const ForgotPasswordPage({super.key});
final LoginConfigModel loginConfig;
const ForgotPasswordPage({
super.key,
this.loginConfig = const LoginConfigModel(),
});
@override
State<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
@@ -18,6 +26,21 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
bool _isLoading = false;
String? _errorMessage;
@override
void initState() {
super.initState();
if (widget.loginConfig.forgetCaptcha) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final server = ServerService.instance.currentServer;
if (server != null) {
CaptchaService.instance.loadCaptcha(server.baseUrl).then((_) {
if (mounted) setState(() {});
});
}
});
}
}
@override
void dispose() {
_emailController.dispose();
@@ -27,14 +50,26 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
Future<void> _sendResetEmail() async {
if (!_formKey.currentState!.validate()) return;
final captcha = CaptchaService.instance;
if (widget.loginConfig.forgetCaptcha && !captcha.isWebCaptchaVerified) {
ToastHelper.failure('请先完成人机验证');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final captchaParams = widget.loginConfig.forgetCaptcha
? captcha.getCaptchaParams()
: <String, String>{};
await AuthService.instance.sendResetPasswordEmail(
email: _emailController.text.trim(),
captcha: captchaParams['captcha'],
ticket: captchaParams['ticket'],
);
if (mounted) {
@@ -43,6 +78,10 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
}
} catch (e) {
if (mounted) {
if (widget.loginConfig.forgetCaptcha) {
await captcha.refreshCaptcha();
setState(() {});
}
setState(() => _errorMessage = e.toString());
}
} finally {
@@ -53,6 +92,7 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final captcha = CaptchaService.instance;
return Scaffold(
appBar: AppBar(title: const Text('忘记密码')),
@@ -91,10 +131,17 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
prefixIcon: Icon(LucideIcons.mail),
),
),
if (widget.loginConfig.forgetCaptcha) ...[
const SizedBox(height: 16),
captcha.buildCaptchaInput(context),
],
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
@@ -121,7 +168,9 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('发送重置邮件'),
),
+117 -44
View File
@@ -5,7 +5,11 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../../core/exceptions/app_exception.dart';
import '../../../core/validators/string_validator.dart';
import '../../../data/models/login_config_model.dart';
import '../../providers/auth_provider.dart';
import '../../../services/api_service.dart';
import '../../../services/auth_service.dart';
import '../../../services/captcha_service.dart';
import '../../../services/server_service.dart';
import '../../../router/app_router.dart';
import 'forgot_password_page.dart';
@@ -27,11 +31,15 @@ class _LoginPageState extends State<LoginPage> {
bool _obscurePassword = true;
bool _rememberMe = false;
bool _isLoading = false;
LoginConfigModel _loginConfig = const LoginConfigModel();
@override
void initState() {
super.initState();
_loadRememberedInfo();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadLoginConfig();
});
}
@override
@@ -57,23 +65,59 @@ class _LoginPageState extends State<LoginPage> {
}
}
Future<void> _loadLoginConfig() async {
final server = ServerService.instance.currentServer;
if (server == null) return;
try {
await ApiService.instance.setBaseUrl(server.baseUrl);
final config = await AuthService.instance.getLoginConfig().timeout(
const Duration(seconds: 10),
);
if (!mounted) return;
setState(() => _loginConfig = config);
if (config.loginCaptcha) {
await CaptchaService.instance.loadCaptcha(server.baseUrl);
if (mounted) setState(() {});
} else {
CaptchaService.instance.clearCaptcha();
}
} catch (_) {}
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
final captcha = CaptchaService.instance;
if (_loginConfig.loginCaptcha && !captcha.isWebCaptchaVerified) {
ToastHelper.failure('请先完成人机验证');
return;
}
final navigator = Navigator.of(context);
final authProvider = Provider.of<AuthProvider>(context, listen: false);
setState(() => _isLoading = true);
try {
final success = await authProvider.passwordLogin(
email: _emailController.text.trim(),
password: _passwordController.text,
rememberMe: _rememberMe,
).timeout(
const Duration(seconds: 5),
onTimeout: () => throw Exception('请求超时'),
);
final captchaParams = _loginConfig.loginCaptcha
? captcha.getCaptchaParams()
: <String, String>{};
final success = await authProvider
.passwordLogin(
email: _emailController.text.trim(),
password: _passwordController.text,
rememberMe: _rememberMe,
captcha: captchaParams['captcha'],
ticket: captchaParams['ticket'],
)
.timeout(
const Duration(seconds: 15),
onTimeout: () => throw Exception('请求超时'),
);
if (mounted) setState(() => _isLoading = false);
@@ -83,6 +127,11 @@ class _LoginPageState extends State<LoginPage> {
await Future.delayed(const Duration(seconds: 1));
if (mounted) navigator.pushReplacementNamed(RouteNames.home);
} else if (mounted) {
if (_loginConfig.loginCaptcha) {
await captcha.refreshCaptcha();
if (mounted) setState(() {});
}
final errorMessage = authProvider.errorMessage;
if (errorMessage != null && errorMessage.isNotEmpty) {
final errorMsg = _parseErrorMessage(errorMessage);
@@ -99,6 +148,11 @@ class _LoginPageState extends State<LoginPage> {
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
if (_loginConfig.loginCaptcha) {
await captcha.refreshCaptcha();
if (mounted) setState(() {});
}
final errorMsg = _parseErrorMessage(e.toString());
ToastHelper.failure(errorMsg);
}
@@ -153,6 +207,7 @@ class _LoginPageState extends State<LoginPage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final captcha = CaptchaService.instance;
return Scaffold(
body: SafeArea(
@@ -168,10 +223,7 @@ class _LoginPageState extends State<LoginPage> {
Center(child: _buildLogo()),
const SizedBox(height: 32),
Center(
child: Text(
'公云存储',
style: theme.textTheme.headlineMedium,
),
child: Text('公云存储', style: theme.textTheme.headlineMedium),
),
const SizedBox(height: 32),
Card(
@@ -196,7 +248,8 @@ class _LoginPageState extends State<LoginPage> {
hintText: '请输入邮箱地址',
prefixIcon: Icon(LucideIcons.mail),
),
onFieldSubmitted: (_) => _focusNode.requestFocus(),
onFieldSubmitted: (_) =>
_focusNode.requestFocus(),
),
const SizedBox(height: 16),
@@ -219,18 +272,27 @@ class _LoginPageState extends State<LoginPage> {
size: 20,
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
setState(
() =>
_obscurePassword = !_obscurePassword,
);
},
),
),
onFieldSubmitted: (_) => _login(),
),
if (_loginConfig.loginCaptcha) ...[
const SizedBox(height: 16),
captcha.buildCaptchaInput(context),
],
const SizedBox(height: 12),
// 记住我
InkWell(
onTap: () => setState(() => _rememberMe = !_rememberMe),
onTap: () =>
setState(() => _rememberMe = !_rememberMe),
borderRadius: BorderRadius.circular(8),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -240,7 +302,9 @@ class _LoginPageState extends State<LoginPage> {
height: 24,
child: Checkbox(
value: _rememberMe,
onChanged: (v) => setState(() => _rememberMe = v ?? false),
onChanged: (v) => setState(
() => _rememberMe = v ?? false,
),
),
),
const SizedBox(width: 8),
@@ -259,7 +323,10 @@ class _LoginPageState extends State<LoginPage> {
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ForgotPasswordPage(),
builder: (context) =>
ForgotPasswordPage(
loginConfig: _loginConfig,
),
),
);
},
@@ -269,7 +336,9 @@ class _LoginPageState extends State<LoginPage> {
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const RegisterPage(),
builder: (context) => RegisterPage(
loginConfig: _loginConfig,
),
),
);
},
@@ -293,7 +362,9 @@ class _LoginPageState extends State<LoginPage> {
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('登录'),
),
@@ -356,17 +427,17 @@ class _TwoFactorDialogState extends State<_TwoFactorDialog>
duration: const Duration(milliseconds: 400),
vsync: this,
);
_shakeAnimation = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0, end: 10), weight: 1),
TweenSequenceItem(tween: Tween(begin: 10, end: -10), weight: 1),
TweenSequenceItem(tween: Tween(begin: -10, end: 8), weight: 1),
TweenSequenceItem(tween: Tween(begin: 8, end: -8), weight: 1),
TweenSequenceItem(tween: Tween(begin: -8, end: 4), weight: 1),
TweenSequenceItem(tween: Tween(begin: 4, end: 0), weight: 1),
]).animate(CurvedAnimation(
parent: _shakeController,
curve: Curves.easeInOut,
));
_shakeAnimation =
TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0, end: 10), weight: 1),
TweenSequenceItem(tween: Tween(begin: 10, end: -10), weight: 1),
TweenSequenceItem(tween: Tween(begin: -10, end: 8), weight: 1),
TweenSequenceItem(tween: Tween(begin: 8, end: -8), weight: 1),
TweenSequenceItem(tween: Tween(begin: -8, end: 4), weight: 1),
TweenSequenceItem(tween: Tween(begin: 4, end: 0), weight: 1),
]).animate(
CurvedAnimation(parent: _shakeController, curve: Curves.easeInOut),
);
_controller.addListener(_onTextChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
@@ -396,16 +467,18 @@ class _TwoFactorDialogState extends State<_TwoFactorDialog>
final authProvider = Provider.of<AuthProvider>(context, listen: false);
try {
final success = await authProvider.twoFactorLogin(
otp: code,
sessionId: widget.sessionId,
email: widget.email,
password: widget.password,
rememberMe: widget.rememberMe,
).timeout(
const Duration(seconds: 5),
onTimeout: () => throw Exception('请求超时'),
);
final success = await authProvider
.twoFactorLogin(
otp: code,
sessionId: widget.sessionId,
email: widget.email,
password: widget.password,
rememberMe: widget.rememberMe,
)
.timeout(
const Duration(seconds: 5),
onTimeout: () => throw Exception('请求超时'),
);
if (!mounted) return;
@@ -493,9 +566,7 @@ class _TwoFactorDialogState extends State<_TwoFactorDialog>
borderRadius: BorderRadius.circular(12),
),
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onSubmitted: (_) => _submit(),
),
),
@@ -503,7 +574,9 @@ class _TwoFactorDialogState extends State<_TwoFactorDialog>
),
actions: [
TextButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
onPressed: _isSubmitting
? null
: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
+65 -8
View File
@@ -1,12 +1,17 @@
import 'package:cloudreve4_flutter/data/models/login_config_model.dart';
import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart';
import 'package:cloudreve4_flutter/services/captcha_service.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../core/validators/string_validator.dart';
import '../../../services/auth_service.dart';
import '../../../services/server_service.dart';
import '../../widgets/toast_helper.dart';
class RegisterPage extends StatefulWidget {
const RegisterPage({super.key});
final LoginConfigModel loginConfig;
const RegisterPage({super.key, this.loginConfig = const LoginConfigModel()});
@override
State<RegisterPage> createState() => _RegisterPageState();
@@ -22,6 +27,21 @@ class _RegisterPageState extends State<RegisterPage> {
bool _isLoading = false;
String? _errorMessage;
@override
void initState() {
super.initState();
if (widget.loginConfig.regCaptcha) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final server = ServerService.instance.currentServer;
if (server != null) {
CaptchaService.instance.loadCaptcha(server.baseUrl).then((_) {
if (mounted) setState(() {});
});
}
});
}
}
@override
void dispose() {
_emailController.dispose();
@@ -38,16 +58,28 @@ class _RegisterPageState extends State<RegisterPage> {
return;
}
final captcha = CaptchaService.instance;
if (widget.loginConfig.regCaptcha && !captcha.isWebCaptchaVerified) {
ToastHelper.failure('请先完成人机验证');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final captchaParams = widget.loginConfig.regCaptcha
? captcha.getCaptchaParams()
: <String, String>{};
final response = await AuthService.instance.signUp(
email: _emailController.text.trim(),
password: _passwordController.text,
language: 'zh-CN',
captcha: captchaParams['captcha'],
ticket: captchaParams['ticket'],
);
if (mounted) {
@@ -58,6 +90,10 @@ class _RegisterPageState extends State<RegisterPage> {
}
} catch (e) {
if (mounted) {
if (widget.loginConfig.regCaptcha) {
await captcha.refreshCaptcha();
setState(() {});
}
setState(() => _errorMessage = e.toString());
}
} finally {
@@ -68,6 +104,7 @@ class _RegisterPageState extends State<RegisterPage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final captcha = CaptchaService.instance;
return Scaffold(
appBar: AppBar(title: const Text('注册')),
@@ -109,10 +146,14 @@ class _RegisterPageState extends State<RegisterPage> {
prefixIcon: const Icon(LucideIcons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? LucideIcons.eye : LucideIcons.eyeOff,
_obscurePassword
? LucideIcons.eye
: LucideIcons.eyeOff,
size: 20,
),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
),
),
),
@@ -122,7 +163,9 @@ class _RegisterPageState extends State<RegisterPage> {
obscureText: _obscureConfirmPassword,
validator: (value) {
if (value == null || value.isEmpty) return '请确认密码';
if (value != _passwordController.text) return '两次输入的密码不一致';
if (value != _passwordController.text) {
return '两次输入的密码不一致';
}
return null;
},
decoration: InputDecoration(
@@ -131,17 +174,29 @@ class _RegisterPageState extends State<RegisterPage> {
prefixIcon: const Icon(LucideIcons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword ? LucideIcons.eye : LucideIcons.eyeOff,
_obscureConfirmPassword
? LucideIcons.eye
: LucideIcons.eyeOff,
size: 20,
),
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
onPressed: () => setState(
() => _obscureConfirmPassword =
!_obscureConfirmPassword,
),
),
),
),
if (widget.loginConfig.regCaptcha) ...[
const SizedBox(height: 16),
captcha.buildCaptchaInput(context),
],
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
@@ -168,7 +223,9 @@ class _RegisterPageState extends State<RegisterPage> {
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('注册'),
),
@@ -0,0 +1,587 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../core/utils/date_utils.dart' as date_utils;
import '../../../core/utils/file_type_utils.dart';
import '../../../data/models/file_model.dart';
import '../../../router/app_router.dart';
import '../../../services/file_service.dart';
import '../../widgets/thumbnail_image.dart';
import '../../widgets/toast_helper.dart';
/// 快捷入口分类页面参数。
///
/// category 对应 Cloudreve V4 文件 URI 查询条件:
/// image / video / audio / document。
class CategoryFilesPageArgs {
final String category;
final String title;
final IconData icon;
final Color color;
const CategoryFilesPageArgs({
required this.category,
required this.title,
required this.icon,
required this.color,
});
factory CategoryFilesPageArgs.fromMap(Map<String, dynamic> map) {
return CategoryFilesPageArgs(
category: map['category'] as String,
title: map['title'] as String,
icon: map['icon'] as IconData? ?? LucideIcons.file,
color: map['color'] as Color? ?? const Color(0xFF64748B),
);
}
}
/// 分类文件瀑布流页面。
///
/// 使用 Cloudreve V4 的文件 URI 查询:
/// cloudreve://my?category=image
/// cloudreve://my?category=video
/// cloudreve://my?category=audio
/// cloudreve://my?category=document
class CategoryFilesPage extends StatefulWidget {
final CategoryFilesPageArgs args;
const CategoryFilesPage({super.key, required this.args});
@override
State<CategoryFilesPage> createState() => _CategoryFilesPageState();
}
class _CategoryFilesPageState extends State<CategoryFilesPage> {
final _fileService = FileService();
final _scrollController = ScrollController();
final List<FileModel> _files = [];
String? _nextPageToken;
String? _contextHint;
bool _isLoading = true;
bool _isLoadingMore = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadFiles(refresh: true);
_scrollController.addListener(_onScroll);
}
@override
void didUpdateWidget(covariant CategoryFilesPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.args.category != widget.args.category) {
_loadFiles(refresh: true);
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_nextPageToken == null || _isLoading || _isLoadingMore) return;
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 320) {
_loadFiles(refresh: false);
}
}
Future<void> _loadFiles({required bool refresh}) async {
if (refresh) {
setState(() {
_isLoading = true;
_isLoadingMore = false;
_errorMessage = null;
_nextPageToken = null;
_contextHint = null;
_files.clear();
});
} else {
setState(() {
_isLoadingMore = true;
_errorMessage = null;
});
}
try {
final response = await _fileService.listFilesByCategory(
category: widget.args.category,
pageSize: 100,
nextPageToken: refresh ? null : _nextPageToken,
);
final filesData = response['files'] as List<dynamic>? ?? const [];
final pagination =
response['pagination'] as Map<String, dynamic>? ?? const {};
final newFiles = filesData
.map((item) => FileModel.fromJson(item as Map<String, dynamic>))
.where((file) => file.isFile)
.toList();
if (!mounted) return;
setState(() {
if (refresh) {
_files
..clear()
..addAll(newFiles);
} else {
final existingIds = _files.map((e) => e.id).toSet();
_files.addAll(
newFiles.where((file) => !existingIds.contains(file.id)),
);
}
_nextPageToken = pagination['next_token'] as String?;
_contextHint = response['context_hint'] as String?;
_isLoading = false;
_isLoadingMore = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = e.toString();
_isLoading = false;
_isLoadingMore = false;
});
}
}
Future<void> _refresh() => _loadFiles(refresh: true);
@override
Widget build(BuildContext context) {
final args = widget.args;
return Scaffold(
appBar: AppBar(
title: Text(args.title),
actions: [
IconButton(
icon: const Icon(LucideIcons.refreshCw),
tooltip: '刷新',
onPressed: _refresh,
),
],
),
body: RefreshIndicator(onRefresh: _refresh, child: _buildBody(context)),
);
}
Widget _buildBody(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null && _files.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(height: MediaQuery.sizeOf(context).height * 0.25),
Icon(
LucideIcons.alertTriangle,
size: 48,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).hintColor),
),
),
const SizedBox(height: 16),
Center(
child: FilledButton.icon(
onPressed: _refresh,
icon: const Icon(LucideIcons.refreshCw, size: 16),
label: const Text('重试'),
),
),
],
);
}
if (_files.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(height: MediaQuery.sizeOf(context).height * 0.25),
Icon(widget.args.icon, size: 52, color: widget.args.color),
const SizedBox(height: 12),
Center(
child: Text(
'没有找到${widget.args.title}',
style: TextStyle(color: Theme.of(context).hintColor),
),
),
],
);
}
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final columnCount = _columnCountForWidth(width);
final spacing = width >= 720 ? 14.0 : 10.0;
final horizontalPadding = width >= 720 ? 16.0 : 10.0;
final columnWidth =
(width - horizontalPadding * 2 - spacing * (columnCount - 1)) /
columnCount;
final columns = List.generate(columnCount, (_) => <FileModel>[]);
final heights = List.generate(columnCount, (_) => 0.0);
for (final file in _files) {
final targetIndex = _indexOfMin(heights);
columns[targetIndex].add(file);
heights[targetIndex] +=
_estimatedTileHeight(file, columnWidth) + spacing;
}
return ListView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(
horizontalPadding,
10,
horizontalPadding,
24,
),
children: [
_buildSummaryHeader(context),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < columnCount; i++) ...[
Expanded(
child: Column(
children: [
for (final file in columns[i])
Padding(
padding: EdgeInsets.only(bottom: spacing),
child: _CategoryFileTile(
file: file,
contextHint: _contextHint,
category: widget.args.category,
accentColor: widget.args.color,
onTap: () => _openFile(context, file),
),
),
],
),
),
if (i != columnCount - 1) SizedBox(width: spacing),
],
],
),
if (_isLoadingMore) ...[
const SizedBox(height: 8),
const Center(child: CircularProgressIndicator()),
] else if (_nextPageToken != null) ...[
const SizedBox(height: 8),
Center(
child: OutlinedButton.icon(
onPressed: () => _loadFiles(refresh: false),
icon: const Icon(LucideIcons.chevronsDown, size: 16),
label: const Text('加载更多'),
),
),
],
],
);
},
);
}
Widget _buildSummaryHeader(BuildContext context) {
final theme = Theme.of(context);
final color = widget.args.color;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: color.withValues(
alpha: theme.brightness == Brightness.dark ? 0.14 : 0.10,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: color.withValues(alpha: 0.20)),
),
child: Row(
children: [
Icon(widget.args.icon, color: color, size: 18),
const SizedBox(width: 8),
Text(
widget.args.title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
Text(
'${_files.length}',
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
),
],
),
);
}
int _columnCountForWidth(double width) {
if (width < 460) return 2;
if (width < 720) return 3;
if (width < 1100) return 4;
return 6;
}
int _indexOfMin(List<double> values) {
var index = 0;
var minValue = values[0];
for (var i = 1; i < values.length; i++) {
if (values[i] < minValue) {
minValue = values[i];
index = i;
}
}
return index;
}
double _estimatedTileHeight(FileModel file, double width) {
final ext = FileTypeUtils.getExtension(file.name);
if (widget.args.category == 'audio') return 112;
if (widget.args.category == 'document') return 124;
if (ext == 'psd' || ext == 'psb') return width * 1.18 + 54;
// 用文件名 hash 做轻微错落,避免完全像普通网格。
final variance = (file.name.hashCode.abs() % 46).toDouble();
return width * 0.92 + 54 + variance;
}
void _openFile(BuildContext context, FileModel file) {
if (FileTypeUtils.isImage(file.name)) {
Navigator.of(context).pushNamed(RouteNames.imagePreview, arguments: file);
} else if (FileTypeUtils.isPdf(file.name)) {
Navigator.of(context).pushNamed(RouteNames.pdfPreview, arguments: file);
} else if (FileTypeUtils.isVideo(file.name)) {
Navigator.of(context).pushNamed(RouteNames.videoPreview, arguments: file);
} else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
} else if (FileTypeUtils.isMarkdown(file.name)) {
Navigator.of(
context,
).pushNamed(RouteNames.markdownPreview, arguments: file);
} else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: file);
} else {
ToastHelper.info(
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
);
}
}
}
class _CategoryFileTile extends StatelessWidget {
final FileModel file;
final String? contextHint;
final String category;
final Color accentColor;
final VoidCallback onTap;
const _CategoryFileTile({
required this.file,
required this.contextHint,
required this.category,
required this.accentColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isMedia = category == 'image' || category == 'video';
final isAudio = category == 'audio';
final isDocument = category == 'document';
final ext = FileTypeUtils.getExtension(file.name);
final isPsd = ext == 'psd' || ext == 'psb';
return Material(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: theme.dividerColor.withValues(alpha: 0.12),
),
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AspectRatio(
aspectRatio: isMedia || isPsd ? 1 : 1.45,
child: Stack(
fit: StackFit.expand,
children: [
ThumbnailImage(
file: file,
contextHint: contextHint,
borderRadius: 0,
),
Positioned(
top: 7,
left: 7,
child: _TypeBadge(
icon: _badgeIcon(),
label: _badgeLabel(ext),
color: accentColor,
compact: isMedia,
),
),
if (category == 'video')
const Center(child: _PlayOverlay()),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
maxLines: isAudio || isDocument ? 2 : 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
Text(
'${FileTypeUtils.getFileTypeDescription(file.name)} · ${date_utils.DateUtils.formatFileSize(file.size)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
),
],
),
),
),
);
}
IconData _badgeIcon() {
switch (category) {
case 'image':
return LucideIcons.image;
case 'video':
return LucideIcons.video;
case 'audio':
return LucideIcons.music;
case 'document':
return LucideIcons.fileText;
default:
return LucideIcons.file;
}
}
String _badgeLabel(String ext) {
if (ext == 'psd' || ext == 'psb') return ext.toUpperCase();
switch (category) {
case 'image':
return '图片';
case 'video':
return '视频';
case 'audio':
return '音乐';
case 'document':
return '文档';
default:
return '文件';
}
}
}
class _TypeBadge extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
final bool compact;
const _TypeBadge({
required this.icon,
required this.label,
required this.color,
this.compact = false,
});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: color.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(9),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 7, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white, size: 12),
if (!compact) ...[
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
],
],
),
),
);
}
}
class _PlayOverlay extends StatelessWidget {
const _PlayOverlay();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
shape: BoxShape.circle,
),
child: const Padding(
padding: EdgeInsets.all(10),
child: Icon(LucideIcons.play, color: Colors.white, size: 22),
),
);
}
}
@@ -1,130 +1,34 @@
import 'package:cloudreve4_flutter/core/constants/quick_access_defaults.dart';
import 'package:cloudreve4_flutter/main.dart' show routeObserver;
import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
import 'package:cloudreve4_flutter/services/storage_service.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
class QuickAccessGrid extends StatefulWidget {
import '../../../../core/constants/quick_access_defaults.dart';
import '../../../../router/app_router.dart';
import '../../../providers/file_manager_provider.dart';
import '../../../providers/navigation_provider.dart';
import '../../../providers/quick_access_provider.dart';
import '../../files/category_files_page.dart';
/// 首页快捷入口。
///
/// 默认四个入口不再跳转到固定文件夹,而是调用 Cloudreve 分类搜索:
/// 图片 / 视频 / 文档 / 音乐。
/// 用户自定义的目录快捷入口会跳转到文件管理器对应路径。
class QuickAccessGrid extends StatelessWidget {
final bool fillHeight;
const QuickAccessGrid({super.key, this.fillHeight = false});
@override
State<QuickAccessGrid> createState() => _QuickAccessGridState();
}
class _QuickAccessGridState extends State<QuickAccessGrid> with RouteAware {
List<QuickAccessConfig> _items = [];
@override
void initState() {
super.initState();
_loadShortcuts();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
}
@override
void didPopNext() {
_loadShortcuts();
}
@override
void dispose() {
routeObserver.unsubscribe(this);
super.dispose();
}
Future<void> _loadShortcuts() async {
// 先尝试 v2 格式
var saved = await StorageService.instance.getString(QuickAccessConfig.storageKey);
if (saved != null && saved.isNotEmpty) {
try {
if (mounted) setState(() => _items = QuickAccessConfig.parseSaved(saved));
return;
} catch (_) {}
}
// 迁移 v1 格式
final v1 = await StorageService.instance.getString('quick_access_shortcuts');
if (v1 != null && v1.isNotEmpty) {
final migrated = QuickAccessConfig.migrateV1(v1);
if (mounted) {
setState(() => _items = migrated);
await StorageService.instance.setString(
QuickAccessConfig.storageKey,
QuickAccessConfig.serialize(migrated),
);
}
return;
}
if (mounted) setState(() => _items = List.from(QuickAccessConfig.defaults));
}
void _navigateTo(String path) {
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
navProvider.setIndex(1);
fileManager.enterFolder(path);
}
Future<void> _editShortcut(int index) async {
final item = _items[index];
final labelController = TextEditingController(text: item.label);
final pathController = TextEditingController(text: item.path);
final result = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('编辑 "${item.label}"'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: labelController,
decoration: const InputDecoration(labelText: '名称'),
),
const SizedBox(height: 12),
TextField(
controller: pathController,
decoration: const InputDecoration(labelText: '目录路径', hintText: '例如: /Images'),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.of(ctx).pop(pathController.text), child: const Text('确定')),
],
),
);
if (result != null && result.isNotEmpty) {
setState(() {
_items[index] = item.copyWith(path: result);
});
await _save();
}
}
Future<void> _save() async {
await StorageService.instance.setString(
QuickAccessConfig.storageKey,
QuickAccessConfig.serialize(_items),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final items = context.select<QuickAccessProvider, List<QuickAccessConfig>>(
(p) => p.items,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: widget.fillHeight ? MainAxisSize.max : MainAxisSize.min,
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 14),
@@ -132,17 +36,22 @@ class _QuickAccessGridState extends State<QuickAccessGrid> with RouteAware {
children: [
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text('快捷入口', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
Text(
'快捷入口',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
..._buildRows(),
..._buildRows(context, items),
],
);
}
List<Widget> _buildRows() {
final total = _items.length;
List<Widget> _buildRows(BuildContext context, List<QuickAccessConfig> items) {
final total = items.length;
if (total == 0) return [];
final maxCols = total > 6 ? 3 : 2;
@@ -159,135 +68,137 @@ class _QuickAccessGridState extends State<QuickAccessGrid> with RouteAware {
if (j > 0) rowItems.add(const SizedBox(width: gap));
rowItems.add(
Expanded(
child: _AccessChip(
item: _items[index],
onTap: () => _navigateTo(_items[index].path),
onLongPress: () => _editShortcut(index),
expanded: true,
fillHeight: widget.fillHeight,
child: _QuickAccessButton(
item: items[index],
onTap: () => _onTap(context, items[index]),
),
),
);
}
final row = Row(children: rowItems);
rows.add(widget.fillHeight ? Expanded(child: row) : row);
rows.add(fillHeight ? Expanded(child: row) : row);
if (i + maxCols < total) {
rows.add(const SizedBox(height: gap));
}
}
return rows;
}
void _onTap(BuildContext context, QuickAccessConfig item) {
if (item.path.startsWith('cloudreve://my?category=')) {
_openCategory(context, item);
} else {
_openDirectory(context, item);
}
}
void _openCategory(BuildContext context, QuickAccessConfig item) {
final args = _argsForItem(item);
Navigator.of(context).pushNamed(RouteNames.categoryFiles, arguments: args);
}
void _openDirectory(BuildContext context, QuickAccessConfig item) {
final dirPath = item.path.startsWith('/') ? item.path : '/${item.path}';
final navProvider = context.read<NavigationProvider>();
final fileManager = context.read<FileManagerProvider>();
navProvider.setIndex(1);
fileManager.enterFolder(dirPath);
}
CategoryFilesPageArgs _argsForItem(QuickAccessConfig item) {
switch (item.id) {
case 'img':
return CategoryFilesPageArgs(
category: 'image',
title: '图片',
icon: LucideIcons.image,
color: item.color,
);
case 'vid':
return CategoryFilesPageArgs(
category: 'video',
title: '视频',
icon: LucideIcons.video,
color: item.color,
);
case 'doc':
return CategoryFilesPageArgs(
category: 'document',
title: '文档',
icon: LucideIcons.fileText,
color: item.color,
);
case 'mus':
return CategoryFilesPageArgs(
category: 'audio',
title: '音乐',
icon: LucideIcons.music,
color: item.color,
);
default:
return CategoryFilesPageArgs(
category: 'document',
title: item.label,
icon: item.icon,
color: item.color,
);
}
}
}
/// 渐变胶囊
class _AccessChip extends StatefulWidget {
class _QuickAccessButton extends StatelessWidget {
final QuickAccessConfig item;
final VoidCallback onTap;
final VoidCallback onLongPress;
final bool expanded;
final bool fillHeight;
const _AccessChip({
required this.item,
required this.onTap,
required this.onLongPress,
this.expanded = false,
this.fillHeight = false,
});
@override
State<_AccessChip> createState() => _AccessChipState();
}
class _AccessChipState extends State<_AccessChip> with SingleTickerProviderStateMixin {
bool _hovered = false;
late final AnimationController _controller;
late final Animation<double> _scaleAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 120),
lowerBound: 0.94,
upperBound: 1.0,
)..value = 1.0;
_scaleAnim = _controller.view;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
const _QuickAccessButton({required this.item, required this.onTap});
@override
Widget build(BuildContext context) {
final item = widget.item;
final gradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [item.color, item.color.darken(0.12)],
);
final isDark = Theme.of(context).brightness == Brightness.dark;
final foreground = isDark ? Colors.white : item.color.darken(0.52);
final child = AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
width: widget.expanded ? double.infinity : null,
height: widget.fillHeight ? double.infinity : null,
alignment: widget.expanded ? Alignment.center : null,
padding: EdgeInsets.symmetric(
horizontal: widget.expanded ? 0 : 18,
vertical: 12,
),
decoration: BoxDecoration(
gradient: gradient,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: item.color.withValues(alpha: _hovered ? 0.4 : 0.18),
blurRadius: _hovered ? 16 : 8,
offset: Offset(0, _hovered ? 6 : 3),
),
],
),
child: Row(
mainAxisSize: widget.expanded ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, size: 18, color: Colors.white),
const SizedBox(width: 8),
Text(
item.label,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 13,
letterSpacing: 0.2,
return Material(
color: item.color.withValues(alpha: isDark ? 0.20 : 0.24),
borderRadius: BorderRadius.circular(18),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
item.color.withValues(alpha: isDark ? 0.34 : 0.72),
item.color.withValues(alpha: isDark ? 0.18 : 0.42),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: item.color.withValues(alpha: 0.28)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, color: foreground, size: 22),
const SizedBox(width: 9),
Flexible(
child: Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontWeight: FontWeight.w800,
fontSize: 14,
),
),
),
],
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
),
);
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTapDown: (_) => _controller.reverse(),
onTapUp: (_) {
_controller.forward();
widget.onTap();
},
onTapCancel: () => _controller.forward(),
onLongPress: widget.onLongPress,
child: AnimatedBuilder(
animation: _scaleAnim,
builder: (context, _) => Transform.scale(scale: _scaleAnim.value, child: child),
),
),
);
@@ -1,56 +1,162 @@
import 'package:cloudreve4_flutter/core/constants/quick_access_defaults.dart';
import 'package:cloudreve4_flutter/services/storage_service.dart';
import 'package:cloudreve4_flutter/presentation/providers/quick_access_provider.dart';
import 'package:cloudreve4_flutter/presentation/widgets/toast_helper.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
class QuickAccessSettingsPage extends StatefulWidget {
class QuickAccessSettingsPage extends StatelessWidget {
const QuickAccessSettingsPage({super.key});
@override
State<QuickAccessSettingsPage> createState() => _QuickAccessSettingsPageState();
}
Widget build(BuildContext context) {
final theme = Theme.of(context);
final provider = context.watch<QuickAccessProvider>();
class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
List<QuickAccessConfig> _items = [];
bool _isLoaded = false;
@override
void initState() {
super.initState();
_loadConfig();
}
Future<void> _loadConfig() async {
var saved = await StorageService.instance.getString(QuickAccessConfig.storageKey);
if (saved != null && saved.isNotEmpty) {
try {
if (mounted) setState(() { _items = QuickAccessConfig.parseSaved(saved); _isLoaded = true; });
return;
} catch (_) {}
}
// 迁移 v1
final v1 = await StorageService.instance.getString('quick_access_shortcuts');
if (v1 != null && v1.isNotEmpty) {
final migrated = QuickAccessConfig.migrateV1(v1);
if (mounted) {
setState(() { _items = migrated; _isLoaded = true; });
await _save();
}
return;
}
if (mounted) setState(() { _items = List.from(QuickAccessConfig.defaults); _isLoaded = true; });
}
Future<void> _save() async {
await StorageService.instance.setString(
QuickAccessConfig.storageKey,
QuickAccessConfig.serialize(_items),
return Scaffold(
appBar: AppBar(title: const Text('快捷入口')),
body: ListView(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'自定义概览页中显示的快捷目录入口。默认入口不可删除,但可编辑路径和调整顺序。',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.hintColor,
),
),
),
if (provider.isLoaded)
...List.generate(provider.items.length, (index) {
final item = provider.items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
item.icon,
size: 20,
color: item.color.darken(0.3),
),
),
title: Row(
children: [
Text(item.label),
if (item.isDefault) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(
alpha: 0.1,
),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'默认',
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
subtitle: Text(
item.path,
style: TextStyle(color: theme.hintColor, fontSize: 12),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(LucideIcons.chevronUp, size: 18),
onPressed: index > 0
? () => provider.moveItem(index, index - 1)
: null,
tooltip: '上移',
visualDensity: VisualDensity.compact,
),
IconButton(
icon: Icon(LucideIcons.chevronDown, size: 18),
onPressed: index < provider.items.length - 1
? () => provider.moveItem(index, index + 1)
: null,
tooltip: '下移',
visualDensity: VisualDensity.compact,
),
IconButton(
icon: Icon(LucideIcons.pencil, size: 16),
onPressed: () => _editItem(context, provider, index),
tooltip: '编辑',
visualDensity: VisualDensity.compact,
),
if (!item.isDefault)
IconButton(
icon: Icon(
LucideIcons.trash2,
size: 16,
color: theme.colorScheme.error,
),
onPressed: () {
provider.deleteItem(index);
ToastHelper.success('快捷入口已删除');
},
tooltip: '删除',
visualDensity: VisualDensity.compact,
),
],
),
),
);
}),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: FilledButton.icon(
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('新增快捷入口'),
onPressed: () => _addItem(context, provider),
),
),
const SizedBox(width: 12),
OutlinedButton.icon(
icon: const Icon(LucideIcons.rotateCcw, size: 16),
label: const Text('恢复默认'),
onPressed: () {
provider.resetToDefaults();
ToastHelper.success('已恢复默认设置');
},
),
],
),
),
const SizedBox(height: 32),
],
),
);
}
Future<void> _editItem(int index) async {
final item = _items[index];
Future<void> _editItem(
BuildContext context,
QuickAccessProvider provider,
int index,
) async {
final item = provider.items[index];
final labelController = TextEditingController(text: item.label);
final pathController = TextEditingController(text: item.path);
@@ -63,19 +169,30 @@ class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
children: [
TextField(
controller: labelController,
decoration: const InputDecoration(labelText: '名称', hintText: '例如: 图片'),
decoration: const InputDecoration(
labelText: '名称',
hintText: '例如: 图片',
),
),
const SizedBox(height: 12),
TextField(
controller: pathController,
decoration: const InputDecoration(labelText: '目录路径', hintText: '例如: /Images'),
decoration: const InputDecoration(
labelText: '目录路径',
hintText: '例如: /Images',
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(_EditResult(labelController.text, pathController.text)),
onPressed: () => Navigator.of(
ctx,
).pop(_EditResult(labelController.text, pathController.text)),
child: const Text('确定'),
),
],
@@ -83,18 +200,21 @@ class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
);
if (result != null) {
setState(() {
_items[index] = item.copyWith(
provider.updateItem(
index,
item.copyWith(
label: result.label.isNotEmpty ? result.label : item.label,
path: result.path.isNotEmpty ? result.path : item.path,
);
});
_save();
),
);
ToastHelper.success('快捷入口已更新');
}
}
Future<void> _addItem() async {
Future<void> _addItem(
BuildContext context,
QuickAccessProvider provider,
) async {
final labelController = TextEditingController();
final pathController = TextEditingController();
IconData selectedIcon = LucideIcons.folder;
@@ -114,48 +234,82 @@ class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
children: [
TextField(
controller: labelController,
decoration: const InputDecoration(labelText: '名称', hintText: '例如: 图片'),
decoration: const InputDecoration(
labelText: '名称',
hintText: '例如: 图片',
),
),
const SizedBox(height: 12),
TextField(
controller: pathController,
decoration: const InputDecoration(labelText: '目录路径', hintText: '例如: /Images'),
decoration: const InputDecoration(
labelText: '目录路径',
hintText: '例如: /Images',
),
),
const SizedBox(height: 16),
Text('图标', style: Theme.of(ctx).textTheme.bodySmall?.copyWith(color: Theme.of(ctx).hintColor)),
Text(
'图标',
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).hintColor,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: QuickAccessConfig.iconPool.map((icon) {
final isSelected = icon.codePoint == selectedIcon.codePoint;
final isSelected =
icon.codePoint == selectedIcon.codePoint;
return GestureDetector(
onTap: () => setDialogState(() => selectedIcon = icon),
onTap: () =>
setDialogState(() => selectedIcon = icon),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isSelected ? Theme.of(ctx).colorScheme.primary.withValues(alpha: 0.15) : null,
color: isSelected
? Theme.of(ctx).colorScheme.primary
.withValues(alpha: 0.15)
: null,
borderRadius: BorderRadius.circular(10),
border: isSelected
? Border.all(color: Theme.of(ctx).colorScheme.primary, width: 2)
: Border.all(color: Theme.of(ctx).dividerColor),
? Border.all(
color: Theme.of(ctx).colorScheme.primary,
width: 2,
)
: Border.all(
color: Theme.of(ctx).dividerColor,
),
),
child: Icon(
icon,
size: 20,
color: isSelected
? Theme.of(ctx).colorScheme.primary
: Theme.of(ctx).hintColor,
),
child: Icon(icon, size: 20, color: isSelected ? Theme.of(ctx).colorScheme.primary : Theme.of(ctx).hintColor),
),
);
}).toList(),
),
const SizedBox(height: 16),
Text('颜色', style: Theme.of(ctx).textTheme.bodySmall?.copyWith(color: Theme.of(ctx).hintColor)),
Text(
'颜色',
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).hintColor,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: QuickAccessConfig.colorPool.map((color) {
final isSelected = color.toARGB32() == selectedColor.toARGB32();
final isSelected =
color.toARGB32() == selectedColor.toARGB32();
return GestureDetector(
onTap: () => setDialogState(() => selectedColor = color),
onTap: () =>
setDialogState(() => selectedColor = color),
child: Container(
width: 36,
height: 36,
@@ -163,11 +317,18 @@ class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
color: color.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(10),
border: isSelected
? Border.all(color: color.darken(0.2), width: 3)
? Border.all(
color: color.darken(0.2),
width: 3,
)
: null,
),
child: isSelected
? Icon(LucideIcons.check, size: 18, color: color.darken(0.3))
? Icon(
LucideIcons.check,
size: 18,
color: color.darken(0.3),
)
: null,
),
);
@@ -177,14 +338,19 @@ class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
),
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(_AddResult(
labelController.text,
pathController.text,
selectedIcon,
selectedColor,
)),
onPressed: () => Navigator.of(ctx).pop(
_AddResult(
labelController.text,
pathController.text,
selectedIcon,
selectedColor,
),
),
child: const Text('添加'),
),
],
@@ -195,152 +361,18 @@ class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
);
if (result != null && result.label.isNotEmpty && result.path.isNotEmpty) {
setState(() {
_items.add(QuickAccessConfig(
provider.addItem(
QuickAccessConfig(
id: 'custom_${DateTime.now().millisecondsSinceEpoch}',
label: result.label,
icon: result.icon,
path: result.path,
color: result.color,
));
});
_save();
),
);
ToastHelper.success('快捷入口已添加');
}
}
void _moveItem(int from, int to) {
if (from < 0 || from >= _items.length || to < 0 || to >= _items.length || from == to) return;
setState(() {
final item = _items.removeAt(from);
_items.insert(to, item);
});
_save();
}
void _deleteItem(int index) {
if (_items[index].isDefault) return;
setState(() {
_items.removeAt(index);
});
_save();
ToastHelper.success('快捷入口已删除');
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('快捷入口')),
body: ListView(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'自定义概览页中显示的快捷目录入口。默认入口不可删除,但可编辑路径和调整顺序。',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
),
),
if (_isLoaded)
...List.generate(_items.length, (index) {
final item = _items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: Icon(item.icon, size: 20, color: item.color.darken(0.3)),
),
title: Row(
children: [
Text(item.label),
if (item.isDefault) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text('默认', style: TextStyle(fontSize: 10, color: theme.colorScheme.primary, fontWeight: FontWeight.w600)),
),
],
],
),
subtitle: Text(item.path, style: TextStyle(color: theme.hintColor, fontSize: 12)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 上移
IconButton(
icon: Icon(LucideIcons.chevronUp, size: 18),
onPressed: index > 0 ? () => _moveItem(index, index - 1) : null,
tooltip: '上移',
visualDensity: VisualDensity.compact,
),
// 下移
IconButton(
icon: Icon(LucideIcons.chevronDown, size: 18),
onPressed: index < _items.length - 1 ? () => _moveItem(index, index + 1) : null,
tooltip: '下移',
visualDensity: VisualDensity.compact,
),
// 编辑
IconButton(
icon: Icon(LucideIcons.pencil, size: 16),
onPressed: () => _editItem(index),
tooltip: '编辑',
visualDensity: VisualDensity.compact,
),
// 删除(默认不可删)
if (!item.isDefault)
IconButton(
icon: Icon(LucideIcons.trash2, size: 16, color: theme.colorScheme.error),
onPressed: () => _deleteItem(index),
tooltip: '删除',
visualDensity: VisualDensity.compact,
),
],
),
),
);
}),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: FilledButton.icon(
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('新增快捷入口'),
onPressed: _addItem,
),
),
const SizedBox(width: 12),
OutlinedButton.icon(
icon: const Icon(LucideIcons.rotateCcw, size: 16),
label: const Text('恢复默认'),
onPressed: () {
setState(() { _items = List.from(QuickAccessConfig.defaults); });
_save();
ToastHelper.success('已恢复默认设置');
},
),
],
),
),
const SizedBox(height: 32),
],
),
);
}
}
class _EditResult {
@@ -121,6 +121,8 @@ class AuthProvider extends ChangeNotifier {
required String email,
required String password,
bool rememberMe = false,
String? captcha,
String? ticket,
}) async {
try {
setState(AuthState.loading);
@@ -141,6 +143,8 @@ class AuthProvider extends ChangeNotifier {
final response = await AuthService.instance.passwordLogin(
email: email,
password: password,
captcha: captcha,
ticket: ticket,
);
AppLogger.d('AuthProvider 登录成功: $response');
// 保存登录信息到当前服务器(包含完整 user 和 token
@@ -257,7 +261,9 @@ class AuthProvider extends ChangeNotifier {
}
// 调用刷新 token 接口
final newToken = await AuthService.instance.refreshToken(currentToken.refreshToken);
final newToken = await AuthService.instance.refreshToken(
currentToken.refreshToken,
);
// 更新用户信息中的 token
final updatedUser = _user!.copyWith(token: newToken);
@@ -0,0 +1,91 @@
import 'package:flutter/foundation.dart';
import '../../core/constants/quick_access_defaults.dart';
import '../../services/storage_service.dart';
class QuickAccessProvider extends ChangeNotifier {
List<QuickAccessConfig> _items = List.from(QuickAccessConfig.defaults);
bool _isLoaded = false;
List<QuickAccessConfig> get items => _items;
bool get isLoaded => _isLoaded;
Future<void> load() async {
if (_isLoaded) return;
final saved = await StorageService.instance.getString(
QuickAccessConfig.storageKey,
);
if (saved != null && saved.isNotEmpty) {
try {
_items = QuickAccessConfig.parseSaved(saved);
_isLoaded = true;
notifyListeners();
return;
} catch (_) {}
}
// 迁移 v1
final v1 = await StorageService.instance.getString(
'quick_access_shortcuts',
);
if (v1 != null && v1.isNotEmpty) {
_items = QuickAccessConfig.migrateV1(v1);
_isLoaded = true;
await _save();
notifyListeners();
return;
}
_items = List.from(QuickAccessConfig.defaults);
_isLoaded = true;
notifyListeners();
}
Future<void> addItem(QuickAccessConfig item) async {
_items = [..._items, item];
await _save();
notifyListeners();
}
Future<void> updateItem(int index, QuickAccessConfig item) async {
_items = [..._items]..[index] = item;
await _save();
notifyListeners();
}
Future<void> moveItem(int from, int to) async {
if (from < 0 ||
from >= _items.length ||
to < 0 ||
to >= _items.length ||
from == to) {
return;
}
final list = [..._items];
final item = list.removeAt(from);
list.insert(to, item);
_items = list;
await _save();
notifyListeners();
}
Future<void> deleteItem(int index) async {
_items = [..._items]..removeAt(index);
await _save();
notifyListeners();
}
Future<void> resetToDefaults() async {
_items = List.from(QuickAccessConfig.defaults);
await _save();
notifyListeners();
}
Future<void> _save() async {
await StorageService.instance.setString(
QuickAccessConfig.storageKey,
QuickAccessConfig.serialize(_items),
);
}
}
+29 -6
View File
@@ -25,7 +25,10 @@ class FileBreadcrumb extends StatelessWidget {
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
border: Border(
top: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5), width: 1),
top: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.5),
width: 1,
),
),
),
child: SingleChildScrollView(
@@ -43,15 +46,20 @@ class FileBreadcrumb extends StatelessWidget {
for (int i = 0; i < pathParts.length; i++) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Icon(LucideIcons.chevronRight, size: 16, color: theme.hintColor.withValues(alpha: 0.5)),
child: Icon(
LucideIcons.chevronRight,
size: 16,
color: theme.hintColor.withValues(alpha: 0.5),
),
),
_buildBreadcrumbItem(
context,
name: pathParts[i],
name: _decodePathSegment(pathParts[i]),
path: '/${pathParts.sublist(0, i + 1).join('/')}',
icon: null,
primaryColor: colorScheme.primary,
onTap: () => onPathTap('/${pathParts.sublist(0, i + 1).join('/')}'),
onTap: () =>
onPathTap('/${pathParts.sublist(0, i + 1).join('/')}'),
),
],
],
@@ -60,6 +68,22 @@ class FileBreadcrumb extends StatelessWidget {
);
}
String _decodePathSegment(String value) {
var decoded = value;
for (var i = 0; i < 5; i++) {
try {
final next = Uri.decodeComponent(decoded);
if (next == decoded) break;
decoded = next;
} on FormatException {
break;
}
}
return decoded;
}
Widget _buildBreadcrumbItem(
BuildContext context, {
required String name,
@@ -81,8 +105,7 @@ class FileBreadcrumb extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Row(
children: [
if (icon != null)
Icon(icon, size: 16, color: primaryColor),
if (icon != null) Icon(icon, size: 16, color: primaryColor),
if (icon != null) const SizedBox(width: 5),
Text(
name,