Merge branch 'feature/flutter-auth' into dev
This commit is contained in:
@@ -45,3 +45,42 @@ For important screens, add widget tests that reduce layout-regression risk:
|
|||||||
- Do not skip design container layers.
|
- Do not skip design container layers.
|
||||||
- Do not start implementation before retrieving design variables.
|
- Do not start implementation before retrieving design variables.
|
||||||
- Do not hardcode colors; use 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`
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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 ApiInterceptor _interceptor;
|
||||||
|
|
||||||
|
factory ApiClient({
|
||||||
|
required String baseUrl,
|
||||||
|
required TokenStorage tokenStorage,
|
||||||
|
Dio? dio,
|
||||||
|
}) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
void setRefreshCallback(Future<bool> Function(String) refresh) {
|
||||||
|
_interceptor.onTokenRefresh = () async {
|
||||||
|
final token = await _tokenStorage.getRefreshToken();
|
||||||
|
if (token == null) return false;
|
||||||
|
return refresh(token);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<T>> get<T>(String path, {Options? options}) async {
|
||||||
|
try {
|
||||||
|
return await _dio.get<T>(path, options: options);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw ApiException.fromDioError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<T>> post<T>(
|
||||||
|
String path, {
|
||||||
|
dynamic data,
|
||||||
|
Options? options,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _dio.post<T>(path, data: data, options: options);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw ApiException.fromDioError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (error is DioException) {
|
||||||
|
final response = error.response;
|
||||||
|
final statusCode = response?.statusCode;
|
||||||
|
final data = response?.data;
|
||||||
|
|
||||||
|
String detail;
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
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 {
|
||||||
|
const ServerException(super.message, {super.statusCode});
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnauthorizedException extends ApiException {
|
||||||
|
const UnauthorizedException([super.message = '请重新登录'])
|
||||||
|
: super(statusCode: 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ValidationException extends ApiException {
|
||||||
|
final Map<String, dynamic>? errors;
|
||||||
|
const ValidationException(super.message, {this.errors, super.statusCode});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import '../storage/token_storage.dart';
|
||||||
|
|
||||||
|
class ApiInterceptor extends Interceptor {
|
||||||
|
final TokenStorage tokenStorage;
|
||||||
|
final Dio dio;
|
||||||
|
Future<bool> Function()? onTokenRefresh;
|
||||||
|
|
||||||
|
ApiInterceptor({
|
||||||
|
required this.tokenStorage,
|
||||||
|
required this.dio,
|
||||||
|
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;
|
||||||
|
} on DioException {
|
||||||
|
// Retry failed, proceed with original error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
class Env {
|
||||||
|
static String get apiUrl {
|
||||||
|
const url = String.fromEnvironment('API_URL');
|
||||||
|
if (url.isNotEmpty) return url;
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return 'http://10.0.2.2:5775';
|
||||||
|
}
|
||||||
|
return 'http://localhost:5775';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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<void> configureDependencies() async {
|
||||||
|
if (sl.isRegistered<ApiClient>()) {
|
||||||
|
await sl.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
final dio = Dio(BaseOptions(baseUrl: Env.apiUrl));
|
||||||
|
final secureStorage = const FlutterSecureStorage();
|
||||||
|
final tokenStorage = SecureTokenStorage(secureStorage);
|
||||||
|
|
||||||
|
final apiClient = ApiClient(
|
||||||
|
baseUrl: Env.apiUrl,
|
||||||
|
tokenStorage: tokenStorage,
|
||||||
|
dio: dio,
|
||||||
|
);
|
||||||
|
sl.registerSingleton<ApiClient>(apiClient);
|
||||||
|
|
||||||
|
final authApi = AuthApi(apiClient);
|
||||||
|
sl.registerSingleton<AuthApi>(authApi);
|
||||||
|
|
||||||
|
final authRepository = AuthRepositoryImpl(
|
||||||
|
api: authApi,
|
||||||
|
tokenStorage: tokenStorage,
|
||||||
|
);
|
||||||
|
sl.registerSingleton<AuthRepository>(authRepository);
|
||||||
|
|
||||||
|
apiClient.setRefreshCallback((token) async {
|
||||||
|
try {
|
||||||
|
await authRepository.refresh(token);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sl.registerSingleton<AuthBloc>(AuthBloc(authRepository));
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:formz/formz.dart';
|
||||||
|
|
||||||
|
class Username extends FormzInput<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../features/auth/presentation/bloc/auth_bloc.dart';
|
||||||
import '../../features/auth/ui/screens/login_email_screen.dart';
|
import '../../features/auth/presentation/bloc/auth_state.dart';
|
||||||
import '../../features/auth/ui/screens/login_password_screen.dart';
|
import 'go_router_refresh_stream.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_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/home/ui/screens/home_screen.dart';
|
||||||
import '../../features/messages/ui/screens/message_invite_list_screen.dart';
|
import '../../features/messages/ui/screens/message_invite_list_screen.dart';
|
||||||
import '../../features/messages/ui/screens/message_invite_detail_screen.dart';
|
import '../../features/messages/ui/screens/message_invite_detail_screen.dart';
|
||||||
@@ -20,78 +20,106 @@ import '../../features/settings/ui/screens/features_screen.dart';
|
|||||||
import '../../features/settings/ui/screens/memory_screen.dart';
|
import '../../features/settings/ui/screens/memory_screen.dart';
|
||||||
import '../../features/settings/ui/screens/account_screen.dart';
|
import '../../features/settings/ui/screens/account_screen.dart';
|
||||||
|
|
||||||
final appRouter = GoRouter(
|
final _protectedRoutes = [
|
||||||
initialLocation: '/',
|
'/home',
|
||||||
routes: [
|
'/contacts',
|
||||||
GoRoute(path: '/', builder: (context, state) => const LoginEmailScreen()),
|
'/contacts/add',
|
||||||
GoRoute(
|
'/calendar/dayweek',
|
||||||
path: '/login/password',
|
'/calendar/month',
|
||||||
builder: (context, state) => const LoginPasswordScreen(),
|
'/calendar/events',
|
||||||
),
|
'/todo',
|
||||||
GoRoute(
|
'/settings',
|
||||||
path: '/login/code',
|
'/settings/features',
|
||||||
builder: (context, state) => const LoginCodeScreen(),
|
'/settings/memory',
|
||||||
),
|
'/settings/account',
|
||||||
GoRoute(
|
'/messages/invites',
|
||||||
path: '/register',
|
];
|
||||||
builder: (context, state) => const RegisterScreen(),
|
|
||||||
),
|
GoRouter createAppRouter(AuthBloc authBloc) {
|
||||||
GoRoute(
|
return GoRouter(
|
||||||
path: '/register/step2',
|
initialLocation: '/',
|
||||||
builder: (context, state) => const RegisterStep2Screen(),
|
refreshListenable: GoRouterRefreshStream(authBloc.stream),
|
||||||
),
|
redirect: (context, state) {
|
||||||
GoRoute(path: '/home', builder: (context, state) => const HomeScreen()),
|
final authState = authBloc.state;
|
||||||
GoRoute(
|
final isAuthenticated = authState is AuthAuthenticated;
|
||||||
path: '/messages/invites',
|
final isAuthRoute =
|
||||||
builder: (context, state) => const MessageInviteListScreen(),
|
state.matchedLocation.startsWith('/login') ||
|
||||||
),
|
state.matchedLocation.startsWith('/register');
|
||||||
GoRoute(
|
final isProtected = _protectedRoutes.any(
|
||||||
path: '/messages/invites/:id',
|
(route) => state.matchedLocation.startsWith(route),
|
||||||
builder: (context, state) => const MessageInviteDetailScreen(),
|
);
|
||||||
),
|
|
||||||
GoRoute(
|
if (!isAuthenticated && isProtected) {
|
||||||
path: '/contacts',
|
return '/';
|
||||||
builder: (context, state) => const ContactsScreen(),
|
}
|
||||||
),
|
if (isAuthenticated && isAuthRoute) {
|
||||||
GoRoute(
|
return '/home';
|
||||||
path: '/contacts/add',
|
}
|
||||||
builder: (context, state) => const AddContactScreen(),
|
return null;
|
||||||
),
|
},
|
||||||
GoRoute(
|
routes: [
|
||||||
path: '/calendar/dayweek',
|
GoRoute(path: '/', builder: (context, state) => const LoginScreen()),
|
||||||
builder: (context, state) => const CalendarDayWeekScreen(),
|
GoRoute(
|
||||||
),
|
path: '/register',
|
||||||
GoRoute(
|
builder: (context, state) => const RegisterScreen(),
|
||||||
path: '/calendar/month',
|
),
|
||||||
builder: (context, state) => const CalendarMonthScreen(),
|
GoRoute(
|
||||||
),
|
path: '/register/verification',
|
||||||
GoRoute(
|
builder: (context, state) => const RegisterVerificationScreen(),
|
||||||
path: '/calendar/events/:id',
|
),
|
||||||
builder: (context, state) => const CalendarEventDetailScreen(),
|
GoRoute(path: '/home', builder: (context, state) => const HomeScreen()),
|
||||||
),
|
GoRoute(
|
||||||
GoRoute(
|
path: '/messages/invites',
|
||||||
path: '/todo',
|
builder: (context, state) => const MessageInviteListScreen(),
|
||||||
builder: (context, state) => const TodoQuadrantsScreen(),
|
),
|
||||||
),
|
GoRoute(
|
||||||
GoRoute(
|
path: '/messages/invites/:id',
|
||||||
path: '/todo/:id',
|
builder: (context, state) => const MessageInviteDetailScreen(),
|
||||||
builder: (context, state) => const TodoDetailScreen(),
|
),
|
||||||
),
|
GoRoute(
|
||||||
GoRoute(
|
path: '/contacts',
|
||||||
path: '/settings',
|
builder: (context, state) => const ContactsScreen(),
|
||||||
builder: (context, state) => const SettingsScreen(),
|
),
|
||||||
),
|
GoRoute(
|
||||||
GoRoute(
|
path: '/contacts/add',
|
||||||
path: '/settings/features',
|
builder: (context, state) => const AddContactScreen(),
|
||||||
builder: (context, state) => const FeaturesScreen(),
|
),
|
||||||
),
|
GoRoute(
|
||||||
GoRoute(
|
path: '/calendar/dayweek',
|
||||||
path: '/settings/memory',
|
builder: (context, state) => const CalendarDayWeekScreen(),
|
||||||
builder: (context, state) => const MemoryScreen(),
|
),
|
||||||
),
|
GoRoute(
|
||||||
GoRoute(
|
path: '/calendar/month',
|
||||||
path: '/settings/account',
|
builder: (context, state) => const CalendarMonthScreen(),
|
||||||
builder: (context, state) => const AccountScreen(),
|
),
|
||||||
),
|
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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class GoRouterRefreshStream extends ChangeNotifier {
|
||||||
|
GoRouterRefreshStream(Stream<dynamic> stream) {
|
||||||
|
notifyListeners();
|
||||||
|
_subscription = stream.listen((_) => notifyListeners());
|
||||||
|
}
|
||||||
|
|
||||||
|
late final StreamSubscription<dynamic> _subscription;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_subscription.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
abstract class TokenStorage {
|
||||||
|
Future<String?> getAccessToken();
|
||||||
|
Future<String?> getRefreshToken();
|
||||||
|
Future<void> saveTokens({required String access, required String refresh});
|
||||||
|
Future<void> clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
class SecureTokenStorage implements TokenStorage {
|
||||||
|
static const _accessTokenKey = 'access_token';
|
||||||
|
static const _refreshTokenKey = 'refresh_token';
|
||||||
|
|
||||||
|
final dynamic _storage;
|
||||||
|
|
||||||
|
SecureTokenStorage([this._storage]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getAccessToken() async {
|
||||||
|
return _storage?.read(key: _accessTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getRefreshToken() async {
|
||||||
|
return _storage?.read(key: _refreshTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveTokens({
|
||||||
|
required String access,
|
||||||
|
required String refresh,
|
||||||
|
}) async {
|
||||||
|
await _storage?.write(key: _accessTokenKey, value: access);
|
||||||
|
await _storage?.write(key: _refreshTokenKey, value: refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async {
|
||||||
|
await _storage?.delete(key: _accessTokenKey);
|
||||||
|
await _storage?.delete(key: _refreshTokenKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = '/api/v1/auth';
|
||||||
|
|
||||||
|
AuthApi(this._client);
|
||||||
|
|
||||||
|
Future<SignupStartResponse> signupStart(SignupStartRequest request) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
'$_prefix/signup/start',
|
||||||
|
data: request.toJson(),
|
||||||
|
);
|
||||||
|
return SignupStartResponse.fromJson(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthResponse> signupVerify(SignupVerifyRequest request) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
'$_prefix/signup/verify',
|
||||||
|
data: request.toJson(),
|
||||||
|
);
|
||||||
|
return AuthResponse.fromJson(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SignupStartResponse> signupResend(SignupResendRequest request) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
'$_prefix/signup/resend',
|
||||||
|
data: request.toJson(),
|
||||||
|
);
|
||||||
|
return SignupStartResponse.fromJson(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthResponse> login(LoginRequest request) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
'$_prefix/login',
|
||||||
|
data: request.toJson(),
|
||||||
|
);
|
||||||
|
return AuthResponse.fromJson(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthResponse> refresh(RefreshRequest request) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
'$_prefix/refresh',
|
||||||
|
data: request.toJson(),
|
||||||
|
);
|
||||||
|
return AuthResponse.fromJson(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout(LogoutRequest request) async {
|
||||||
|
await _client.post('$_prefix/logout', data: request.toJson());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SignupStartResponse> signupStart(SignupStartRequest request);
|
||||||
|
Future<AuthResponse> signupVerify(SignupVerifyRequest request);
|
||||||
|
Future<SignupStartResponse> signupResend(SignupResendRequest request);
|
||||||
|
Future<AuthResponse> login(LoginRequest request);
|
||||||
|
Future<AuthResponse> refresh(String refreshToken);
|
||||||
|
Future<void> logout();
|
||||||
|
Future<String?> getAccessToken();
|
||||||
|
Future<String?> getRefreshToken();
|
||||||
|
Future<bool> isAuthenticated();
|
||||||
|
}
|
||||||
@@ -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<SignupStartResponse> signupStart(SignupStartRequest request) {
|
||||||
|
return _api.signupStart(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthResponse> signupVerify(SignupVerifyRequest request) async {
|
||||||
|
final response = await _api.signupVerify(request);
|
||||||
|
await _tokenStorage.saveTokens(
|
||||||
|
access: response.accessToken,
|
||||||
|
refresh: response.refreshToken,
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SignupStartResponse> signupResend(SignupResendRequest request) {
|
||||||
|
return _api.signupResend(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthResponse> login(LoginRequest request) async {
|
||||||
|
final response = await _api.login(request);
|
||||||
|
await _tokenStorage.saveTokens(
|
||||||
|
access: response.accessToken,
|
||||||
|
refresh: response.refreshToken,
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthResponse> 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<void> logout() async {
|
||||||
|
final refreshToken = await _tokenStorage.getRefreshToken();
|
||||||
|
if (refreshToken != null) {
|
||||||
|
await _api.logout(LogoutRequest(refreshToken: refreshToken));
|
||||||
|
}
|
||||||
|
await _tokenStorage.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getAccessToken() => _tokenStorage.getAccessToken();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getRefreshToken() => _tokenStorage.getRefreshToken();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isAuthenticated() async {
|
||||||
|
final token = await _tokenStorage.getAccessToken();
|
||||||
|
return token != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
class AuthUser {
|
||||||
|
final String id;
|
||||||
|
final String email;
|
||||||
|
|
||||||
|
const AuthUser({required this.id, required this.email});
|
||||||
|
|
||||||
|
factory AuthUser.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, dynamic> json) {
|
||||||
|
return SignupStartResponse(
|
||||||
|
status: json['status'] as String,
|
||||||
|
email: json['email'] as String,
|
||||||
|
message: json['message'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
class LoginRequest {
|
||||||
|
final String email;
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
const LoginRequest({required this.email, required this.password});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {'email': email, 'password': password};
|
||||||
|
}
|
||||||
|
|
||||||
|
class RefreshRequest {
|
||||||
|
final String refreshToken;
|
||||||
|
|
||||||
|
const RefreshRequest({required this.refreshToken});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {'refresh_token': refreshToken};
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogoutRequest {
|
||||||
|
final String refreshToken;
|
||||||
|
|
||||||
|
const LogoutRequest({required this.refreshToken});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {'refresh_token': refreshToken};
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> toJson() => {
|
||||||
|
'username': username,
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class SignupVerifyRequest {
|
||||||
|
final String email;
|
||||||
|
final String token;
|
||||||
|
|
||||||
|
const SignupVerifyRequest({required this.email, required this.token});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {'email': email, 'token': token};
|
||||||
|
}
|
||||||
|
|
||||||
|
class SignupResendRequest {
|
||||||
|
final String email;
|
||||||
|
|
||||||
|
const SignupResendRequest({required this.email});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {'email': email};
|
||||||
|
}
|
||||||
@@ -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<AuthEvent, AuthState> {
|
||||||
|
final AuthRepository _repository;
|
||||||
|
|
||||||
|
AuthBloc(this._repository) : super(AuthInitial()) {
|
||||||
|
on<AuthStarted>(_onStarted);
|
||||||
|
on<AuthLoggedIn>(_onLoggedIn);
|
||||||
|
on<AuthLoggedOut>(_onLoggedOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onStarted(AuthStarted event, Emitter<AuthState> 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<AuthState> emit) {
|
||||||
|
emit(AuthAuthenticated(user: event.user));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoggedOut(
|
||||||
|
AuthLoggedOut event,
|
||||||
|
Emitter<AuthState> emit,
|
||||||
|
) async {
|
||||||
|
await _repository.logout();
|
||||||
|
emit(AuthUnauthenticated());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import '../../data/models/auth_response.dart';
|
||||||
|
|
||||||
|
abstract class AuthEvent extends Equatable {
|
||||||
|
const AuthEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthStarted extends AuthEvent {}
|
||||||
|
|
||||||
|
class AuthLoggedIn extends AuthEvent {
|
||||||
|
final AuthUser user;
|
||||||
|
|
||||||
|
const AuthLoggedIn({required this.user});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [user];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthLoggedOut extends AuthEvent {}
|
||||||
@@ -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<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthInitial extends AuthState {}
|
||||||
|
|
||||||
|
class AuthLoading extends AuthState {}
|
||||||
|
|
||||||
|
class AuthAuthenticated extends AuthState {
|
||||||
|
final AuthUser user;
|
||||||
|
|
||||||
|
const AuthAuthenticated({required this.user});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [user];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthUnauthenticated extends AuthState {}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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 '../../../../core/form_inputs/form_inputs.dart';
|
||||||
|
|
||||||
|
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<Object?> get props => [email, password, status, errorMessage];
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginCubit extends Cubit<LoginState> {
|
||||||
|
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<AuthResponse?> 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) {
|
||||||
|
final message = e is ApiException ? e.message : e.toString();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: FormzSubmissionStatus.failure,
|
||||||
|
errorMessage: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
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 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<Object?> get props => [
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
verificationCode,
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
pendingEmail,
|
||||||
|
codeSent,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class RegisterCubit extends Cubit<RegisterState> {
|
||||||
|
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<bool> 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) {
|
||||||
|
final message = e is ApiException ? e.message : e.toString();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: FormzSubmissionStatus.failure,
|
||||||
|
errorMessage: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthResponse?> 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) {
|
||||||
|
final message = e is ApiException ? e.message : e.toString();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: FormzSubmissionStatus.failure,
|
||||||
|
errorMessage: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resendCode() async {
|
||||||
|
if (state.pendingEmail == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _repository.signupResend(
|
||||||
|
SignupResendRequest(email: state.pendingEmail!),
|
||||||
|
);
|
||||||
|
emit(state.copyWith(codeSent: true));
|
||||||
|
} catch (e) {
|
||||||
|
final message = e is ApiException ? e.message : e.toString();
|
||||||
|
emit(state.copyWith(errorMessage: message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoginCodeScreen> createState() => _LoginCodeScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginCodeScreenState extends State<LoginCodeScreen> {
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<LoginEmailScreen> createState() => _LoginEmailScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginEmailScreenState extends State<LoginEmailScreen> {
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
@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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +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 LoginPasswordScreen extends StatefulWidget {
|
|
||||||
const LoginPasswordScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<LoginPasswordScreen> createState() => _LoginPasswordScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginPasswordScreenState extends State<LoginPasswordScreen> {
|
|
||||||
final _passwordController = TextEditingController();
|
|
||||||
bool _obscureText = true;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_passwordController.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),
|
|
||||||
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),
|
|
||||||
AppButton(text: '登录', onPressed: () => context.go('/home')),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
AppButton(
|
|
||||||
text: '使用验证码登录',
|
|
||||||
isOutlined: true,
|
|
||||||
onPressed: () => context.push('/login/code'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildFooter() {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => context.push('/register'),
|
|
||||||
child: const Text(
|
|
||||||
'还没有账号?去注册',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: AppColors.slate500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
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 '../../../../core/di/injection.dart';
|
||||||
|
import '../../../../shared/widgets/app_button.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 LoginScreen extends StatelessWidget {
|
||||||
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) => LoginCubit(sl<AuthRepository>()),
|
||||||
|
child: const LoginView(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginView extends StatefulWidget {
|
||||||
|
const LoginView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginView> createState() => _LoginViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginViewState extends State<LoginView> {
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLogin() async {
|
||||||
|
final cubit = context.read<LoginCubit>();
|
||||||
|
cubit.emailChanged(_emailController.text);
|
||||||
|
cubit.passwordChanged(_passwordController.text);
|
||||||
|
|
||||||
|
if (!cubit.state.isValid) return;
|
||||||
|
|
||||||
|
final response = await cubit.submit();
|
||||||
|
if (response != null && mounted) {
|
||||||
|
context.read<AuthBloc>().add(AuthLoggedIn(user: response.user));
|
||||||
|
context.go('/home');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(
|
||||||
|
key: const Key('login_main_content'),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_buildAppIcon(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildAppTitle(),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
_buildFormContainer(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(key: const Key('login_footer'), child: _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 BlocBuilder<LoginCubit, LoginState>(
|
||||||
|
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: [
|
||||||
|
_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)
|
||||||
|
AppBanner(message: state.errorMessage!, type: ToastType.error)
|
||||||
|
else if (fieldError != null)
|
||||||
|
AppBanner(message: fieldError, type: ToastType.warning),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AppButton(
|
||||||
|
text: '登录',
|
||||||
|
onPressed: state.status == FormzSubmissionStatus.inProgress
|
||||||
|
? null
|
||||||
|
: _handleLogin,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'),
|
||||||
|
child: const Text(
|
||||||
|
'还没有账号?去注册',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.slate500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,64 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 'package:go_router/go_router.dart';
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
import '../../../../core/theme/design_tokens.dart';
|
||||||
|
import '../../../../core/di/injection.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.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';
|
||||||
|
|
||||||
class RegisterScreen extends StatefulWidget {
|
class RegisterScreen extends StatelessWidget {
|
||||||
const RegisterScreen({super.key});
|
const RegisterScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) => RegisterCubit(sl<AuthRepository>()),
|
||||||
|
child: const RegisterView(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RegisterScreenState extends State<RegisterScreen> {
|
class RegisterView extends StatefulWidget {
|
||||||
|
const RegisterView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RegisterView> createState() => _RegisterViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegisterViewState extends State<RegisterView> {
|
||||||
final _nicknameController = TextEditingController();
|
final _nicknameController = TextEditingController();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _obscureText = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nicknameController.dispose();
|
_nicknameController.dispose();
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleNext() async {
|
||||||
|
final cubit = context.read<RegisterCubit>();
|
||||||
|
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/verification', extra: cubit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -93,23 +131,39 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFormContainer() {
|
Widget _buildFormContainer() {
|
||||||
return SizedBox(
|
return BlocBuilder<RegisterCubit, RegisterState>(
|
||||||
width: 327,
|
builder: (context, state) {
|
||||||
child: Column(
|
return SizedBox(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
width: 327,
|
||||||
children: [
|
child: Column(
|
||||||
_buildInput('昵称', '请输入昵称', _nicknameController),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const SizedBox(height: 12),
|
children: [
|
||||||
_buildInput('邮箱', '请输入邮箱', _emailController),
|
_buildInput('昵称', '请输入昵称', _nicknameController),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildStepIndicator(),
|
_buildInput('邮箱', '请输入邮箱', _emailController),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
AppButton(
|
_buildPasswordInput(),
|
||||||
text: '下一步',
|
const SizedBox(height: 12),
|
||||||
onPressed: () => context.push('/register/step2'),
|
_buildStepIndicator(),
|
||||||
|
if (state.errorMessage != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: AppBanner(
|
||||||
|
message: state.errorMessage!,
|
||||||
|
type: ToastType.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AppButton(
|
||||||
|
text: '下一步',
|
||||||
|
onPressed: state.status == FormzSubmissionStatus.inProgress
|
||||||
|
? null
|
||||||
|
: _handleNext,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +192,42 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
Widget _buildStepIndicator() {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
+85
-82
@@ -1,29 +1,74 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:formz/formz.dart';
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
import '../../../../core/theme/design_tokens.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.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 StatefulWidget {
|
class RegisterVerificationScreen extends StatelessWidget {
|
||||||
const RegisterStep2Screen({super.key});
|
final RegisterCubit? cubit;
|
||||||
|
|
||||||
|
const RegisterVerificationScreen({super.key, this.cubit});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RegisterStep2Screen> 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 RegisterVerificationView(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RegisterStep2ScreenState extends State<RegisterStep2Screen> {
|
class RegisterVerificationView extends StatefulWidget {
|
||||||
final _passwordController = TextEditingController();
|
const RegisterVerificationView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RegisterVerificationView> createState() =>
|
||||||
|
_RegisterVerificationViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegisterVerificationViewState extends State<RegisterVerificationView> {
|
||||||
final _codeController = TextEditingController();
|
final _codeController = TextEditingController();
|
||||||
final _inviteController = TextEditingController();
|
|
||||||
bool _obscureText = true;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_passwordController.dispose();
|
|
||||||
_codeController.dispose();
|
_codeController.dispose();
|
||||||
_inviteController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleComplete() async {
|
||||||
|
final cubit = context.read<RegisterCubit>();
|
||||||
|
cubit.verificationCodeChanged(_codeController.text);
|
||||||
|
|
||||||
|
if (!cubit.state.isStep2Valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await cubit.submitStep2();
|
||||||
|
if (response != null && mounted) {
|
||||||
|
context.read<AuthBloc>().add(AuthLoggedIn(user: response.user));
|
||||||
|
context.go('/home');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleResendCode() async {
|
||||||
|
await context.read<RegisterCubit>().resendCode();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -96,62 +141,39 @@ class _RegisterStep2ScreenState extends State<RegisterStep2Screen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFormContainer() {
|
Widget _buildFormContainer() {
|
||||||
return SizedBox(
|
return BlocBuilder<RegisterCubit, RegisterState>(
|
||||||
width: 327,
|
builder: (context, state) {
|
||||||
child: Column(
|
return SizedBox(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
width: 327,
|
||||||
children: [
|
child: Column(
|
||||||
_buildPasswordInput(),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const SizedBox(height: 12),
|
children: [
|
||||||
_buildCodeInput(),
|
_buildCodeInput(state),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildInviteInput(),
|
_buildStepIndicator(),
|
||||||
const SizedBox(height: 12),
|
if (state.errorMessage != null)
|
||||||
_buildStepIndicator(),
|
Padding(
|
||||||
const SizedBox(height: 12),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
AppButton(text: '完成注册', onPressed: () {}),
|
child: AppBanner(
|
||||||
],
|
message: state.errorMessage!,
|
||||||
),
|
type: ToastType.error,
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Widget _buildPasswordInput() {
|
AppButton(
|
||||||
return Column(
|
text: '完成注册',
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onPressed: state.status == FormzSubmissionStatus.inProgress
|
||||||
children: [
|
? null
|
||||||
const Text(
|
: _handleComplete,
|
||||||
'密码',
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
onPressed: () {
|
],
|
||||||
setState(() {
|
|
||||||
_obscureText = !_obscureText;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCodeInput() {
|
Widget _buildCodeInput(RegisterState state) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -187,7 +209,9 @@ class _RegisterStep2ScreenState extends State<RegisterStep2Screen> {
|
|||||||
width: 112,
|
width: 112,
|
||||||
height: 40,
|
height: 40,
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: () {},
|
onPressed: state.status == FormzSubmissionStatus.inProgress
|
||||||
|
? null
|
||||||
|
: _handleResendCode,
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
side: const BorderSide(color: AppColors.input),
|
side: const BorderSide(color: AppColors.input),
|
||||||
@@ -211,27 +235,6 @@ class _RegisterStep2ScreenState extends State<RegisterStep2Screen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
Widget _buildStepIndicator() {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
+24
-9
@@ -1,21 +1,36 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/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() {
|
void main() async {
|
||||||
runApp(const LinksyApp());
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await configureDependencies();
|
||||||
|
|
||||||
|
final authBloc = sl<AuthBloc>();
|
||||||
|
authBloc.add(AuthStarted());
|
||||||
|
|
||||||
|
runApp(LinksyApp(authBloc: authBloc));
|
||||||
}
|
}
|
||||||
|
|
||||||
class LinksyApp extends StatelessWidget {
|
class LinksyApp extends StatelessWidget {
|
||||||
const LinksyApp({super.key});
|
final AuthBloc authBloc;
|
||||||
|
|
||||||
|
const LinksyApp({super.key, required this.authBloc});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp.router(
|
return BlocProvider<AuthBloc>.value(
|
||||||
title: 'Linksy',
|
value: authBloc,
|
||||||
debugShowCheckedModeBanner: false,
|
child: MaterialApp.router(
|
||||||
theme: AppTheme.light,
|
title: 'Linksy',
|
||||||
routerConfig: appRouter,
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: AppTheme.light,
|
||||||
|
routerConfig: createAppRouter(authBloc),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import '../../core/theme/design_tokens.dart';
|
|||||||
class AppButton extends StatelessWidget {
|
class AppButton extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
final VoidCallback? onPressed;
|
final VoidCallback? onPressed;
|
||||||
final bool isPrimary;
|
|
||||||
final bool isOutlined;
|
final bool isOutlined;
|
||||||
final double height;
|
final double height;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
@@ -13,7 +12,6 @@ class AppButton extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.text,
|
required this.text,
|
||||||
this.onPressed,
|
this.onPressed,
|
||||||
this.isPrimary = true,
|
|
||||||
this.isOutlined = false,
|
this.isOutlined = false,
|
||||||
this.height = 44,
|
this.height = 44,
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
|
|||||||
+16
-13
@@ -1,38 +1,41 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 String message;
|
||||||
|
final ToastType type;
|
||||||
final bool visible;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!visible) return const SizedBox.shrink();
|
if (!visible) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final config = ToastTypeConfig.fromType(type);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.warningBackground,
|
color: config.backgroundColor,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(config.icon, size: 16, color: config.iconColor),
|
||||||
Icons.warning_amber_rounded,
|
|
||||||
size: 16,
|
|
||||||
color: AppColors.warningText,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
message,
|
message,
|
||||||
style: const TextStyle(
|
style: TextStyle(fontSize: 13, color: config.textColor),
|
||||||
fontSize: 13,
|
|
||||||
color: AppColors.warningText,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export 'toast.dart';
|
||||||
|
export 'toast_type.dart';
|
||||||
|
export 'toast_type_config.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<Offset> _slideAnimation;
|
||||||
|
late Animation<double> _fadeAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_slideAnimation = Tween<Offset>(
|
||||||
|
begin: const Offset(0, -1),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
||||||
|
|
||||||
|
_fadeAnimation = Tween<double>(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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
enum ToastType { info, success, warning, error }
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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<ApiException>());
|
||||||
|
expect(apiException.message, contains('网络错误'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UnauthorizedException has default message', () {
|
||||||
|
const exception = UnauthorizedException();
|
||||||
|
expect(exception.message, '请重新登录');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mocktail/mocktail.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 MockApiClient extends Mock implements ApiClient {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AuthRepositoryImpl repository;
|
||||||
|
late MockAuthApi mockApi;
|
||||||
|
late MockTokenStorage mockStorage;
|
||||||
|
|
||||||
|
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('AuthRepositoryImpl', () {
|
||||||
|
test('signupStart calls api and returns response', () async {
|
||||||
|
when(() => mockApi.signupStart(any())).thenAnswer(
|
||||||
|
(_) async => const SignupStartResponse(
|
||||||
|
status: 'pending_verification',
|
||||||
|
email: 'test@example.com',
|
||||||
|
message: 'Verification code sent',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await repository.signupStart(
|
||||||
|
const SignupStartRequest(
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status, 'pending_verification');
|
||||||
|
verify(() => mockApi.signupStart(any())).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login calls api and saves tokens', () async {
|
||||||
|
when(() => mockApi.login(any())).thenAnswer(
|
||||||
|
(_) async => AuthResponse(
|
||||||
|
accessToken: 'access_token',
|
||||||
|
refreshToken: 'refresh_token',
|
||||||
|
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.login(
|
||||||
|
const LoginRequest(email: 'test@example.com', password: 'password123'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.accessToken, 'access_token');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<AuthBloc, AuthState>(
|
||||||
|
'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<AuthBloc, AuthState>(
|
||||||
|
'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<AuthAuthenticated>()],
|
||||||
|
);
|
||||||
|
|
||||||
|
blocTest<AuthBloc, AuthState>(
|
||||||
|
'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<AuthBloc, AuthState>(
|
||||||
|
'emits [AuthAuthenticated] when AuthLoggedIn',
|
||||||
|
build: () => authBloc,
|
||||||
|
act: (bloc) => bloc.add(
|
||||||
|
AuthLoggedIn(
|
||||||
|
user: const AuthUser(id: '1', email: 'test@example.com'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
expect: () => [isA<AuthAuthenticated>()],
|
||||||
|
);
|
||||||
|
|
||||||
|
blocTest<AuthBloc, AuthState>(
|
||||||
|
'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()],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<LoginCubit, LoginState>(
|
||||||
|
'emailChanged updates email',
|
||||||
|
build: () => cubit,
|
||||||
|
act: (c) => c.emailChanged('test@example.com'),
|
||||||
|
expect: () => [isA<LoginState>()],
|
||||||
|
);
|
||||||
|
|
||||||
|
blocTest<LoginCubit, LoginState>(
|
||||||
|
'passwordChanged updates password',
|
||||||
|
build: () => cubit,
|
||||||
|
act: (c) => c.passwordChanged('password123'),
|
||||||
|
expect: () => [isA<LoginState>()],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<RegisterCubit, RegisterState>(
|
||||||
|
'usernameChanged updates username',
|
||||||
|
build: () => cubit,
|
||||||
|
act: (c) => c.usernameChanged('testuser'),
|
||||||
|
expect: () => [isA<RegisterState>()],
|
||||||
|
);
|
||||||
|
|
||||||
|
blocTest<RegisterCubit, RegisterState>(
|
||||||
|
'emailChanged updates email',
|
||||||
|
build: () => cubit,
|
||||||
|
act: (c) => c.emailChanged('test@example.com'),
|
||||||
|
expect: () => [isA<RegisterState>()],
|
||||||
|
);
|
||||||
|
|
||||||
|
blocTest<RegisterCubit, RegisterState>(
|
||||||
|
'passwordChanged updates password',
|
||||||
|
build: () => cubit,
|
||||||
|
act: (c) => c.passwordChanged('password123'),
|
||||||
|
expect: () => [isA<RegisterState>()],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,19 +1,53 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:social_app/main.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() {
|
void main() {
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue(FakeAuthState());
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
if (sl.isRegistered<AuthRepository>()) {
|
||||||
|
await sl.reset();
|
||||||
|
}
|
||||||
|
sl.registerSingleton<AuthRepository>(MockAuthRepository());
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('Login screen loads correctly', (WidgetTester tester) async {
|
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('linksy'), findsOneWidget);
|
||||||
expect(find.text('继续'), findsOneWidget);
|
expect(find.text('登录'), findsOneWidget);
|
||||||
expect(find.text('还没有账号?去注册'), findsOneWidget);
|
expect(find.text('还没有账号?去注册'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Main content is vertically centered above footer', (
|
testWidgets('Main content is vertically centered above footer', (
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
) async {
|
) 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 safeAreaRect = tester.getRect(find.byType(SafeArea));
|
||||||
final mainRect = tester.getRect(
|
final mainRect = tester.getRect(
|
||||||
|
|||||||
Reference in New Issue
Block a user