docs: 更新 HTTP 错误码、用户积分、占卜运行及用户资料协议文档
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
# State Management
|
||||
|
||||
> State management patterns in Flutter app.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This app uses **ChangeNotifier + Provider** for state management:
|
||||
|
||||
- **AuthBloc** uses `ChangeNotifier` for auth state
|
||||
- **State classes** are immutable value objects
|
||||
- **Repository pattern** separates data access from business logic
|
||||
- **DI** via provider/factory pattern
|
||||
|
||||
---
|
||||
|
||||
## State Management Pattern
|
||||
|
||||
### Bloc/Cubit Pattern
|
||||
|
||||
**Use `ChangeNotifier` for complex state:**
|
||||
|
||||
```dart
|
||||
// features/auth/presentation/bloc/auth_bloc.dart
|
||||
class AuthBloc extends ChangeNotifier {
|
||||
AuthBloc({required AuthRepository repository}) : _repository = repository;
|
||||
|
||||
final AuthRepository _repository;
|
||||
final Logger _logger = getLogger('features.auth.bloc');
|
||||
AuthState _state = AuthState.initial;
|
||||
|
||||
AuthState get state => _state;
|
||||
|
||||
Future<void> loginWithOtp({
|
||||
required String email,
|
||||
required String otp,
|
||||
}) async {
|
||||
final user = await _repository.loginWithEmailOtp(email: email, otp: otp);
|
||||
_logger.info(message: 'User logged in', extra: {'user_id': user.id});
|
||||
_state = AuthState(status: AuthStatus.authenticated, user: user);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Immutable State
|
||||
|
||||
**State classes should be immutable:**
|
||||
|
||||
```dart
|
||||
// features/auth/presentation/bloc/auth_state.dart
|
||||
class AuthState {
|
||||
const AuthState({
|
||||
required this.status,
|
||||
this.user,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final AuthStatus status;
|
||||
final AuthUser? user;
|
||||
final String? errorMessage;
|
||||
|
||||
factory AuthState.initial() => const AuthState(status: AuthStatus.initial);
|
||||
|
||||
AuthState copyWith({
|
||||
AuthStatus? status,
|
||||
AuthUser? user,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return AuthState(
|
||||
status: status ?? this.status,
|
||||
user: user ?? this.user,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum AuthStatus {
|
||||
initial,
|
||||
loading,
|
||||
authenticated,
|
||||
unauthenticated,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
### Repository Interface
|
||||
|
||||
**Define interface in presentation layer:**
|
||||
|
||||
```dart
|
||||
// features/auth/data/repositories/auth_repository.dart
|
||||
abstract class AuthRepository {
|
||||
Future<AuthUser?> recoverSession();
|
||||
Future<void> sendOtp(String email);
|
||||
Future<AuthUser> loginWithEmailOtp({
|
||||
required String email,
|
||||
required String otp,
|
||||
});
|
||||
Future<void> logout();
|
||||
Future<void> clearLocalSession();
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Implementation
|
||||
|
||||
**Implement with data sources:**
|
||||
|
||||
```dart
|
||||
class AuthRepositoryImpl implements AuthRepository {
|
||||
AuthRepositoryImpl({
|
||||
required AuthApi api,
|
||||
required SessionStore sessionStore,
|
||||
}) : _api = api,
|
||||
_sessionStore = sessionStore;
|
||||
|
||||
final AuthApi _api;
|
||||
final SessionStore _sessionStore;
|
||||
|
||||
@override
|
||||
Future<AuthUser?> recoverSession() async {
|
||||
final session = await _sessionStore.load();
|
||||
if (session == null) return null;
|
||||
|
||||
try {
|
||||
return await _api.getCurrentUser(session.accessToken);
|
||||
} catch (e) {
|
||||
await clearLocalSession();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling in State
|
||||
|
||||
### Try-Catch with Logging
|
||||
|
||||
**Every async operation should handle errors:**
|
||||
|
||||
```dart
|
||||
Future<void> start() async {
|
||||
_state = _state.copyWith(status: AuthStatus.loading);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final user = await _repository.recoverSession();
|
||||
if (user == null) {
|
||||
_state = const AuthState(status: AuthStatus.unauthenticated);
|
||||
} else {
|
||||
_state = AuthState(status: AuthStatus.authenticated, user: user);
|
||||
}
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Session recovery failed: ${error.runtimeType}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
await _repository.clearLocalSession();
|
||||
_state = AuthState(
|
||||
status: AuthStatus.unauthenticated,
|
||||
errorMessage: _toSafeMessage(error),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Global Error Handling
|
||||
|
||||
**401 Session Invalidation:**
|
||||
|
||||
```dart
|
||||
// Global callback for 401 errors
|
||||
Future<void> handleUnauthorized401() async {
|
||||
if (_handlingUnauthorized) return;
|
||||
_handlingUnauthorized = true;
|
||||
|
||||
try {
|
||||
await _repository.clearLocalSession();
|
||||
_logger.warning(message: 'Session invalidated by 401 callback');
|
||||
_state = const AuthState(status: AuthStatus.unauthenticated);
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_handlingUnauthorized = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DI Pattern
|
||||
|
||||
### Factory Registration
|
||||
|
||||
**Register repositories and blocs:**
|
||||
|
||||
```dart
|
||||
// app/di/di.dart
|
||||
class ServiceLocator {
|
||||
static late AuthRepository authRepository;
|
||||
static late AuthBloc authBloc;
|
||||
|
||||
static void setup() {
|
||||
authRepository = AuthRepositoryImpl(
|
||||
api: AuthApiImpl(),
|
||||
sessionStore: SessionStore(),
|
||||
);
|
||||
|
||||
authBloc = AuthBloc(repository: authRepository);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Widget Access
|
||||
|
||||
**Access via Provider or global:**
|
||||
|
||||
```dart
|
||||
// Using global reference
|
||||
final authBloc = ServiceLocator.authBloc;
|
||||
|
||||
// In widget
|
||||
class LoginScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: authBloc,
|
||||
builder: (context, child) {
|
||||
if (authBloc.state.status == AuthStatus.loading) {
|
||||
return AppLoadingIndicator();
|
||||
}
|
||||
return LoginForm();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Mutable State
|
||||
|
||||
```dart
|
||||
// WRONG: Mutating state directly
|
||||
class AuthBloc extends ChangeNotifier {
|
||||
AuthUser? user;
|
||||
|
||||
void login() {
|
||||
user = fetchUser(); // Direct mutation
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Right: Immutable state with copyWith:**
|
||||
|
||||
```dart
|
||||
class AuthBloc extends ChangeNotifier {
|
||||
AuthState _state = AuthState.initial;
|
||||
|
||||
AuthState get state => _state;
|
||||
|
||||
void login() async {
|
||||
final user = await fetchUser();
|
||||
_state = _state.copyWith(user: user, status: AuthStatus.authenticated);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Importing Feature Data Layer from Other Features
|
||||
|
||||
```dart
|
||||
// WRONG: Direct import from another feature
|
||||
import 'package:app/features/auth/data/repositories/auth_repository.dart';
|
||||
```
|
||||
|
||||
**Right: Access via app-level facade or DI:**
|
||||
|
||||
```dart
|
||||
final authRepository = ServiceLocator.authRepository;
|
||||
```
|
||||
|
||||
### ❌ Skipping Error Logging
|
||||
|
||||
```dart
|
||||
// WRONG: No logging
|
||||
try {
|
||||
await repository.operation();
|
||||
} catch (e) {
|
||||
state = AuthState(status: AuthStatus.error);
|
||||
}
|
||||
```
|
||||
|
||||
**Right: Log before state change:**
|
||||
|
||||
```dart
|
||||
try {
|
||||
await repository.operation();
|
||||
} catch (e, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Operation failed',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
state = AuthState(status: AuthStatus.error);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Creating Per-Widget State Instances
|
||||
|
||||
```dart
|
||||
// WRONG: New instance per widget
|
||||
class MyWidget extends StatelessWidget {
|
||||
final authBloc = AuthBloc(); // New instance every build
|
||||
}
|
||||
```
|
||||
|
||||
**Right: Use DI singleton:**
|
||||
|
||||
```dart
|
||||
class MyWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authBloc = ServiceLocator.authBloc; // Singleton
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Naming Convention
|
||||
|
||||
**Logger module path:**
|
||||
|
||||
| Feature | Module Path |
|
||||
|---------|------------|
|
||||
| auth | `features.auth` |
|
||||
| home | `features.home` |
|
||||
| divination | `features.divination` |
|
||||
| settings | `features.settings` |
|
||||
|
||||
```dart
|
||||
class AuthBloc extends ChangeNotifier {
|
||||
final Logger _logger = getLogger('features.auth.bloc');
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing State
|
||||
|
||||
### Unit Testing Bloc
|
||||
|
||||
```dart
|
||||
test('AuthBloc login success', () async {
|
||||
final mockRepo = MockAuthRepository();
|
||||
final bloc = AuthBloc(repository: mockRepo);
|
||||
|
||||
when(mockRepo.loginWithEmailOtp(
|
||||
email: 'test@example.com',
|
||||
otp: '123456',
|
||||
)).thenAnswer((_) async => AuthUser(id: '123', email: 'test@example.com'));
|
||||
|
||||
await bloc.loginWithOtp(email: 'test@example.com', otp: '123456');
|
||||
|
||||
expect(bloc.state.status, AuthStatus.authenticated);
|
||||
expect(bloc.state.user?.id, '123');
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
```dart
|
||||
testWidgets('Login screen shows error on failed login', (tester) async {
|
||||
await tester.pumpWidget(MyApp());
|
||||
|
||||
await tester.enterText(find.byKey(Key('email_field')), 'test@example.com');
|
||||
await tester.enterText(find.byKey(Key('otp_field')), 'wrong');
|
||||
await tester.tap(find.byKey(Key('login_button')));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Request failed, please try again'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user