398 lines
8.1 KiB
Markdown
398 lines
8.1 KiB
Markdown
|
|
# 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);
|
||
|
|
});
|
||
|
|
```
|