From 75f4d2c3fb8685b330d4c7f51a8a857655f67320 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 14:36:03 +0800 Subject: [PATCH 01/12] feat(apps): add core API infrastructure --- apps/lib/core/api/api_client.dart | 59 +++++++++++++++++++ apps/lib/core/api/api_exception.dart | 29 +++++++++ apps/lib/core/api/api_interceptor.dart | 40 +++++++++++++ apps/lib/core/storage/token_storage.dart | 40 +++++++++++++ apps/test/core/api/api_exception_test.dart | 24 ++++++++ .../test/core/storage/token_storage_test.dart | 44 ++++++++++++++ 6 files changed, 236 insertions(+) create mode 100644 apps/lib/core/api/api_client.dart create mode 100644 apps/lib/core/api/api_exception.dart create mode 100644 apps/lib/core/api/api_interceptor.dart create mode 100644 apps/lib/core/storage/token_storage.dart create mode 100644 apps/test/core/api/api_exception_test.dart create mode 100644 apps/test/core/storage/token_storage_test.dart diff --git a/apps/lib/core/api/api_client.dart b/apps/lib/core/api/api_client.dart new file mode 100644 index 0000000..d01f0d9 --- /dev/null +++ b/apps/lib/core/api/api_client.dart @@ -0,0 +1,59 @@ +import 'package:dio/dio.dart'; +import 'api_exception.dart'; +import 'api_interceptor.dart'; +import '../storage/token_storage.dart'; + +class ApiClient { + final Dio _dio; + final TokenStorage _tokenStorage; + final Future Function(String)? _refreshToken; + + ApiClient({ + required String baseUrl, + required TokenStorage tokenStorage, + Dio? dio, + Future Function(String)? refreshToken, + }) : _tokenStorage = tokenStorage, + _refreshToken = refreshToken, + _dio = dio ?? Dio(BaseOptions(baseUrl: baseUrl)) { + _dio.interceptors.add( + ApiInterceptor( + tokenStorage: _tokenStorage, + onTokenRefresh: _handleTokenRefresh, + ), + ); + } + + Dio get dio => _dio; + + Future _handleTokenRefresh() async { + final refreshToken = await _tokenStorage.getRefreshToken(); + if (refreshToken == null || _refreshToken == null) return false; + try { + final success = await _refreshToken!(refreshToken); + return success; + } catch (_) { + return false; + } + } + + Future> get(String path, {Options? options}) async { + try { + return await _dio.get(path, options: options); + } catch (e) { + throw ApiException.fromDioError(e); + } + } + + Future> post( + String path, { + dynamic data, + Options? options, + }) async { + try { + return await _dio.post(path, data: data, options: options); + } catch (e) { + throw ApiException.fromDioError(e); + } + } +} diff --git a/apps/lib/core/api/api_exception.dart b/apps/lib/core/api/api_exception.dart new file mode 100644 index 0000000..231b933 --- /dev/null +++ b/apps/lib/core/api/api_exception.dart @@ -0,0 +1,29 @@ +abstract class ApiException implements Exception { + final String message; + final int? statusCode; + + const ApiException(this.message, {this.statusCode}); + + factory ApiException.fromDioError(Object error) { + if (error is ApiException) return error; + return ServerException('Request failed: ${error.toString()}'); + } +} + +class NetworkException extends ApiException { + const NetworkException(super.message); +} + +class ServerException extends ApiException { + const ServerException(super.message, {super.statusCode}); +} + +class UnauthorizedException extends ApiException { + const UnauthorizedException([super.message = 'Authentication required']) + : super(statusCode: 401); +} + +class ValidationException extends ApiException { + final Map? errors; + const ValidationException(super.message, {this.errors, super.statusCode}); +} diff --git a/apps/lib/core/api/api_interceptor.dart b/apps/lib/core/api/api_interceptor.dart new file mode 100644 index 0000000..74550fe --- /dev/null +++ b/apps/lib/core/api/api_interceptor.dart @@ -0,0 +1,40 @@ +import 'package:dio/dio.dart'; +import '../storage/token_storage.dart'; + +class ApiInterceptor extends Interceptor { + final TokenStorage tokenStorage; + final Future Function()? onTokenRefresh; + + ApiInterceptor({required this.tokenStorage, this.onTokenRefresh}); + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final token = await tokenStorage.getAccessToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + if (err.response?.statusCode == 401 && onTokenRefresh != null) { + final refreshed = await onTokenRefresh!(); + if (refreshed) { + final token = await tokenStorage.getAccessToken(); + if (token != null) { + err.requestOptions.headers['Authorization'] = 'Bearer $token'; + try { + final response = await Dio().fetch(err.requestOptions); + handler.resolve(response); + return; + } catch (_) {} + } + } + } + handler.next(err); + } +} diff --git a/apps/lib/core/storage/token_storage.dart b/apps/lib/core/storage/token_storage.dart new file mode 100644 index 0000000..9015357 --- /dev/null +++ b/apps/lib/core/storage/token_storage.dart @@ -0,0 +1,40 @@ +abstract class TokenStorage { + Future getAccessToken(); + Future getRefreshToken(); + Future saveTokens({required String access, required String refresh}); + Future clear(); +} + +class SecureTokenStorage implements TokenStorage { + static const _accessTokenKey = 'access_token'; + static const _refreshTokenKey = 'refresh_token'; + + final dynamic _storage; + + SecureTokenStorage([this._storage]); + + @override + Future getAccessToken() async { + return _storage?.read(key: _accessTokenKey); + } + + @override + Future getRefreshToken() async { + return _storage?.read(key: _refreshTokenKey); + } + + @override + Future saveTokens({ + required String access, + required String refresh, + }) async { + await _storage?.write(key: _accessTokenKey, value: access); + await _storage?.write(key: _refreshTokenKey, value: refresh); + } + + @override + Future clear() async { + await _storage?.delete(key: _accessTokenKey); + await _storage?.delete(key: _refreshTokenKey); + } +} diff --git a/apps/test/core/api/api_exception_test.dart b/apps/test/core/api/api_exception_test.dart new file mode 100644 index 0000000..e42825c --- /dev/null +++ b/apps/test/core/api/api_exception_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/api/api_exception.dart'; + +void main() { + group('ApiException', () { + test('creates from DioException with 400 status', () { + final dioException = Exception('Bad request'); + final apiException = ApiException.fromDioError(dioException); + + expect(apiException, isA()); + expect(apiException.message, contains('Request failed')); + }); + + test('NetworkException has correct message', () { + const exception = NetworkException('No internet'); + expect(exception.message, 'No internet'); + }); + + test('UnauthorizedException has default message', () { + const exception = UnauthorizedException(); + expect(exception.message, 'Authentication required'); + }); + }); +} diff --git a/apps/test/core/storage/token_storage_test.dart b/apps/test/core/storage/token_storage_test.dart new file mode 100644 index 0000000..ea79f96 --- /dev/null +++ b/apps/test/core/storage/token_storage_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/core/storage/token_storage.dart'; + +class MockTokenStorage extends Mock implements TokenStorage {} + +void main() { + late TokenStorage storage; + + setUp(() { + storage = MockTokenStorage(); + }); + + group('TokenStorage', () { + test('saves and retrieves access token', () async { + when( + () => storage.getAccessToken(), + ).thenAnswer((_) async => 'test_access'); + when( + () => + storage.saveTokens(access: 'test_access', refresh: 'test_refresh'), + ).thenAnswer((_) async {}); + + await storage.saveTokens(access: 'test_access', refresh: 'test_refresh'); + final token = await storage.getAccessToken(); + + expect(token, 'test_access'); + verify( + () => + storage.saveTokens(access: 'test_access', refresh: 'test_refresh'), + ).called(1); + }); + + test('clear removes all tokens', () async { + when(() => storage.clear()).thenAnswer((_) async {}); + when(() => storage.getAccessToken()).thenAnswer((_) async => null); + + await storage.clear(); + final token = await storage.getAccessToken(); + + expect(token, isNull); + }); + }); +} From 23f5662e7b4dc4ce9b46eb4c350b935c53ad4eca Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 14:41:27 +0800 Subject: [PATCH 02/12] fix(apps): improve ApiInterceptor retry and error handling --- apps/lib/core/api/api_client.dart | 5 +++-- apps/lib/core/api/api_interceptor.dart | 13 ++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/lib/core/api/api_client.dart b/apps/lib/core/api/api_client.dart index d01f0d9..731cd62 100644 --- a/apps/lib/core/api/api_client.dart +++ b/apps/lib/core/api/api_client.dart @@ -19,6 +19,7 @@ class ApiClient { _dio.interceptors.add( ApiInterceptor( tokenStorage: _tokenStorage, + dio: _dio, onTokenRefresh: _handleTokenRefresh, ), ); @@ -40,7 +41,7 @@ class ApiClient { Future> get(String path, {Options? options}) async { try { return await _dio.get(path, options: options); - } catch (e) { + } on DioException catch (e) { throw ApiException.fromDioError(e); } } @@ -52,7 +53,7 @@ class ApiClient { }) async { try { return await _dio.post(path, data: data, options: options); - } catch (e) { + } on DioException catch (e) { throw ApiException.fromDioError(e); } } diff --git a/apps/lib/core/api/api_interceptor.dart b/apps/lib/core/api/api_interceptor.dart index 74550fe..a2db41c 100644 --- a/apps/lib/core/api/api_interceptor.dart +++ b/apps/lib/core/api/api_interceptor.dart @@ -4,8 +4,13 @@ import '../storage/token_storage.dart'; class ApiInterceptor extends Interceptor { final TokenStorage tokenStorage; final Future Function()? onTokenRefresh; + final Dio dio; - ApiInterceptor({required this.tokenStorage, this.onTokenRefresh}); + ApiInterceptor({ + required this.tokenStorage, + required this.dio, + this.onTokenRefresh, + }); @override void onRequest( @@ -28,10 +33,12 @@ class ApiInterceptor extends Interceptor { if (token != null) { err.requestOptions.headers['Authorization'] = 'Bearer $token'; try { - final response = await Dio().fetch(err.requestOptions); + final response = await dio.fetch(err.requestOptions); handler.resolve(response); return; - } catch (_) {} + } on DioException { + // Retry failed, proceed with original error + } } } } From bfec6ffd7d11961b7cde81fbeccebab4d260fac0 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 14:45:08 +0800 Subject: [PATCH 03/12] feat(apps): add auth data models --- .../auth/data/models/auth_response.dart | 56 +++++++++++++++++++ .../auth/data/models/login_request.dart | 24 ++++++++ .../auth/data/models/signup_request.dart | 34 +++++++++++ .../auth/data/models/auth_models_test.dart | 56 +++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 apps/lib/features/auth/data/models/auth_response.dart create mode 100644 apps/lib/features/auth/data/models/login_request.dart create mode 100644 apps/lib/features/auth/data/models/signup_request.dart create mode 100644 apps/test/features/auth/data/models/auth_models_test.dart diff --git a/apps/lib/features/auth/data/models/auth_response.dart b/apps/lib/features/auth/data/models/auth_response.dart new file mode 100644 index 0000000..b2dd3c5 --- /dev/null +++ b/apps/lib/features/auth/data/models/auth_response.dart @@ -0,0 +1,56 @@ +class AuthUser { + final String id; + final String email; + + const AuthUser({required this.id, required this.email}); + + factory AuthUser.fromJson(Map json) { + return AuthUser(id: json['id'] as String, email: json['email'] as String); + } +} + +class AuthResponse { + final String accessToken; + final String refreshToken; + final int expiresIn; + final String tokenType; + final AuthUser user; + + const AuthResponse({ + required this.accessToken, + required this.refreshToken, + required this.expiresIn, + required this.tokenType, + required this.user, + }); + + factory AuthResponse.fromJson(Map json) { + return AuthResponse( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, + expiresIn: json['expires_in'] as int, + tokenType: json['token_type'] as String, + user: AuthUser.fromJson(json['user'] as Map), + ); + } +} + +class SignupStartResponse { + final String status; + final String email; + final String message; + + const SignupStartResponse({ + required this.status, + required this.email, + required this.message, + }); + + factory SignupStartResponse.fromJson(Map json) { + return SignupStartResponse( + status: json['status'] as String, + email: json['email'] as String, + message: json['message'] as String, + ); + } +} diff --git a/apps/lib/features/auth/data/models/login_request.dart b/apps/lib/features/auth/data/models/login_request.dart new file mode 100644 index 0000000..8d772a2 --- /dev/null +++ b/apps/lib/features/auth/data/models/login_request.dart @@ -0,0 +1,24 @@ +class LoginRequest { + final String email; + final String password; + + const LoginRequest({required this.email, required this.password}); + + Map toJson() => {'email': email, 'password': password}; +} + +class RefreshRequest { + final String refreshToken; + + const RefreshRequest({required this.refreshToken}); + + Map toJson() => {'refresh_token': refreshToken}; +} + +class LogoutRequest { + final String refreshToken; + + const LogoutRequest({required this.refreshToken}); + + Map toJson() => {'refresh_token': refreshToken}; +} diff --git a/apps/lib/features/auth/data/models/signup_request.dart b/apps/lib/features/auth/data/models/signup_request.dart new file mode 100644 index 0000000..55e59d8 --- /dev/null +++ b/apps/lib/features/auth/data/models/signup_request.dart @@ -0,0 +1,34 @@ +class SignupStartRequest { + final String username; + final String email; + final String password; + + const SignupStartRequest({ + required this.username, + required this.email, + required this.password, + }); + + Map toJson() => { + 'username': username, + 'email': email, + 'password': password, + }; +} + +class SignupVerifyRequest { + final String email; + final String token; + + const SignupVerifyRequest({required this.email, required this.token}); + + Map toJson() => {'email': email, 'token': token}; +} + +class SignupResendRequest { + final String email; + + const SignupResendRequest({required this.email}); + + Map toJson() => {'email': email}; +} diff --git a/apps/test/features/auth/data/models/auth_models_test.dart b/apps/test/features/auth/data/models/auth_models_test.dart new file mode 100644 index 0000000..7641de1 --- /dev/null +++ b/apps/test/features/auth/data/models/auth_models_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/auth/data/models/signup_request.dart'; +import 'package:social_app/features/auth/data/models/login_request.dart'; +import 'package:social_app/features/auth/data/models/auth_response.dart'; + +void main() { + group('SignupStartRequest', () { + test('serializes to JSON', () { + final request = SignupStartRequest( + username: 'testuser', + email: 'test@example.com', + password: 'password123', + ); + + final json = request.toJson(); + + expect(json['username'], 'testuser'); + expect(json['email'], 'test@example.com'); + expect(json['password'], 'password123'); + }); + }); + + group('LoginRequest', () { + test('serializes to JSON', () { + final request = LoginRequest( + email: 'test@example.com', + password: 'password123', + ); + + final json = request.toJson(); + + expect(json['email'], 'test@example.com'); + expect(json['password'], 'password123'); + }); + }); + + group('AuthResponse', () { + test('parses from JSON', () { + final json = { + 'access_token': 'test_access', + 'refresh_token': 'test_refresh', + 'expires_in': 3600, + 'token_type': 'bearer', + 'user': {'id': '123', 'email': 'test@example.com'}, + }; + + final response = AuthResponse.fromJson(json); + + expect(response.accessToken, 'test_access'); + expect(response.refreshToken, 'test_refresh'); + expect(response.expiresIn, 3600); + expect(response.user.id, '123'); + expect(response.user.email, 'test@example.com'); + }); + }); +} From b00cfc80ab4a0e20104c6ce6b965bf805a39078c Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 14:51:21 +0800 Subject: [PATCH 04/12] feat(apps): add auth repository --- apps/lib/features/auth/data/auth_api.dart | 55 +++++++++++++ .../features/auth/data/auth_repository.dart | 15 ++++ .../auth/data/auth_repository_impl.dart | 78 +++++++++++++++++++ .../auth/data/auth_repository_test.dart | 74 ++++++++++++++++++ 4 files changed, 222 insertions(+) create mode 100644 apps/lib/features/auth/data/auth_api.dart create mode 100644 apps/lib/features/auth/data/auth_repository.dart create mode 100644 apps/lib/features/auth/data/auth_repository_impl.dart create mode 100644 apps/test/features/auth/data/auth_repository_test.dart diff --git a/apps/lib/features/auth/data/auth_api.dart b/apps/lib/features/auth/data/auth_api.dart new file mode 100644 index 0000000..33e6010 --- /dev/null +++ b/apps/lib/features/auth/data/auth_api.dart @@ -0,0 +1,55 @@ +import 'package:social_app/core/api/api_client.dart'; +import 'models/signup_request.dart'; +import 'models/login_request.dart'; +import 'models/auth_response.dart'; + +class AuthApi { + final ApiClient _client; + static const _prefix = '/v1/auth'; + + AuthApi(this._client); + + Future signupStart(SignupStartRequest request) async { + final response = await _client.post( + '$_prefix/signup/start', + data: request.toJson(), + ); + return SignupStartResponse.fromJson(response.data); + } + + Future signupVerify(SignupVerifyRequest request) async { + final response = await _client.post( + '$_prefix/signup/verify', + data: request.toJson(), + ); + return AuthResponse.fromJson(response.data); + } + + Future signupResend(SignupResendRequest request) async { + final response = await _client.post( + '$_prefix/signup/resend', + data: request.toJson(), + ); + return SignupStartResponse.fromJson(response.data); + } + + Future login(LoginRequest request) async { + final response = await _client.post( + '$_prefix/login', + data: request.toJson(), + ); + return AuthResponse.fromJson(response.data); + } + + Future refresh(RefreshRequest request) async { + final response = await _client.post( + '$_prefix/refresh', + data: request.toJson(), + ); + return AuthResponse.fromJson(response.data); + } + + Future logout(LogoutRequest request) async { + await _client.post('$_prefix/logout', data: request.toJson()); + } +} diff --git a/apps/lib/features/auth/data/auth_repository.dart b/apps/lib/features/auth/data/auth_repository.dart new file mode 100644 index 0000000..20f1cda --- /dev/null +++ b/apps/lib/features/auth/data/auth_repository.dart @@ -0,0 +1,15 @@ +import 'package:social_app/features/auth/data/models/signup_request.dart'; +import 'package:social_app/features/auth/data/models/login_request.dart'; +import 'package:social_app/features/auth/data/models/auth_response.dart'; + +abstract class AuthRepository { + Future signupStart(SignupStartRequest request); + Future signupVerify(SignupVerifyRequest request); + Future signupResend(SignupResendRequest request); + Future login(LoginRequest request); + Future refresh(String refreshToken); + Future logout(); + Future getAccessToken(); + Future getRefreshToken(); + Future isAuthenticated(); +} diff --git a/apps/lib/features/auth/data/auth_repository_impl.dart b/apps/lib/features/auth/data/auth_repository_impl.dart new file mode 100644 index 0000000..3860ab7 --- /dev/null +++ b/apps/lib/features/auth/data/auth_repository_impl.dart @@ -0,0 +1,78 @@ +import 'package:social_app/core/storage/token_storage.dart'; +import 'auth_api.dart'; +import 'auth_repository.dart'; +import 'models/signup_request.dart'; +import 'models/login_request.dart'; +import 'models/auth_response.dart'; + +class AuthRepositoryImpl implements AuthRepository { + final AuthApi _api; + final TokenStorage _tokenStorage; + + AuthRepositoryImpl({required AuthApi api, required TokenStorage tokenStorage}) + : _api = api, + _tokenStorage = tokenStorage; + + @override + Future signupStart(SignupStartRequest request) { + return _api.signupStart(request); + } + + @override + Future signupVerify(SignupVerifyRequest request) async { + final response = await _api.signupVerify(request); + await _tokenStorage.saveTokens( + access: response.accessToken, + refresh: response.refreshToken, + ); + return response; + } + + @override + Future signupResend(SignupResendRequest request) { + return _api.signupResend(request); + } + + @override + Future login(LoginRequest request) async { + final response = await _api.login(request); + await _tokenStorage.saveTokens( + access: response.accessToken, + refresh: response.refreshToken, + ); + return response; + } + + @override + Future refresh(String refreshToken) async { + final response = await _api.refresh( + RefreshRequest(refreshToken: refreshToken), + ); + await _tokenStorage.saveTokens( + access: response.accessToken, + refresh: response.refreshToken, + ); + return response; + } + + @override + Future logout() async { + final refreshToken = await _tokenStorage.getRefreshToken(); + if (refreshToken != null) { + await _api.logout(LogoutRequest(refreshToken: refreshToken)); + } + await _tokenStorage.clear(); + } + + @override + Future getAccessToken() => _tokenStorage.getAccessToken(); + + @override + Future getRefreshToken() => _tokenStorage.getRefreshToken(); + + @override + Future isAuthenticated() async { + final token = await _tokenStorage.getAccessToken(); + return token != null; + } +} diff --git a/apps/test/features/auth/data/auth_repository_test.dart b/apps/test/features/auth/data/auth_repository_test.dart new file mode 100644 index 0000000..5fda708 --- /dev/null +++ b/apps/test/features/auth/data/auth_repository_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/data/auth_repository_impl.dart'; +import 'package:social_app/features/auth/data/models/signup_request.dart'; +import 'package:social_app/features/auth/data/models/login_request.dart'; +import 'package:social_app/features/auth/data/models/auth_response.dart'; +import 'package:social_app/core/storage/token_storage.dart'; + +class MockTokenStorage extends Mock implements TokenStorage {} + +class FakeSignupStartRequest extends Fake implements SignupStartRequest {} + +class FakeLoginRequest extends Fake implements LoginRequest {} + +void main() { + late AuthRepository repository; + late MockTokenStorage mockStorage; + + setUpAll(() { + registerFallbackValue(FakeSignupStartRequest()); + registerFallbackValue(FakeLoginRequest()); + }); + + setUp(() { + mockStorage = MockTokenStorage(); + }); + + group('AuthRepository', () { + test('signupStart returns SignupStartResponse', () async { + final repo = _MockAuthRepository(); + when(() => repo.signupStart(any())).thenAnswer( + (_) async => const SignupStartResponse( + status: 'pending_verification', + email: 'test@example.com', + message: 'Verification code sent', + ), + ); + + final result = await repo.signupStart( + const SignupStartRequest( + username: 'testuser', + email: 'test@example.com', + password: 'password123', + ), + ); + + expect(result.status, 'pending_verification'); + expect(result.email, 'test@example.com'); + }); + + test('login returns AuthResponse and saves tokens', () async { + final repo = _MockAuthRepository(); + when(() => repo.login(any())).thenAnswer( + (_) async => AuthResponse( + accessToken: 'access_token', + refreshToken: 'refresh_token', + expiresIn: 3600, + tokenType: 'bearer', + user: const AuthUser(id: '123', email: 'test@example.com'), + ), + ); + + final result = await repo.login( + const LoginRequest(email: 'test@example.com', password: 'password123'), + ); + + expect(result.accessToken, 'access_token'); + expect(result.user.email, 'test@example.com'); + }); + }); +} + +class _MockAuthRepository extends Mock implements AuthRepository {} From 3be03d8c7488f335cf2c602dc677e18f0d4e5c80 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 14:55:07 +0800 Subject: [PATCH 05/12] fix(apps): improve auth repository tests --- .../auth/data/auth_repository_test.dart | 101 ++++++++++++++---- 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/apps/test/features/auth/data/auth_repository_test.dart b/apps/test/features/auth/data/auth_repository_test.dart index 5fda708..011b69c 100644 --- a/apps/test/features/auth/data/auth_repository_test.dart +++ b/apps/test/features/auth/data/auth_repository_test.dart @@ -1,35 +1,41 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/data/auth_api.dart'; import 'package:social_app/features/auth/data/auth_repository_impl.dart'; import 'package:social_app/features/auth/data/models/signup_request.dart'; import 'package:social_app/features/auth/data/models/login_request.dart'; import 'package:social_app/features/auth/data/models/auth_response.dart'; import 'package:social_app/core/storage/token_storage.dart'; +import 'package:social_app/core/api/api_client.dart'; + +class MockAuthApi extends Mock implements AuthApi {} class MockTokenStorage extends Mock implements TokenStorage {} -class FakeSignupStartRequest extends Fake implements SignupStartRequest {} - -class FakeLoginRequest extends Fake implements LoginRequest {} +class MockApiClient extends Mock implements ApiClient {} void main() { - late AuthRepository repository; + late AuthRepositoryImpl repository; + late MockAuthApi mockApi; late MockTokenStorage mockStorage; - setUpAll(() { - registerFallbackValue(FakeSignupStartRequest()); - registerFallbackValue(FakeLoginRequest()); - }); - setUp(() { + mockApi = MockAuthApi(); mockStorage = MockTokenStorage(); + repository = AuthRepositoryImpl(api: mockApi, tokenStorage: mockStorage); + registerFallbackValue( + const SignupStartRequest(username: '', email: '', password: ''), + ); + registerFallbackValue(const LoginRequest(email: '', password: '')); + registerFallbackValue(const SignupVerifyRequest(email: '', token: '')); + registerFallbackValue(const SignupResendRequest(email: '')); + registerFallbackValue(const LogoutRequest(refreshToken: '')); + registerFallbackValue(const RefreshRequest(refreshToken: '')); }); - group('AuthRepository', () { - test('signupStart returns SignupStartResponse', () async { - final repo = _MockAuthRepository(); - when(() => repo.signupStart(any())).thenAnswer( + group('AuthRepositoryImpl', () { + test('signupStart calls api and returns response', () async { + when(() => mockApi.signupStart(any())).thenAnswer( (_) async => const SignupStartResponse( status: 'pending_verification', email: 'test@example.com', @@ -37,7 +43,7 @@ void main() { ), ); - final result = await repo.signupStart( + final result = await repository.signupStart( const SignupStartRequest( username: 'testuser', email: 'test@example.com', @@ -46,12 +52,11 @@ void main() { ); expect(result.status, 'pending_verification'); - expect(result.email, 'test@example.com'); + verify(() => mockApi.signupStart(any())).called(1); }); - test('login returns AuthResponse and saves tokens', () async { - final repo = _MockAuthRepository(); - when(() => repo.login(any())).thenAnswer( + test('login calls api and saves tokens', () async { + when(() => mockApi.login(any())).thenAnswer( (_) async => AuthResponse( accessToken: 'access_token', refreshToken: 'refresh_token', @@ -60,15 +65,65 @@ void main() { user: const AuthUser(id: '123', email: 'test@example.com'), ), ); + when( + () => mockStorage.saveTokens( + access: any(named: 'access'), + refresh: any(named: 'refresh'), + ), + ).thenAnswer((_) async {}); - final result = await repo.login( + final result = await repository.login( const LoginRequest(email: 'test@example.com', password: 'password123'), ); expect(result.accessToken, 'access_token'); - expect(result.user.email, 'test@example.com'); + verify( + () => mockStorage.saveTokens( + access: 'access_token', + refresh: 'refresh_token', + ), + ).called(1); + }); + + test('logout calls api with refresh token and clears storage', () async { + when( + () => mockStorage.getRefreshToken(), + ).thenAnswer((_) async => 'refresh_token'); + when(() => mockApi.logout(any())).thenAnswer((_) async {}); + when(() => mockStorage.clear()).thenAnswer((_) async {}); + + await repository.logout(); + + verify(() => mockApi.logout(any())).called(1); + verify(() => mockStorage.clear()).called(1); + }); + + test('refresh saves new tokens', () async { + when(() => mockApi.refresh(any())).thenAnswer( + (_) async => AuthResponse( + accessToken: 'new_access', + refreshToken: 'new_refresh', + expiresIn: 3600, + tokenType: 'bearer', + user: const AuthUser(id: '123', email: 'test@example.com'), + ), + ); + when( + () => mockStorage.saveTokens( + access: any(named: 'access'), + refresh: any(named: 'refresh'), + ), + ).thenAnswer((_) async {}); + + final result = await repository.refresh('old_refresh'); + + expect(result.accessToken, 'new_access'); + verify( + () => mockStorage.saveTokens( + access: 'new_access', + refresh: 'new_refresh', + ), + ).called(1); }); }); } - -class _MockAuthRepository extends Mock implements AuthRepository {} From 9b51c8b29347ef704bd05b4aef7ced3ebbd8d210 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 14:59:20 +0800 Subject: [PATCH 06/12] feat(apps): add AuthBloc for global auth state --- .../auth/presentation/bloc/auth_bloc.dart | 45 +++++++++ .../auth/presentation/bloc/auth_event.dart | 22 +++++ .../auth/presentation/bloc/auth_state.dart | 26 +++++ .../presentation/bloc/auth_bloc_test.dart | 99 +++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 apps/lib/features/auth/presentation/bloc/auth_bloc.dart create mode 100644 apps/lib/features/auth/presentation/bloc/auth_event.dart create mode 100644 apps/lib/features/auth/presentation/bloc/auth_state.dart create mode 100644 apps/test/features/auth/presentation/bloc/auth_bloc_test.dart diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart new file mode 100644 index 0000000..3b3d790 --- /dev/null +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -0,0 +1,45 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../data/auth_repository.dart'; +import 'auth_event.dart'; +import 'auth_state.dart'; + +class AuthBloc extends Bloc { + final AuthRepository _repository; + + AuthBloc(this._repository) : super(AuthInitial()) { + on(_onStarted); + on(_onLoggedIn); + on(_onLoggedOut); + } + + Future _onStarted(AuthStarted event, Emitter emit) async { + emit(AuthLoading()); + final refreshToken = await _repository.getRefreshToken(); + if (refreshToken != null) { + try { + final response = await _repository.refresh(refreshToken); + emit( + AuthAuthenticated( + user: AuthUser(id: response.user.id, email: response.user.email), + ), + ); + return; + } catch (_) { + await _repository.logout(); + } + } + emit(AuthUnauthenticated()); + } + + void _onLoggedIn(AuthLoggedIn event, Emitter emit) { + emit(AuthAuthenticated(user: event.user)); + } + + Future _onLoggedOut( + AuthLoggedOut event, + Emitter emit, + ) async { + await _repository.logout(); + emit(AuthUnauthenticated()); + } +} diff --git a/apps/lib/features/auth/presentation/bloc/auth_event.dart b/apps/lib/features/auth/presentation/bloc/auth_event.dart new file mode 100644 index 0000000..3b773df --- /dev/null +++ b/apps/lib/features/auth/presentation/bloc/auth_event.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; +import '../../data/models/auth_response.dart'; + +abstract class AuthEvent extends Equatable { + const AuthEvent(); + + @override + List get props => []; +} + +class AuthStarted extends AuthEvent {} + +class AuthLoggedIn extends AuthEvent { + final AuthUser user; + + const AuthLoggedIn({required this.user}); + + @override + List get props => [user]; +} + +class AuthLoggedOut extends AuthEvent {} diff --git a/apps/lib/features/auth/presentation/bloc/auth_state.dart b/apps/lib/features/auth/presentation/bloc/auth_state.dart new file mode 100644 index 0000000..040d2e3 --- /dev/null +++ b/apps/lib/features/auth/presentation/bloc/auth_state.dart @@ -0,0 +1,26 @@ +import 'package:equatable/equatable.dart'; +import '../../data/models/auth_response.dart'; + +export '../../data/models/auth_response.dart' show AuthUser; + +abstract class AuthState extends Equatable { + const AuthState(); + + @override + List get props => []; +} + +class AuthInitial extends AuthState {} + +class AuthLoading extends AuthState {} + +class AuthAuthenticated extends AuthState { + final AuthUser user; + + const AuthAuthenticated({required this.user}); + + @override + List get props => [user]; +} + +class AuthUnauthenticated extends AuthState {} diff --git a/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart b/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart new file mode 100644 index 0000000..38244f7 --- /dev/null +++ b/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart @@ -0,0 +1,99 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/data/models/auth_response.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_event.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late AuthBloc authBloc; + late MockAuthRepository mockRepository; + + setUp(() { + mockRepository = MockAuthRepository(); + authBloc = AuthBloc(mockRepository); + }); + + tearDown(() { + authBloc.close(); + }); + + group('AuthBloc', () { + blocTest( + 'emits [AuthLoading, AuthUnauthenticated] when AuthStarted and no refresh token', + build: () { + when( + () => mockRepository.getRefreshToken(), + ).thenAnswer((_) async => null); + return authBloc; + }, + act: (bloc) => bloc.add(AuthStarted()), + expect: () => [AuthLoading(), AuthUnauthenticated()], + ); + + blocTest( + 'emits [AuthLoading, AuthAuthenticated] when AuthStarted with valid refresh token', + build: () { + when( + () => mockRepository.getRefreshToken(), + ).thenAnswer((_) async => 'valid_refresh'); + when(() => mockRepository.refresh('valid_refresh')).thenAnswer( + (_) async => AuthResponse( + accessToken: 'new_access', + refreshToken: 'new_refresh', + expiresIn: 3600, + tokenType: 'bearer', + user: const AuthUser(id: '123', email: 'test@example.com'), + ), + ); + return authBloc; + }, + act: (bloc) => bloc.add(AuthStarted()), + expect: () => [AuthLoading(), isA()], + ); + + blocTest( + 'emits [AuthLoading, AuthUnauthenticated] when refresh token expired', + build: () { + when( + () => mockRepository.getRefreshToken(), + ).thenAnswer((_) async => 'expired_refresh'); + when( + () => mockRepository.refresh('expired_refresh'), + ).thenThrow(Exception('Invalid refresh token')); + when(() => mockRepository.logout()).thenAnswer((_) async {}); + return authBloc; + }, + act: (bloc) => bloc.add(AuthStarted()), + expect: () => [AuthLoading(), AuthUnauthenticated()], + ); + + blocTest( + 'emits [AuthAuthenticated] when AuthLoggedIn', + build: () => authBloc, + act: (bloc) => bloc.add( + AuthLoggedIn( + user: const AuthUser(id: '1', email: 'test@example.com'), + ), + ), + expect: () => [isA()], + ); + + blocTest( + 'emits [AuthUnauthenticated] when AuthLoggedOut', + build: () { + when(() => mockRepository.logout()).thenAnswer((_) async {}); + return authBloc; + }, + seed: () => AuthAuthenticated( + user: const AuthUser(id: '1', email: 'test@example.com'), + ), + act: (bloc) => bloc.add(AuthLoggedOut()), + expect: () => [AuthUnauthenticated()], + ); + }); +} From 89d2722241ea72a2e164b772c479067809f4a340 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 15:05:29 +0800 Subject: [PATCH 07/12] feat(apps): add RegisterCubit for signup form --- .../presentation/cubits/register_cubit.dart | 209 ++++++++++++++++++ .../cubits/register_cubit_test.dart | 49 ++++ 2 files changed, 258 insertions(+) create mode 100644 apps/lib/features/auth/presentation/cubits/register_cubit.dart create mode 100644 apps/test/features/auth/presentation/cubits/register_cubit_test.dart diff --git a/apps/lib/features/auth/presentation/cubits/register_cubit.dart b/apps/lib/features/auth/presentation/cubits/register_cubit.dart new file mode 100644 index 0000000..7f367b6 --- /dev/null +++ b/apps/lib/features/auth/presentation/cubits/register_cubit.dart @@ -0,0 +1,209 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; +import 'package:equatable/equatable.dart'; +import '../../data/auth_repository.dart'; +import '../../data/models/signup_request.dart'; +import '../../data/models/auth_response.dart'; + +class Username extends FormzInput { + const Username.pure() : super.pure(''); + const Username.dirty([super.value = '']) : super.dirty(); + + @override + String? validator(String value) { + if (value.isEmpty) return 'Username is required'; + if (value.length < 3) return 'Username must be at least 3 characters'; + if (value.length > 30) return 'Username must be at most 30 characters'; + return null; + } +} + +class Email extends FormzInput { + const Email.pure() : super.pure(''); + const Email.dirty([super.value = '']) : super.dirty(); + + static final _regex = RegExp(r'^[\w.-]+@[\w.-]+\.\w+$'); + + @override + String? validator(String value) { + if (value.isEmpty) return 'Email is required'; + if (!_regex.hasMatch(value)) return 'Invalid email format'; + return null; + } +} + +class Password extends FormzInput { + const Password.pure() : super.pure(''); + const Password.dirty([super.value = '']) : super.dirty(); + + @override + String? validator(String value) { + if (value.isEmpty) return 'Password is required'; + if (value.length < 6) return 'Password must be at least 6 characters'; + return null; + } +} + +class VerificationCode extends FormzInput { + const VerificationCode.pure() : super.pure(''); + const VerificationCode.dirty([super.value = '']) : super.dirty(); + + @override + String? validator(String value) { + if (value.isEmpty) return 'Code is required'; + if (!RegExp(r'^\d{6}$').hasMatch(value)) return 'Code must be 6 digits'; + return null; + } +} + +class RegisterState extends Equatable { + final Username username; + final Email email; + final Password password; + final VerificationCode verificationCode; + final FormzSubmissionStatus status; + final String? errorMessage; + final String? pendingEmail; + final bool codeSent; + + const RegisterState({ + this.username = const Username.pure(), + this.email = const Email.pure(), + this.password = const Password.pure(), + this.verificationCode = const VerificationCode.pure(), + this.status = FormzSubmissionStatus.initial, + this.errorMessage, + this.pendingEmail, + this.codeSent = false, + }); + + bool get isStep1Valid => + username.isValid && email.isValid && password.isValid; + bool get isStep2Valid => verificationCode.isValid; + + RegisterState copyWith({ + Username? username, + Email? email, + Password? password, + VerificationCode? verificationCode, + FormzSubmissionStatus? status, + String? errorMessage, + String? pendingEmail, + bool? codeSent, + }) { + return RegisterState( + username: username ?? this.username, + email: email ?? this.email, + password: password ?? this.password, + verificationCode: verificationCode ?? this.verificationCode, + status: status ?? this.status, + errorMessage: errorMessage, + pendingEmail: pendingEmail ?? this.pendingEmail, + codeSent: codeSent ?? this.codeSent, + ); + } + + @override + List get props => [ + username, + email, + password, + verificationCode, + status, + errorMessage, + pendingEmail, + codeSent, + ]; +} + +class RegisterCubit extends Cubit { + final AuthRepository _repository; + + RegisterCubit(this._repository) : super(const RegisterState()); + + void usernameChanged(String value) { + emit(state.copyWith(username: Username.dirty(value))); + } + + void emailChanged(String value) { + emit(state.copyWith(email: Email.dirty(value))); + } + + void passwordChanged(String value) { + emit(state.copyWith(password: Password.dirty(value))); + } + + void verificationCodeChanged(String value) { + emit(state.copyWith(verificationCode: VerificationCode.dirty(value))); + } + + Future submitStep1() async { + if (!state.isStep1Valid) return false; + + emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); + + try { + final response = await _repository.signupStart( + SignupStartRequest( + username: state.username.value, + email: state.email.value, + password: state.password.value, + ), + ); + emit( + state.copyWith( + status: FormzSubmissionStatus.success, + pendingEmail: response.email, + codeSent: true, + ), + ); + return true; + } catch (e) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: e.toString(), + ), + ); + return false; + } + } + + Future submitStep2() async { + if (!state.isStep2Valid || state.pendingEmail == null) return null; + + emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); + + try { + final response = await _repository.signupVerify( + SignupVerifyRequest( + email: state.pendingEmail!, + token: state.verificationCode.value, + ), + ); + emit(state.copyWith(status: FormzSubmissionStatus.success)); + return response; + } catch (e) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: e.toString(), + ), + ); + return null; + } + } + + Future resendCode() async { + if (state.pendingEmail == null) return; + + try { + await _repository.signupResend( + SignupResendRequest(email: state.pendingEmail!), + ); + emit(state.copyWith(codeSent: true)); + } catch (e) { + emit(state.copyWith(errorMessage: e.toString())); + } + } +} diff --git a/apps/test/features/auth/presentation/cubits/register_cubit_test.dart b/apps/test/features/auth/presentation/cubits/register_cubit_test.dart new file mode 100644 index 0000000..a2bfc56 --- /dev/null +++ b/apps/test/features/auth/presentation/cubits/register_cubit_test.dart @@ -0,0 +1,49 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:formz/formz.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/presentation/cubits/register_cubit.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late RegisterCubit cubit; + late MockAuthRepository mockRepository; + + setUp(() { + mockRepository = MockAuthRepository(); + cubit = RegisterCubit(mockRepository); + }); + + tearDown(() { + cubit.close(); + }); + + group('RegisterCubit', () { + test('initial state has pure status', () { + expect(cubit.state.status, FormzSubmissionStatus.initial); + }); + + blocTest( + 'usernameChanged updates username', + build: () => cubit, + act: (c) => c.usernameChanged('testuser'), + expect: () => [isA()], + ); + + blocTest( + 'emailChanged updates email', + build: () => cubit, + act: (c) => c.emailChanged('test@example.com'), + expect: () => [isA()], + ); + + blocTest( + 'passwordChanged updates password', + build: () => cubit, + act: (c) => c.passwordChanged('password123'), + expect: () => [isA()], + ); + }); +} From c9195b81b68326c6bf36b52f03926b96e1509f38 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 15:09:29 +0800 Subject: [PATCH 08/12] feat(apps): add LoginCubit for login form --- .../auth/presentation/cubits/login_cubit.dart | 76 +++++++++++++++++++ .../presentation/cubits/login_cubit_test.dart | 42 ++++++++++ 2 files changed, 118 insertions(+) create mode 100644 apps/lib/features/auth/presentation/cubits/login_cubit.dart create mode 100644 apps/test/features/auth/presentation/cubits/login_cubit_test.dart diff --git a/apps/lib/features/auth/presentation/cubits/login_cubit.dart b/apps/lib/features/auth/presentation/cubits/login_cubit.dart new file mode 100644 index 0000000..817656e --- /dev/null +++ b/apps/lib/features/auth/presentation/cubits/login_cubit.dart @@ -0,0 +1,76 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; +import 'package:equatable/equatable.dart'; +import '../../data/auth_repository.dart'; +import '../../data/models/login_request.dart'; +import '../../data/models/auth_response.dart'; +import 'register_cubit.dart' show Email, Password; + +class LoginState extends Equatable { + final Email email; + final Password password; + final FormzSubmissionStatus status; + final String? errorMessage; + + const LoginState({ + this.email = const Email.pure(), + this.password = const Password.pure(), + this.status = FormzSubmissionStatus.initial, + this.errorMessage, + }); + + bool get isValid => email.isValid && password.isValid; + + LoginState copyWith({ + Email? email, + Password? password, + FormzSubmissionStatus? status, + String? errorMessage, + }) { + return LoginState( + email: email ?? this.email, + password: password ?? this.password, + status: status ?? this.status, + errorMessage: errorMessage, + ); + } + + @override + List get props => [email, password, status, errorMessage]; +} + +class LoginCubit extends Cubit { + final AuthRepository _repository; + + LoginCubit(this._repository) : super(const LoginState()); + + void emailChanged(String value) { + emit(state.copyWith(email: Email.dirty(value))); + } + + void passwordChanged(String value) { + emit(state.copyWith(password: Password.dirty(value))); + } + + Future submit() async { + if (!state.isValid) return null; + + emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); + + try { + final response = await _repository.login( + LoginRequest(email: state.email.value, password: state.password.value), + ); + emit(state.copyWith(status: FormzSubmissionStatus.success)); + return response; + } catch (e) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: e.toString(), + ), + ); + return null; + } + } +} diff --git a/apps/test/features/auth/presentation/cubits/login_cubit_test.dart b/apps/test/features/auth/presentation/cubits/login_cubit_test.dart new file mode 100644 index 0000000..3403ff7 --- /dev/null +++ b/apps/test/features/auth/presentation/cubits/login_cubit_test.dart @@ -0,0 +1,42 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:formz/formz.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/presentation/cubits/login_cubit.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late LoginCubit cubit; + late MockAuthRepository mockRepository; + + setUp(() { + mockRepository = MockAuthRepository(); + cubit = LoginCubit(mockRepository); + }); + + tearDown(() { + cubit.close(); + }); + + group('LoginCubit', () { + test('initial state has pure status', () { + expect(cubit.state.status, FormzSubmissionStatus.initial); + }); + + blocTest( + 'emailChanged updates email', + build: () => cubit, + act: (c) => c.emailChanged('test@example.com'), + expect: () => [isA()], + ); + + blocTest( + 'passwordChanged updates password', + build: () => cubit, + act: (c) => c.passwordChanged('password123'), + expect: () => [isA()], + ); + }); +} From 0a7e1cd2d41dd24379427b152adbff46616020c3 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 15:11:25 +0800 Subject: [PATCH 09/12] feat(apps): add dependency injection configuration --- apps/lib/core/config/env.dart | 6 ++++ apps/lib/core/di/injection.dart | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 apps/lib/core/config/env.dart create mode 100644 apps/lib/core/di/injection.dart diff --git a/apps/lib/core/config/env.dart b/apps/lib/core/config/env.dart new file mode 100644 index 0000000..8267e2f --- /dev/null +++ b/apps/lib/core/config/env.dart @@ -0,0 +1,6 @@ +class Env { + static String get apiUrl { + const url = String.fromEnvironment('API_URL'); + return url.isNotEmpty ? url : 'http://localhost:8000'; + } +} diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart new file mode 100644 index 0000000..6fb9452 --- /dev/null +++ b/apps/lib/core/di/injection.dart @@ -0,0 +1,52 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:get_it/get_it.dart'; +import '../api/api_client.dart'; +import '../storage/token_storage.dart'; +import '../config/env.dart'; +import '../../features/auth/data/auth_api.dart'; +import '../../features/auth/data/auth_repository.dart'; +import '../../features/auth/data/auth_repository_impl.dart'; +import '../../features/auth/presentation/bloc/auth_bloc.dart'; + +final sl = GetIt.instance; + +Future configureDependencies() async { + final dio = Dio(BaseOptions(baseUrl: Env.apiUrl)); + final secureStorage = const FlutterSecureStorage(); + final tokenStorage = SecureTokenStorage(secureStorage); + + sl.registerSingleton(dio); + sl.registerSingleton(secureStorage); + sl.registerSingleton(tokenStorage); + + final authApi = AuthApi( + ApiClient(baseUrl: Env.apiUrl, tokenStorage: tokenStorage, dio: dio), + ); + sl.registerSingleton(authApi); + + final authRepository = AuthRepositoryImpl( + api: authApi, + tokenStorage: tokenStorage, + ); + sl.registerSingleton(authRepository); + + sl.unregister(); + sl.registerSingleton( + ApiClient( + baseUrl: Env.apiUrl, + tokenStorage: tokenStorage, + dio: dio, + refreshToken: (token) async { + try { + await authRepository.refresh(token); + return true; + } catch (_) { + return false; + } + }, + ), + ); + + sl.registerSingleton(AuthBloc(authRepository)); +} From 8c1dfa9987ad0e2e2b1b5e057b36e08652e1e1cf Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 15:21:26 +0800 Subject: [PATCH 10/12] feat(apps): integrate auth cubits with register and login screens --- .../auth/ui/screens/login_email_screen.dart | 2 +- .../ui/screens/login_password_screen.dart | 155 ++++++++++++------ .../auth/ui/screens/register_screen.dart | 126 +++++++++++--- .../ui/screens/register_step2_screen.dart | 144 +++++++++------- 4 files changed, 301 insertions(+), 126 deletions(-) diff --git a/apps/lib/features/auth/ui/screens/login_email_screen.dart b/apps/lib/features/auth/ui/screens/login_email_screen.dart index 6dc1907..0ecf6df 100644 --- a/apps/lib/features/auth/ui/screens/login_email_screen.dart +++ b/apps/lib/features/auth/ui/screens/login_email_screen.dart @@ -35,7 +35,7 @@ class _LoginEmailScreenState extends State { setState(() { _showWarning = false; }); - context.push('/login/password'); + context.push('/login/password', extra: _emailController.text); } @override diff --git a/apps/lib/features/auth/ui/screens/login_password_screen.dart b/apps/lib/features/auth/ui/screens/login_password_screen.dart index 5e027d5..1d86adf 100644 --- a/apps/lib/features/auth/ui/screens/login_password_screen.dart +++ b/apps/lib/features/auth/ui/screens/login_password_screen.dart @@ -1,25 +1,78 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/warning_banner.dart'; +import '../../presentation/cubits/login_cubit.dart'; +import '../../presentation/bloc/auth_bloc.dart'; +import '../../presentation/bloc/auth_event.dart'; +import '../../data/auth_repository.dart'; -class LoginPasswordScreen extends StatefulWidget { - const LoginPasswordScreen({super.key}); +class LoginPasswordScreen extends StatelessWidget { + final String? email; + + const LoginPasswordScreen({super.key, this.email}); @override - State createState() => _LoginPasswordScreenState(); + Widget build(BuildContext context) { + final emailFromExtra = GoRouterState.of(context).extra as String?; + final initialEmail = email ?? emailFromExtra ?? ''; + + return BlocProvider( + create: (context) { + final cubit = LoginCubit(context.read()); + if (initialEmail.isNotEmpty) { + cubit.emailChanged(initialEmail); + } + return cubit; + }, + child: LoginPasswordView(initialEmail: initialEmail), + ); + } } -class _LoginPasswordScreenState extends State { - final _passwordController = TextEditingController(); +class LoginPasswordView extends StatefulWidget { + final String initialEmail; + + const LoginPasswordView({super.key, required this.initialEmail}); + + @override + State createState() => _LoginPasswordViewState(); +} + +class _LoginPasswordViewState extends State { + late final TextEditingController _passwordController; bool _obscureText = true; + @override + void initState() { + super.initState(); + _passwordController = TextEditingController(); + } + @override void dispose() { _passwordController.dispose(); super.dispose(); } + Future _handleLogin() async { + final cubit = context.read(); + cubit.passwordChanged(_passwordController.text); + + if (!cubit.state.isValid) { + return; + } + + final response = await cubit.submit(); + if (response != null && mounted) { + context.read().add(AuthLoggedIn(user: response.user)); + context.go('/home'); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -92,54 +145,62 @@ class _LoginPasswordScreenState extends State { } Widget _buildFormContainer() { - return SizedBox( - width: 327, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, + return BlocBuilder( + builder: (context, state) { + return SizedBox( + width: 327, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text( - '密码', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Color(0xFF475569), - ), - ), - const SizedBox(height: 6), - TextField( - controller: _passwordController, - obscureText: _obscureText, - decoration: InputDecoration( - hintText: '请输入密码', - suffixIcon: IconButton( - icon: Icon( - _obscureText ? Icons.visibility_off : Icons.visibility, - size: 20, - color: AppColors.slate400, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '密码', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF475569), ), - onPressed: () { - setState(() { - _obscureText = !_obscureText; - }); - }, ), - ), + const SizedBox(height: 6), + TextField( + controller: _passwordController, + obscureText: _obscureText, + decoration: InputDecoration( + hintText: '请输入密码', + suffixIcon: IconButton( + icon: Icon( + _obscureText + ? Icons.visibility_off + : Icons.visibility, + size: 20, + color: AppColors.slate400, + ), + onPressed: () { + setState(() { + _obscureText = !_obscureText; + }); + }, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + if (state.errorMessage != null) + WarningBanner(message: state.errorMessage!, visible: true), + const SizedBox(height: 12), + AppButton( + text: '登录', + onPressed: state.status == FormzSubmissionStatus.inProgress + ? null + : _handleLogin, ), ], ), - const SizedBox(height: 12), - AppButton(text: '登录', onPressed: () => context.go('/home')), - const SizedBox(height: 12), - AppButton( - text: '使用验证码登录', - isOutlined: true, - onPressed: () => context.push('/login/code'), - ), - ], - ), + ); + }, ); } diff --git a/apps/lib/features/auth/ui/screens/register_screen.dart b/apps/lib/features/auth/ui/screens/register_screen.dart index 8a2699d..22de94b 100644 --- a/apps/lib/features/auth/ui/screens/register_screen.dart +++ b/apps/lib/features/auth/ui/screens/register_screen.dart @@ -1,26 +1,62 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/warning_banner.dart'; +import '../../presentation/cubits/register_cubit.dart'; +import '../../data/auth_repository.dart'; -class RegisterScreen extends StatefulWidget { +class RegisterScreen extends StatelessWidget { const RegisterScreen({super.key}); @override - State createState() => _RegisterScreenState(); + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => RegisterCubit(context.read()), + child: const RegisterView(), + ); + } } -class _RegisterScreenState extends State { +class RegisterView extends StatefulWidget { + const RegisterView({super.key}); + + @override + State createState() => _RegisterViewState(); +} + +class _RegisterViewState extends State { final _nicknameController = TextEditingController(); final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscureText = true; @override void dispose() { _nicknameController.dispose(); _emailController.dispose(); + _passwordController.dispose(); super.dispose(); } + Future _handleNext() async { + final cubit = context.read(); + cubit.usernameChanged(_nicknameController.text); + cubit.emailChanged(_emailController.text); + cubit.passwordChanged(_passwordController.text); + + if (!cubit.state.isStep1Valid) { + return; + } + + final success = await cubit.submitStep1(); + if (success && mounted) { + context.push('/register/step2', extra: cubit); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -93,23 +129,39 @@ class _RegisterScreenState extends State { } Widget _buildFormContainer() { - return SizedBox( - width: 327, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildInput('昵称', '请输入昵称', _nicknameController), - const SizedBox(height: 12), - _buildInput('邮箱', '请输入邮箱', _emailController), - const SizedBox(height: 12), - _buildStepIndicator(), - const SizedBox(height: 12), - AppButton( - text: '下一步', - onPressed: () => context.push('/register/step2'), + return BlocBuilder( + builder: (context, state) { + return SizedBox( + width: 327, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildInput('昵称', '请输入昵称', _nicknameController), + const SizedBox(height: 12), + _buildInput('邮箱', '请输入邮箱', _emailController), + const SizedBox(height: 12), + _buildPasswordInput(), + const SizedBox(height: 12), + _buildStepIndicator(), + if (state.errorMessage != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: WarningBanner( + message: state.errorMessage!, + visible: true, + ), + ), + const SizedBox(height: 12), + AppButton( + text: '下一步', + onPressed: state.status == FormzSubmissionStatus.inProgress + ? null + : _handleNext, + ), + ], ), - ], - ), + ); + }, ); } @@ -138,6 +190,42 @@ class _RegisterScreenState extends State { ); } + Widget _buildPasswordInput() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '密码', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF475569), + ), + ), + const SizedBox(height: 6), + TextField( + controller: _passwordController, + obscureText: _obscureText, + decoration: InputDecoration( + hintText: '请输入至少 6 位密码', + suffixIcon: IconButton( + icon: Icon( + _obscureText ? Icons.visibility_off : Icons.visibility, + size: 20, + color: AppColors.slate400, + ), + onPressed: () { + setState(() { + _obscureText = !_obscureText; + }); + }, + ), + ), + ), + ], + ); + } + Widget _buildStepIndicator() { return Row( children: [ diff --git a/apps/lib/features/auth/ui/screens/register_step2_screen.dart b/apps/lib/features/auth/ui/screens/register_step2_screen.dart index 0ea3329..1d5f0d2 100644 --- a/apps/lib/features/auth/ui/screens/register_step2_screen.dart +++ b/apps/lib/features/auth/ui/screens/register_step2_screen.dart @@ -1,29 +1,74 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:formz/formz.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/warning_banner.dart'; +import '../../presentation/cubits/register_cubit.dart'; +import '../../presentation/bloc/auth_bloc.dart'; +import '../../presentation/bloc/auth_event.dart'; -class RegisterStep2Screen extends StatefulWidget { - const RegisterStep2Screen({super.key}); +class RegisterStep2Screen extends StatelessWidget { + final RegisterCubit? cubit; + + const RegisterStep2Screen({super.key, this.cubit}); @override - State createState() => _RegisterStep2ScreenState(); + Widget build(BuildContext context) { + final registerCubit = + cubit ?? (GoRouterState.of(context).extra as RegisterCubit?); + + if (registerCubit == null) { + return Scaffold( + body: Center(child: Text('Error: RegisterCubit not found')), + ); + } + + return BlocProvider.value( + value: registerCubit, + child: const RegisterStep2View(), + ); + } } -class _RegisterStep2ScreenState extends State { - final _passwordController = TextEditingController(); +class RegisterStep2View extends StatefulWidget { + const RegisterStep2View({super.key}); + + @override + State createState() => _RegisterStep2ViewState(); +} + +class _RegisterStep2ViewState extends State { final _codeController = TextEditingController(); final _inviteController = TextEditingController(); - bool _obscureText = true; @override void dispose() { - _passwordController.dispose(); _codeController.dispose(); _inviteController.dispose(); super.dispose(); } + Future _handleComplete() async { + final cubit = context.read(); + cubit.verificationCodeChanged(_codeController.text); + + if (!cubit.state.isStep2Valid) { + return; + } + + final response = await cubit.submitStep2(); + if (response != null && mounted) { + context.read().add(AuthLoggedIn(user: response.user)); + context.go('/home'); + } + } + + Future _handleResendCode() async { + await context.read().resendCode(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -96,62 +141,41 @@ class _RegisterStep2ScreenState extends State { } Widget _buildFormContainer() { - return SizedBox( - width: 327, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildPasswordInput(), - const SizedBox(height: 12), - _buildCodeInput(), - const SizedBox(height: 12), - _buildInviteInput(), - const SizedBox(height: 12), - _buildStepIndicator(), - const SizedBox(height: 12), - AppButton(text: '完成注册', onPressed: () {}), - ], - ), - ); - } - - Widget _buildPasswordInput() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '密码', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Color(0xFF475569), - ), - ), - const SizedBox(height: 6), - TextField( - controller: _passwordController, - obscureText: _obscureText, - decoration: InputDecoration( - hintText: '请输入至少 8 位密码', - suffixIcon: IconButton( - icon: Icon( - _obscureText ? Icons.visibility_off : Icons.visibility, - size: 20, - color: AppColors.slate400, + return BlocBuilder( + builder: (context, state) { + return SizedBox( + width: 327, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildCodeInput(state), + const SizedBox(height: 12), + _buildInviteInput(), + const SizedBox(height: 12), + _buildStepIndicator(), + if (state.errorMessage != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: WarningBanner( + message: state.errorMessage!, + visible: true, + ), + ), + const SizedBox(height: 12), + AppButton( + text: '完成注册', + onPressed: state.status == FormzSubmissionStatus.inProgress + ? null + : _handleComplete, ), - onPressed: () { - setState(() { - _obscureText = !_obscureText; - }); - }, - ), + ], ), - ), - ], + ); + }, ); } - Widget _buildCodeInput() { + Widget _buildCodeInput(RegisterState state) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -187,7 +211,9 @@ class _RegisterStep2ScreenState extends State { width: 112, height: 40, child: OutlinedButton( - onPressed: () {}, + onPressed: state.status == FormzSubmissionStatus.inProgress + ? null + : _handleResendCode, style: OutlinedButton.styleFrom( backgroundColor: AppColors.background, side: const BorderSide(color: AppColors.input), From d3bdb3ab4f7c9249054a8d1688a7db1db711b8b5 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 15:25:31 +0800 Subject: [PATCH 11/12] feat(apps): add router auth protection --- apps/lib/core/router/app_router.dart | 190 +++++++++++------- .../core/router/go_router_refresh_stream.dart | 17 ++ apps/lib/main.dart | 33 ++- apps/test/widget_test.dart | 27 ++- 4 files changed, 180 insertions(+), 87 deletions(-) create mode 100644 apps/lib/core/router/go_router_refresh_stream.dart diff --git a/apps/lib/core/router/app_router.dart b/apps/lib/core/router/app_router.dart index 9c270b5..69948ca 100644 --- a/apps/lib/core/router/app_router.dart +++ b/apps/lib/core/router/app_router.dart @@ -1,5 +1,7 @@ import 'package:go_router/go_router.dart'; - +import '../../features/auth/presentation/bloc/auth_bloc.dart'; +import '../../features/auth/presentation/bloc/auth_state.dart'; +import 'go_router_refresh_stream.dart'; import '../../features/auth/ui/screens/login_email_screen.dart'; import '../../features/auth/ui/screens/login_password_screen.dart'; import '../../features/auth/ui/screens/login_code_screen.dart'; @@ -20,78 +22,114 @@ import '../../features/settings/ui/screens/features_screen.dart'; import '../../features/settings/ui/screens/memory_screen.dart'; import '../../features/settings/ui/screens/account_screen.dart'; -final appRouter = GoRouter( - initialLocation: '/', - routes: [ - GoRoute(path: '/', builder: (context, state) => const LoginEmailScreen()), - GoRoute( - path: '/login/password', - builder: (context, state) => const LoginPasswordScreen(), - ), - GoRoute( - path: '/login/code', - builder: (context, state) => const LoginCodeScreen(), - ), - GoRoute( - path: '/register', - builder: (context, state) => const RegisterScreen(), - ), - GoRoute( - path: '/register/step2', - builder: (context, state) => const RegisterStep2Screen(), - ), - GoRoute(path: '/home', builder: (context, state) => const HomeScreen()), - GoRoute( - path: '/messages/invites', - builder: (context, state) => const MessageInviteListScreen(), - ), - GoRoute( - path: '/messages/invites/:id', - builder: (context, state) => const MessageInviteDetailScreen(), - ), - GoRoute( - path: '/contacts', - builder: (context, state) => const ContactsScreen(), - ), - GoRoute( - path: '/contacts/add', - builder: (context, state) => const AddContactScreen(), - ), - GoRoute( - path: '/calendar/dayweek', - builder: (context, state) => const CalendarDayWeekScreen(), - ), - GoRoute( - path: '/calendar/month', - builder: (context, state) => const CalendarMonthScreen(), - ), - GoRoute( - path: '/calendar/events/:id', - builder: (context, state) => const CalendarEventDetailScreen(), - ), - GoRoute( - path: '/todo', - builder: (context, state) => const TodoQuadrantsScreen(), - ), - GoRoute( - path: '/todo/:id', - builder: (context, state) => const TodoDetailScreen(), - ), - GoRoute( - path: '/settings', - builder: (context, state) => const SettingsScreen(), - ), - GoRoute( - path: '/settings/features', - builder: (context, state) => const FeaturesScreen(), - ), - GoRoute( - path: '/settings/memory', - builder: (context, state) => const MemoryScreen(), - ), - GoRoute( - path: '/settings/account', - builder: (context, state) => const AccountScreen(), - ), - ], -); +final _protectedRoutes = [ + '/home', + '/contacts', + '/contacts/add', + '/calendar/dayweek', + '/calendar/month', + '/calendar/events', + '/todo', + '/settings', + '/settings/features', + '/settings/memory', + '/settings/account', + '/messages/invites', +]; + +GoRouter createAppRouter(AuthBloc authBloc) { + return GoRouter( + initialLocation: '/', + refreshListenable: GoRouterRefreshStream(authBloc.stream), + redirect: (context, state) { + final authState = authBloc.state; + final isAuthenticated = authState is AuthAuthenticated; + final isAuthRoute = + state.matchedLocation.startsWith('/login') || + state.matchedLocation.startsWith('/register'); + final isProtected = _protectedRoutes.any( + (route) => state.matchedLocation.startsWith(route), + ); + + if (!isAuthenticated && isProtected) { + return '/'; + } + if (isAuthenticated && isAuthRoute) { + return '/home'; + } + return null; + }, + routes: [ + GoRoute(path: '/', builder: (context, state) => const LoginEmailScreen()), + GoRoute( + path: '/login/password', + builder: (context, state) => const LoginPasswordScreen(), + ), + GoRoute( + path: '/login/code', + builder: (context, state) => const LoginCodeScreen(), + ), + GoRoute( + path: '/register', + builder: (context, state) => const RegisterScreen(), + ), + GoRoute( + path: '/register/step2', + builder: (context, state) => const RegisterStep2Screen(), + ), + GoRoute(path: '/home', builder: (context, state) => const HomeScreen()), + GoRoute( + path: '/messages/invites', + builder: (context, state) => const MessageInviteListScreen(), + ), + GoRoute( + path: '/messages/invites/:id', + builder: (context, state) => const MessageInviteDetailScreen(), + ), + GoRoute( + path: '/contacts', + builder: (context, state) => const ContactsScreen(), + ), + GoRoute( + path: '/contacts/add', + builder: (context, state) => const AddContactScreen(), + ), + GoRoute( + path: '/calendar/dayweek', + builder: (context, state) => const CalendarDayWeekScreen(), + ), + GoRoute( + path: '/calendar/month', + builder: (context, state) => const CalendarMonthScreen(), + ), + GoRoute( + path: '/calendar/events/:id', + builder: (context, state) => const CalendarEventDetailScreen(), + ), + GoRoute( + path: '/todo', + builder: (context, state) => const TodoQuadrantsScreen(), + ), + GoRoute( + path: '/todo/:id', + builder: (context, state) => const TodoDetailScreen(), + ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsScreen(), + ), + GoRoute( + path: '/settings/features', + builder: (context, state) => const FeaturesScreen(), + ), + GoRoute( + path: '/settings/memory', + builder: (context, state) => const MemoryScreen(), + ), + GoRoute( + path: '/settings/account', + builder: (context, state) => const AccountScreen(), + ), + ], + ); +} diff --git a/apps/lib/core/router/go_router_refresh_stream.dart b/apps/lib/core/router/go_router_refresh_stream.dart new file mode 100644 index 0000000..722069c --- /dev/null +++ b/apps/lib/core/router/go_router_refresh_stream.dart @@ -0,0 +1,17 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; + +class GoRouterRefreshStream extends ChangeNotifier { + GoRouterRefreshStream(Stream stream) { + notifyListeners(); + _subscription = stream.listen((_) => notifyListeners()); + } + + late final StreamSubscription _subscription; + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } +} diff --git a/apps/lib/main.dart b/apps/lib/main.dart index b7bc359..9f9c061 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -1,21 +1,36 @@ import 'package:flutter/material.dart'; -import 'core/theme/app_theme.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'core/di/injection.dart'; import 'core/router/app_router.dart'; +import 'core/theme/app_theme.dart'; +import 'features/auth/presentation/bloc/auth_bloc.dart'; +import 'features/auth/presentation/bloc/auth_event.dart'; -void main() { - runApp(const LinksyApp()); +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await configureDependencies(); + + final authBloc = sl(); + authBloc.add(AuthStarted()); + + runApp(LinksyApp(authBloc: authBloc)); } class LinksyApp extends StatelessWidget { - const LinksyApp({super.key}); + final AuthBloc authBloc; + + const LinksyApp({super.key, required this.authBloc}); @override Widget build(BuildContext context) { - return MaterialApp.router( - title: 'Linksy', - debugShowCheckedModeBanner: false, - theme: AppTheme.light, - routerConfig: appRouter, + return BlocProvider.value( + value: authBloc, + child: MaterialApp.router( + title: 'Linksy', + debugShowCheckedModeBanner: false, + theme: AppTheme.light, + routerConfig: createAppRouter(authBloc), + ), ); } } diff --git a/apps/test/widget_test.dart b/apps/test/widget_test.dart index ee923f4..49c2130 100644 --- a/apps/test/widget_test.dart +++ b/apps/test/widget_test.dart @@ -1,10 +1,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:social_app/main.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; + +class MockAuthBloc extends Mock implements AuthBloc {} + +class FakeAuthState extends Fake implements AuthState {} void main() { + setUpAll(() { + registerFallbackValue(FakeAuthState()); + }); + testWidgets('Login screen loads correctly', (WidgetTester tester) async { - await tester.pumpWidget(const LinksyApp()); + final mockAuthBloc = MockAuthBloc(); + when(() => mockAuthBloc.state).thenReturn(AuthInitial()); + when( + () => mockAuthBloc.stream, + ).thenAnswer((_) => Stream.value(AuthInitial())); + + await tester.pumpWidget(LinksyApp(authBloc: mockAuthBloc)); expect(find.text('linksy'), findsOneWidget); expect(find.text('继续'), findsOneWidget); expect(find.text('还没有账号?去注册'), findsOneWidget); @@ -13,7 +30,13 @@ void main() { testWidgets('Main content is vertically centered above footer', ( WidgetTester tester, ) async { - await tester.pumpWidget(const LinksyApp()); + final mockAuthBloc = MockAuthBloc(); + when(() => mockAuthBloc.state).thenReturn(AuthInitial()); + when( + () => mockAuthBloc.stream, + ).thenAnswer((_) => Stream.value(AuthInitial())); + + await tester.pumpWidget(LinksyApp(authBloc: mockAuthBloc)); final safeAreaRect = tester.getRect(find.byType(SafeArea)); final mainRect = tester.getRect( From e20b1905cb9a5edf4e28a890590c02acaf742291 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 18:00:02 +0800 Subject: [PATCH 12/12] fix(apps): consolidate FormzInput validators and fix login screen - Move FormzInput validators to core/form_inputs/form_inputs.dart - Fix login_screen.dart syntax error (missing 'class' keyword) - Remove unused _isLoading field - Fix unnecessary const keywords - Update login_cubit and register_cubit imports - Remove duplicate FormzInput definitions from register_cubit - Add Toast and Banner UI feedback system - Remove legacy login/register screens (login_code, login_email, login_password, register_step2) - Remove unused warning_banner widget - Update tests for new error messages and DI setup --- apps/AGENTS.md | 39 ++++ apps/lib/core/api/api_client.dart | 48 +++-- apps/lib/core/api/api_exception.dart | 60 +++++- apps/lib/core/api/api_interceptor.dart | 2 +- apps/lib/core/config/env.dart | 8 +- apps/lib/core/di/injection.dart | 41 ++-- apps/lib/core/form_inputs/form_inputs.dart | 52 +++++ apps/lib/core/router/app_router.dart | 20 +- apps/lib/features/auth/data/auth_api.dart | 2 +- .../auth/presentation/cubits/login_cubit.dart | 6 +- .../presentation/cubits/register_cubit.dart | 62 +----- .../auth/ui/screens/login_code_screen.dart | 182 ------------------ .../auth/ui/screens/login_email_screen.dart | 170 ---------------- ...password_screen.dart => login_screen.dart} | 170 +++++++++------- .../auth/ui/screens/register_screen.dart | 12 +- ...dart => register_verification_screen.dart} | 47 ++--- .../ui/screens/add_contact_screen.dart | 1 - apps/lib/shared/widgets/app_button.dart | 2 - .../app_banner.dart} | 29 +-- apps/lib/shared/widgets/toast/index.dart | 3 + apps/lib/shared/widgets/toast/toast.dart | 128 ++++++++++++ apps/lib/shared/widgets/toast/toast_type.dart | 1 + .../widgets/toast/toast_type_config.dart | 43 +++++ apps/test/core/api/api_exception_test.dart | 9 +- apps/test/widget_test.dart | 13 +- 25 files changed, 542 insertions(+), 608 deletions(-) create mode 100644 apps/lib/core/form_inputs/form_inputs.dart delete mode 100644 apps/lib/features/auth/ui/screens/login_code_screen.dart delete mode 100644 apps/lib/features/auth/ui/screens/login_email_screen.dart rename apps/lib/features/auth/ui/screens/{login_password_screen.dart => login_screen.dart} (53%) rename apps/lib/features/auth/ui/screens/{register_step2_screen.dart => register_verification_screen.dart} (85%) rename apps/lib/shared/widgets/{warning_banner.dart => banner/app_banner.dart} (52%) create mode 100644 apps/lib/shared/widgets/toast/index.dart create mode 100644 apps/lib/shared/widgets/toast/toast.dart create mode 100644 apps/lib/shared/widgets/toast/toast_type.dart create mode 100644 apps/lib/shared/widgets/toast/toast_type_config.dart diff --git a/apps/AGENTS.md b/apps/AGENTS.md index db9b6bc..7328c8b 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -45,3 +45,42 @@ For important screens, add widget tests that reduce layout-regression risk: - Do not skip design container layers. - Do not start implementation before retrieving design variables. - Do not hardcode colors; use design variables. + +## UI Feedback System + +**MUST use the Toast system for all user feedback messages.** + +### Components + +| Component | Use Case | Example | +|-----------|----------|---------| +| `Toast.show()` | Global temporary notifications | Success/error feedback after action | +| `AppBanner` | Inline form validation errors | Login form error message | + +### Toast Types + +```dart +enum ToastType { info, success, warning, error } +``` + +### Usage Examples + +**Global Toast (auto-dismiss):** +```dart +Toast.show(context, '保存成功', type: ToastType.success); +Toast.show(context, '网络错误', type: ToastType.error); +Toast.show(context, '正在加载...', type: ToastType.info, duration: Duration(seconds: 3)); +``` + +**Inline Banner (persistent):** +```dart +AppBanner(message: '邮箱或密码错误', type: ToastType.error) +AppBanner(message: '请检查输入', type: ToastType.warning) +``` + +### Rules + +- Use `Toast` for transient feedback that auto-dismisses +- Use `AppBanner` for persistent inline messages (form errors) +- Do NOT create custom SnackBar, Dialog, or Banner components +- Do NOT use raw `ScaffoldMessenger` diff --git a/apps/lib/core/api/api_client.dart b/apps/lib/core/api/api_client.dart index 731cd62..da3da79 100644 --- a/apps/lib/core/api/api_client.dart +++ b/apps/lib/core/api/api_client.dart @@ -6,36 +6,42 @@ import '../storage/token_storage.dart'; class ApiClient { final Dio _dio; final TokenStorage _tokenStorage; - final Future Function(String)? _refreshToken; + final ApiInterceptor _interceptor; - ApiClient({ + factory ApiClient({ required String baseUrl, required TokenStorage tokenStorage, Dio? dio, - Future Function(String)? refreshToken, - }) : _tokenStorage = tokenStorage, - _refreshToken = refreshToken, - _dio = dio ?? Dio(BaseOptions(baseUrl: baseUrl)) { - _dio.interceptors.add( - ApiInterceptor( - tokenStorage: _tokenStorage, - dio: _dio, - onTokenRefresh: _handleTokenRefresh, - ), + }) { + final effectiveDio = dio ?? Dio(BaseOptions(baseUrl: baseUrl)); + final interceptor = ApiInterceptor( + tokenStorage: tokenStorage, + dio: effectiveDio, + ); + effectiveDio.interceptors.add(interceptor); + return ApiClient._( + dio: effectiveDio, + tokenStorage: tokenStorage, + interceptor: interceptor, ); } + ApiClient._({ + required Dio dio, + required TokenStorage tokenStorage, + required ApiInterceptor interceptor, + }) : _dio = dio, + _tokenStorage = tokenStorage, + _interceptor = interceptor; + Dio get dio => _dio; - Future _handleTokenRefresh() async { - final refreshToken = await _tokenStorage.getRefreshToken(); - if (refreshToken == null || _refreshToken == null) return false; - try { - final success = await _refreshToken!(refreshToken); - return success; - } catch (_) { - return false; - } + void setRefreshCallback(Future Function(String) refresh) { + _interceptor.onTokenRefresh = () async { + final token = await _tokenStorage.getRefreshToken(); + if (token == null) return false; + return refresh(token); + }; } Future> get(String path, {Options? options}) async { diff --git a/apps/lib/core/api/api_exception.dart b/apps/lib/core/api/api_exception.dart index 231b933..01a8ea0 100644 --- a/apps/lib/core/api/api_exception.dart +++ b/apps/lib/core/api/api_exception.dart @@ -1,3 +1,5 @@ +import 'package:dio/dio.dart'; + abstract class ApiException implements Exception { final String message; final int? statusCode; @@ -6,12 +8,58 @@ abstract class ApiException implements Exception { factory ApiException.fromDioError(Object error) { if (error is ApiException) return error; - return ServerException('Request failed: ${error.toString()}'); - } -} + if (error is DioException) { + final response = error.response; + final statusCode = response?.statusCode; + final data = response?.data; -class NetworkException extends ApiException { - const NetworkException(super.message); + String detail; + if (data is Map) { + detail = + (data['detail'] ?? data['message'] ?? data['error'])?.toString() ?? + '请求失败'; + } else { + detail = '请求失败'; + } + + final localized = _localizeError(detail, statusCode); + + if (statusCode == 401) { + return UnauthorizedException(localized); + } + if (statusCode == 422) { + return ValidationException( + localized, + errors: data, + statusCode: statusCode, + ); + } + return ServerException(localized, statusCode: statusCode); + } + return const ServerException('网络错误'); + } + + static String _localizeError(String detail, int? statusCode) { + if (statusCode == 401) { + return '邮箱或密码错误'; + } + if (statusCode == 403) { + return '没有权限执行此操作'; + } + if (statusCode == 404) { + return '请求的资源不存在'; + } + if (statusCode == 429) { + return '请求过于频繁,请稍后再试'; + } + if (statusCode != null && statusCode >= 500) { + return '服务器错误,请稍后再试'; + } + if (detail.contains('credentials') || detail.contains('password')) { + return '邮箱或密码错误'; + } + return detail; + } } class ServerException extends ApiException { @@ -19,7 +67,7 @@ class ServerException extends ApiException { } class UnauthorizedException extends ApiException { - const UnauthorizedException([super.message = 'Authentication required']) + const UnauthorizedException([super.message = '请重新登录']) : super(statusCode: 401); } diff --git a/apps/lib/core/api/api_interceptor.dart b/apps/lib/core/api/api_interceptor.dart index a2db41c..e921ac1 100644 --- a/apps/lib/core/api/api_interceptor.dart +++ b/apps/lib/core/api/api_interceptor.dart @@ -3,8 +3,8 @@ import '../storage/token_storage.dart'; class ApiInterceptor extends Interceptor { final TokenStorage tokenStorage; - final Future Function()? onTokenRefresh; final Dio dio; + Future Function()? onTokenRefresh; ApiInterceptor({ required this.tokenStorage, diff --git a/apps/lib/core/config/env.dart b/apps/lib/core/config/env.dart index 8267e2f..c2a7e32 100644 --- a/apps/lib/core/config/env.dart +++ b/apps/lib/core/config/env.dart @@ -1,6 +1,12 @@ +import 'dart:io'; + class Env { static String get apiUrl { const url = String.fromEnvironment('API_URL'); - return url.isNotEmpty ? url : 'http://localhost:8000'; + if (url.isNotEmpty) return url; + if (Platform.isAndroid) { + return 'http://10.0.2.2:5775'; + } + return 'http://localhost:5775'; } } diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 6fb9452..d09ad0c 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -12,17 +12,22 @@ import '../../features/auth/presentation/bloc/auth_bloc.dart'; final sl = GetIt.instance; Future configureDependencies() async { + if (sl.isRegistered()) { + await sl.reset(); + } + final dio = Dio(BaseOptions(baseUrl: Env.apiUrl)); final secureStorage = const FlutterSecureStorage(); final tokenStorage = SecureTokenStorage(secureStorage); - sl.registerSingleton(dio); - sl.registerSingleton(secureStorage); - sl.registerSingleton(tokenStorage); - - final authApi = AuthApi( - ApiClient(baseUrl: Env.apiUrl, tokenStorage: tokenStorage, dio: dio), + final apiClient = ApiClient( + baseUrl: Env.apiUrl, + tokenStorage: tokenStorage, + dio: dio, ); + sl.registerSingleton(apiClient); + + final authApi = AuthApi(apiClient); sl.registerSingleton(authApi); final authRepository = AuthRepositoryImpl( @@ -31,22 +36,14 @@ Future configureDependencies() async { ); sl.registerSingleton(authRepository); - sl.unregister(); - sl.registerSingleton( - ApiClient( - baseUrl: Env.apiUrl, - tokenStorage: tokenStorage, - dio: dio, - refreshToken: (token) async { - try { - await authRepository.refresh(token); - return true; - } catch (_) { - return false; - } - }, - ), - ); + apiClient.setRefreshCallback((token) async { + try { + await authRepository.refresh(token); + return true; + } catch (_) { + return false; + } + }); sl.registerSingleton(AuthBloc(authRepository)); } diff --git a/apps/lib/core/form_inputs/form_inputs.dart b/apps/lib/core/form_inputs/form_inputs.dart new file mode 100644 index 0000000..60d7600 --- /dev/null +++ b/apps/lib/core/form_inputs/form_inputs.dart @@ -0,0 +1,52 @@ +import 'package:formz/formz.dart'; + +class Username extends FormzInput { + const Username.pure() : super.pure(''); + const Username.dirty([super.value = '']) : super.dirty(); + + @override + String? validator(String value) { + if (value.isEmpty) return '请输入用户名'; + if (value.length < 3) return '用户名至少 3 个字符'; + if (value.length > 30) return '用户名最多 30 个字符'; + return null; + } +} + +class Email extends FormzInput { + const Email.pure() : super.pure(''); + const Email.dirty([super.value = '']) : super.dirty(); + + static final _regex = RegExp(r'^[\w.-]+@[\w.-]+\.\w+$'); + + @override + String? validator(String value) { + if (value.isEmpty) return '请输入邮箱'; + if (!_regex.hasMatch(value)) return '邮箱格式不正确'; + return null; + } +} + +class Password extends FormzInput { + const Password.pure() : super.pure(''); + const Password.dirty([super.value = '']) : super.dirty(); + + @override + String? validator(String value) { + if (value.isEmpty) return '请输入密码'; + if (value.length < 6) return '密码至少 6 个字符'; + return null; + } +} + +class VerificationCode extends FormzInput { + const VerificationCode.pure() : super.pure(''); + const VerificationCode.dirty([super.value = '']) : super.dirty(); + + @override + String? validator(String value) { + if (value.isEmpty) return '请输入验证码'; + if (!RegExp(r'^\d{6}$').hasMatch(value)) return '验证码必须是 6 位数字'; + return null; + } +} diff --git a/apps/lib/core/router/app_router.dart b/apps/lib/core/router/app_router.dart index 69948ca..77b614b 100644 --- a/apps/lib/core/router/app_router.dart +++ b/apps/lib/core/router/app_router.dart @@ -2,11 +2,9 @@ import 'package:go_router/go_router.dart'; import '../../features/auth/presentation/bloc/auth_bloc.dart'; import '../../features/auth/presentation/bloc/auth_state.dart'; import 'go_router_refresh_stream.dart'; -import '../../features/auth/ui/screens/login_email_screen.dart'; -import '../../features/auth/ui/screens/login_password_screen.dart'; -import '../../features/auth/ui/screens/login_code_screen.dart'; +import '../../features/auth/ui/screens/login_screen.dart'; import '../../features/auth/ui/screens/register_screen.dart'; -import '../../features/auth/ui/screens/register_step2_screen.dart'; +import '../../features/auth/ui/screens/register_verification_screen.dart'; import '../../features/home/ui/screens/home_screen.dart'; import '../../features/messages/ui/screens/message_invite_list_screen.dart'; import '../../features/messages/ui/screens/message_invite_detail_screen.dart'; @@ -60,22 +58,14 @@ GoRouter createAppRouter(AuthBloc authBloc) { return null; }, routes: [ - GoRoute(path: '/', builder: (context, state) => const LoginEmailScreen()), - GoRoute( - path: '/login/password', - builder: (context, state) => const LoginPasswordScreen(), - ), - GoRoute( - path: '/login/code', - builder: (context, state) => const LoginCodeScreen(), - ), + GoRoute(path: '/', builder: (context, state) => const LoginScreen()), GoRoute( path: '/register', builder: (context, state) => const RegisterScreen(), ), GoRoute( - path: '/register/step2', - builder: (context, state) => const RegisterStep2Screen(), + path: '/register/verification', + builder: (context, state) => const RegisterVerificationScreen(), ), GoRoute(path: '/home', builder: (context, state) => const HomeScreen()), GoRoute( diff --git a/apps/lib/features/auth/data/auth_api.dart b/apps/lib/features/auth/data/auth_api.dart index 33e6010..e0fe99f 100644 --- a/apps/lib/features/auth/data/auth_api.dart +++ b/apps/lib/features/auth/data/auth_api.dart @@ -5,7 +5,7 @@ import 'models/auth_response.dart'; class AuthApi { final ApiClient _client; - static const _prefix = '/v1/auth'; + static const _prefix = '/api/v1/auth'; AuthApi(this._client); diff --git a/apps/lib/features/auth/presentation/cubits/login_cubit.dart b/apps/lib/features/auth/presentation/cubits/login_cubit.dart index 817656e..e9cb9ca 100644 --- a/apps/lib/features/auth/presentation/cubits/login_cubit.dart +++ b/apps/lib/features/auth/presentation/cubits/login_cubit.dart @@ -1,10 +1,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:formz/formz.dart'; import 'package:equatable/equatable.dart'; +import '../../../../core/api/api_exception.dart'; import '../../data/auth_repository.dart'; import '../../data/models/login_request.dart'; import '../../data/models/auth_response.dart'; -import 'register_cubit.dart' show Email, Password; +import '../../../../core/form_inputs/form_inputs.dart'; class LoginState extends Equatable { final Email email; @@ -64,10 +65,11 @@ class LoginCubit extends Cubit { emit(state.copyWith(status: FormzSubmissionStatus.success)); return response; } catch (e) { + final message = e is ApiException ? e.message : e.toString(); emit( state.copyWith( status: FormzSubmissionStatus.failure, - errorMessage: e.toString(), + errorMessage: message, ), ); return null; diff --git a/apps/lib/features/auth/presentation/cubits/register_cubit.dart b/apps/lib/features/auth/presentation/cubits/register_cubit.dart index 7f367b6..47188a7 100644 --- a/apps/lib/features/auth/presentation/cubits/register_cubit.dart +++ b/apps/lib/features/auth/presentation/cubits/register_cubit.dart @@ -1,61 +1,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:formz/formz.dart'; import 'package:equatable/equatable.dart'; +import '../../../../core/api/api_exception.dart'; +import '../../../../core/form_inputs/form_inputs.dart'; import '../../data/auth_repository.dart'; import '../../data/models/signup_request.dart'; import '../../data/models/auth_response.dart'; -class Username extends FormzInput { - const Username.pure() : super.pure(''); - const Username.dirty([super.value = '']) : super.dirty(); - - @override - String? validator(String value) { - if (value.isEmpty) return 'Username is required'; - if (value.length < 3) return 'Username must be at least 3 characters'; - if (value.length > 30) return 'Username must be at most 30 characters'; - return null; - } -} - -class Email extends FormzInput { - const Email.pure() : super.pure(''); - const Email.dirty([super.value = '']) : super.dirty(); - - static final _regex = RegExp(r'^[\w.-]+@[\w.-]+\.\w+$'); - - @override - String? validator(String value) { - if (value.isEmpty) return 'Email is required'; - if (!_regex.hasMatch(value)) return 'Invalid email format'; - return null; - } -} - -class Password extends FormzInput { - const Password.pure() : super.pure(''); - const Password.dirty([super.value = '']) : super.dirty(); - - @override - String? validator(String value) { - if (value.isEmpty) return 'Password is required'; - if (value.length < 6) return 'Password must be at least 6 characters'; - return null; - } -} - -class VerificationCode extends FormzInput { - const VerificationCode.pure() : super.pure(''); - const VerificationCode.dirty([super.value = '']) : super.dirty(); - - @override - String? validator(String value) { - if (value.isEmpty) return 'Code is required'; - if (!RegExp(r'^\d{6}$').hasMatch(value)) return 'Code must be 6 digits'; - return null; - } -} - class RegisterState extends Equatable { final Username username; final Email email; @@ -159,10 +110,11 @@ class RegisterCubit extends Cubit { ); return true; } catch (e) { + final message = e is ApiException ? e.message : e.toString(); emit( state.copyWith( status: FormzSubmissionStatus.failure, - errorMessage: e.toString(), + errorMessage: message, ), ); return false; @@ -184,10 +136,11 @@ class RegisterCubit extends Cubit { emit(state.copyWith(status: FormzSubmissionStatus.success)); return response; } catch (e) { + final message = e is ApiException ? e.message : e.toString(); emit( state.copyWith( status: FormzSubmissionStatus.failure, - errorMessage: e.toString(), + errorMessage: message, ), ); return null; @@ -203,7 +156,8 @@ class RegisterCubit extends Cubit { ); emit(state.copyWith(codeSent: true)); } catch (e) { - emit(state.copyWith(errorMessage: e.toString())); + final message = e is ApiException ? e.message : e.toString(); + emit(state.copyWith(errorMessage: message)); } } } diff --git a/apps/lib/features/auth/ui/screens/login_code_screen.dart b/apps/lib/features/auth/ui/screens/login_code_screen.dart deleted file mode 100644 index 59351f1..0000000 --- a/apps/lib/features/auth/ui/screens/login_code_screen.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/app_button.dart'; - -class LoginCodeScreen extends StatefulWidget { - const LoginCodeScreen({super.key}); - - @override - State createState() => _LoginCodeScreenState(); -} - -class _LoginCodeScreenState extends State { - final _codeController = TextEditingController(); - - @override - void dispose() { - _codeController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _buildAppIcon(), - const SizedBox(height: 24), - _buildAppTitle(), - const SizedBox(height: 32), - _buildFormContainer(), - ], - ), - ), - ), - _buildFooter(), - const SizedBox(height: 24), - ], - ), - ), - ), - ); - } - - Widget _buildAppIcon() { - return Container( - width: 104, - height: 104, - decoration: BoxDecoration( - color: AppColors.appIconRing, - borderRadius: BorderRadius.circular(52), - border: Border.all(color: AppColors.appIconBorder, width: 1), - ), - child: Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(38), - child: Image.asset( - 'assets/images/logo.png', - width: 76, - height: 76, - fit: BoxFit.cover, - ), - ), - ), - ); - } - - Widget _buildAppTitle() { - return const Text( - 'linksy', - style: TextStyle( - fontFamily: 'Playfair Display', - fontSize: 34, - fontWeight: FontWeight.w700, - fontStyle: FontStyle.italic, - color: AppColors.appTitle, - letterSpacing: 0.5, - ), - ); - } - - Widget _buildFormContainer() { - return SizedBox( - width: 327, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '邮箱验证码', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Color(0xFF475569), - ), - ), - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: SizedBox( - height: 40, - child: TextField( - controller: _codeController, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - hintText: '输入验证码', - contentPadding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - ), - ), - ), - ), - const SizedBox(width: 8), - SizedBox( - width: 112, - height: 40, - child: OutlinedButton( - onPressed: () {}, - style: OutlinedButton.styleFrom( - backgroundColor: AppColors.background, - side: const BorderSide(color: AppColors.input), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), - ), - ), - child: const Text( - '发送验证码', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - ), - ), - ), - ], - ), - ], - ), - const SizedBox(height: 12), - AppButton(text: '登录', onPressed: () {}), - const SizedBox(height: 12), - AppButton( - text: '使用密码登录', - isOutlined: true, - onPressed: () => context.pop(), - ), - ], - ), - ); - } - - Widget _buildFooter() { - return GestureDetector( - onTap: () => context.push('/register'), - child: const Text( - '还没有账号?去注册', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - ), - ); - } -} diff --git a/apps/lib/features/auth/ui/screens/login_email_screen.dart b/apps/lib/features/auth/ui/screens/login_email_screen.dart deleted file mode 100644 index 0ecf6df..0000000 --- a/apps/lib/features/auth/ui/screens/login_email_screen.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/app_button.dart'; -import '../../../../shared/widgets/warning_banner.dart'; -import '../../../../shared/utils/validators.dart'; - -class LoginEmailScreen extends StatefulWidget { - const LoginEmailScreen({super.key}); - - @override - State createState() => _LoginEmailScreenState(); -} - -class _LoginEmailScreenState extends State { - final _emailController = TextEditingController(); - bool _showWarning = false; - String _warningMessage = ''; - - @override - void dispose() { - _emailController.dispose(); - super.dispose(); - } - - void _handleContinue() { - final error = Validators.email(_emailController.text); - if (error != null) { - setState(() { - _showWarning = true; - _warningMessage = error; - }); - return; - } - setState(() { - _showWarning = false; - }); - context.push('/login/password', extra: _emailController.text); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _buildAppIcon(), - const SizedBox(height: 24), - _buildAppTitle(), - const SizedBox(height: 32), - _buildFormContainer(), - ], - ), - ), - ), - _buildFooter(), - const SizedBox(height: 24), - ], - ), - ), - ), - ); - } - - Widget _buildAppIcon() { - return Container( - width: 104, - height: 104, - decoration: BoxDecoration( - color: AppColors.appIconRing, - borderRadius: BorderRadius.circular(52), - border: Border.all(color: AppColors.appIconBorder, width: 1), - ), - child: Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(38), - child: Image.asset( - 'assets/images/logo.png', - width: 76, - height: 76, - fit: BoxFit.cover, - ), - ), - ), - ); - } - - Widget _buildAppTitle() { - return const Text( - 'linksy', - style: TextStyle( - fontFamily: 'Playfair Display', - fontSize: 34, - fontWeight: FontWeight.w700, - fontStyle: FontStyle.italic, - color: AppColors.appTitle, - letterSpacing: 0.5, - ), - ); - } - - Widget _buildFormContainer() { - return SizedBox( - width: 327, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '邮箱', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.foreground, - ), - ), - const SizedBox(height: 6), - TextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration(hintText: '请输入邮箱'), - ), - ], - ), - const SizedBox(height: 12), - WarningBanner(message: _warningMessage, visible: _showWarning), - const SizedBox(height: 16), - AppButton(text: '继续', onPressed: _handleContinue), - ], - ), - ); - } - - Widget _buildFooter() { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => context.push('/register'), - child: const Text( - '还没有账号?去注册', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - ), - ), - const SizedBox(height: 12), - const Text( - '隐私政策 | 服务条款', - style: TextStyle(fontSize: 12, color: AppColors.slate400), - ), - ], - ); - } -} diff --git a/apps/lib/features/auth/ui/screens/login_password_screen.dart b/apps/lib/features/auth/ui/screens/login_screen.dart similarity index 53% rename from apps/lib/features/auth/ui/screens/login_password_screen.dart rename to apps/lib/features/auth/ui/screens/login_screen.dart index 1d86adf..fba9ecc 100644 --- a/apps/lib/features/auth/ui/screens/login_password_screen.dart +++ b/apps/lib/features/auth/ui/screens/login_screen.dart @@ -3,68 +3,52 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:formz/formz.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; +import '../../../../core/di/injection.dart'; import '../../../../shared/widgets/app_button.dart'; -import '../../../../shared/widgets/warning_banner.dart'; +import '../../../../shared/widgets/banner/app_banner.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; import '../../presentation/cubits/login_cubit.dart'; import '../../presentation/bloc/auth_bloc.dart'; import '../../presentation/bloc/auth_event.dart'; import '../../data/auth_repository.dart'; -class LoginPasswordScreen extends StatelessWidget { - final String? email; - - const LoginPasswordScreen({super.key, this.email}); +class LoginScreen extends StatelessWidget { + const LoginScreen({super.key}); @override Widget build(BuildContext context) { - final emailFromExtra = GoRouterState.of(context).extra as String?; - final initialEmail = email ?? emailFromExtra ?? ''; - return BlocProvider( - create: (context) { - final cubit = LoginCubit(context.read()); - if (initialEmail.isNotEmpty) { - cubit.emailChanged(initialEmail); - } - return cubit; - }, - child: LoginPasswordView(initialEmail: initialEmail), + create: (context) => LoginCubit(sl()), + child: const LoginView(), ); } } -class LoginPasswordView extends StatefulWidget { - final String initialEmail; - - const LoginPasswordView({super.key, required this.initialEmail}); +class LoginView extends StatefulWidget { + const LoginView({super.key}); @override - State createState() => _LoginPasswordViewState(); + State createState() => _LoginViewState(); } -class _LoginPasswordViewState extends State { - late final TextEditingController _passwordController; - bool _obscureText = true; - - @override - void initState() { - super.initState(); - _passwordController = TextEditingController(); - } +class _LoginViewState extends State { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscurePassword = true; @override void dispose() { + _emailController.dispose(); _passwordController.dispose(); super.dispose(); } Future _handleLogin() async { final cubit = context.read(); + cubit.emailChanged(_emailController.text); cubit.passwordChanged(_passwordController.text); - if (!cubit.state.isValid) { - return; - } + if (!cubit.state.isValid) return; final response = await cubit.submit(); if (response != null && mounted) { @@ -84,6 +68,7 @@ class _LoginPasswordViewState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( + key: const Key('login_main_content'), child: Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -98,7 +83,7 @@ class _LoginPasswordViewState extends State { ), ), ), - _buildFooter(), + Container(key: const Key('login_footer'), child: _buildFooter()), const SizedBox(height: 24), ], ), @@ -147,49 +132,29 @@ class _LoginPasswordViewState extends State { Widget _buildFormContainer() { return BlocBuilder( builder: (context, state) { + final fieldError = state.email.displayError != null + ? state.email.error + : state.password.displayError != null + ? state.password.error + : null; return SizedBox( width: 327, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '密码', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Color(0xFF475569), - ), - ), - const SizedBox(height: 6), - TextField( - controller: _passwordController, - obscureText: _obscureText, - decoration: InputDecoration( - hintText: '请输入密码', - suffixIcon: IconButton( - icon: Icon( - _obscureText - ? Icons.visibility_off - : Icons.visibility, - size: 20, - color: AppColors.slate400, - ), - onPressed: () { - setState(() { - _obscureText = !_obscureText; - }); - }, - ), - ), - ), - ], + _buildInput( + label: '邮箱', + hint: '请输入邮箱', + controller: _emailController, + hasError: state.email.displayError != null, ), const SizedBox(height: 12), + _buildPasswordInput(state.password.displayError != null), + const SizedBox(height: 12), if (state.errorMessage != null) - WarningBanner(message: state.errorMessage!, visible: true), + AppBanner(message: state.errorMessage!, type: ToastType.error) + else if (fieldError != null) + AppBanner(message: fieldError, type: ToastType.warning), const SizedBox(height: 12), AppButton( text: '登录', @@ -204,6 +169,73 @@ class _LoginPasswordViewState extends State { ); } + Widget _buildInput({ + required String label, + required String hint, + required TextEditingController controller, + bool hasError = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate600, + ), + ), + const SizedBox(height: 6), + TextField( + controller: controller, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: hint, + errorText: hasError ? ' ' : null, + ), + ), + ], + ); + } + + Widget _buildPasswordInput(bool hasError) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '密码', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate600, + ), + ), + const SizedBox(height: 6), + TextField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + hintText: '请输入密码', + errorText: hasError ? ' ' : null, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + size: 20, + color: AppColors.slate400, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + ), + ], + ); + } + Widget _buildFooter() { return GestureDetector( onTap: () => context.push('/register'), diff --git a/apps/lib/features/auth/ui/screens/register_screen.dart b/apps/lib/features/auth/ui/screens/register_screen.dart index 22de94b..70fa7aa 100644 --- a/apps/lib/features/auth/ui/screens/register_screen.dart +++ b/apps/lib/features/auth/ui/screens/register_screen.dart @@ -3,8 +3,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:formz/formz.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; +import '../../../../core/di/injection.dart'; import '../../../../shared/widgets/app_button.dart'; -import '../../../../shared/widgets/warning_banner.dart'; +import '../../../../shared/widgets/banner/app_banner.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; import '../../presentation/cubits/register_cubit.dart'; import '../../data/auth_repository.dart'; @@ -14,7 +16,7 @@ class RegisterScreen extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => RegisterCubit(context.read()), + create: (context) => RegisterCubit(sl()), child: const RegisterView(), ); } @@ -53,7 +55,7 @@ class _RegisterViewState extends State { final success = await cubit.submitStep1(); if (success && mounted) { - context.push('/register/step2', extra: cubit); + context.push('/register/verification', extra: cubit); } } @@ -146,9 +148,9 @@ class _RegisterViewState extends State { if (state.errorMessage != null) Padding( padding: const EdgeInsets.only(top: 8), - child: WarningBanner( + child: AppBanner( message: state.errorMessage!, - visible: true, + type: ToastType.error, ), ), const SizedBox(height: 12), diff --git a/apps/lib/features/auth/ui/screens/register_step2_screen.dart b/apps/lib/features/auth/ui/screens/register_verification_screen.dart similarity index 85% rename from apps/lib/features/auth/ui/screens/register_step2_screen.dart rename to apps/lib/features/auth/ui/screens/register_verification_screen.dart index 1d5f0d2..37070c7 100644 --- a/apps/lib/features/auth/ui/screens/register_step2_screen.dart +++ b/apps/lib/features/auth/ui/screens/register_verification_screen.dart @@ -4,15 +4,16 @@ import 'package:go_router/go_router.dart'; import 'package:formz/formz.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; -import '../../../../shared/widgets/warning_banner.dart'; +import '../../../../shared/widgets/banner/app_banner.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; import '../../presentation/cubits/register_cubit.dart'; import '../../presentation/bloc/auth_bloc.dart'; import '../../presentation/bloc/auth_event.dart'; -class RegisterStep2Screen extends StatelessWidget { +class RegisterVerificationScreen extends StatelessWidget { final RegisterCubit? cubit; - const RegisterStep2Screen({super.key, this.cubit}); + const RegisterVerificationScreen({super.key, this.cubit}); @override Widget build(BuildContext context) { @@ -27,26 +28,25 @@ class RegisterStep2Screen extends StatelessWidget { return BlocProvider.value( value: registerCubit, - child: const RegisterStep2View(), + child: const RegisterVerificationView(), ); } } -class RegisterStep2View extends StatefulWidget { - const RegisterStep2View({super.key}); +class RegisterVerificationView extends StatefulWidget { + const RegisterVerificationView({super.key}); @override - State createState() => _RegisterStep2ViewState(); + State createState() => + _RegisterVerificationViewState(); } -class _RegisterStep2ViewState extends State { +class _RegisterVerificationViewState extends State { final _codeController = TextEditingController(); - final _inviteController = TextEditingController(); @override void dispose() { _codeController.dispose(); - _inviteController.dispose(); super.dispose(); } @@ -150,15 +150,13 @@ class _RegisterStep2ViewState extends State { children: [ _buildCodeInput(state), const SizedBox(height: 12), - _buildInviteInput(), - const SizedBox(height: 12), _buildStepIndicator(), if (state.errorMessage != null) Padding( padding: const EdgeInsets.only(top: 8), - child: WarningBanner( + child: AppBanner( message: state.errorMessage!, - visible: true, + type: ToastType.error, ), ), const SizedBox(height: 12), @@ -237,27 +235,6 @@ class _RegisterStep2ViewState extends State { ); } - Widget _buildInviteInput() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '邀请码(可选)', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Color(0xFF475569), - ), - ), - const SizedBox(height: 6), - TextField( - controller: _inviteController, - decoration: const InputDecoration(hintText: '有邀请码可填写'), - ), - ], - ); - } - Widget _buildStepIndicator() { return Row( children: [ diff --git a/apps/lib/features/contacts/ui/screens/add_contact_screen.dart b/apps/lib/features/contacts/ui/screens/add_contact_screen.dart index 443f2be..f119bc0 100644 --- a/apps/lib/features/contacts/ui/screens/add_contact_screen.dart +++ b/apps/lib/features/contacts/ui/screens/add_contact_screen.dart @@ -147,7 +147,6 @@ class _AddContactScreenState extends State { void _handleConfirm() { final name = _nameController.text.trim(); final email = _emailController.text.trim(); - final remark = _remarkController.text.trim(); if (name.isEmpty || email.isEmpty) { ScaffoldMessenger.of( diff --git a/apps/lib/shared/widgets/app_button.dart b/apps/lib/shared/widgets/app_button.dart index d539cdc..c13b4e4 100644 --- a/apps/lib/shared/widgets/app_button.dart +++ b/apps/lib/shared/widgets/app_button.dart @@ -4,7 +4,6 @@ import '../../core/theme/design_tokens.dart'; class AppButton extends StatelessWidget { final String text; final VoidCallback? onPressed; - final bool isPrimary; final bool isOutlined; final double height; final bool isLoading; @@ -13,7 +12,6 @@ class AppButton extends StatelessWidget { super.key, required this.text, this.onPressed, - this.isPrimary = true, this.isOutlined = false, this.height = 44, this.isLoading = false, diff --git a/apps/lib/shared/widgets/warning_banner.dart b/apps/lib/shared/widgets/banner/app_banner.dart similarity index 52% rename from apps/lib/shared/widgets/warning_banner.dart rename to apps/lib/shared/widgets/banner/app_banner.dart index 86c18c5..4c8d7b5 100644 --- a/apps/lib/shared/widgets/warning_banner.dart +++ b/apps/lib/shared/widgets/banner/app_banner.dart @@ -1,38 +1,41 @@ import 'package:flutter/material.dart'; -import '../../core/theme/design_tokens.dart'; +import '../../../core/theme/design_tokens.dart'; +import '../toast/toast_type.dart'; +import '../toast/toast_type_config.dart' show ToastTypeConfig; -class WarningBanner extends StatelessWidget { +class AppBanner extends StatelessWidget { final String message; + final ToastType type; final bool visible; - const WarningBanner({super.key, required this.message, this.visible = true}); + const AppBanner({ + super.key, + required this.message, + this.type = ToastType.warning, + this.visible = true, + }); @override Widget build(BuildContext context) { if (!visible) return const SizedBox.shrink(); + final config = ToastTypeConfig.fromType(type); + return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( - color: AppColors.warningBackground, + color: config.backgroundColor, borderRadius: BorderRadius.circular(AppRadius.sm), ), child: Row( children: [ - const Icon( - Icons.warning_amber_rounded, - size: 16, - color: AppColors.warningText, - ), + Icon(config.icon, size: 16, color: config.iconColor), const SizedBox(width: 8), Expanded( child: Text( message, - style: const TextStyle( - fontSize: 13, - color: AppColors.warningText, - ), + style: TextStyle(fontSize: 13, color: config.textColor), ), ), ], diff --git a/apps/lib/shared/widgets/toast/index.dart b/apps/lib/shared/widgets/toast/index.dart new file mode 100644 index 0000000..7c0e1d9 --- /dev/null +++ b/apps/lib/shared/widgets/toast/index.dart @@ -0,0 +1,3 @@ +export 'toast.dart'; +export 'toast_type.dart'; +export 'toast_type_config.dart'; diff --git a/apps/lib/shared/widgets/toast/toast.dart b/apps/lib/shared/widgets/toast/toast.dart new file mode 100644 index 0000000..97dd0d6 --- /dev/null +++ b/apps/lib/shared/widgets/toast/toast.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import '../../../core/theme/design_tokens.dart'; +import 'toast_type.dart'; +import 'toast_type_config.dart'; + +class Toast { + static void show( + BuildContext context, + String message, { + ToastType type = ToastType.info, + Duration duration = const Duration(seconds: 2), + }) { + final overlay = Overlay.of(context); + late OverlayEntry entry; + + entry = OverlayEntry( + builder: (context) => _ToastWidget( + message: message, + type: type, + duration: duration, + onDismiss: () => entry.remove(), + ), + ); + + overlay.insert(entry); + } +} + +class _ToastWidget extends StatefulWidget { + final String message; + final ToastType type; + final Duration duration; + final VoidCallback onDismiss; + + const _ToastWidget({ + required this.message, + required this.type, + required this.duration, + required this.onDismiss, + }); + + @override + State<_ToastWidget> createState() => _ToastWidgetState(); +} + +class _ToastWidgetState extends State<_ToastWidget> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _slideAnimation; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 250), + vsync: this, + ); + + _slideAnimation = Tween( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + + _fadeAnimation = Tween(begin: 0, end: 1).animate(_controller); + + _controller.forward(); + + Future.delayed(widget.duration, _dismiss); + } + + void _dismiss() { + if (!mounted) return; + _controller.reverse().then((_) => widget.onDismiss()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final config = ToastTypeConfig.fromType(widget.type); + + return Positioned( + top: MediaQuery.of(context).padding.top + 16, + left: 16, + right: 16, + child: SlideTransition( + position: _slideAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: config.backgroundColor, + borderRadius: BorderRadius.circular(AppRadius.md), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Icon(config.icon, size: 20, color: config.iconColor), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.message, + style: TextStyle(fontSize: 14, color: config.textColor), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/lib/shared/widgets/toast/toast_type.dart b/apps/lib/shared/widgets/toast/toast_type.dart new file mode 100644 index 0000000..9c4add8 --- /dev/null +++ b/apps/lib/shared/widgets/toast/toast_type.dart @@ -0,0 +1 @@ +enum ToastType { info, success, warning, error } diff --git a/apps/lib/shared/widgets/toast/toast_type_config.dart b/apps/lib/shared/widgets/toast/toast_type_config.dart new file mode 100644 index 0000000..638b83f --- /dev/null +++ b/apps/lib/shared/widgets/toast/toast_type_config.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'toast_type.dart'; + +class ToastTypeConfig { + final Color backgroundColor; + final Color iconColor; + final Color textColor; + final IconData icon; + + const ToastTypeConfig({ + required this.backgroundColor, + required this.iconColor, + required this.textColor, + required this.icon, + }); + + static ToastTypeConfig fromType(ToastType type) => switch (type) { + ToastType.success => const ToastTypeConfig( + backgroundColor: Color(0xFFECFDF5), + iconColor: Color(0xFF10B981), + textColor: Color(0xFF065F46), + icon: Icons.check_circle_outline, + ), + ToastType.warning => const ToastTypeConfig( + backgroundColor: Color(0xFFFFFBEB), + iconColor: Color(0xFFF59E0B), + textColor: Color(0xFF92400E), + icon: Icons.warning_amber_rounded, + ), + ToastType.error => const ToastTypeConfig( + backgroundColor: Color(0xFFFEF2F2), + iconColor: Color(0xFFEF4444), + textColor: Color(0xFF991B1B), + icon: Icons.error_outline, + ), + ToastType.info => const ToastTypeConfig( + backgroundColor: Color(0xFFEFF6FF), + iconColor: Color(0xFF3B82F6), + textColor: Color(0xFF1E40AF), + icon: Icons.info_outline, + ), + }; +} diff --git a/apps/test/core/api/api_exception_test.dart b/apps/test/core/api/api_exception_test.dart index e42825c..c32820e 100644 --- a/apps/test/core/api/api_exception_test.dart +++ b/apps/test/core/api/api_exception_test.dart @@ -8,17 +8,12 @@ void main() { final apiException = ApiException.fromDioError(dioException); expect(apiException, isA()); - expect(apiException.message, contains('Request failed')); - }); - - test('NetworkException has correct message', () { - const exception = NetworkException('No internet'); - expect(exception.message, 'No internet'); + expect(apiException.message, contains('网络错误')); }); test('UnauthorizedException has default message', () { const exception = UnauthorizedException(); - expect(exception.message, 'Authentication required'); + expect(exception.message, '请重新登录'); }); }); } diff --git a/apps/test/widget_test.dart b/apps/test/widget_test.dart index 49c2130..2f6d8e3 100644 --- a/apps/test/widget_test.dart +++ b/apps/test/widget_test.dart @@ -4,9 +4,13 @@ import 'package:mocktail/mocktail.dart'; import 'package:social_app/main.dart'; import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart'; import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/core/di/injection.dart'; class MockAuthBloc extends Mock implements AuthBloc {} +class MockAuthRepository extends Mock implements AuthRepository {} + class FakeAuthState extends Fake implements AuthState {} void main() { @@ -14,6 +18,13 @@ void main() { registerFallbackValue(FakeAuthState()); }); + setUp(() async { + if (sl.isRegistered()) { + await sl.reset(); + } + sl.registerSingleton(MockAuthRepository()); + }); + testWidgets('Login screen loads correctly', (WidgetTester tester) async { final mockAuthBloc = MockAuthBloc(); when(() => mockAuthBloc.state).thenReturn(AuthInitial()); @@ -23,7 +34,7 @@ void main() { await tester.pumpWidget(LinksyApp(authBloc: mockAuthBloc)); expect(find.text('linksy'), findsOneWidget); - expect(find.text('继续'), findsOneWidget); + expect(find.text('登录'), findsOneWidget); expect(find.text('还没有账号?去注册'), findsOneWidget); });