fix(apps): consolidate FormzInput validators and fix login screen
- Move FormzInput validators to core/form_inputs/form_inputs.dart - Fix login_screen.dart syntax error (missing 'class' keyword) - Remove unused _isLoading field - Fix unnecessary const keywords - Update login_cubit and register_cubit imports - Remove duplicate FormzInput definitions from register_cubit - Add Toast and Banner UI feedback system - Remove legacy login/register screens (login_code, login_email, login_password, register_step2) - Remove unused warning_banner widget - Update tests for new error messages and DI setup
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 start implementation before retrieving design variables.
|
||||
- Do not hardcode colors; use design variables.
|
||||
|
||||
## UI Feedback System
|
||||
|
||||
**MUST use the Toast system for all user feedback messages.**
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Use Case | Example |
|
||||
|-----------|----------|---------|
|
||||
| `Toast.show()` | Global temporary notifications | Success/error feedback after action |
|
||||
| `AppBanner` | Inline form validation errors | Login form error message |
|
||||
|
||||
### Toast Types
|
||||
|
||||
```dart
|
||||
enum ToastType { info, success, warning, error }
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
**Global Toast (auto-dismiss):**
|
||||
```dart
|
||||
Toast.show(context, '保存成功', type: ToastType.success);
|
||||
Toast.show(context, '网络错误', type: ToastType.error);
|
||||
Toast.show(context, '正在加载...', type: ToastType.info, duration: Duration(seconds: 3));
|
||||
```
|
||||
|
||||
**Inline Banner (persistent):**
|
||||
```dart
|
||||
AppBanner(message: '邮箱或密码错误', type: ToastType.error)
|
||||
AppBanner(message: '请检查输入', type: ToastType.warning)
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Use `Toast` for transient feedback that auto-dismisses
|
||||
- Use `AppBanner` for persistent inline messages (form errors)
|
||||
- Do NOT create custom SnackBar, Dialog, or Banner components
|
||||
- Do NOT use raw `ScaffoldMessenger`
|
||||
|
||||
@@ -6,36 +6,42 @@ import '../storage/token_storage.dart';
|
||||
class ApiClient {
|
||||
final Dio _dio;
|
||||
final TokenStorage _tokenStorage;
|
||||
final Future<bool> Function(String)? _refreshToken;
|
||||
final ApiInterceptor _interceptor;
|
||||
|
||||
ApiClient({
|
||||
factory ApiClient({
|
||||
required String baseUrl,
|
||||
required TokenStorage tokenStorage,
|
||||
Dio? dio,
|
||||
Future<bool> Function(String)? refreshToken,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
_refreshToken = refreshToken,
|
||||
_dio = dio ?? Dio(BaseOptions(baseUrl: baseUrl)) {
|
||||
_dio.interceptors.add(
|
||||
ApiInterceptor(
|
||||
tokenStorage: _tokenStorage,
|
||||
dio: _dio,
|
||||
onTokenRefresh: _handleTokenRefresh,
|
||||
),
|
||||
}) {
|
||||
final effectiveDio = dio ?? Dio(BaseOptions(baseUrl: baseUrl));
|
||||
final interceptor = ApiInterceptor(
|
||||
tokenStorage: tokenStorage,
|
||||
dio: effectiveDio,
|
||||
);
|
||||
effectiveDio.interceptors.add(interceptor);
|
||||
return ApiClient._(
|
||||
dio: effectiveDio,
|
||||
tokenStorage: tokenStorage,
|
||||
interceptor: interceptor,
|
||||
);
|
||||
}
|
||||
|
||||
ApiClient._({
|
||||
required Dio dio,
|
||||
required TokenStorage tokenStorage,
|
||||
required ApiInterceptor interceptor,
|
||||
}) : _dio = dio,
|
||||
_tokenStorage = tokenStorage,
|
||||
_interceptor = interceptor;
|
||||
|
||||
Dio get dio => _dio;
|
||||
|
||||
Future<bool> _handleTokenRefresh() async {
|
||||
final refreshToken = await _tokenStorage.getRefreshToken();
|
||||
if (refreshToken == null || _refreshToken == null) return false;
|
||||
try {
|
||||
final success = await _refreshToken!(refreshToken);
|
||||
return success;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
void setRefreshCallback(Future<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 {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
abstract class ApiException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
@@ -6,12 +8,58 @@ abstract class ApiException implements Exception {
|
||||
|
||||
factory ApiException.fromDioError(Object error) {
|
||||
if (error is ApiException) return error;
|
||||
return ServerException('Request failed: ${error.toString()}');
|
||||
}
|
||||
}
|
||||
if (error is DioException) {
|
||||
final response = error.response;
|
||||
final statusCode = response?.statusCode;
|
||||
final data = response?.data;
|
||||
|
||||
class NetworkException extends ApiException {
|
||||
const NetworkException(super.message);
|
||||
String detail;
|
||||
if (data is Map<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 {
|
||||
@@ -19,7 +67,7 @@ class ServerException extends ApiException {
|
||||
}
|
||||
|
||||
class UnauthorizedException extends ApiException {
|
||||
const UnauthorizedException([super.message = 'Authentication required'])
|
||||
const UnauthorizedException([super.message = '请重新登录'])
|
||||
: super(statusCode: 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import '../storage/token_storage.dart';
|
||||
|
||||
class ApiInterceptor extends Interceptor {
|
||||
final TokenStorage tokenStorage;
|
||||
final Future<bool> Function()? onTokenRefresh;
|
||||
final Dio dio;
|
||||
Future<bool> Function()? onTokenRefresh;
|
||||
|
||||
ApiInterceptor({
|
||||
required this.tokenStorage,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import 'dart:io';
|
||||
|
||||
class Env {
|
||||
static String get apiUrl {
|
||||
const url = String.fromEnvironment('API_URL');
|
||||
return url.isNotEmpty ? url : 'http://localhost:8000';
|
||||
if (url.isNotEmpty) return url;
|
||||
if (Platform.isAndroid) {
|
||||
return 'http://10.0.2.2:5775';
|
||||
}
|
||||
return 'http://localhost:5775';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,22 @@ 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);
|
||||
|
||||
sl.registerSingleton<Dio>(dio);
|
||||
sl.registerSingleton<FlutterSecureStorage>(secureStorage);
|
||||
sl.registerSingleton<TokenStorage>(tokenStorage);
|
||||
|
||||
final authApi = AuthApi(
|
||||
ApiClient(baseUrl: Env.apiUrl, tokenStorage: tokenStorage, dio: dio),
|
||||
final apiClient = ApiClient(
|
||||
baseUrl: Env.apiUrl,
|
||||
tokenStorage: tokenStorage,
|
||||
dio: dio,
|
||||
);
|
||||
sl.registerSingleton<ApiClient>(apiClient);
|
||||
|
||||
final authApi = AuthApi(apiClient);
|
||||
sl.registerSingleton<AuthApi>(authApi);
|
||||
|
||||
final authRepository = AuthRepositoryImpl(
|
||||
@@ -31,22 +36,14 @@ Future<void> configureDependencies() async {
|
||||
);
|
||||
sl.registerSingleton<AuthRepository>(authRepository);
|
||||
|
||||
sl.unregister<ApiClient>();
|
||||
sl.registerSingleton<ApiClient>(
|
||||
ApiClient(
|
||||
baseUrl: Env.apiUrl,
|
||||
tokenStorage: tokenStorage,
|
||||
dio: dio,
|
||||
refreshToken: (token) async {
|
||||
try {
|
||||
await authRepository.refresh(token);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
apiClient.setRefreshCallback((token) async {
|
||||
try {
|
||||
await authRepository.refresh(token);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
sl.registerSingleton<AuthBloc>(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;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,9 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import '../../features/auth/presentation/bloc/auth_state.dart';
|
||||
import 'go_router_refresh_stream.dart';
|
||||
import '../../features/auth/ui/screens/login_email_screen.dart';
|
||||
import '../../features/auth/ui/screens/login_password_screen.dart';
|
||||
import '../../features/auth/ui/screens/login_code_screen.dart';
|
||||
import '../../features/auth/ui/screens/login_screen.dart';
|
||||
import '../../features/auth/ui/screens/register_screen.dart';
|
||||
import '../../features/auth/ui/screens/register_step2_screen.dart';
|
||||
import '../../features/auth/ui/screens/register_verification_screen.dart';
|
||||
import '../../features/home/ui/screens/home_screen.dart';
|
||||
import '../../features/messages/ui/screens/message_invite_list_screen.dart';
|
||||
import '../../features/messages/ui/screens/message_invite_detail_screen.dart';
|
||||
@@ -60,22 +58,14 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (context, state) => const LoginEmailScreen()),
|
||||
GoRoute(
|
||||
path: '/login/password',
|
||||
builder: (context, state) => const LoginPasswordScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/login/code',
|
||||
builder: (context, state) => const LoginCodeScreen(),
|
||||
),
|
||||
GoRoute(path: '/', builder: (context, state) => const LoginScreen()),
|
||||
GoRoute(
|
||||
path: '/register',
|
||||
builder: (context, state) => const RegisterScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/register/step2',
|
||||
builder: (context, state) => const RegisterStep2Screen(),
|
||||
path: '/register/verification',
|
||||
builder: (context, state) => const RegisterVerificationScreen(),
|
||||
),
|
||||
GoRoute(path: '/home', builder: (context, state) => const HomeScreen()),
|
||||
GoRoute(
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'models/auth_response.dart';
|
||||
|
||||
class AuthApi {
|
||||
final ApiClient _client;
|
||||
static const _prefix = '/v1/auth';
|
||||
static const _prefix = '/api/v1/auth';
|
||||
|
||||
AuthApi(this._client);
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:formz/formz.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../../core/api/api_exception.dart';
|
||||
import '../../data/auth_repository.dart';
|
||||
import '../../data/models/login_request.dart';
|
||||
import '../../data/models/auth_response.dart';
|
||||
import 'register_cubit.dart' show Email, Password;
|
||||
import '../../../../core/form_inputs/form_inputs.dart';
|
||||
|
||||
class LoginState extends Equatable {
|
||||
final Email email;
|
||||
@@ -64,10 +65,11 @@ class LoginCubit extends Cubit<LoginState> {
|
||||
emit(state.copyWith(status: FormzSubmissionStatus.success));
|
||||
return response;
|
||||
} catch (e) {
|
||||
final message = e is ApiException ? e.message : e.toString();
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: FormzSubmissionStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
errorMessage: message,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
|
||||
@@ -1,61 +1,12 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:formz/formz.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../../core/api/api_exception.dart';
|
||||
import '../../../../core/form_inputs/form_inputs.dart';
|
||||
import '../../data/auth_repository.dart';
|
||||
import '../../data/models/signup_request.dart';
|
||||
import '../../data/models/auth_response.dart';
|
||||
|
||||
class Username extends FormzInput<String, String> {
|
||||
const Username.pure() : super.pure('');
|
||||
const Username.dirty([super.value = '']) : super.dirty();
|
||||
|
||||
@override
|
||||
String? validator(String value) {
|
||||
if (value.isEmpty) return 'Username is required';
|
||||
if (value.length < 3) return 'Username must be at least 3 characters';
|
||||
if (value.length > 30) return 'Username must be at most 30 characters';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class Email extends FormzInput<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 'Email is required';
|
||||
if (!_regex.hasMatch(value)) return 'Invalid email format';
|
||||
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 'Password is required';
|
||||
if (value.length < 6) return 'Password must be at least 6 characters';
|
||||
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 'Code is required';
|
||||
if (!RegExp(r'^\d{6}$').hasMatch(value)) return 'Code must be 6 digits';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class RegisterState extends Equatable {
|
||||
final Username username;
|
||||
final Email email;
|
||||
@@ -159,10 +110,11 @@ class RegisterCubit extends Cubit<RegisterState> {
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
final message = e is ApiException ? e.message : e.toString();
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: FormzSubmissionStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
errorMessage: message,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
@@ -184,10 +136,11 @@ class RegisterCubit extends Cubit<RegisterState> {
|
||||
emit(state.copyWith(status: FormzSubmissionStatus.success));
|
||||
return response;
|
||||
} catch (e) {
|
||||
final message = e is ApiException ? e.message : e.toString();
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: FormzSubmissionStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
errorMessage: message,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
@@ -203,7 +156,8 @@ class RegisterCubit extends Cubit<RegisterState> {
|
||||
);
|
||||
emit(state.copyWith(codeSent: true));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(errorMessage: e.toString()));
|
||||
final message = e is ApiException ? e.message : e.toString();
|
||||
emit(state.copyWith(errorMessage: message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', extra: _emailController.text);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildAppIcon(),
|
||||
const SizedBox(height: 24),
|
||||
_buildAppTitle(),
|
||||
const SizedBox(height: 32),
|
||||
_buildFormContainer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildFooter(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppIcon() {
|
||||
return Container(
|
||||
width: 104,
|
||||
height: 104,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.appIconRing,
|
||||
borderRadius: BorderRadius.circular(52),
|
||||
border: Border.all(color: AppColors.appIconBorder, width: 1),
|
||||
),
|
||||
child: Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(38),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 76,
|
||||
height: 76,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppTitle() {
|
||||
return const Text(
|
||||
'linksy',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Playfair Display',
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: AppColors.appTitle,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFormContainer() {
|
||||
return SizedBox(
|
||||
width: 327,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'邮箱',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(hintText: '请输入邮箱'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
WarningBanner(message: _warningMessage, visible: _showWarning),
|
||||
const SizedBox(height: 16),
|
||||
AppButton(text: '继续', onPressed: _handleContinue),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => context.push('/register'),
|
||||
child: const Text(
|
||||
'还没有账号?去注册',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'隐私政策 | 服务条款',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate400),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+101
-69
@@ -3,68 +3,52 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:formz/formz.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/warning_banner.dart';
|
||||
import '../../../../shared/widgets/banner/app_banner.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../presentation/cubits/login_cubit.dart';
|
||||
import '../../presentation/bloc/auth_bloc.dart';
|
||||
import '../../presentation/bloc/auth_event.dart';
|
||||
import '../../data/auth_repository.dart';
|
||||
|
||||
class LoginPasswordScreen extends StatelessWidget {
|
||||
final String? email;
|
||||
|
||||
const LoginPasswordScreen({super.key, this.email});
|
||||
class LoginScreen extends StatelessWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final emailFromExtra = GoRouterState.of(context).extra as String?;
|
||||
final initialEmail = email ?? emailFromExtra ?? '';
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) {
|
||||
final cubit = LoginCubit(context.read<AuthRepository>());
|
||||
if (initialEmail.isNotEmpty) {
|
||||
cubit.emailChanged(initialEmail);
|
||||
}
|
||||
return cubit;
|
||||
},
|
||||
child: LoginPasswordView(initialEmail: initialEmail),
|
||||
create: (context) => LoginCubit(sl<AuthRepository>()),
|
||||
child: const LoginView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginPasswordView extends StatefulWidget {
|
||||
final String initialEmail;
|
||||
|
||||
const LoginPasswordView({super.key, required this.initialEmail});
|
||||
class LoginView extends StatefulWidget {
|
||||
const LoginView({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPasswordView> createState() => _LoginPasswordViewState();
|
||||
State<LoginView> createState() => _LoginViewState();
|
||||
}
|
||||
|
||||
class _LoginPasswordViewState extends State<LoginPasswordView> {
|
||||
late final TextEditingController _passwordController;
|
||||
bool _obscureText = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_passwordController = TextEditingController();
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (!cubit.state.isValid) return;
|
||||
|
||||
final response = await cubit.submit();
|
||||
if (response != null && mounted) {
|
||||
@@ -84,6 +68,7 @@ class _LoginPasswordViewState extends State<LoginPasswordView> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
key: const Key('login_main_content'),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -98,7 +83,7 @@ class _LoginPasswordViewState extends State<LoginPasswordView> {
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildFooter(),
|
||||
Container(key: const Key('login_footer'), child: _buildFooter()),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
@@ -147,49 +132,29 @@ class _LoginPasswordViewState extends State<LoginPasswordView> {
|
||||
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: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'密码',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF475569),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscureText,
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入密码',
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureText
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
size: 20,
|
||||
color: AppColors.slate400,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscureText = !_obscureText;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
_buildInput(
|
||||
label: '邮箱',
|
||||
hint: '请输入邮箱',
|
||||
controller: _emailController,
|
||||
hasError: state.email.displayError != null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildPasswordInput(state.password.displayError != null),
|
||||
const SizedBox(height: 12),
|
||||
if (state.errorMessage != null)
|
||||
WarningBanner(message: state.errorMessage!, visible: true),
|
||||
AppBanner(message: state.errorMessage!, type: ToastType.error)
|
||||
else if (fieldError != null)
|
||||
AppBanner(message: fieldError, type: ToastType.warning),
|
||||
const SizedBox(height: 12),
|
||||
AppButton(
|
||||
text: '登录',
|
||||
@@ -204,6 +169,73 @@ class _LoginPasswordViewState extends State<LoginPasswordView> {
|
||||
);
|
||||
}
|
||||
|
||||
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'),
|
||||
@@ -3,8 +3,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:formz/formz.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/warning_banner.dart';
|
||||
import '../../../../shared/widgets/banner/app_banner.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../presentation/cubits/register_cubit.dart';
|
||||
import '../../data/auth_repository.dart';
|
||||
|
||||
@@ -14,7 +16,7 @@ class RegisterScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => RegisterCubit(context.read<AuthRepository>()),
|
||||
create: (context) => RegisterCubit(sl<AuthRepository>()),
|
||||
child: const RegisterView(),
|
||||
);
|
||||
}
|
||||
@@ -53,7 +55,7 @@ class _RegisterViewState extends State<RegisterView> {
|
||||
|
||||
final success = await cubit.submitStep1();
|
||||
if (success && mounted) {
|
||||
context.push('/register/step2', extra: cubit);
|
||||
context.push('/register/verification', extra: cubit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,9 +148,9 @@ class _RegisterViewState extends State<RegisterView> {
|
||||
if (state.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: WarningBanner(
|
||||
child: AppBanner(
|
||||
message: state.errorMessage!,
|
||||
visible: true,
|
||||
type: ToastType.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
+12
-35
@@ -4,15 +4,16 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:formz/formz.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/warning_banner.dart';
|
||||
import '../../../../shared/widgets/banner/app_banner.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../presentation/cubits/register_cubit.dart';
|
||||
import '../../presentation/bloc/auth_bloc.dart';
|
||||
import '../../presentation/bloc/auth_event.dart';
|
||||
|
||||
class RegisterStep2Screen extends StatelessWidget {
|
||||
class RegisterVerificationScreen extends StatelessWidget {
|
||||
final RegisterCubit? cubit;
|
||||
|
||||
const RegisterStep2Screen({super.key, this.cubit});
|
||||
const RegisterVerificationScreen({super.key, this.cubit});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -27,26 +28,25 @@ class RegisterStep2Screen extends StatelessWidget {
|
||||
|
||||
return BlocProvider.value(
|
||||
value: registerCubit,
|
||||
child: const RegisterStep2View(),
|
||||
child: const RegisterVerificationView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RegisterStep2View extends StatefulWidget {
|
||||
const RegisterStep2View({super.key});
|
||||
class RegisterVerificationView extends StatefulWidget {
|
||||
const RegisterVerificationView({super.key});
|
||||
|
||||
@override
|
||||
State<RegisterStep2View> createState() => _RegisterStep2ViewState();
|
||||
State<RegisterVerificationView> createState() =>
|
||||
_RegisterVerificationViewState();
|
||||
}
|
||||
|
||||
class _RegisterStep2ViewState extends State<RegisterStep2View> {
|
||||
class _RegisterVerificationViewState extends State<RegisterVerificationView> {
|
||||
final _codeController = TextEditingController();
|
||||
final _inviteController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_codeController.dispose();
|
||||
_inviteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -150,15 +150,13 @@ class _RegisterStep2ViewState extends State<RegisterStep2View> {
|
||||
children: [
|
||||
_buildCodeInput(state),
|
||||
const SizedBox(height: 12),
|
||||
_buildInviteInput(),
|
||||
const SizedBox(height: 12),
|
||||
_buildStepIndicator(),
|
||||
if (state.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: WarningBanner(
|
||||
child: AppBanner(
|
||||
message: state.errorMessage!,
|
||||
visible: true,
|
||||
type: ToastType.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -237,27 +235,6 @@ class _RegisterStep2ViewState extends State<RegisterStep2View> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInviteInput() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'邀请码(可选)',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF475569),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _inviteController,
|
||||
decoration: const InputDecoration(hintText: '有邀请码可填写'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepIndicator() {
|
||||
return Row(
|
||||
children: [
|
||||
@@ -147,7 +147,6 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
||||
void _handleConfirm() {
|
||||
final name = _nameController.text.trim();
|
||||
final email = _emailController.text.trim();
|
||||
final remark = _remarkController.text.trim();
|
||||
|
||||
if (name.isEmpty || email.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
|
||||
@@ -4,7 +4,6 @@ import '../../core/theme/design_tokens.dart';
|
||||
class AppButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isPrimary;
|
||||
final bool isOutlined;
|
||||
final double height;
|
||||
final bool isLoading;
|
||||
@@ -13,7 +12,6 @@ class AppButton extends StatelessWidget {
|
||||
super.key,
|
||||
required this.text,
|
||||
this.onPressed,
|
||||
this.isPrimary = true,
|
||||
this.isOutlined = false,
|
||||
this.height = 44,
|
||||
this.isLoading = false,
|
||||
|
||||
+16
-13
@@ -1,38 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/theme/design_tokens.dart';
|
||||
import '../../../core/theme/design_tokens.dart';
|
||||
import '../toast/toast_type.dart';
|
||||
import '../toast/toast_type_config.dart' show ToastTypeConfig;
|
||||
|
||||
class WarningBanner extends StatelessWidget {
|
||||
class AppBanner extends StatelessWidget {
|
||||
final String message;
|
||||
final ToastType type;
|
||||
final bool visible;
|
||||
|
||||
const WarningBanner({super.key, required this.message, this.visible = true});
|
||||
const AppBanner({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.type = ToastType.warning,
|
||||
this.visible = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!visible) return const SizedBox.shrink();
|
||||
|
||||
final config = ToastTypeConfig.fromType(type);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warningBackground,
|
||||
color: config.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 16,
|
||||
color: AppColors.warningText,
|
||||
),
|
||||
Icon(config.icon, size: 16, color: config.iconColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.warningText,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: config.textColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -8,17 +8,12 @@ void main() {
|
||||
final apiException = ApiException.fromDioError(dioException);
|
||||
|
||||
expect(apiException, isA<ApiException>());
|
||||
expect(apiException.message, contains('Request failed'));
|
||||
});
|
||||
|
||||
test('NetworkException has correct message', () {
|
||||
const exception = NetworkException('No internet');
|
||||
expect(exception.message, 'No internet');
|
||||
expect(apiException.message, contains('网络错误'));
|
||||
});
|
||||
|
||||
test('UnauthorizedException has default message', () {
|
||||
const exception = UnauthorizedException();
|
||||
expect(exception.message, 'Authentication required');
|
||||
expect(exception.message, '请重新登录');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/main.dart';
|
||||
import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
|
||||
import 'package:social_app/features/auth/data/auth_repository.dart';
|
||||
import 'package:social_app/core/di/injection.dart';
|
||||
|
||||
class MockAuthBloc extends Mock implements AuthBloc {}
|
||||
|
||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||
|
||||
class FakeAuthState extends Fake implements AuthState {}
|
||||
|
||||
void main() {
|
||||
@@ -14,6 +18,13 @@ void main() {
|
||||
registerFallbackValue(FakeAuthState());
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
if (sl.isRegistered<AuthRepository>()) {
|
||||
await sl.reset();
|
||||
}
|
||||
sl.registerSingleton<AuthRepository>(MockAuthRepository());
|
||||
});
|
||||
|
||||
testWidgets('Login screen loads correctly', (WidgetTester tester) async {
|
||||
final mockAuthBloc = MockAuthBloc();
|
||||
when(() => mockAuthBloc.state).thenReturn(AuthInitial());
|
||||
@@ -23,7 +34,7 @@ void main() {
|
||||
|
||||
await tester.pumpWidget(LinksyApp(authBloc: mockAuthBloc));
|
||||
expect(find.text('linksy'), findsOneWidget);
|
||||
expect(find.text('继续'), findsOneWidget);
|
||||
expect(find.text('登录'), findsOneWidget);
|
||||
expect(find.text('还没有账号?去注册'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user