From 8d890c0424885399e3f74b9b7a8677643bf37281 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 4 Sep 2026 11:52:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=90=BD=E5=9C=B0=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E7=BA=B5=E5=88=87=E2=80=94=E2=80=94=E7=BD=91=E7=BB=9C=E5=B1=82?= =?UTF-8?q?=E3=80=81=E8=AE=A4=E8=AF=81=E4=BC=9A=E8=AF=9D=E4=B8=8E=E7=99=BB?= =?UTF-8?q?=E5=BD=95/=E6=B3=A8=E5=86=8C/Splash=20=E4=B8=89=E9=A1=B5?= =?UTF-8?q?=EF=BC=88ADR-003/ADR-004=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 lib/core/network/:ApiClient(dio 封装,错误信封→类型化异常,base URL 经 --dart-define=PATBOND_API_BASE_URL 注入)、AuthInterceptor(Bearer + 设备标识)、TokenRefresher(单飞刷新,401/40101 重放一次,仅 40102/再 401 清会话) - 新增 lib/features/auth/:SessionManager(token 只进 flutter_secure_storage)、AuthRepository(login/register/refresh/logout/me,注册带 Idempotency-Key)、Splash(500ms 最短停留/5s 超时/错误态重试+改用账号登录)、登录页与注册页(照 12 号组装稿,错误三层映射,预留区不渲染) - App 根组件改为认证状态机驱动(Splash↔登录↔主壳 300ms fade);个人中心退出登录接入真实 logout - 顺带修复:FIX-1 促销卡渐变改 [primaryStrong, primary]、FIX-2 补 helperStyle: muted、README dart format 命令补 --output=none - 新增依赖:dio ^5.11.1、flutter_secure_storage ^11.0.0、uuid ^4.6.0 - 门禁:dart format(0 changed)/ flutter analyze(No issues)/ flutter test(30 passed,其中新增 23:TokenRefresher 5 + AuthRepository 10 + 登录页 4 + 注册页 4) Co-Authored-By: Claude Fable 5 --- README.md | 2 +- lib/app/app.dart | 68 +++- lib/core/navigation/fade_route.dart | 13 + lib/core/network/api_client.dart | 149 ++++++++ lib/core/network/api_envelope.dart | 20 ++ lib/core/network/api_exception.dart | 59 ++++ lib/core/network/token_refresher.dart | 66 ++++ lib/core/theme/app_theme.dart | 1 + lib/features/auth/auth_models.dart | 57 ++++ lib/features/auth/auth_repository.dart | 111 ++++++ lib/features/auth/login_page.dart | 221 ++++++++++++ lib/features/auth/register_page.dart | 318 ++++++++++++++++++ lib/features/auth/session_manager.dart | 159 +++++++++ lib/features/auth/splash_page.dart | 147 ++++++++ lib/features/home/home_page.dart | 5 +- lib/features/main/main_shell_page.dart | 7 +- lib/features/profile/profile_page.dart | 7 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 248 ++++++++++++++ pubspec.yaml | 3 + test/core/network/token_refresher_test.dart | 111 ++++++ test/features/auth/auth_repository_test.dart | 216 ++++++++++++ test/features/auth/login_page_test.dart | 99 ++++++ test/features/auth/register_page_test.dart | 78 +++++ test/helpers/auth_test_helpers.dart | 128 +++++++ test/widget_test.dart | 9 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 2 + 30 files changed, 2306 insertions(+), 9 deletions(-) create mode 100644 lib/core/navigation/fade_route.dart create mode 100644 lib/core/network/api_client.dart create mode 100644 lib/core/network/api_envelope.dart create mode 100644 lib/core/network/api_exception.dart create mode 100644 lib/core/network/token_refresher.dart create mode 100644 lib/features/auth/auth_models.dart create mode 100644 lib/features/auth/auth_repository.dart create mode 100644 lib/features/auth/login_page.dart create mode 100644 lib/features/auth/register_page.dart create mode 100644 lib/features/auth/session_manager.dart create mode 100644 lib/features/auth/splash_page.dart create mode 100644 test/core/network/token_refresher_test.dart create mode 100644 test/features/auth/auth_repository_test.dart create mode 100644 test/features/auth/login_page_test.dart create mode 100644 test/features/auth/register_page_test.dart create mode 100644 test/helpers/auth_test_helpers.dart diff --git a/README.md b/README.md index 1937630..3397e09 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ flutter run -d chrome ## 验证 ```bash -dart format --set-exit-if-changed lib test +dart format --output=none --set-exit-if-changed lib test flutter analyze flutter test flutter build apk --debug diff --git a/lib/app/app.dart b/lib/app/app.dart index abe3f5d..f04c3a0 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,10 +1,20 @@ import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/network/api_client.dart'; +import 'package:patbond_flutter/core/network/token_refresher.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/features/auth/auth_repository.dart'; +import 'package:patbond_flutter/features/auth/login_page.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; +import 'package:patbond_flutter/features/auth/splash_page.dart'; import 'package:patbond_flutter/features/main/main_shell_page.dart'; import 'package:patbond_flutter/state/app_state.dart'; class App extends StatefulWidget { - const App({super.key}); + const App({super.key, this.sessionManager, this.authRepository}); + + /// 测试注入口;生产默认走安全存储 + 真实 API。 + final SessionManager? sessionManager; + final AuthRepository? authRepository; @override State createState() => _AppState(); @@ -12,26 +22,80 @@ class App extends StatefulWidget { class _AppState extends State { late final AppState appState; + late final SessionManager sessionManager; + late final AuthRepository authRepository; @override void initState() { super.initState(); appState = AppState()..load(); + sessionManager = + widget.sessionManager ?? + SessionManager(store: const SecureTokenStore()); + authRepository = widget.authRepository ?? _buildRepository(); + } + + AuthRepository _buildRepository() { + final dio = buildPatbondDio(session: sessionManager); + final refresher = TokenRefresher(dio: dio, session: sessionManager); + final api = ApiClient( + dio: dio, + session: sessionManager, + refresher: refresher, + ); + return ApiAuthRepository( + api: api, + session: sessionManager, + refresher: refresher, + ); } @override void dispose() { appState.dispose(); + if (widget.sessionManager == null) sessionManager.dispose(); super.dispose(); } + Widget _homeForStatus() { + switch (sessionManager.status) { + case AuthStatus.unknown: + return SplashPage( + key: const ValueKey('splash'), + authRepository: authRepository, + sessionManager: sessionManager, + ); + case AuthStatus.unauthenticated: + return LoginPage( + key: const ValueKey('login'), + authRepository: authRepository, + ); + case AuthStatus.authenticated: + return MainShellPage( + key: const ValueKey('shell'), + appState: appState, + onLogout: authRepository.logout, + ); + } + } + @override Widget build(BuildContext context) { return MaterialApp( title: 'Patbond', debugShowCheckedModeBanner: false, theme: buildAppTheme(), - home: MainShellPage(appState: appState), + home: ListenableBuilder( + listenable: sessionManager, + builder: (context, _) { + // 认证状态切换统一 300ms 淡入淡出(Splash ↔ 登录 ↔ 主壳, + // BrandMark 同构保证品牌区过渡对位,组装稿 §7)。 + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: _homeForStatus(), + ); + }, + ), ); } } diff --git a/lib/core/navigation/fade_route.dart b/lib/core/navigation/fade_route.dart new file mode 100644 index 0000000..4dcb87f --- /dev/null +++ b/lib/core/navigation/fade_route.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +/// 统一页面切换:300ms 淡入淡出(12 号组装稿 §7 流程约束)。 +Route fadePageRoute(Widget page) { + return PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 300), + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + ); +} diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart new file mode 100644 index 0000000..9a7644d --- /dev/null +++ b/lib/core/network/api_client.dart @@ -0,0 +1,149 @@ +import 'package:dio/dio.dart'; +import 'package:patbond_flutter/core/network/api_envelope.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/network/token_refresher.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; + +/// API 基地址:`--dart-define=PATBOND_API_BASE_URL=...` 注入,默认本机后端。 +const String patbondApiBaseUrl = String.fromEnvironment( + 'PATBOND_API_BASE_URL', + defaultValue: 'http://127.0.0.1:8081', +); + +/// 构建全局共用的 Dio 实例。 +/// +/// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成 +/// 类型化异常,不依赖 dio 的 4xx/5xx 抛错路径。 +Dio buildPatbondDio({required SessionManager session, String? baseUrl}) { + final dio = Dio( + BaseOptions( + baseUrl: baseUrl ?? patbondApiBaseUrl, + connectTimeout: const Duration(seconds: 5), + sendTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + contentType: Headers.jsonContentType, + validateStatus: (_) => true, + ), + ); + dio.interceptors.add(AuthInterceptor(session)); + return dio; +} + +/// 鉴权拦截器:对标记 [ApiClient.requiresAuthExtra] 的请求附加 +/// `Authorization: Bearer`,并统一携带设备标识。 +class AuthInterceptor extends Interceptor { + AuthInterceptor(this._session); + + final SessionManager _session; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final deviceId = _session.deviceId; + if (deviceId != null) { + options.headers['X-Device-Id'] = deviceId; + } + if (options.extra[ApiClient.requiresAuthExtra] == true) { + final token = _session.accessToken; + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + } + handler.next(options); + } +} + +/// HTTP 客户端封装:错误信封 → 类型化异常,401/40101 → 刷新后重放一次。 +class ApiClient { + ApiClient({ + required this._dio, + required this._session, + required this._refresher, + }); + + /// RequestOptions.extra 标记:该请求需要 Bearer 鉴权。 + static const requiresAuthExtra = 'patbond.requiresAuth'; + + final Dio _dio; + final SessionManager _session; + final TokenRefresher _refresher; + + /// 发起请求并返回信封中的 `data`。 + /// + /// 鉴权请求遇 HTTP 401 或 code 40101 时:经单飞刷新后原样重放一次; + /// 重放仍失败则清会话并抛 [SessionExpiredException]。 + Future request( + String path, { + String method = 'POST', + Object? body, + Map? headers, + bool requiresAuth = false, + }) async { + var response = await _send( + path, + method: method, + body: body, + headers: headers, + requiresAuth: requiresAuth, + ); + + if (requiresAuth && _isAuthFailure(response)) { + await _refresher.refresh(); + response = await _send( + path, + method: method, + body: body, + headers: headers, + requiresAuth: requiresAuth, + ); + if (_isAuthFailure(response)) { + await _session.clearSession(); + throw const SessionExpiredException('刷新后重放仍未通过鉴权'); + } + } + + return _unwrap(response); + } + + Future> _send( + String path, { + required String method, + required bool requiresAuth, + Object? body, + Map? headers, + }) async { + try { + return await _dio.request( + path, + data: body, + options: Options( + method: method, + headers: headers, + extra: {requiresAuthExtra: requiresAuth}, + ), + ); + } on DioException catch (error) { + throw ApiNetworkException('请求失败:${error.type.name}'); + } + } + + bool _isAuthFailure(Response response) { + if (response.statusCode == 401) return true; + return ApiEnvelope.tryParse(response.data)?.code == + ApiCodes.accessTokenInvalid; + } + + Object? _unwrap(Response response) { + final status = response.statusCode ?? 0; + if (status == 429) { + throw const ApiRateLimitException(); + } + final envelope = ApiEnvelope.tryParse(response.data); + if (envelope == null || status >= 500) { + throw ApiNetworkException('响应异常:HTTP $status'); + } + if (envelope.code == ApiCodes.ok) { + return envelope.data; + } + throw ApiBusinessException(code: envelope.code, message: envelope.message); + } +} diff --git a/lib/core/network/api_envelope.dart b/lib/core/network/api_envelope.dart new file mode 100644 index 0000000..202490e --- /dev/null +++ b/lib/core/network/api_envelope.dart @@ -0,0 +1,20 @@ +/// 服务端统一响应信封 `{code, message, data}` 的解析结果。 +class ApiEnvelope { + const ApiEnvelope({required this.code, required this.message, this.data}); + + final int code; + final String message; + final Object? data; + + /// 从响应体解析信封;结构不符(非 JSON 对象 / 无 int code)返回 null。 + static ApiEnvelope? tryParse(Object? body) { + if (body is! Map) return null; + final code = body['code']; + if (code is! int) return null; + return ApiEnvelope( + code: code, + message: body['message'] is String ? body['message'] as String : '', + data: body['data'], + ); + } +} diff --git a/lib/core/network/api_exception.dart b/lib/core/network/api_exception.dart new file mode 100644 index 0000000..3e3604e --- /dev/null +++ b/lib/core/network/api_exception.dart @@ -0,0 +1,59 @@ +/// 服务端错误信封 `{code, message, data}` 的业务码常量(接口契约冻结稿)。 +abstract final class ApiCodes { + static const ok = 0; + + /// 参数错误。 + static const paramError = 40000; + + /// 用户名或密码错误。 + static const badCredentials = 40100; + + /// access token 无效或过期。 + static const accessTokenInvalid = 40101; + + /// refresh token 已失效。 + static const refreshTokenInvalid = 40102; + + /// 用户名重复。 + static const usernameTaken = 40900; + + /// 手机号重复。 + static const phoneTaken = 40901; +} + +/// API 调用的类型化异常。页面按类型映射为三层错误呈现 +/// (字段级 / 表单横幅 / SnackBar,见 12 号组装稿 §4), +/// 服务端原始 message 一律不直接透出给用户。 +sealed class ApiException implements Exception { + const ApiException(this.message); + + /// 服务端返回的原始 message(仅用于日志排查,不上屏)。 + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// 系统错误:超时、断网、5xx、响应无法解析。归瞬态层(SnackBar + 重试)。 +final class ApiNetworkException extends ApiException { + const ApiNetworkException([super.message = '网络或服务不可用']); +} + +/// 业务错误:错误信封 code != 0(40000/40100/40900/40901 等)。 +final class ApiBusinessException extends ApiException { + const ApiBusinessException({required this.code, required String message}) + : super(message); + + final int code; +} + +/// 限流(HTTP 429):映射为横幅「尝试次数过多,请稍后再试」。 +final class ApiRateLimitException extends ApiException { + const ApiRateLimitException([super.message = '请求过于频繁']); +} + +/// 会话已失效:refresh token 失效(40102)或刷新重放后仍 401。 +/// 抛出前本地会话已被清除,应用会自动回到登录页。 +final class SessionExpiredException extends ApiException { + const SessionExpiredException([super.message = '会话已失效']); +} diff --git a/lib/core/network/token_refresher.dart b/lib/core/network/token_refresher.dart new file mode 100644 index 0000000..05cb59d --- /dev/null +++ b/lib/core/network/token_refresher.dart @@ -0,0 +1,66 @@ +import 'package:dio/dio.dart'; +import 'package:patbond_flutter/core/network/api_envelope.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; + +/// 单飞(single-flight)token 刷新器。 +/// +/// 并发触发的多个 401/40101 只发出一次 `POST /auth/refresh`, +/// 其余调用共享同一个进行中的刷新结果。 +/// +/// 清会话纪律(12 号组装稿 §7):只有服务端明确判定 refresh 失效 +/// (40102 或 HTTP 401)才清除本地会话;网络失败、5xx 一律保留 token。 +class TokenRefresher { + TokenRefresher({required this._dio, required this._session}); + + final Dio _dio; + final SessionManager _session; + + Future? _inflight; + + /// 刷新并轮换 token。成功后新 token 已写入 [SessionManager]。 + /// + /// 抛出 [SessionExpiredException](会话已被清除)或 + /// [ApiNetworkException](token 保留,可重试)。 + Future refresh() { + return _inflight ??= _doRefresh().whenComplete(() => _inflight = null); + } + + Future _doRefresh() async { + await _session.loadFromStorage(); + final refreshToken = _session.refreshToken; + if (refreshToken == null) { + await _session.clearSession(); + throw const SessionExpiredException('本地无 refresh token'); + } + + final Response response; + try { + response = await _dio.post( + '/api/v1/auth/refresh', + data: {'refreshToken': refreshToken}, + ); + } on DioException catch (error) { + throw ApiNetworkException('刷新请求失败:${error.type.name}'); + } + + final envelope = ApiEnvelope.tryParse(response.data); + if (response.statusCode == 401 || + envelope?.code == ApiCodes.refreshTokenInvalid) { + await _session.clearSession(); + throw const SessionExpiredException('refresh token 已失效'); + } + if (envelope != null && + envelope.code == ApiCodes.ok && + envelope.data is Map) { + await _session.updateTokens( + AuthTokens.fromJson(envelope.data! as Map), + ); + return; + } + throw ApiNetworkException( + '刷新响应异常:HTTP ${response.statusCode},code ${envelope?.code}', + ); + } +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 5699f67..0ac4af5 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -160,6 +160,7 @@ ThemeData buildAppTheme() { borderSide: BorderSide(color: AppColors.error, width: 1.5), ), errorStyle: TextStyle(color: AppColors.error, fontSize: 12), + helperStyle: TextStyle(color: AppColors.muted, fontSize: 12), ), snackBarTheme: const SnackBarThemeData( behavior: SnackBarBehavior.floating, diff --git a/lib/features/auth/auth_models.dart b/lib/features/auth/auth_models.dart new file mode 100644 index 0000000..e57da99 --- /dev/null +++ b/lib/features/auth/auth_models.dart @@ -0,0 +1,57 @@ +/// 认证接口的响应模型(接口契约冻结稿,字段名与后端一致)。 +class AuthTokens { + const AuthTokens({ + required this.userId, + required this.tokenType, + required this.accessToken, + required this.accessTokenExpiresAt, + required this.refreshToken, + required this.refreshTokenExpiresAt, + }); + + factory AuthTokens.fromJson(Map json) { + return AuthTokens( + userId: json['userId'] as String, + tokenType: json['tokenType'] as String, + accessToken: json['accessToken'] as String, + accessTokenExpiresAt: DateTime.parse( + json['accessTokenExpiresAt'] as String, + ), + refreshToken: json['refreshToken'] as String, + refreshTokenExpiresAt: DateTime.parse( + json['refreshTokenExpiresAt'] as String, + ), + ); + } + + final String userId; + final String tokenType; + final String accessToken; + final DateTime accessTokenExpiresAt; + final String refreshToken; + final DateTime refreshTokenExpiresAt; +} + +/// `GET /api/v1/me` 的用户资料。 +class UserProfile { + const UserProfile({ + required this.userId, + required this.username, + required this.phone, + required this.createdAt, + }); + + factory UserProfile.fromJson(Map json) { + return UserProfile( + userId: json['userId'] as String, + username: json['username'] as String, + phone: json['phone'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + final String userId; + final String username; + final String phone; + final DateTime createdAt; +} diff --git a/lib/features/auth/auth_repository.dart b/lib/features/auth/auth_repository.dart new file mode 100644 index 0000000..cafd711 --- /dev/null +++ b/lib/features/auth/auth_repository.dart @@ -0,0 +1,111 @@ +import 'package:patbond_flutter/core/network/api_client.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/network/token_refresher.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; +import 'package:uuid/uuid.dart'; + +/// Splash 会话恢复的裁决结果。状态切换由 Splash 在最短停留时间后执行。 +enum SessionRestoreResult { authenticated, noSession } + +/// 认证仓库接口(页面依赖此抽象,widget 测试注入假实现)。 +abstract class AuthRepository { + Future login({required String username, required String password}); + + Future register({ + required String username, + required String phone, + required String password, + }); + + /// 撤销当前会话并清除本地凭证。服务端调用失败也保证本地清除。 + Future logout(); + + /// 启动恢复:本地有 refresh token 则静默刷新。 + /// 网络失败抛 [ApiNetworkException](token 保留); + /// refresh 失效抛 [SessionExpiredException](会话已清)。 + Future restoreSession(); + + Future me(); +} + +class ApiAuthRepository implements AuthRepository { + ApiAuthRepository({ + required this._api, + required this._session, + required this._refresher, + this._uuid = const Uuid(), + }); + + final ApiClient _api; + final SessionManager _session; + final TokenRefresher _refresher; + final Uuid _uuid; + + @override + Future login({ + required String username, + required String password, + }) async { + final data = await _api.request( + '/api/v1/auth/login', + body: {'username': username, 'password': password}, + ); + await _session.saveSession( + AuthTokens.fromJson(data! as Map), + ); + } + + @override + Future register({ + required String username, + required String phone, + required String password, + }) async { + final data = await _api.request( + '/api/v1/auth/register', + body: {'username': username, 'phone': phone, 'password': password}, + // 每次提交一个幂等键;token 刷新后的自动重放沿用同一个键。 + headers: {'Idempotency-Key': _uuid.v4()}, + ); + await _session.saveSession( + AuthTokens.fromJson(data! as Map), + ); + } + + @override + Future logout() async { + final refreshToken = _session.refreshToken; + try { + await _api.request( + '/api/v1/auth/logout', + body: {'refreshToken': refreshToken}, + requiresAuth: true, + ); + } on ApiException { + // 服务端撤销失败不阻塞本地登出;refresh 侧最终会自然过期。 + } finally { + await _session.clearSession(); + } + } + + @override + Future restoreSession() async { + await _session.loadFromStorage(); + if (_session.refreshToken == null) { + return SessionRestoreResult.noSession; + } + await _refresher.refresh(); + return SessionRestoreResult.authenticated; + } + + @override + Future me() async { + final data = await _api.request( + '/api/v1/me', + method: 'GET', + requiresAuth: true, + ); + return UserProfile.fromJson(data! as Map); + } +} diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart new file mode 100644 index 0000000..a1b89e3 --- /dev/null +++ b/lib/features/auth/login_page.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:patbond_flutter/core/navigation/fade_route.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_text_field.dart'; +import 'package:patbond_flutter/core/widgets/auth_scaffold.dart'; +import 'package:patbond_flutter/core/widgets/brand_mark.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/core/widgets/primary_button.dart'; +import 'package:patbond_flutter/features/auth/auth_repository.dart'; +import 'package:patbond_flutter/features/auth/register_page.dart'; + +enum _Field { account, password } + +/// 登录页(12 号组装稿 §5)。登录成功后由认证状态机切入主壳。 +class LoginPage extends StatefulWidget { + const LoginPage({required this.authRepository, super.key}); + + final AuthRepository authRepository; + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _accountCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + String? _accountError; + String? _passwordError; + String? _formError; + bool _submitting = false; + + @override + void dispose() { + _accountCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + String? _validateAccount() => + _accountCtrl.text.trim().isEmpty ? '请输入用户名或手机号' : null; + + String? _validatePassword() => + _passwordCtrl.text.trim().isEmpty ? '请输入密码' : null; + + void _validateAccountOnBlur() { + if (!mounted) return; + setState(() => _accountError = _validateAccount()); + } + + void _validatePasswordOnBlur() { + if (!mounted) return; + setState(() => _passwordError = _validatePassword()); + } + + /// 字段变更即清除本字段错误与表单级横幅。 + void _clearErrors({required _Field field}) { + if (_formError == null && + (field == _Field.account ? _accountError : _passwordError) == null) { + return; + } + setState(() { + _formError = null; + if (field == _Field.account) _accountError = null; + if (field == _Field.password) _passwordError = null; + }); + } + + void _showFormError(String message) { + setState(() => _formError = message); + SemanticsService.sendAnnouncement( + View.of(context), + message, + TextDirection.ltr, + ); + } + + void _showNetworkSnackBar() { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('网络异常,请检查网络后重试'), + action: SnackBarAction(label: '重试', onPressed: _submit), + ), + ); + } + + Future _submit() async { + if (_submitting) return; + final accountError = _validateAccount(); + final passwordError = _validatePassword(); + if (accountError != null || passwordError != null) { + setState(() { + _accountError = accountError; + _passwordError = passwordError; + }); + return; + } + setState(() { + _submitting = true; + _formError = null; + }); + try { + await widget.authRepository.login( + username: _accountCtrl.text.trim(), + password: _passwordCtrl.text.trim(), + ); + TextInput.finishAutofillContext(); + // 认证状态机随 saveSession 切换,根路由 300ms fade 进主壳。 + } on ApiBusinessException catch (error) { + if (!mounted) return; + _showFormError( + error.code == ApiCodes.badCredentials ? '用户名或密码错误' : '登录失败,请稍后重试', + ); + } on ApiRateLimitException { + if (!mounted) return; + _showFormError('尝试次数过多,请稍后再试'); + } on ApiNetworkException { + if (!mounted) return; + _showNetworkSnackBar(); + } on SessionExpiredException { + // 登录请求不带会话,理论不可达;静默留在登录页。 + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _goRegister() { + Navigator.of(context).push( + fadePageRoute(RegisterPage(authRepository: widget.authRepository)), + ); + } + + @override + Widget build(BuildContext context) { + return AuthScaffold( + child: AutofillGroup( + child: Column( + // AuthScaffold 内禁用 Spacer,垂直居中用 mainAxisAlignment(§4 总则)。 + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 48), + const BrandMark(), + const SizedBox(height: 48), + Focus( + onFocusChange: (hasFocus) { + if (!hasFocus) _validateAccountOnBlur(); + }, + child: AppTextField( + label: '用户名 / 手机号', + controller: _accountCtrl, + prefixIcon: Icons.person_outline_rounded, + errorText: _accountError, + enabled: !_submitting, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.username], + onChanged: (_) => _clearErrors(field: _Field.account), + ), + ), + const SizedBox(height: 16), + Focus( + onFocusChange: (hasFocus) { + if (!hasFocus) _validatePasswordOnBlur(); + }, + child: AppTextField( + label: '密码', + controller: _passwordCtrl, + prefixIcon: Icons.lock_outline_rounded, + obscurable: true, + errorText: _passwordError, + enabled: !_submitting, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.password], + onChanged: (_) => _clearErrors(field: _Field.password), + onSubmitted: (_) => _submit(), + ), + ), + if (_formError != null) ...[ + const SizedBox(height: 16), + InlineErrorBanner(message: _formError!), + ], + const SizedBox(height: 24), + PrimaryButton( + label: '登录', + isLoading: _submitting, + onPressed: _submit, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '还没有账号?', + style: TextStyle(color: AppColors.muted, fontSize: 14), + ), + TextButton( + style: TextButton.styleFrom( + foregroundColor: AppColors.primaryStrong, + minimumSize: const Size(44, 44), + padding: const EdgeInsets.symmetric(horizontal: 8), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + onPressed: _submitting ? null : _goRegister, + child: const Text('立即注册'), + ), + ], + ), + const SizedBox(height: 24), + // 协议行:v1 协议页未就绪,整行不渲染(组装稿 §5)。 + // 预留区(短信验证码/第三方登录):不渲染任何占位(ADR-004)。 + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/register_page.dart b/lib/features/auth/register_page.dart new file mode 100644 index 0000000..1175e92 --- /dev/null +++ b/lib/features/auth/register_page.dart @@ -0,0 +1,318 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_text_field.dart'; +import 'package:patbond_flutter/core/widgets/auth_scaffold.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/core/widgets/primary_button.dart'; +import 'package:patbond_flutter/features/auth/auth_repository.dart'; + +enum _Field { username, phone, password, confirm } + +/// 注册页(12 号组装稿 §6)。字段按 ADR-004:用户名 + 手机号 + 密码 + 确认密码; +/// 短信验证码行与第三方登录预留区一律不渲染。注册成功即建立会话直接进首页。 +class RegisterPage extends StatefulWidget { + const RegisterPage({required this.authRepository, super.key}); + + final AuthRepository authRepository; + + @override + State createState() => _RegisterPageState(); +} + +class _RegisterPageState extends State { + static final _usernamePattern = RegExp(r'^[A-Za-z][A-Za-z0-9_]{2,19}$'); + static final _phonePattern = RegExp(r'^1\d{10}$'); + + final _usernameCtrl = TextEditingController(); + final _phoneCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + String? _usernameError; + String? _phoneError; + String? _passwordError; + String? _confirmError; + String? _formError; + bool _submitting = false; + + @override + void dispose() { + _usernameCtrl.dispose(); + _phoneCtrl.dispose(); + _passwordCtrl.dispose(); + _confirmCtrl.dispose(); + super.dispose(); + } + + String? _validateUsernameValue() { + final value = _usernameCtrl.text.trim(); + if (value.isEmpty) return '请输入用户名'; + if (!_usernamePattern.hasMatch(value)) { + return '用户名需 3–20 位,字母开头,可含数字和下划线'; + } + return null; + } + + String? _validatePhoneValue() { + final value = _phoneCtrl.text.trim(); + if (value.isEmpty) return '请输入手机号'; + if (!_phonePattern.hasMatch(value)) return '请输入正确的 11 位手机号'; + return null; + } + + String? _validatePasswordValue() { + final value = _passwordCtrl.text; + final lengthOk = value.length >= 8 && value.length <= 32; + final hasLetter = value.contains(RegExp('[A-Za-z]')); + final hasDigit = value.contains(RegExp('[0-9]')); + if (!lengthOk || !hasLetter || !hasDigit) { + return '密码需 8–32 位,且同时包含字母和数字'; + } + return null; + } + + String? _validateConfirmValue() => + _confirmCtrl.text == _passwordCtrl.text ? null : '两次输入的密码不一致'; + + void _validateOnBlur(_Field field) { + if (!mounted) return; + setState(() { + switch (field) { + case _Field.username: + _usernameError = _validateUsernameValue(); + case _Field.phone: + _phoneError = _validatePhoneValue(); + case _Field.password: + _passwordError = _validatePasswordValue(); + case _Field.confirm: + _confirmError = _validateConfirmValue(); + } + }); + } + + /// 字段变更清除本字段错误与横幅;密码变更时若确认密码已有值, + /// 同步重校验一致性(组装稿 §6)。 + void _clearError(_Field field) { + setState(() { + _formError = null; + switch (field) { + case _Field.username: + _usernameError = null; + case _Field.phone: + _phoneError = null; + case _Field.password: + _passwordError = null; + if (_confirmCtrl.text.isNotEmpty) { + _confirmError = _validateConfirmValue(); + } + case _Field.confirm: + _confirmError = null; + } + }); + } + + void _showFormError(String message) { + setState(() => _formError = message); + SemanticsService.sendAnnouncement( + View.of(context), + message, + TextDirection.ltr, + ); + } + + Future _submit() async { + if (_submitting) return; + final usernameError = _validateUsernameValue(); + final phoneError = _validatePhoneValue(); + final passwordError = _validatePasswordValue(); + final confirmError = _validateConfirmValue(); + if (usernameError != null || + phoneError != null || + passwordError != null || + confirmError != null) { + setState(() { + _usernameError = usernameError; + _phoneError = phoneError; + _passwordError = passwordError; + _confirmError = confirmError; + }); + return; + } + setState(() { + _submitting = true; + _formError = null; + }); + try { + await widget.authRepository.register( + username: _usernameCtrl.text.trim(), + phone: _phoneCtrl.text.trim(), + password: _passwordCtrl.text, + ); + TextInput.finishAutofillContext(); + if (!mounted) return; + // 会话已建立,根路由切主壳;弹掉注册页露出新首页,不回登录页。 + Navigator.of(context).popUntil((route) => route.isFirst); + } on ApiBusinessException catch (error) { + if (!mounted) return; + switch (error.code) { + case ApiCodes.usernameTaken: + setState(() => _usernameError = '该用户名已被使用'); + case ApiCodes.phoneTaken: + setState(() => _phoneError = '该手机号已注册,可直接登录'); + default: + _showFormError('注册失败,请稍后重试'); + } + } on ApiRateLimitException { + if (!mounted) return; + _showFormError('尝试次数过多,请稍后再试'); + } on ApiNetworkException { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('网络异常,请检查网络后重试'), + action: SnackBarAction(label: '重试', onPressed: _submit), + ), + ); + } on SessionExpiredException { + // 注册请求不带会话,理论不可达。 + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Widget _blurValidated(_Field field, Widget child) { + return Focus( + onFocusChange: (hasFocus) { + if (!hasFocus) _validateOnBlur(field); + }, + child: child, + ); + } + + @override + Widget build(BuildContext context) { + return AuthScaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + foregroundColor: AppColors.ink, + ), + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Text('创建账号', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + Text( + '加入 Patbond,记录毛孩子的每一天', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 32), + _blurValidated( + _Field.username, + AppTextField( + label: '用户名', + controller: _usernameCtrl, + prefixIcon: Icons.person_outline_rounded, + errorText: _usernameError, + enabled: !_submitting, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.newUsername], + onChanged: (_) => _clearError(_Field.username), + ), + ), + const SizedBox(height: 16), + _blurValidated( + _Field.phone, + AppTextField( + label: '手机号', + controller: _phoneCtrl, + prefixIcon: Icons.phone_iphone_rounded, + errorText: _phoneError, + enabled: !_submitting, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.telephoneNumber], + onChanged: (_) => _clearError(_Field.phone), + ), + ), + const SizedBox(height: 16), + _blurValidated( + _Field.password, + AppTextField( + label: '密码', + controller: _passwordCtrl, + prefixIcon: Icons.lock_outline_rounded, + obscurable: true, + errorText: _passwordError, + helperText: '密码 8–32 位,需包含字母和数字', + enabled: !_submitting, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.newPassword], + onChanged: (_) => _clearError(_Field.password), + ), + ), + const SizedBox(height: 16), + _blurValidated( + _Field.confirm, + AppTextField( + label: '确认密码', + controller: _confirmCtrl, + prefixIcon: Icons.lock_outline_rounded, + obscurable: true, + errorText: _confirmError, + enabled: !_submitting, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.newPassword], + onChanged: (_) => _clearError(_Field.confirm), + onSubmitted: (_) => _submit(), + ), + ), + if (_formError != null) ...[ + const SizedBox(height: 16), + InlineErrorBanner(message: _formError!), + ], + const SizedBox(height: 32), + PrimaryButton( + label: '注册', + isLoading: _submitting, + onPressed: _submit, + ), + const SizedBox(height: 16), + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '已有账号?', + style: TextStyle(color: AppColors.muted, fontSize: 14), + ), + TextButton( + style: TextButton.styleFrom( + foregroundColor: AppColors.primaryStrong, + minimumSize: const Size(44, 44), + padding: const EdgeInsets.symmetric(horizontal: 8), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + onPressed: _submitting + ? null + : () => Navigator.of(context).pop(), + child: const Text('直接登录'), + ), + ], + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/session_manager.dart b/lib/features/auth/session_manager.dart new file mode 100644 index 0000000..bd6d31a --- /dev/null +++ b/lib/features/auth/session_manager.dart @@ -0,0 +1,159 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:uuid/uuid.dart'; + +/// 认证状态机:unknown(启动恢复中,停留 Splash)→ authenticated / unauthenticated。 +enum AuthStatus { unknown, authenticated, unauthenticated } + +/// token 持久化抽象。生产实现走系统安全存储(Keychain / Keystore), +/// 测试注入内存实现。token 绝不写入 SharedPreferences(开发计划 §4.2)。 +abstract class TokenStore { + Future read(String key); + Future write(String key, String value); + Future delete(String key); +} + +/// 基于 flutter_secure_storage 的生产实现。 +class SecureTokenStore implements TokenStore { + const SecureTokenStore([this._storage = const FlutterSecureStorage()]); + + final FlutterSecureStorage _storage; + + @override + Future read(String key) => _storage.read(key: key); + + @override + Future write(String key, String value) => + _storage.write(key: key, value: value); + + @override + Future delete(String key) => _storage.delete(key: key); +} + +/// 会话管理:token 的内存副本 + 安全存储持久化 + 认证状态广播。 +/// +/// 状态变更只通过 [saveSession](登录/注册成功)、[markAuthenticated] / +/// [markUnauthenticated](Splash 恢复裁决)与 [clearSession](登出/会话失效)。 +/// TokenRefresher 轮换 token 用 [updateTokens],不改状态—— +/// Splash 需要在最短停留时间后才切页(12 号组装稿 §7)。 +class SessionManager extends ChangeNotifier { + SessionManager({required this._store, this._uuid = const Uuid()}); + + static const _accessTokenKey = 'patbond_access_token'; + static const _refreshTokenKey = 'patbond_refresh_token'; + static const _accessExpiresKey = 'patbond_access_token_expires_at'; + static const _refreshExpiresKey = 'patbond_refresh_token_expires_at'; + static const _userIdKey = 'patbond_user_id'; + static const _deviceIdKey = 'patbond_device_id'; + + final TokenStore _store; + final Uuid _uuid; + + AuthStatus _status = AuthStatus.unknown; + String? _accessToken; + String? _refreshToken; + String? _userId; + String? _deviceId; + bool _loaded = false; + + AuthStatus get status => _status; + String? get accessToken => _accessToken; + String? get refreshToken => _refreshToken; + String? get userId => _userId; + + /// 设备标识:首次生成 UUID 并持久化,跨会话稳定。 + String? get deviceId => _deviceId; + + /// 从安全存储载入 token 到内存(幂等)。存储读取失败按无会话处理, + /// 不阻塞启动。 + Future loadFromStorage() async { + if (_loaded) return; + _accessToken = await _readOrNull(_accessTokenKey); + _refreshToken = await _readOrNull(_refreshTokenKey); + _userId = await _readOrNull(_userIdKey); + _deviceId = await _readOrNull(_deviceIdKey); + if (_deviceId == null) { + _deviceId = _uuid.v4(); + await _writeQuietly(_deviceIdKey, _deviceId!); + } + _loaded = true; + } + + /// 登录/注册成功:持久化整套 token 并进入已认证态。 + Future saveSession(AuthTokens tokens) async { + await updateTokens(tokens); + _status = AuthStatus.authenticated; + notifyListeners(); + } + + /// 仅写入/轮换 token,不改变认证状态(刷新流程用)。 + Future updateTokens(AuthTokens tokens) async { + _accessToken = tokens.accessToken; + _refreshToken = tokens.refreshToken; + _userId = tokens.userId; + _loaded = true; + await _writeQuietly(_accessTokenKey, tokens.accessToken); + await _writeQuietly(_refreshTokenKey, tokens.refreshToken); + await _writeQuietly( + _accessExpiresKey, + tokens.accessTokenExpiresAt.toIso8601String(), + ); + await _writeQuietly( + _refreshExpiresKey, + tokens.refreshTokenExpiresAt.toIso8601String(), + ); + await _writeQuietly(_userIdKey, tokens.userId); + } + + /// Splash 恢复成功后的显式切态。 + void markAuthenticated() { + _status = AuthStatus.authenticated; + notifyListeners(); + } + + /// 无本地会话或用户选择改用账号登录。 + void markUnauthenticated() { + _status = AuthStatus.unauthenticated; + notifyListeners(); + } + + /// 清除会话(登出 / refresh 失效)。设备标识保留。 + Future clearSession() async { + _accessToken = null; + _refreshToken = null; + _userId = null; + _status = AuthStatus.unauthenticated; + notifyListeners(); + for (final key in const [ + _accessTokenKey, + _refreshTokenKey, + _accessExpiresKey, + _refreshExpiresKey, + _userIdKey, + ]) { + try { + await _store.delete(key); + } catch (error) { + debugPrint('清除会话存储失败($key):$error'); + } + } + } + + Future _readOrNull(String key) async { + try { + return await _store.read(key); + } catch (error) { + debugPrint('读取安全存储失败($key):$error'); + return null; + } + } + + Future _writeQuietly(String key, String value) async { + try { + await _store.write(key, value); + } catch (error) { + debugPrint('写入安全存储失败($key):$error'); + } + } +} diff --git a/lib/features/auth/splash_page.dart b/lib/features/auth/splash_page.dart new file mode 100644 index 0000000..7f51184 --- /dev/null +++ b/lib/features/auth/splash_page.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/brand_mark.dart'; +import 'package:patbond_flutter/features/auth/auth_repository.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; + +enum _SplashState { checking, failed } + +/// 启动 Splash(12 号组装稿 §7):静默恢复会话。 +/// +/// 流程约束:最短停留 500ms;刷新超时 5s;spinner 等待超过 300ms 才出现; +/// 网络失败不清除本地 refresh token(只有服务端明确 401/40102 才清)。 +class SplashPage extends StatefulWidget { + const SplashPage({ + required this.authRepository, + required this.sessionManager, + super.key, + }); + + final AuthRepository authRepository; + final SessionManager sessionManager; + + @override + State createState() => _SplashPageState(); +} + +class _SplashPageState extends State { + _SplashState _state = _SplashState.checking; + bool _showSpinner = false; + Timer? _spinnerTimer; + + @override + void initState() { + super.initState(); + _restore(); + } + + @override + void dispose() { + _spinnerTimer?.cancel(); + super.dispose(); + } + + Future _restore() async { + _spinnerTimer?.cancel(); + _spinnerTimer = Timer(const Duration(milliseconds: 300), () { + if (mounted) setState(() => _showSpinner = true); + }); + final minStay = Future.delayed(const Duration(milliseconds: 500)); + try { + final result = await widget.authRepository.restoreSession().timeout( + const Duration(seconds: 5), + ); + await minStay; + if (!mounted) return; + switch (result) { + case SessionRestoreResult.authenticated: + widget.sessionManager.markAuthenticated(); + case SessionRestoreResult.noSession: + widget.sessionManager.markUnauthenticated(); + } + } on SessionExpiredException { + // refresh 已被服务端判失效,会话已清、状态机已切换,无需处理。 + } catch (_) { + // 网络失败 / 超时:token 保留,转错误态供重试。 + await minStay; + if (!mounted) return; + setState(() { + _state = _SplashState.failed; + _showSpinner = false; + }); + } + } + + void _retryRefresh() { + setState(() { + _state = _SplashState.checking; + _showSpinner = false; + }); + _restore(); + } + + Future _clearCredentialsAndGoLogin() async { + // 逃生通道:放弃本地凭证,改用账号登录。 + await widget.sessionManager.clearSession(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.canvas, + body: Center( + child: _state == _SplashState.checking + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + const BrandMark(), + const SizedBox(height: 32), + // 占位保高度,spinner 出现时不跳动。 + SizedBox.square( + dimension: 20, + child: _showSpinner + ? const CircularProgressIndicator( + strokeWidth: 2.5, + color: AppColors.primary, + ) + : null, + ), + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + const BrandMark(), + const SizedBox(height: 24), + const Text( + '网络连接失败,无法恢复登录', + style: TextStyle(color: AppColors.ink, fontSize: 14), + ), + const SizedBox(height: 16), + OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size(120, 44), + foregroundColor: AppColors.primaryStrong, + side: const BorderSide(color: AppColors.border), + ), + onPressed: _retryRefresh, + child: const Text('重试'), + ), + const SizedBox(height: 8), + TextButton( + style: TextButton.styleFrom( + foregroundColor: AppColors.primaryStrong, + minimumSize: const Size(44, 44), + ), + onPressed: _clearCredentialsAndGoLogin, + child: const Text('改用账号登录'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart index e18e7c0..5ad80ba 100644 --- a/lib/features/home/home_page.dart +++ b/lib/features/home/home_page.dart @@ -619,7 +619,10 @@ class _PromoCard extends StatelessWidget { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( - gradient: AppColors.brandGradient, + // 深端在左承载白字(4.5:1 达标);brandGradient 不承载正文文字(FIX-1)。 + gradient: const LinearGradient( + colors: [AppColors.primaryStrong, AppColors.primary], + ), borderRadius: BorderRadius.circular(26), ), child: Row( diff --git a/lib/features/main/main_shell_page.dart b/lib/features/main/main_shell_page.dart index 55cd86d..310b9b4 100644 --- a/lib/features/main/main_shell_page.dart +++ b/lib/features/main/main_shell_page.dart @@ -11,10 +11,13 @@ import 'package:patbond_flutter/state/app_state.dart'; import 'package:patbond_flutter/widgets/common.dart'; class MainShellPage extends StatefulWidget { - const MainShellPage({required this.appState, super.key}); + const MainShellPage({required this.appState, super.key, this.onLogout}); final AppState appState; + /// 退出登录:调 logout 接口并清会话,认证状态机自动回登录页。 + final Future Function()? onLogout; + @override State createState() => _MainShellPageState(); } @@ -77,7 +80,7 @@ class _MainShellPageState extends State { showPersonal: showPersonalServices, locationWeather: widget.appState.locationWeather, ), - ProfilePage(appState: widget.appState), + ProfilePage(appState: widget.appState, onLogout: widget.onLogout), ]; return Scaffold( diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index ff5530e..3b89bc1 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -5,10 +5,13 @@ import 'package:patbond_flutter/state/app_state.dart'; import 'package:patbond_flutter/widgets/common.dart'; class ProfilePage extends StatelessWidget { - const ProfilePage({required this.appState, super.key}); + const ProfilePage({required this.appState, super.key, this.onLogout}); final AppState appState; + /// 真实退出登录入口;未接线时保持演示提示。 + final Future Function()? onLogout; + static const menuItems = [ (Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'), (Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'), @@ -133,7 +136,7 @@ class ProfilePage extends StatelessWidget { ), const SizedBox(height: 10), TextButton( - onPressed: () => showDemoMessage(context, '退出登录'), + onPressed: onLogout ?? () => showDemoMessage(context, '退出登录'), child: const Text('切换账号或退出登录'), ), ], diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..d0e7f79 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..ce58916 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 724bb2a..ee71999 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,10 @@ import FlutterMacOS import Foundation +import flutter_secure_storage_darwin import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index f986267..32d9f2f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -33,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -41,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -49,6 +73,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dio: + dependency: "direct main" + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.dev" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.dev" + source: hosted + version: "2.2.2" fake_async: dependency: transitive description: @@ -65,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -73,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -86,6 +142,54 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6" + url: "https://pub.dev" + source: hosted + version: "11.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter @@ -96,6 +200,46 @@ packages: description: flutter source: sdk version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" leak_tracker: dependency: transitive description: @@ -128,6 +272,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -152,6 +304,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -160,6 +336,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -200,6 +400,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" shared_preferences: dependency: "direct main" description: @@ -309,6 +525,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: @@ -333,6 +565,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" xdg_directories: dependency: transitive description: @@ -341,6 +581,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" sdks: dart: ">=3.12.2 <4.0.0" flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 61cc510..64301fc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,9 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 shared_preferences: ^2.5.4 + dio: ^5.11.1 + flutter_secure_storage: ^11.0.0 + uuid: ^4.6.0 dev_dependencies: flutter_test: diff --git a/test/core/network/token_refresher_test.dart b/test/core/network/token_refresher_test.dart new file mode 100644 index 0000000..5e8e0e0 --- /dev/null +++ b/test/core/network/token_refresher_test.dart @@ -0,0 +1,111 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/network/token_refresher.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; + +import '../../helpers/auth_test_helpers.dart'; + +void main() { + late SessionManager session; + late Dio dio; + late FakeHttpAdapter adapter; + late TokenRefresher refresher; + + Future setUpWith( + Future Function(RequestOptions) handler, { + bool seedTokens = true, + }) async { + session = SessionManager(store: InMemoryTokenStore()); + if (seedTokens) { + await session.updateTokens( + sampleTokens(access: 'old-access', refresh: 'old-refresh'), + ); + } + dio = Dio(BaseOptions(validateStatus: (_) => true)); + adapter = FakeHttpAdapter(handler); + dio.httpClientAdapter = adapter; + refresher = TokenRefresher(dio: dio, session: session); + } + + test('并发触发只发出一次刷新请求(单飞),并轮换 token', () async { + await setUpWith((options) async { + await Future.delayed(const Duration(milliseconds: 20)); + return jsonResponse( + 200, + okEnvelope(tokenDataJson(access: 'new-access', refresh: 'new-refresh')), + ); + }); + + await Future.wait([refresher.refresh(), refresher.refresh()]); + + expect(adapter.requests, hasLength(1)); + expect(adapter.requests.single.path, '/api/v1/auth/refresh'); + expect(adapter.requests.single.data, {'refreshToken': 'old-refresh'}); + expect(session.accessToken, 'new-access'); + expect(session.refreshToken, 'new-refresh'); + }); + + test('40102 清除会话并抛 SessionExpiredException', () async { + await setUpWith( + (options) async => + jsonResponse(401, errorEnvelope(40102, 'refresh token 已失效')), + ); + + await expectLater( + refresher.refresh(), + throwsA(isA()), + ); + expect(session.accessToken, isNull); + expect(session.refreshToken, isNull); + expect(session.status, AuthStatus.unauthenticated); + }); + + test('网络失败不清除本地 token,抛 ApiNetworkException', () async { + await setUpWith((options) async { + throw DioException( + requestOptions: options, + type: DioExceptionType.connectionTimeout, + ); + }); + + await expectLater(refresher.refresh(), throwsA(isA())); + expect(session.refreshToken, 'old-refresh'); + expect(session.accessToken, 'old-access'); + }); + + test('上一次刷新结束后单飞复位,可再次刷新', () async { + var calls = 0; + await setUpWith((options) async { + calls += 1; + if (calls == 1) { + throw DioException( + requestOptions: options, + type: DioExceptionType.connectionError, + ); + } + return jsonResponse( + 200, + okEnvelope(tokenDataJson(access: 'a2', refresh: 'r2')), + ); + }); + + await expectLater(refresher.refresh(), throwsA(isA())); + await refresher.refresh(); + expect(calls, 2); + expect(session.refreshToken, 'r2'); + }); + + test('本地无 refresh token 时直接判会话失效', () async { + await setUpWith( + (options) async => jsonResponse(200, okEnvelope(tokenDataJson())), + seedTokens: false, + ); + + await expectLater( + refresher.refresh(), + throwsA(isA()), + ); + expect(adapter.requests, isEmpty); + }); +} diff --git a/test/features/auth/auth_repository_test.dart b/test/features/auth/auth_repository_test.dart new file mode 100644 index 0000000..9d33c0e --- /dev/null +++ b/test/features/auth/auth_repository_test.dart @@ -0,0 +1,216 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_client.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/network/token_refresher.dart'; +import 'package:patbond_flutter/features/auth/auth_repository.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; + +import '../../helpers/auth_test_helpers.dart'; + +void main() { + late SessionManager session; + late FakeHttpAdapter adapter; + late ApiAuthRepository repository; + + Future setUpWith( + Future Function(RequestOptions) handler, { + bool seedTokens = false, + }) async { + session = SessionManager(store: InMemoryTokenStore()); + if (seedTokens) { + await session.updateTokens( + sampleTokens(access: 'old-access', refresh: 'old-refresh'), + ); + } + final dio = buildPatbondDio(session: session, baseUrl: 'http://test.local'); + adapter = FakeHttpAdapter(handler); + dio.httpClientAdapter = adapter; + final refresher = TokenRefresher(dio: dio, session: session); + repository = ApiAuthRepository( + api: ApiClient(dio: dio, session: session, refresher: refresher), + session: session, + refresher: refresher, + ); + } + + test('登录成功:按契约发请求并把 token 存入会话', () async { + await setUpWith( + (options) async => jsonResponse( + 200, + okEnvelope(tokenDataJson(access: 'a1', refresh: 'r1')), + ), + ); + + await repository.login(username: 'gunnar', password: 'passw0rd'); + + final request = adapter.requests.single; + expect(request.path, '/api/v1/auth/login'); + expect(request.method, 'POST'); + expect(request.data, {'username': 'gunnar', 'password': 'passw0rd'}); + expect(session.accessToken, 'a1'); + expect(session.refreshToken, 'r1'); + expect(session.userId, 'user-1'); + expect(session.status, AuthStatus.authenticated); + }); + + test('登录 40100:抛业务异常且不建立会话', () async { + await setUpWith( + (options) async => jsonResponse(401, errorEnvelope(40100, '用户名或密码错误')), + ); + + await expectLater( + repository.login(username: 'gunnar', password: 'wrong'), + throwsA( + isA().having( + (e) => e.code, + 'code', + ApiCodes.badCredentials, + ), + ), + ); + expect(session.status, AuthStatus.unknown); + expect(session.accessToken, isNull); + }); + + test('注册:携带 Idempotency-Key,40900 映射为业务异常', () async { + await setUpWith( + (options) async => jsonResponse(409, errorEnvelope(40900, '用户名重复')), + ); + + await expectLater( + repository.register( + username: 'gunnar', + phone: '13800138000', + password: 'passw0rd', + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + ApiCodes.usernameTaken, + ), + ), + ); + final request = adapter.requests.single; + expect(request.path, '/api/v1/auth/register'); + expect(request.data, { + 'username': 'gunnar', + 'phone': '13800138000', + 'password': 'passw0rd', + }); + expect(request.headers['Idempotency-Key'], isNotEmpty); + }); + + test('鉴权请求 40101:单飞刷新后重放一次并携带新 token', () async { + await setUpWith(seedTokens: true, (options) async { + if (options.path == '/api/v1/me') { + final auth = options.headers['Authorization']; + if (auth == 'Bearer old-access') { + return jsonResponse(401, errorEnvelope(40101, 'access token 过期')); + } + return jsonResponse( + 200, + okEnvelope({ + 'userId': 'user-1', + 'username': 'gunnar', + 'phone': '13800138000', + 'createdAt': '2026-09-01T10:00:00+08:00', + }), + ); + } + return jsonResponse( + 200, + okEnvelope(tokenDataJson(access: 'new-access', refresh: 'new-refresh')), + ); + }); + + final profile = await repository.me(); + + expect(profile.username, 'gunnar'); + expect(adapter.requests.map((r) => r.path).toList(), [ + '/api/v1/me', + '/api/v1/auth/refresh', + '/api/v1/me', + ]); + expect(adapter.requests.last.headers['Authorization'], 'Bearer new-access'); + }); + + test('刷新后重放仍 401:清会话并抛 SessionExpiredException', () async { + await setUpWith(seedTokens: true, (options) async { + if (options.path == '/api/v1/me') { + return jsonResponse(401, errorEnvelope(40101, 'access token 无效')); + } + return jsonResponse(200, okEnvelope(tokenDataJson())); + }); + + await expectLater(repository.me(), throwsA(isA())); + expect(session.refreshToken, isNull); + expect(session.status, AuthStatus.unauthenticated); + }); + + test('登出:服务端网络失败也保证本地会话清除', () async { + await setUpWith(seedTokens: true, (options) async { + throw DioException( + requestOptions: options, + type: DioExceptionType.connectionError, + ); + }); + session.markAuthenticated(); + + await repository.logout(); + + expect(session.accessToken, isNull); + expect(session.refreshToken, isNull); + expect(session.status, AuthStatus.unauthenticated); + }); + + test('会话恢复:无 refresh token 返回 noSession,不发请求', () async { + await setUpWith( + (options) async => jsonResponse(200, okEnvelope(tokenDataJson())), + ); + + final result = await repository.restoreSession(); + + expect(result, SessionRestoreResult.noSession); + expect(adapter.requests, isEmpty); + }); + + test('会话恢复:有 refresh token 时静默刷新并返回 authenticated', () async { + await setUpWith( + seedTokens: true, + (options) async => jsonResponse( + 200, + okEnvelope(tokenDataJson(access: 'a2', refresh: 'r2')), + ), + ); + + final result = await repository.restoreSession(); + + expect(result, SessionRestoreResult.authenticated); + expect(adapter.requests.single.path, '/api/v1/auth/refresh'); + expect(session.accessToken, 'a2'); + }); + + test('5xx 归为系统错误 ApiNetworkException', () async { + await setUpWith( + (options) async => jsonResponse(500, {'error': 'Internal Server Error'}), + ); + + await expectLater( + repository.login(username: 'gunnar', password: 'passw0rd'), + throwsA(isA()), + ); + }); + + test('HTTP 429 映射为 ApiRateLimitException', () async { + await setUpWith( + (options) async => jsonResponse(429, errorEnvelope(42900, '限流')), + ); + + await expectLater( + repository.login(username: 'gunnar', password: 'passw0rd'), + throwsA(isA()), + ); + }); +} diff --git a/test/features/auth/login_page_test.dart b/test/features/auth/login_page_test.dart new file mode 100644 index 0000000..95688ac --- /dev/null +++ b/test/features/auth/login_page_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/features/auth/auth_repository.dart'; +import 'package:patbond_flutter/features/auth/login_page.dart'; + +import '../../helpers/auth_test_helpers.dart'; + +void main() { + Future pumpLogin(WidgetTester tester, AuthRepository repository) { + return tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: LoginPage(authRepository: repository), + ), + ); + } + + Future fillValidInput(WidgetTester tester) async { + await tester.enterText(find.byType(TextFormField).at(0), 'gunnar'); + await tester.enterText(find.byType(TextFormField).at(1), 'passw0rd'); + } + + testWidgets('初始态:品牌区、两个输入框、登录按钮与注册链接,无错误无 loading', (tester) async { + await pumpLogin(tester, FakeAuthRepository()); + + expect(find.text('Patbond'), findsOneWidget); + expect(find.text('用户名 / 手机号'), findsOneWidget); + expect(find.text('密码'), findsOneWidget); + expect(find.text('登录'), findsOneWidget); + expect(find.text('立即注册'), findsOneWidget); + expect(find.byType(InlineErrorBanner), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('loading 态:按钮转圈、字段禁用、注册链接不可点', (tester) async { + final completer = Completer(); + final repository = FakeAuthRepository(loginHandler: () => completer.future); + await pumpLogin(tester, repository); + + await fillValidInput(tester); + await tester.tap(find.text('登录')); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + final fields = tester.widgetList(find.byType(TextFormField)); + expect(fields.every((field) => field.enabled == false), isTrue); + final registerLink = tester.widget( + find.widgetWithText(TextButton, '立即注册'), + ); + expect(registerLink.onPressed, isNull); + + completer.complete(); + await tester.pumpAndSettle(); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('字段错误态:空表单提交显示两条字段级错误,输入即清除', (tester) async { + final repository = FakeAuthRepository(); + await pumpLogin(tester, repository); + + await tester.tap(find.text('登录')); + await tester.pump(); + + expect(find.text('请输入用户名或手机号'), findsOneWidget); + expect(find.text('请输入密码'), findsOneWidget); + expect(repository.loginCalls, 0); + + await tester.enterText(find.byType(TextFormField).at(0), 'g'); + await tester.pump(); + expect(find.text('请输入用户名或手机号'), findsNothing); + expect(find.text('请输入密码'), findsOneWidget); + }); + + testWidgets('横幅错误态:40100 显示「用户名或密码错误」,任一字段输入即清除', (tester) async { + final repository = FakeAuthRepository( + loginHandler: () => Future.error( + const ApiBusinessException(code: ApiCodes.badCredentials, message: ''), + ), + ); + await pumpLogin(tester, repository); + + await fillValidInput(tester); + await tester.tap(find.text('登录')); + await tester.pumpAndSettle(); + + expect(find.byType(InlineErrorBanner), findsOneWidget); + expect(find.text('用户名或密码错误'), findsOneWidget); + expect(repository.loginCalls, 1); + + await tester.enterText(find.byType(TextFormField).at(1), 'changed1'); + await tester.pump(); + expect(find.byType(InlineErrorBanner), findsNothing); + }); +} diff --git a/test/features/auth/register_page_test.dart b/test/features/auth/register_page_test.dart new file mode 100644 index 0000000..e16f659 --- /dev/null +++ b/test/features/auth/register_page_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/features/auth/register_page.dart'; + +import '../../helpers/auth_test_helpers.dart'; + +void main() { + Future pumpRegister( + WidgetTester tester, + FakeAuthRepository repository, + ) { + return tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: RegisterPage(authRepository: repository), + ), + ); + } + + testWidgets('渲染四个字段与 helperText,不渲染任何预留区', (tester) async { + await pumpRegister(tester, FakeAuthRepository()); + + expect(find.text('创建账号'), findsOneWidget); + expect(find.byType(TextFormField), findsNWidgets(4)); + expect(find.text('密码 8–32 位,需包含字母和数字'), findsOneWidget); + // ADR-004:短信验证码与第三方登录预留区不渲染。 + expect(find.textContaining('验证码'), findsNothing); + expect(find.text('注册'), findsOneWidget); + expect(find.text('直接登录'), findsOneWidget); + }); + + testWidgets('空表单提交:非空校验拦截,不发请求', (tester) async { + final repository = FakeAuthRepository(); + await pumpRegister(tester, repository); + + await tester.tap(find.text('注册')); + await tester.pump(); + + expect(find.text('请输入用户名'), findsOneWidget); + expect(find.text('请输入手机号'), findsOneWidget); + expect(find.text('密码需 8–32 位,且同时包含字母和数字'), findsOneWidget); + expect(repository.registerCalls, 0); + }); + + testWidgets('格式校验:用户名/手机号/密码/确认密码逐项报错', (tester) async { + final repository = FakeAuthRepository(); + await pumpRegister(tester, repository); + + await tester.enterText(find.byType(TextFormField).at(0), '1abc'); + await tester.enterText(find.byType(TextFormField).at(1), '23800138000'); + await tester.enterText(find.byType(TextFormField).at(2), 'abcdefgh'); + await tester.enterText(find.byType(TextFormField).at(3), 'abcdefg1'); + await tester.tap(find.text('注册')); + await tester.pump(); + + expect(find.text('用户名需 3–20 位,字母开头,可含数字和下划线'), findsOneWidget); + expect(find.text('请输入正确的 11 位手机号'), findsOneWidget); + expect(find.text('密码需 8–32 位,且同时包含字母和数字'), findsOneWidget); + expect(find.text('两次输入的密码不一致'), findsOneWidget); + expect(repository.registerCalls, 0); + }); + + testWidgets('全部合法:提交调用注册接口', (tester) async { + final repository = FakeAuthRepository(); + await pumpRegister(tester, repository); + + await tester.enterText(find.byType(TextFormField).at(0), 'gunnar'); + await tester.enterText(find.byType(TextFormField).at(1), '13800138000'); + await tester.enterText(find.byType(TextFormField).at(2), 'passw0rd'); + await tester.enterText(find.byType(TextFormField).at(3), 'passw0rd'); + await tester.tap(find.text('注册')); + await tester.pumpAndSettle(); + + expect(repository.registerCalls, 1); + expect(find.text('两次输入的密码不一致'), findsNothing); + }); +} diff --git a/test/helpers/auth_test_helpers.dart b/test/helpers/auth_test_helpers.dart new file mode 100644 index 0000000..a2bb24b --- /dev/null +++ b/test/helpers/auth_test_helpers.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/auth/auth_repository.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; + +/// 测试用内存 token 存储。 +class InMemoryTokenStore implements TokenStore { + final Map values = {}; + + @override + Future read(String key) async => values[key]; + + @override + Future write(String key, String value) async => values[key] = value; + + @override + Future delete(String key) async => values.remove(key); +} + +/// 测试用假认证仓库:各方法可注入行为,默认成功空实现。 +class FakeAuthRepository implements AuthRepository { + FakeAuthRepository({ + this.loginHandler, + this.registerHandler, + this.restoreHandler, + }); + + final Future Function()? loginHandler; + final Future Function()? registerHandler; + final Future Function()? restoreHandler; + + int loginCalls = 0; + int registerCalls = 0; + int logoutCalls = 0; + + @override + Future login({required String username, required String password}) { + loginCalls += 1; + return loginHandler?.call() ?? Future.value(); + } + + @override + Future register({ + required String username, + required String phone, + required String password, + }) { + registerCalls += 1; + return registerHandler?.call() ?? Future.value(); + } + + @override + Future logout() async { + logoutCalls += 1; + } + + @override + Future restoreSession() { + return restoreHandler?.call() ?? + Future.value(SessionRestoreResult.noSession); + } + + @override + Future me() async => throw UnimplementedError(); +} + +/// mock dio:用假 HttpClientAdapter 返回预设响应并记录请求。 +class FakeHttpAdapter implements HttpClientAdapter { + FakeHttpAdapter(this.handler); + + final Future Function(RequestOptions options) handler; + final List requests = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) { + requests.add(options); + return handler(options); + } + + @override + void close({bool force = false}) {} +} + +ResponseBody jsonResponse(int statusCode, Map body) { + return ResponseBody.fromString( + jsonEncode(body), + statusCode, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); +} + +Map okEnvelope(Map data) => { + 'code': 0, + 'message': 'ok', + 'data': data, +}; + +Map errorEnvelope(int code, [String message = 'error']) => { + 'code': code, + 'message': message, + 'data': null, +}; + +Map tokenDataJson({ + String access = 'access-1', + String refresh = 'refresh-1', +}) => { + 'userId': 'user-1', + 'tokenType': 'Bearer', + 'accessToken': access, + 'accessTokenExpiresAt': '2026-09-04T13:00:00+08:00', + 'refreshToken': refresh, + 'refreshTokenExpiresAt': '2026-10-04T12:00:00+08:00', +}; + +AuthTokens sampleTokens({ + String access = 'access-0', + String refresh = 'refresh-0', +}) => AuthTokens.fromJson(tokenDataJson(access: access, refresh: refresh)); diff --git a/test/widget_test.dart b/test/widget_test.dart index c869c96..86ae8fe 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,12 +1,19 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:patbond_flutter/app/app.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'helpers/auth_test_helpers.dart'; + void main() { testWidgets('Patbond renders the main navigation', (tester) async { SharedPreferences.setMockInitialValues({}); + final session = SessionManager(store: InMemoryTokenStore()) + ..markAuthenticated(); - await tester.pumpWidget(const App()); + await tester.pumpWidget( + App(sessionManager: session, authRepository: FakeAuthRepository()), + ); await tester.pumpAndSettle(); expect(find.text('Patbond'), findsWidgets); diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..0c50753 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..d0b33f8 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)