/// 登录页(UI-03 页面 01 重设计,图01)。 /// /// 结构:摄影拼贴顶部(渐变占位)→ 品牌 → 主标题(l10n `loginTitle`) /// → 简短说明 → 账号/密码(显示/隐藏切换)→ Sign in → 管理员提供账号 /// 说明;右上角语言选择(复用 [showLanguageSheet])。 /// /// 拼贴高度随可用高度自适应(正常 25%-32%;短屏/键盘开启时压缩), /// 整页可滚动;提交期间禁用防重复,失败在表单附近以 StateBadge 展示 /// 错误码本地化文案。无注册/飞书/找回密码入口。 library; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/api_client.dart'; import '../core/app_exception.dart'; import '../core/error_l10n.dart'; import '../features/uploads/engine_scope.dart'; import '../l10n/app_localizations.dart'; import '../ui/components/language_sheet.dart'; import '../ui/components/state_badge.dart'; import '../ui/tokens.dart'; /// 登录页。 class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @override ConsumerState createState() => _LoginPageState(); } class _LoginPageState extends ConsumerState { final _formKey = GlobalKey(); final _accountController = TextEditingController(); final _passwordController = TextEditingController(); bool _submitting = false; bool _obscurePassword = true; /// 最近一次提交失败文案(内联 StateBadge 展示;null = 无错误)。 String? _errorText; @override void dispose() { _accountController.dispose(); _passwordController.dispose(); super.dispose(); } Future _submit() async { if (!_formKey.currentState!.validate()) return; setState(() { _submitting = true; _errorText = null; }); final l10n = AppLocalizations.of(context); final navigator = Navigator.of(context); try { await ref.read(apiClientProvider).login( _accountController.text.trim(), _passwordController.text, ); // 登录成功:刷新会话账号 → 引擎以新账号 scope start(Task 9 接线)。 await ref.read(sessionAccountProvider.notifier).refresh(); navigator.pushReplacementNamed('/home'); } on AppException catch (error) { // 429(AUTH_RATE_LIMITED)等文案经错误码映射;登录页的 // AUTH_INVALID_CREDENTIALS 即「账号或密码错误」。 if (mounted) setState(() => _errorText = l10n.messageForError(error)); } on Object { if (mounted) setState(() => _errorText = l10n.errorGeneric); } finally { if (mounted) setState(() => _submitting = false); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppLocalizations.of(context); return Scaffold( // 顶部拼贴通到状态栏后(无 AppBar);键盘由 Scaffold 默认压缩 body。 body: SafeArea( top: false, child: LayoutBuilder( builder: (context, constraints) { final keyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0; // 正常高度 28%(25%-32% 区间);键盘/短屏压缩至 10%。 final headerHeight = (constraints.maxHeight * (keyboardOpen ? 0.10 : 0.28)) .clamp(96.0, 320.0); return SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ _CollageHeader(height: headerHeight), Padding( padding: const EdgeInsets.fromLTRB( kSpace24, kSpace24, kSpace24, kSpace24, ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 460), child: Form( key: _formKey, autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _BrandRow(), const SizedBox(height: kSpace16), Text( l10n.loginTitle, // UI06:app-preview .login-content h1——27px/700。 style: theme.textTheme.titleLarge?.copyWith( fontSize: 27, fontWeight: FontWeight.w700, letterSpacing: -0.5, ), ), const SizedBox(height: kSpace8), Text( l10n.loginSubtitle, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: kSpace24), TextFormField( controller: _accountController, autofillHints: const [ AutofillHints.username, ], textInputAction: TextInputAction.next, decoration: InputDecoration( labelText: l10n.username, ), validator: (value) => (value == null || value.trim().isEmpty) ? l10n.fieldRequired : null, ), const SizedBox(height: kSpace16), TextFormField( controller: _passwordController, obscureText: _obscurePassword, enableSuggestions: false, autocorrect: false, autofillHints: const [ AutofillHints.password, ], textInputAction: TextInputAction.done, onFieldSubmitted: (_) => _submitting ? null : _submit(), decoration: InputDecoration( labelText: l10n.password, suffixIcon: Tooltip( message: _obscurePassword ? l10n.loginShowPassword : l10n.loginHidePassword, child: IconButton( onPressed: () => setState( () => _obscurePassword = !_obscurePassword, ), icon: Icon( _obscurePassword ? Icons.visibility_outlined : Icons .visibility_off_outlined, size: 24, ), ), ), ), validator: (value) => (value == null || value.isEmpty) ? l10n.fieldRequired : null, ), if (_errorText != null) ...[ const SizedBox(height: kSpace16), StateBadge( variant: BadgeVariant.error, label: _errorText!, ), ], const SizedBox(height: kSpace24), FilledButton( onPressed: _submitting ? null : _submit, child: _submitting ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2, ), ) : Text(l10n.login), ), const SizedBox(height: kSpace12), Text( l10n.loginAskAdmin, textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ), ), ], ), ), ); }, ), ), ); } } /// 摄影拼贴顶部(渐变 + 半透明照片色块占位)+ 右上语言选择入口。 /// /// TODO(brand-asset): 换用项目正式品牌/摄影素材(UI-03 §3.1:不得裁切 /// 设计板图片作背景;当前为可替换的中性渐变占位,不依赖远程图库)。 class _CollageHeader extends StatelessWidget { const _CollageHeader({required this.height}); /// 占位高度(调用方按可用高度/键盘状态计算)。 final double height; /// 拼贴占位(测试定位用)。 static const Key collageKey = Key('login-collage'); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final soft = Color.lerp(scheme.primary, scheme.surface, 0.55); return SizedBox( height: height, child: Stack( children: [ // 渐变拼贴占位(浅深色均由语义色派生,不硬编码白 token)。 Container( key: collageKey, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [scheme.primary, soft!], ), ), ), // 半透明「照片」色块(模拟拼贴节奏的装饰,可整体替换)。 Positioned( left: 28, top: 24, child: _tile(120, 84, Colors.white.withValues(alpha: 0.16), rotate: -0.08), ), Positioned( right: 44, top: 40, child: _tile(132, 92, Colors.white.withValues(alpha: 0.12), rotate: 0.06), ), Positioned( left: 96, bottom: 18, child: _tile(150, 72, Colors.white.withValues(alpha: 0.10), rotate: -0.04), ), // 右上语言选择(浮于拼贴之上)。 Positioned( top: MediaQuery.paddingOf(context).top + 8, right: kSpace12, child: const _LanguagePill(), ), ], ), ); } Widget _tile(double width, double height, Color color, {double rotate = 0}) { return Transform.rotate( angle: rotate, child: Container( width: width, height: height, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(kRadiusInput), ), ), ); } } /// 品牌行:小 logo 点 + 产品名(沿用现有 appName,不新增 slogan)。 class _BrandRow extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); return Row( children: [ Container( width: 32, height: 32, decoration: BoxDecoration( color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(10), ), child: Icon( Icons.photo_camera_outlined, size: 18, color: theme.colorScheme.onPrimary, ), ), const SizedBox(width: kSpace12), Text( AppLocalizations.of(context).appName, style: theme.textTheme.titleSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ); } } /// 语言选择胶囊(显示当前生效语言简称,点开共享语言面板)。 class _LanguagePill extends StatelessWidget { const _LanguagePill(); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppLocalizations.of(context); final isZh = Localizations.maybeLocaleOf(context)?.languageCode == 'zh'; return Semantics( label: l10n.language, button: true, child: Material( color: theme.colorScheme.surface.withValues(alpha: 0.92), borderRadius: BorderRadius.circular(20), child: InkWell( onTap: () => showLanguageSheet(context), borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.symmetric( horizontal: kSpace12, vertical: kSpace8, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.language, size: 18, color: theme.colorScheme.onSurface, ), const SizedBox(width: kSpace4), Text( isZh ? '中文' : 'EN', style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.onSurface, ), ), Icon( Icons.arrow_drop_down, size: 18, color: theme.colorScheme.onSurfaceVariant, ), ], ), ), ), ), ); } }