import 'package:flutter/material.dart'; import '../config/responsive.dart'; import '../config/strings.dart'; import '../models/session_model.dart'; import '../models/session_share.dart'; import '../services/file_picker_service.dart'; import '../services/qr_service.dart'; import '../services/title_api_service.dart'; import '../widgets/app_notice.dart'; import 'home_page.dart'; import 'settings_page.dart'; /// 登录页:粘贴 / 识别 QR 令牌,走 Aime 登录后进入主页。 /// /// 也接受主页导出的「连接信息」(实验性):那是一串 Base64,直接续上原有会话。 class LoginPage extends StatefulWidget { const LoginPage({super.key}); @override State createState() => _LoginPageState(); } class _LoginPageState extends State { final _qrController = TextEditingController(); String _rawInput = ''; SessionShare? _share; bool _loading = false; bool _forcePreviewApi = false; @override void dispose() { _qrController.dispose(); super.dispose(); } void _onQRContentChanged(String value) { final text = value.trim(); setState(() { _rawInput = text; _share = SessionShare.tryDecode(text); }); } Future _onUploadQR() async { try { final bytes = await pickImageBytes(); if (bytes == null) return; final result = await decodeQRFromBytes(bytes); if (result != null && result.isNotEmpty) { if (!mounted) return; _qrController.text = result; _onQRContentChanged(result); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text(AppStrings.qrScanSuccess)), ); } } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text(AppStrings.qrScanFailed)), ); } } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${AppStrings.qrScanError}: $e')), ); } } } Future _onLogin() async { if (_rawInput.isEmpty) return; setState(() => _loading = true); try { final result = await SessionModel.instance.loginWithQr(_rawInput); if (!mounted) return; if (result.success) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => HomePage( userId: result.userId, token: result.token, forcePreviewApi: _forcePreviewApi, ), ), ); } else { context.showSnack( '${AppStrings.loginFailed}:' '${result.message ?? 'errorID=${result.errorId}'}', ); } } on FormatException catch (e) { // 令牌格式不对 / 没配 Keychip:登录请求根本没发出去,要说清楚是哪一种。 if (mounted) { context.showSnack('${AppStrings.qrTokenUnrecognized}:${e.message}'); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${AppStrings.requestFailed}: ${describeApiError(e)}'), ), ); } } finally { if (mounted) { setState(() => _loading = false); } } } String _restoreMessage(SessionRestoreResult result) { if (!result.reusedCookie) return AppStrings.sessionRestoreToken; return result.loggedIn ? AppStrings.sessionRestoreCookie : AppStrings.sessionRestoreReadOnly; } /// 实验性:用主页导出的连接信息恢复会话。带可用 JSESSIONID 就直接续上那次登录, /// 否则退回用令牌重新登录。 Future _onRestoreSession() async { final share = _share; if (share == null) return; setState(() => _loading = true); try { final result = await SessionModel.instance.restoreFromShare(share); if (!mounted) return; context.showSnack(_restoreMessage(result)); Navigator.of(context).push( MaterialPageRoute( builder: (_) => HomePage( userId: share.userId, token: share.token, restored: result, ), ), ); } catch (e) { if (mounted) { context.showSnack( '${AppStrings.sessionRestoreFailed}: ${describeApiError(e)}', ); } } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final isRestore = _share != null; return Scaffold( appBar: AppBar( title: const Text(AppStrings.appTitle), actions: [ IconButton( icon: const Icon(Icons.settings), tooltip: AppStrings.settingsTooltip, onPressed: () { Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => const SettingsPage())); }, ), ], ), body: Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), child: responsiveBody( context, child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.qr_code_scanner, size: 64, color: theme.colorScheme.primary, ), const SizedBox(height: 16), Text( AppStrings.appTitle, style: theme.textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.primary, ), ), const SizedBox(height: 48), Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide( color: theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.key, size: 18, color: theme.colorScheme.primary, ), const SizedBox(width: 8), Text( AppStrings.qrCodeToken, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 12), TextField( controller: _qrController, maxLines: 3, onChanged: _onQRContentChanged, style: theme.textTheme.bodyMedium?.copyWith( fontFamily: 'monospace', letterSpacing: 0.5, ), decoration: InputDecoration( hintText: AppStrings.qrHint, border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), ), contentPadding: const EdgeInsets.all(14), ), ), ], ), ), ), const SizedBox(height: 28), if (isRestore) const AppNotice( AppStrings.sessionShareDetected, icon: Icons.science_outlined, ), Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: _loading ? null : _onUploadQR, icon: const Icon(Icons.image, size: 20), label: const Text(AppStrings.uploadQR), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ), ), const SizedBox(width: 16), Expanded( child: FilledButton.icon( onPressed: isRestore ? (_loading ? null : _onRestoreSession) : (_rawInput.isNotEmpty && !_loading) ? _onLogin : null, icon: _loading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : Icon( isRestore ? Icons.link : Icons.arrow_forward, size: 20, ), label: Text( isRestore ? (_loading ? AppStrings.restoringSession : AppStrings.restoreSession) : (_loading ? AppStrings.loggingIn : AppStrings.login), ), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ), ), ], ), const SizedBox(height: 16), if (!isRestore) CheckboxListTile( value: _forcePreviewApi, onChanged: (v) => setState(() => _forcePreviewApi = v ?? false), title: const Text(AppStrings.forcePreviewApi), controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, dense: true, ), ], ), ), ), ), ); } }