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:
TongTongStudio
2026-08-03 16:47:08 +08:00
parent bb488d216d
commit 79cffcb041
20 changed files with 642 additions and 451 deletions

View File

@@ -1,5 +1,6 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/network/api_exception.dart';
import '../data/auth_providers.dart';
import '../domain/auth_repository.dart';
import '../domain/auth_state.dart';
@@ -7,6 +8,9 @@ import '../domain/auth_state.dart';
part 'auth_controller.g.dart';
/// 登录状态控制器。
///
/// 会话级持有(`keepAlive`),负责令牌恢复、登录 / 注册 / 退出,
/// 以及启动期的登录态校验verify + refresh 重试)。
@Riverpod(keepAlive: true)
class AuthController extends _$AuthController {
AuthRepository get _repository => ref.read(authRepositoryProvider);
@@ -19,38 +23,143 @@ class AuthController extends _$AuthController {
repository.accessToken != null && await repository.hasRefreshToken();
return AuthState(
loggedIn: loggedIn,
restoring: false,
username: await repository.getUsername(),
);
}
/// 登录:成功返回 true失败返回 false 并写入错误信息。
AuthState get _current => state.valueOrNull ?? const AuthState();
/// 校验本地令牌是否仍然有效(对应启动页流程)。
///
/// 无令牌直接返回 false`verify` 失败时尝试 `refresh` 后重试一次,
/// 仍失败则清空令牌。
Future<bool> checkLoginState() async {
final repository = _repository;
await repository.restore();
if (repository.accessToken == null) {
if (!await repository.hasRefreshToken()) {
await _reset();
return false;
}
}
if (await _verifyQuietly()) {
state = AsyncData(
_current.copyWith(
loggedIn: true,
username: await repository.getUsername(),
),
);
return true;
}
// access token 失效:用 refreshToken 续期后再校验一次。
try {
await repository.refresh();
} on Object {
await _reset();
return false;
}
if (await _verifyQuietly()) {
state = AsyncData(
_current.copyWith(
loggedIn: true,
username: await repository.getUsername(),
),
);
return true;
}
await _reset();
return false;
}
Future<bool> _verifyQuietly() async {
try {
await _repository.verify();
return true;
} on Object {
return false;
}
}
Future<void> _reset() async {
final username = await _repository.getUsername();
await _repository.clear();
state = AsyncData(AuthState(username: username));
}
/// 登录:成功返回 true失败返回 false 并写入 [AuthState.error]。
Future<bool> login(String username, String password) async {
state = AsyncData(_current.copyWith(submitting: true, error: null));
try {
await _repository.login(username, password);
state = AsyncData(
AuthState(loggedIn: true, username: username),
);
state = AsyncData(AuthState(loggedIn: true, username: username));
return true;
} catch (e) {
state = AsyncData(
AuthState(loggedIn: false, error: e.toString()),
_current.copyWith(
submitting: false,
loggedIn: false,
error: _describe(e, '登录失败'),
),
);
return false;
}
}
/// 退出登录:清空令牌并复位状态
/// 注册:成功后不自动登录,仅通过 [AuthState.alert] 提示前往登录
Future<bool> register(String username, String password) async {
state = AsyncData(_current.copyWith(submitting: true, error: null));
try {
await _repository.register(username, password);
state = AsyncData(
_current.copyWith(
submitting: false,
error: null,
alert: '注册成功,请登录',
),
);
return true;
} catch (e) {
state = AsyncData(
_current.copyWith(
submitting: false,
error: _describe(e, '注册失败'),
),
);
return false;
}
}
/// 退出登录:清空令牌并复位状态(保留用户名用于回显)。
Future<void> logout() async {
await _repository.clear();
state = const AsyncData(AuthState());
await _reset();
}
/// 展示一次性提示(如从主页因登录失效跳转时携带的文案)。
void showAlert(String message) {
state = AsyncData(_current.copyWith(alert: message));
}
/// 消费一次性提示,避免重建时重复展示。
void consumeAlert() {
if (_current.alert != null) {
state = AsyncData(_current.copyWith(alert: null));
}
}
/// 清空错误信息。
void clearError() {
final current = state.valueOrNull;
if (current != null && current.error != null) {
state = AsyncData(current.copyWith(error: null));
if (_current.error != null) {
state = AsyncData(_current.copyWith(error: null));
}
}
/// 优先展示服务端返回的错误码,其余异常回落到通用提示。
String _describe(Object e, String fallback) {
if (e is ApiException) return e.code;
return '$fallback$e';
}
}

View File

@@ -6,10 +6,13 @@ part of 'auth_controller.dart';
// RiverpodGenerator
// **************************************************************************
String _$authControllerHash() => r'35d9a474535207949af85ca44019d70f50afd7c2';
String _$authControllerHash() => r'49ebaa798497842d2219e19dbf93618e5e15f0de';
/// 登录状态控制器。
///
/// 会话级持有(`keepAlive`),负责令牌恢复、登录 / 注册 / 退出,
/// 以及启动期的登录态校验verify + refresh 重试)。
///
/// Copied from [AuthController].
@ProviderFor(AuthController)
final authControllerProvider =

View File

@@ -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,
),
);
}
}

View File

@@ -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(),
],
),
),
);
}
}

View File

@@ -69,6 +69,7 @@ class _LoginDialogState extends ConsumerState<LoginDialog> {
Navigator.of(context).pop(true);
} else {
final error = ref.read(authControllerProvider).valueOrNull?.error;
debugPrint('login error: $error');
_showAlert(
context,
l10n.loginFailed(error ?? ''),