Files
photo-live/apps/mobile/lib/pages/login_page.dart
T
2026-09-22 00:28:39 +08:00

391 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,
style: theme.textTheme.titleLarge,
),
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,
),
],
),
),
),
),
);
}
}