feat(auth): 添加登录/注册页面及令牌恢复流程
- 新增 SplashPage(启动页)和 LoginPage(登录页),支持路由跳转 - 扩展 AuthState,增加 submitting/alert/busy 状态及消费机制 - AuthController 增加 checkLoginState/register/showAlert 等方法 - 删除旧模型文件(binding_item, token_response, verify_response) - 删除旧版 LoginPage,迁移至 features/auth 目录 - 更新本地化字符串,增加注册相关文案 - 调整默认 API 地址为本地开发环境
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
|
||||
|
||||
import '../../domain/auth_state.dart';
|
||||
import '../auth_controller.dart';
|
||||
|
||||
/// 独立登录页,对应 Android 端 `activity/login/LoginActivity`。
|
||||
///
|
||||
/// - 用户名 / 密码双输入,均非空才允许提交;
|
||||
/// - 支持注册(`POST /api/auth/register`),注册成功后停留在本页提示登录;
|
||||
/// - 登录成功(`POST /api/auth/login`)后跳转设置页 `/`;
|
||||
/// - 提交过程中禁用输入与按钮,展示加载指示。
|
||||
///
|
||||
/// 业务逻辑全部位于 [AuthController],本页仅消费状态。
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key, this.message});
|
||||
|
||||
/// 从其他页面因登录失效跳转过来时展示的提示文案。
|
||||
final String? message;
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _passwordFocus = FocusNode();
|
||||
|
||||
/// 本地校验错误(未触达网络层时的即时提示)。
|
||||
String? _localError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final notifier = ref.read(authControllerProvider.notifier);
|
||||
final message = widget.message;
|
||||
if (message != null && message.isNotEmpty) {
|
||||
notifier.showAlert(message);
|
||||
}
|
||||
// 回显上次登录的用户名(对应 LoginViewModel.lastUsername())。
|
||||
final username = ref.read(authControllerProvider).valueOrNull?.username;
|
||||
if (username != null && username.isNotEmpty) {
|
||||
_usernameController.text = username;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_passwordFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 读取并校验输入;返回 null 表示校验未通过(错误已展示)。
|
||||
({String username, String password})? _readInput(AppLocalizations l10n) {
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
if (username.isEmpty || password.isEmpty) {
|
||||
setState(() => _localError = l10n.usernamePasswordRequired);
|
||||
return null;
|
||||
}
|
||||
setState(() => _localError = null);
|
||||
return (username: username, password: password);
|
||||
}
|
||||
|
||||
Future<void> _login(AppLocalizations l10n) async {
|
||||
final input = _readInput(l10n);
|
||||
if (input == null) return;
|
||||
|
||||
final ok = await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.login(input.username, input.password);
|
||||
if (!mounted || !ok) return;
|
||||
context.go('/');
|
||||
}
|
||||
|
||||
Future<void> _register(AppLocalizations l10n) async {
|
||||
final input = _readInput(l10n);
|
||||
if (input == null) return;
|
||||
|
||||
await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.register(input.username, input.password);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final authState =
|
||||
ref.watch(authControllerProvider).valueOrNull ?? const AuthState();
|
||||
final busy = authState.busy;
|
||||
|
||||
// 一次性提示:展示后立即消费,避免重建时重复弹出。
|
||||
ref.listen(authControllerProvider, (previous, next) {
|
||||
final alert = next.valueOrNull?.alert;
|
||||
if (alert == null || alert.isEmpty) return;
|
||||
ref.read(authControllerProvider.notifier).consumeAlert();
|
||||
_showAlert(context, alert, l10n);
|
||||
});
|
||||
|
||||
final error = _localError ?? authState.error;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(middle: Text(l10n.login)),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_LoginHeader(title: l10n.appTitle),
|
||||
const SizedBox(height: 32),
|
||||
_LoginField(
|
||||
controller: _usernameController,
|
||||
enabled: !busy,
|
||||
placeholder: l10n.username,
|
||||
icon: CupertinoIcons.person,
|
||||
textInputAction: TextInputAction.next,
|
||||
onSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_LoginField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
enabled: !busy,
|
||||
placeholder: l10n.password,
|
||||
icon: CupertinoIcons.lock,
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => busy ? null : _login(l10n),
|
||||
),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_ErrorText(message: error),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
CupertinoButton.filled(
|
||||
onPressed: busy ? null : () => _login(l10n),
|
||||
child: busy
|
||||
? const CupertinoActivityIndicator(
|
||||
color: CupertinoColors.white,
|
||||
)
|
||||
: Text(l10n.login),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoButton(
|
||||
onPressed: busy ? null : () => _register(l10n),
|
||||
child: Text(l10n.registerAccount),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void _showAlert(
|
||||
BuildContext context,
|
||||
String message,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(l10n.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 页头:图标 + 标题 + 副标题。
|
||||
class _LoginHeader extends StatelessWidget {
|
||||
const _LoginHeader({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.device_phone_portrait,
|
||||
size: 56,
|
||||
color: CupertinoColors.activeBlue,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.loginSubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 带前置图标的登录输入框。
|
||||
class _LoginField extends StatelessWidget {
|
||||
const _LoginField({
|
||||
required this.controller,
|
||||
required this.enabled,
|
||||
required this.placeholder,
|
||||
required this.icon,
|
||||
required this.textInputAction,
|
||||
required this.onSubmitted,
|
||||
this.focusNode,
|
||||
this.obscureText = false,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode? focusNode;
|
||||
final bool enabled;
|
||||
final String placeholder;
|
||||
final IconData icon;
|
||||
final bool obscureText;
|
||||
final TextInputAction textInputAction;
|
||||
final ValueChanged<String> onSubmitted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoTextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
enabled: enabled,
|
||||
placeholder: placeholder,
|
||||
obscureText: obscureText,
|
||||
autocorrect: false,
|
||||
textInputAction: textInputAction,
|
||||
onSubmitted: onSubmitted,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
||||
prefix: Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Icon(icon, size: 20, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 表单错误提示文案。
|
||||
class _ErrorText extends StatelessWidget {
|
||||
const _ErrorText({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: CupertinoColors.destructiveRed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
|
||||
|
||||
import '../auth_controller.dart';
|
||||
|
||||
/// 启动页,对应 Android 端 `activity/splash/SplashActivity`。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 由 [AuthController] 从安全存储恢复令牌;
|
||||
/// 2. 调用 `checkLoginState()` 校验(失效时用 refreshToken 续期后重试一次);
|
||||
/// 3. 校验通过跳转设置页(`/`),否则清空令牌并跳转登录页(`/login`)。
|
||||
class SplashPage extends ConsumerStatefulWidget {
|
||||
const SplashPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SplashPage> createState() => _SplashPageState();
|
||||
}
|
||||
|
||||
class _SplashPageState extends ConsumerState<SplashPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// build() 期间不能直接改动 provider 状态,推迟到首帧之后。
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
final loggedIn =
|
||||
await ref.read(authControllerProvider.notifier).checkLoginState();
|
||||
if (!mounted) return;
|
||||
context.go(loggedIn ? '/' : '/login');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return CupertinoPageScaffold(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.device_phone_portrait,
|
||||
size: 64,
|
||||
color: CupertinoColors.activeBlue,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
l10n.appTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const CupertinoActivityIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user