Files
photo-live/apps/mobile/lib/pages/login_page.dart
T
浅念 6074fc8ed3 feat(ui06): App line A0-A7 — nav pill removal, light segments, bare summary, token alignment to app-preview
- tokens:次文字 #69778E、soft/card-border/seg 色组、输入圆角 10(kRadiusField)
- theme:导航 indicatorColor 透明+显式选中蓝;卡片去阴影改 1px #EDF0F5 边框;输入边框同步
- 新组件 SegmentTabs(#EDF1F6 轨道/选中实蓝/light 浅蓝变体/null 无高亮)替换 M3 SegmentedButton
- 上传中心:摘要去白卡直排+主数字 32/40;completed 次级筛选无高亮语义保留
- 相册卡 scrim 30% 起 #0C1728E8;导航角标红底白字白边;登录主标题 27/700
- theme_test 三处旧规格断言随 UI06 值更新;397/397 全绿、analyze 0 issues
- 真机证据:MuMu + 测试服务器 API,登录/相册/详情/上传 4 页截图确认达标
- 登记偏差:语义色保持对比度合规深色版;视口矩阵随 G2;详见验收记录 §6

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 08:09:20 +08:00

396 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 登录页(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<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>();
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<void> _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,
),
],
),
),
),
),
);
}
}