docs: 更新 HTTP 错误码、用户积分、占卜运行及用户资料协议文档

This commit is contained in:
qzl
2026-04-10 16:45:45 +08:00
parent 1bc8bc6a27
commit 17ef460391
78 changed files with 18680 additions and 25 deletions
@@ -0,0 +1,297 @@
# Directory Structure
> How Flutter app code is organized in this project.
---
## Overview
This Flutter app follows a **feature-first architecture** with clear separation of concerns:
- **Feature modules** in `features/` for bounded product capabilities
- **Core infrastructure** in `core/` for cross-feature protocols
- **Shared UI components** in `shared/` for reusable widgets
- **Data layer** in `data/` for infrastructure abstractions
---
## Directory Layout
```
apps/lib/
├── main.dart # Only root entry file
├── app/ # App bootstrap & DI
│ ├── di/ # Dependency injection setup
│ ├── router.dart # Route definitions
│ └── app.dart # App configuration
├── core/ # Cross-feature infrastructure
│ ├── auth/ # Session store, auth state
│ ├── config/ # Env configuration
│ ├── logging/ # Structured logging
│ └── network/ # HTTP client, error mapping
├── data/ # Shared infrastructure ONLY
│ ├── cache/ # Cache implementations
│ ├── network/ # Network adapters
│ └── storage/ # Local storage
├── features/ # Feature modules
│ ├── auth/ # Authentication feature
│ ├── home/ # Home feature
│ ├── divination/ # Divination feature
│ ├── settings/ # Settings feature
│ └── ... # Other features
├── shared/ # Reusable UI components
│ ├── widgets/ # Shared widgets
│ └── theme/ # App theme, design tokens
└── l10n/ # Localization
```
---
## Module Organization
### Feature Module Structure
Each feature follows consistent structure:
```
features/<feature>/
├── data/ # Data layer
│ ├── apis/ # API clients
│ ├── repositories/ # Repository implementations
│ ├── services/ # Feature-specific services
│ └── models/ # Data models/DTOs
└── presentation/ # Presentation layer
├── bloc/ # State management (BLoC/Cubit)
├── screens/ # Screen widgets
└── widgets/ # Feature-specific widgets
```
**Example: `features/auth/`**
```
features/auth/
├── data/
│ ├── apis/
│ │ └── auth_api.dart # HTTP API calls
│ ├── repositories/
│ │ └── auth_repository.dart # Repository implementation
│ └── models/
│ ├── auth_user.dart # User model
│ └── session_response.dart # Session DTO
└── presentation/
├── bloc/
│ ├── auth_bloc.dart # AuthBloc (ChangeNotifier)
│ └── auth_state.dart # AuthState
└── screens/
└── login_screen.dart # Login screen widget
```
### Core Module Structure
**Core contains cross-feature infrastructure:**
```
core/
├── auth/
│ └── session_store.dart # Global session management
├── config/
│ └── env.dart # Environment configuration
├── logging/
│ ├── logger.dart # Logger interface
│ ├── log_service.dart # LogService implementation
│ ├── log_entry.dart # Log entry model
│ └── error_handler.dart # Global error handler
└── network/
├── api_problem.dart # RFC7807 error model
└── api_problem_mapper.dart # Error mapping
```
### Shared Widget Structure
**Shared contains reusable UI components:**
```
shared/
├── widgets/
│ ├── app_banner.dart # App-wide banner
│ ├── app_loading_indicator.dart # Loading indicator
│ ├── bottom_nav_bar.dart # Navigation bar
│ └── divination/ # Divination domain widgets
│ ├── gua_icon.dart # Gua icon widget
│ ├── yao_glyph.dart # Yao glyph widget
│ └── ...
└── theme/
├── app_theme.dart # Theme definition
└── design_tokens.dart # Spacing, radius, colors
```
---
## Placement Rules
### Where to Put Code
| Code Type | Location | Reason |
|-----------|----------|--------|
| Feature business logic | `features/<feature>/` | Bounded context |
| Cross-feature protocol | `core/` | Shared by multiple features |
| Reusable UI widget | `shared/widgets/` | Reusable by multiple screens |
| Infrastructure abstraction | `data/` | Cache/network/storage |
| Feature repository/model | `features/<feature>/data/` | Feature-scoped data |
### Decision Tree
```
Is it feature-specific business logic?
→ Yes: features/<feature>/
→ No: Is it reusable UI?
→ Yes: shared/widgets/
→ No: Is it infrastructure?
→ Yes: data/
→ No: Is it cross-feature protocol?
→ Yes: core/
→ No: Re-evaluate
```
### Forbidden Patterns
**❌ Do NOT:**
1. Place feature business repositories in `data/`
- Wrong: `data/repositories/auth_repository.dart`
- Right: `features/auth/data/repositories/auth_repository.dart`
2. Create directories under `lib/` other than allowed second-level
- Wrong: `lib/utils/`, `lib/helpers/`, `lib/constants/`
- Right: Use `core/`, `shared/`, or feature-specific locations
3. Put feature-specific UI in `shared/widgets/`
- Wrong: `shared/widgets/auth_login_form.dart`
- Right: `features/auth/presentation/widgets/login_form.dart`
4. Import feature data layer from other features
- Wrong: `import 'package:app/features/auth/data/repositories/auth_repository.dart'` in `features/home/`
- Right: Access via app-level facade or DI
---
## Naming Conventions
### Files
- **snake_case**: `auth_bloc.dart`, `login_screen.dart`
- **Feature prefix for shared**: `app_loading_indicator.dart`, `app_banner.dart`
### Classes
- **PascalCase**: `AuthBloc`, `AuthState`, `LoginScreen`
- **Suffixes**:
- `*Bloc` / `*Cubit` - State management
- `*Repository` - Data access
- `*Api` - HTTP clients
- `*Service` - Business services
- `*Screen` - Screen widgets
- `*Widget` - Reusable widgets
### Directories
- **Plural for collections**: `screens/`, `widgets/`, `models/`
- **Singular for features**: `auth/`, `home/`, `divination/`
---
## Examples
### Well-organized Feature: `features/divination/`
```
features/divination/
├── data/
│ ├── apis/
│ │ └── divination_api.dart # HTTP API
│ ├── repositories/
│ │ └── divination_repository.dart # Repository
│ ├── services/
│ │ └── voice_recorder.dart # Feature service
│ └── models/
│ ├── divination_result.dart # Domain model
│ ├── divination_params.dart # Request params
│ └── follow_up_message.dart # Message model
└── presentation/
└── screens/
├── divination_screen.dart # Main screen
├── auto_divination_screen.dart # Auto mode
├── manual_divination_screen.dart # Manual mode
└── follow_up_chat_screen.dart # Follow-up chat
```
### Shared Widget: `shared/widgets/app_loading_indicator.dart`
```dart
import 'package:flutter/material.dart';
class AppLoadingIndicator extends StatelessWidget {
const AppLoadingIndicator({super.key});
@override
Widget build(BuildContext context) {
return const CircularProgressIndicator();
}
}
```
### Core Infrastructure: `core/logging/logger.dart`
```dart
import 'log_service.dart';
class Logger {
final String module;
Logger(this.module, this._service);
static void setLogService(LogService service) {
_globalLogService = service;
}
void error({
required String message,
required Object error,
required StackTrace stackTrace,
}) {
_service!.error(
message: message,
error: error,
stackTrace: stackTrace,
module: module,
);
}
}
Logger getLogger(String module) => Logger.get(module);
```
---
## Key Principles
### Directory Contract (Must)
1. **Only allowed second-level directories**: `app/`, `core/`, `data/`, `features/`, `shared/`, `l10n/`
2. **Only one root entry**: `lib/main.dart`
3. **No ad-hoc directories**: No `utils/`, `helpers/`, `constants/` under `lib/`
4. **Feature isolation**: Features should not import each other's data layer
### Layer Boundaries
1. **Presentation****Data** (via Repository interface)
2. **Data****Core/Infrastructure** (via DI)
3. **Core****Nothing** (foundation layer)
4. **Shared****Core** (for utilities)
5. **Feature****Core** + **Shared** (for cross-cutting concerns)
### Data Layer Boundary (Must)
- `data/` = infrastructure abstractions (cache/network/storage)
- `features/<feature>/data/` = feature business repositories/models
- **NEVER** mix these boundaries
+505
View File
@@ -0,0 +1,505 @@
# Error Handling
> How errors are handled in Flutter app.
---
## Overview
This app follows **RFC 7807 Problem Details** for error handling:
- **Error model**: `ApiProblem` class
- **Error parsing**: `api_problem_mapper.dart` maps HTTP errors to `ApiProblem`
- **Error codes**: Machine-readable codes from `docs/protocols/common/http-error-codes.md`
- **User messages**: Safe, localized messages via `l10n`
---
## Error Types
### `ApiProblem`
**Custom exception for HTTP API errors:**
```dart
// core/network/api_problem.dart
class ApiProblem implements Exception {
ApiProblem({
required this.status,
required this.title,
required this.detail,
this.code,
});
final int status;
final String title;
final String detail;
final String? code;
String toUserMessage() {
return 'Request failed';
}
@override
String toString() => toUserMessage();
}
```
**Properties:**
- `status`: HTTP status code (int)
- `title`: Error title (str)
- `detail`: Human-readable detail (str)
- `code`: Machine-readable error code (str?, `UPPER_SNAKE_CASE`)
---
## Error Parsing
### RFC 7807 Response Format
**Backend returns `application/problem+json`:**
```json
{
"code": "AGENT_FORBIDDEN",
"detail": "Forbidden",
"params": {
"resource": "agent_session",
"action": "update"
}
}
```
**Frontend parsing:**
```dart
// core/network/api_problem_mapper.dart
class ApiProblemMapper {
static ApiProblem? tryParse(Response response) {
if (response.statusCode < 400) return null;
try {
final json = jsonDecode(response.body);
return ApiProblem(
status: response.statusCode,
title: json['title'] ?? 'Error',
detail: json['detail'] ?? 'Request failed',
code: json['code'],
);
} catch (e) {
return ApiProblem(
status: response.statusCode,
title: 'Error',
detail: 'Request failed',
);
}
}
}
```
### Error Code Mapping
**Map error codes to l10n keys:**
```dart
String getLocalizedErrorMessage(BuildContext context, ApiProblem problem) {
final code = problem.code;
if (code == null) {
return _getStatusGenericMessage(context, problem.status);
}
// Map code to l10n key
final l10nKey = _codeToL10nKey[code];
if (l10nKey != null) {
return AppLocalizations.of(context)!.getString(l10nKey);
}
// Fallback
return AppLocalizations.of(context)!.genericErrorMessage;
}
const _codeToL10nKey = {
'AGENT_FORBIDDEN': 'error_agent_forbidden',
'INVALID_INPUT': 'error_invalid_input',
'VALIDATION_ERROR': 'error_validation_failed',
// ...
};
```
### Fallback Order
**Unknown error code handling:**
```
1. code -> l10n key (if code exists)
2. status -> status-generic localized message
3. safe generic localized message
```
```dart
String getStatusGenericMessage(BuildContext context, int status) {
switch (status) {
case 401:
return AppLocalizations.of(context)!.errorUnauthorized;
case 403:
return AppLocalizations.of(context)!.errorForbidden;
case 404:
return AppLocalizations.of(context)!.errorNotFound;
case 500:
return AppLocalizations.of(context)!.errorServerError;
default:
return AppLocalizations.of(context)!.genericErrorMessage;
}
}
```
---
## Error Handling Patterns
### API Layer
**Catch network errors and map to ApiProblem:**
```dart
// features/auth/data/apis/auth_api.dart
class AuthApi {
Future<AuthUser> loginWithEmailOtp({
required String email,
required String otp,
}) async {
try {
final response = await _httpClient.post(
'/auth/login',
body: {'email': email, 'otp': otp},
);
final problem = ApiProblemMapper.tryParse(response);
if (problem != null) {
throw problem;
}
return AuthUser.fromJson(jsonDecode(response.body));
} on SocketException {
throw ApiProblem(
status: 0,
title: 'Network Error',
detail: 'No internet connection',
);
} on TimeoutException {
throw ApiProblem(
status: 0,
title: 'Timeout',
detail: 'Request timed out',
);
}
}
}
```
### Repository Layer
**Propagate errors with context:**
```dart
// features/auth/data/repositories/auth_repository.dart
class AuthRepositoryImpl implements AuthRepository {
final AuthApi _api;
final SessionStore _sessionStore;
@override
Future<AuthUser> loginWithEmailOtp({
required String email,
required String otp,
}) async {
try {
final user = await _api.loginWithEmailOtp(email: email, otp: otp);
await _sessionStore.save(user.session);
return user;
} on ApiProblem {
rethrow;
} catch (e, stackTrace) {
_logger.error(
message: 'Login failed',
error: e,
stackTrace: stackTrace,
);
throw ApiProblem(
status: 500,
title: 'Error',
detail: 'Request failed, please try again',
);
}
}
}
```
### BLoC/State Layer
**Handle and log errors:**
```dart
// features/auth/presentation/bloc/auth_bloc.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();
} on ApiProblem catch (problem) {
_logger.error(
message: 'Session recovery failed',
error: problem,
stackTrace: StackTrace.current,
);
await _repository.clearLocalSession();
_state = AuthState(
status: AuthStatus.unauthenticated,
errorMessage: problem.toUserMessage(),
);
notifyListeners();
} catch (e, stackTrace) {
_logger.error(
message: 'Session recovery failed: ${e.runtimeType}',
error: e,
stackTrace: stackTrace,
);
await _repository.clearLocalSession();
_state = AuthState(
status: AuthStatus.unauthenticated,
errorMessage: 'Request failed, please try again',
);
notifyListeners();
}
}
```
### UI Layer
**Display errors with Toast/Banner:**
```dart
// features/auth/presentation/screens/login_screen.dart
class LoginScreen extends StatelessWidget {
Future<void> _handleLogin() async {
try {
await authBloc.loginWithOtp(email: email, otp: otp);
// Success - navigate or show success
} on ApiProblem catch (problem) {
Toast.show(
context: context,
message: problem.toUserMessage(),
type: ToastType.error,
);
} catch (e) {
Toast.show(
context: context,
message: 'Request failed, please try again',
type: ToastType.error,
);
}
}
}
```
---
## Global Error Handling
### 401 Session Invalidation
**AuthBloc handles global 401 callback:**
```dart
// features/auth/presentation/bloc/auth_bloc.dart
class AuthBloc extends ChangeNotifier {
bool _handlingUnauthorized = false;
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;
}
}
}
```
**HttpClient intercepts 401:**
```dart
// core/network/http_client.dart
class HttpClient {
Future<Response> get(String path) async {
final response = await _innerClient.get(path);
if (response.statusCode == 401) {
// Trigger global 401 callback
await ServiceLocator.authBloc.handleUnauthorized401();
}
return response;
}
}
```
---
## Common Mistakes
### ❌ Ignoring Error Codes
```dart
// WRONG: Only using detail text
if (response.statusCode == 400) {
showError(json['detail']); // Unstable contract
}
```
**Right: Use code field:**
```dart
final code = json['code'];
final message = getLocalizedErrorMessage(context, code);
showError(message);
```
### ❌ Nested Try-Catch Without Logging
```dart
// WRONG: Silent failure
try {
await operation();
} catch (e) {
// No logging, no re-raise
return null;
}
```
**Right: Log and propagate:**
```dart
try {
await operation();
} catch (e, stackTrace) {
_logger.error(
message: 'Operation failed',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
```
### ❌ Feature-Level Token Clearing
```dart
// WRONG: Feature clearing tokens directly
class SomeBloc {
Future<void> handleError() async {
await tokenStore.clear(); // Wrong!
state = ErrorState();
}
}
```
**Right: Global callback via AuthBloc:**
```dart
// Only AuthBloc should clear session
// HttpClient triggers AuthBloc.handleUnauthorized401()
```
### ❌ Localized Error from Detail
```dart
// WRONG: Translating free-text detail
final message = localize(json['detail']);
```
**Right: Map code to l10n:**
```dart
final code = json['code'];
final l10nKey = _codeToL10nKey[code];
final message = l10nKey != null
? AppLocalizations.of(context)!.getString(l10nKey)
: genericMessage;
```
---
## Error Response Contract
**Single source of truth: `docs/protocols/common/http-error-codes.md`**
**Workflow:**
1. Backend defines new error code → update protocol doc
2. Frontend updates code-to-l10n mapping
3. Both sides use same `code` field
**Example protocol update:**
```markdown
## AGENT_FORBIDDEN
- **Code**: `AGENT_FORBIDDEN`
- **Status**: 403
- **Detail**: "Forbidden"
- **L10n Key**: `error_agent_forbidden`
- **Description**: User does not have permission to access agent resource
```
---
## Testing Error Handling
### Unit Testing
```dart
test('ApiProblemMapper parses error response', () {
final response = MockResponse(
statusCode: 403,
body: jsonEncode({
'code': 'AGENT_FORBIDDEN',
'detail': 'Forbidden',
}),
);
final problem = ApiProblemMapper.tryParse(response);
expect(problem, isNotNull);
expect(problem!.status, 403);
expect(problem.code, 'AGENT_FORBIDDEN');
});
```
### Widget Testing
```dart
testWidgets('Login screen shows error on failed login', (tester) async {
await tester.pumpWidget(MyApp());
// Trigger error
await tester.enterText(find.byKey(Key('email_field')), 'test@example.com');
await tester.tap(find.byKey(Key('login_button')));
await tester.pumpAndSettle();
expect(find.text('Request failed, please try again'), findsOneWidget);
});
```
+122
View File
@@ -0,0 +1,122 @@
# Flutter App Development Guidelines
> Best practices for Flutter app development in this project.
---
## Overview
This directory contains guidelines for Flutter app development. Fill in each file with your project's specific conventions.
---
## Guidelines Index
| Guide | Description | Status |
|-------|-------------|--------|
| [Directory Structure](./directory-structure.md) | Module organization and file layout | Filled |
| [State Management](./state-management.md) | BLoC/Cubit, repository pattern, DI | Filled |
| [Error Handling](./error-handling.md) | ApiProblem, error parsing, l10n mapping | Filled |
| [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | Filled |
| [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | Filled |
---
## How These Guidelines Were Filled
Each guideline file was populated based on:
1. **Existing codebase patterns** from `apps/lib/`
2. **AGENTS.md rules** from `apps/AGENTS.md`
3. **Real code examples** from `features/auth/`, `core/logging/`, etc.
---
## Quick Start
### Directory Structure
- **Feature modules**: `features/<feature>/data/` + `features/<feature>/presentation/`
- **Core infrastructure**: `core/` (cross-feature)
- **Shared widgets**: `shared/widgets/`
- **Data layer**: `data/` (infrastructure only, not feature repositories)
### State Management
- **Pattern**: `ChangeNotifier` + immutable state
- **State**: Use `copyWith` pattern
- **DI**: Singleton blocs/repositories via ServiceLocator
### Error Handling
- **Format**: RFC 7807 (`ApiProblem`)
- **Mapping**: Error code → l10n key
- **Global**: 401 handled via `AuthBloc.handleUnauthorized401()`
### Logging
- **Library**: Custom `Logger` class
- **Module path**: `features.<feature>.<component>`
- **Error**: Always log with `error`, `stackTrace`, `message`
- **Forbidden**: PII, tokens, passwords
### Quality
- **Colors**: Semantic colors from `Theme.of(context).colorScheme`
- **Spacing**: Use `AppSpacing` / `AppRadius` tokens
- **Tests**: Unit, widget, integration tests for critical flows
---
## Key Principles
### Layer Boundaries
1. **Presentation****Repository** (via interface)
2. **Repository****Core/Infrastructure** (via DI)
3. **Core****Nothing** (foundation layer)
4. **Shared****Core** (for utilities)
5. **Feature****Core** + **Shared** (for cross-cutting concerns)
### Data Layer Boundary (Must)
- `data/` = infrastructure abstractions (cache/network/storage)
- `features/<feature>/data/` = feature business repositories/models
- **NEVER** mix these boundaries
### Error Code Contract (Must)
- **Single source of truth**: `docs/protocols/common/http-error-codes.md`
- **Frontend mapping**: Error code → l10n key
- **Backend updates → Frontend mapping updates** (must stay in sync)
---
## Common Anti-Patterns
### ❌ Do NOT:
1. Hardcode colors (`Color(0xFF...)`, `Colors.blue`)
2. Hardcode spacing (`Padding(padding: EdgeInsets.all(16.0))`)
3. Place feature repositories in shared `data/`
4. Import feature data layer from other features
5. Create new second-level directories under `lib/`
6. Use `print()` instead of Logger
7. Log PII/tokens/passwords
8. Catch exceptions without logging
---
## Architecture Decision Records
When making architectural decisions, document:
1. **Context**: What problem are we solving?
2. **Decision**: What approach did we choose?
3. **Consequences**: What are the trade-offs?
Add new ADRs to this directory if needed.
---
**Language**: All documentation should be written in **English**.
@@ -0,0 +1,542 @@
# Logging Guidelines
> How logging is done in Flutter app.
---
## Overview
This app uses **structured logging** with custom `Logger` class:
- **Library**: Custom `Logger` class in `core/logging/`
- **Interface**: `getLogger(module)` from `core/logging/logger.dart`
- **Log levels**: `debug`, `info`, `warning`, `error`
- **Sensitive fields**: Never log passwords, tokens, PII
---
## Logger Setup
### Import and Initialize
```dart
import 'core/logging/logger.dart';
class SomeBloc extends ChangeNotifier {
final Logger _logger = getLogger('features.auth.bloc');
}
```
### Module Naming Convention
| Feature | Module Path |
|---------|------------|
| auth | `features.auth` |
| home | `features.home` |
| divination | `features.divination` |
| settings | `features.settings` |
**Examples:**
```dart
// features/auth/presentation/bloc/auth_bloc.dart
final Logger _logger = getLogger('features.auth.bloc');
// features/home/data/repositories/home_repository.dart
final Logger _logger = getLogger('features.home.repository');
// core/network/http_client.dart
final Logger _logger = getLogger('core.network.http_client');
```
---
## Log Levels
| Level | When to Use | Noise Level | Required |
|-------|-------------|-------------|----------|
| **error** | All exceptions and failures | Required | Never skip |
| **warning** | Degraded behavior, retry, fallback | Minimal | Only when action taken |
| **info** | Key business events | Minimal | Only milestones |
| **debug** | Detailed flow tracing (dev only) | High | Avoid in release |
---
## Error Logging Requirements
### Every try-catch MUST log the exception:
```dart
try {
await _repository.someOperation();
} catch (e, stackTrace) {
_logger.error(
message: 'Operation failed: $operationName',
error: e,
stackTrace: stackTrace,
extra: {'context': 'relevant_data'},
);
// handle error
}
```
### Error Logging Pattern
```dart
// features/auth/presentation/bloc/auth_bloc.dart
Future<void> start() async {
try {
final user = await _repository.recoverSession();
// ...
} catch (error, stackTrace) {
_logger.error(
message: 'Session recovery failed: ${error.runtimeType}',
error: error.runtimeType.toString(),
stackTrace: stackTrace,
);
await _repository.clearLocalSession();
_state = AuthState(
status: AuthStatus.unauthenticated,
errorMessage: _toSafeMessage(error),
);
notifyListeners();
}
}
```
---
## Info Logging Requirements
### Only log these milestone events:
- User login/logout
- Message sent/received
- Data sync completed
- Important state transitions
### Info Logging Pattern
```dart
// Login success
_logger.info(
message: 'User logged in',
extra: {'user_id': user.id},
);
// Run success
_logger.info(
message: 'Run completed',
extra: {
'run_id': runId,
'thread_id': threadId,
'duration_ms': duration.inMilliseconds,
},
);
```
### ❌ DO NOT log for every operation:
```dart
// WRONG: Logging every keystroke
onChanged: (value) {
_logger.info('Input changed: $value'); // Too noisy
}
```
---
## Warning Logging Requirements
### Only log when taking corrective action:
- Retrying after failure
- Using fallback data
- Skipping malformed data
- Deprecation warnings
### Warning Logging Pattern
```dart
// Cache miss with fallback
_logger.warning(
message: 'Cache miss, loading from remote',
extra: {'key': cacheKey},
);
// Retry attempt
_logger.warning(
message: 'Retry attempt',
extra: {'attempt': 2, 'max_attempts': 3},
);
// Fallback data
_logger.warning(
message: 'Using fallback data due to network timeout',
extra: {'timeout_ms': 5000},
);
```
---
## Debug Logging
### Use sparingly, only in debug builds:
```dart
if (kDebugMode) {
_logger.debug(
message: 'Variable value',
extra: {'variable': expensiveObject.toString()},
);
}
```
**Note:** Debug logs are automatically filtered in release builds.
---
## Prohibited Practices
### ❌ Never log sensitive data
```dart
// WRONG: Logging password
_logger.info(message: 'User login', extra: {'password': password});
// WRONG: Logging token
_logger.debug(message: 'API call', extra: {'token': accessToken});
// WRONG: Logging PII
_logger.info(message: 'User profile', extra: {'email': userEmail});
```
### ❌ Never log at debug level in production
```dart
// WRONG: Will log in release build
_logger.debug(message: 'Debug info: $sensitiveData');
```
**Right way:** Use `kDebugMode` guard:
```dart
if (kDebugMode) {
_logger.debug(message: 'Variable value', extra: {'var': value});
}
```
### ❌ Never skip error logging
```dart
// WRONG: Exception is caught but not logged
try {
await operation();
} catch (e) {
state = ErrorState();
// Missing error log!
}
```
### ❌ Never log in every iteration
```dart
// WRONG: Log every iteration
for (item in items) {
_logger.debug('Processing item: ${item.id}'); // Too noisy
process(item);
}
```
**Right: Log only failures:**
```dart
for (item in items) {
try {
process(item);
} catch (e, stackTrace) {
_logger.error(
message: 'Item processing failed',
error: e,
stackTrace: stackTrace,
extra: {'item_id': item.id},
);
}
}
```
---
## Logger Implementation
### Core Logger Class
```dart
// core/logging/logger.dart
import 'log_entry.dart';
import 'log_service.dart';
class Logger {
final String module;
final LogService? _service;
final bool _isNoOp;
Logger(this.module, this._service) : _isNoOp = _service == null;
factory Logger.get(String module) {
return Logger(module, _globalLogService);
}
void error({
required String message,
required Object error,
required StackTrace stackTrace,
Map<String, dynamic>? extra,
}) {
if (_isNoOp) {
debugPrint(LogEntry(
message: message,
module: module,
errorType: error.runtimeType.toString(),
).toConsoleString());
return;
}
_service!.error(
message: message,
error: error,
stackTrace: stackTrace,
module: module,
extra: extra ?? {},
);
}
void info({
required String message,
Map<String, dynamic>? extra,
}) {
if (_isNoOp) return;
_service!.info(
message: message,
module: module,
extra: extra ?? {},
);
}
void warning({
required String message,
Map<String, dynamic>? extra,
}) {
if (_isNoOp) return;
_service!.warning(
message: message,
module: module,
extra: extra ?? {},
);
}
void debug({
required String message,
Map<String, dynamic>? extra,
}) {
if (_isNoOp || !kDebugMode) return;
_service!.debug(
message: message,
module: module,
extra: extra ?? {},
);
}
}
Logger getLogger(String module) => Logger.get(module);
```
---
## Log Entry Structure
### LogEntry Fields
```dart
// core/logging/log_entry.dart
class LogEntry {
final DateTime timestamp;
final LogLevel level;
final String message;
final String module;
final String? errorType;
final String? errorMessage;
final String? stackTrace;
final Map<String, dynamic>? extra;
}
enum LogLevel { debug, info, warning, error }
```
### Log Format
```json
{
"timestamp": "2026-04-10T12:34:56.789Z",
"level": "error",
"module": "features.auth.bloc",
"message": "Session recovery failed: SocketException",
"error_type": "SocketException",
"error_message": "Connection refused",
"stack_trace": "...",
"extra": {
"user_id": "123e4567-e89b-12d3-a456-426614174000"
}
}
```
---
## Common Mistakes
### ❌ Using `print()` instead of Logger
```dart
// WRONG: Never use print in production code
print('User logged in: $userId');
```
**Right:** Use `Logger`:
```dart
_logger.info(message: 'User logged in', extra: {'user_id': userId});
```
### ❌ Logging sensitive data
```dart
// WRONG: Logging PII
_logger.info(message: 'User profile', extra: {
'email': userEmail,
'phone': userPhone,
});
```
**Right:** Exclude sensitive fields:
```dart
_logger.info(message: 'User profile loaded', extra: {'user_id': userId});
```
### ❌ Catching without logging
```dart
// WRONG: Silent failure
try {
await service.doSomething();
} catch (e) {
// No logging, no re-raise
return null;
}
```
**Right:** Log and re-raise:
```dart
try {
await service.doSomething();
} catch (e, stackTrace) {
_logger.error(
message: 'Operation failed',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
```
---
## Examples from Codebase
### AuthBloc Error Handling
```dart
// features/auth/presentation/bloc/auth_bloc.dart
class AuthBloc extends ChangeNotifier {
final Logger _logger = getLogger('features.auth.bloc');
Future<void> start() async {
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.runtimeType.toString(),
stackTrace: stackTrace,
);
await _repository.clearLocalSession();
_state = AuthState(
status: AuthStatus.unauthenticated,
errorMessage: _toSafeMessage(error),
);
notifyListeners();
}
}
Future<void> logout() async {
_logger.info(message: 'User logged out');
_state = const AuthState(status: AuthStatus.unauthenticated);
notifyListeners();
unawaited(
_repository.logout().catchError((Object error, StackTrace stackTrace) {
_logger.error(
message: 'User logout failed: ${error.runtimeType}',
error: error.runtimeType.toString(),
stackTrace: stackTrace,
);
}),
);
}
}
```
### Network Error Handling
```dart
// core/network/http_client.dart
final Logger _logger = getLogger('core.network.http_client');
Future<Response> get(String path) async {
try {
final response = await _innerClient.get(path);
final problem = ApiProblemMapper.tryParse(response);
if (problem != null && response.statusCode != 401) {
_logger.warning(
message: 'HTTP error response',
extra: {
'status': response.statusCode,
'path': path,
'code': problem.code,
},
);
}
return response;
} on SocketException catch (e, stackTrace) {
_logger.error(
message: 'Network error',
error: e,
stackTrace: stackTrace,
extra: {'path': path},
);
throw ApiProblem(
status: 0,
title: 'Network Error',
detail: 'No internet connection',
);
}
}
```
@@ -0,0 +1,533 @@
# Quality Guidelines
> Code quality standards for Flutter app development.
---
## Overview
This app enforces quality through:
- **Linting**: Flutter/Dart analysis
- **Architecture**: Feature-first with clear boundaries
- **Testing**: Widget tests, unit tests, integration tests
- **Code review**: Checklist-based reviews
---
## Forbidden Patterns
### ❌ Hardcoded Colors
```dart
// WRONG: Hardcoded hex color
Container(
color: Color(0xFF2196F3),
)
// WRONG: Using Colors.*
Container(
color: Colors.blue,
)
```
**Right: Use semantic colors:**
```dart
// Semantic colors from theme
Container(
color: Theme.of(context).colorScheme.primary,
)
// Brand palette colors
Container(
color: Theme.of(context).extension<AppColorPalette>()!.brandPrimary,
)
```
### ❌ Hardcoded Spacing
```dart
// WRONG: Magic numbers
Padding(
padding: EdgeInsets.all(16.0),
)
// WRONG: Hardcoded values
SizedBox(height: 24.0)
```
**Right: Use `AppSpacing` / `AppRadius`:**
```dart
import 'shared/theme/design_tokens.dart';
Padding(
padding: AppSpacing.allMedium,
)
SizedBox(height: AppSpacing.large)
```
### ❌ Feature Data Import 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;
```
### ❌ Creating Per-Widget State Instances
```dart
// WRONG: New instance per build
class MyWidget extends StatelessWidget {
final authBloc = AuthBloc(); // New instance every time
}
```
**Right: Use singleton from DI:**
```dart
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final authBloc = ServiceLocator.authBloc; // Singleton
}
}
```
### ❌ Place Feature Repositories in Shared Data Layer
```dart
// WRONG: Feature repository in shared data/
// data/repositories/auth_repository.dart
class AuthRepositoryImpl implements AuthRepository {}
```
**Right: Keep in feature's data layer:**
```dart
// features/auth/data/repositories/auth_repository.dart
class AuthRepositoryImpl implements AuthRepository {}
```
### ❌ New Second-Level Directories Under `lib/`
```dart
// WRONG: Ad-hoc directories
lib/
utils/
helpers/
constants/
```
**Right: Use allowed directories only:**
```dart
lib/
app/ # Bootstrap, DI, router
core/ # Cross-feature infrastructure
data/ # Shared infrastructure
features/ # Feature modules
shared/ # Reusable widgets
l10n/ # Localization
```
---
## Required Patterns
### ✅ Semantic Color System
```dart
import 'package:flutter/material.dart';
// Use semantic colors
ThemeData lightTheme = ThemeData(
colorScheme: ColorScheme.light(
primary: Color(0xFF2196F3),
secondary: Color(0xFF03DAC6),
surface: Color(0xFFFFFFFF),
error: Color(0xFFB00020),
),
);
// Access via theme
Container(
color: Theme.of(context).colorScheme.primary,
)
```
### ✅ Design Tokens for Spacing
```dart
// shared/theme/design_tokens.dart
class AppSpacing {
static const double xsmall = 4.0;
static const double small = 8.0;
static const double medium = 16.0;
static const double large = 24.0;
static const double xlarge = 32.0;
static EdgeInsets get allMedium => EdgeInsets.all(medium);
static EdgeInsets get horizontalMedium => EdgeInsets.symmetric(horizontal: medium);
}
class AppRadius {
static const double small = 4.0;
static const double medium = 8.0;
static const double large = 12.0;
}
```
### ✅ Repository Pattern with DI
```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);
}
}
```
### ✅ Immutable State with copyWith
```dart
class AuthState {
const AuthState({
required this.status,
this.user,
this.errorMessage,
});
final AuthStatus status;
final AuthUser? user;
final String? errorMessage;
AuthState copyWith({
AuthStatus? status,
AuthUser? user,
String? errorMessage,
}) {
return AuthState(
status: status ?? this.status,
user: user ?? this.user,
errorMessage: errorMessage ?? this.errorMessage,
);
}
}
```
### ✅ Error Logging in Try-Catch
```dart
try {
await _repository.operation();
} catch (e, stackTrace) {
_logger.error(
message: 'Operation failed',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
```
### ✅ Logger Module Naming
```dart
class AuthBloc extends ChangeNotifier {
final Logger _logger = getLogger('features.auth.bloc');
}
class HomeRepository {
final Logger _logger = getLogger('features.home.repository');
}
class HttpClient {
final Logger _logger = getLogger('core.network.http_client');
}
```
---
## Testing Requirements
### Test Organization
```
apps/test/
├── unit/ # Unit tests
├── widgets/ # Widget tests
├── integration/ # Integration tests
└── test_utils/ # Test utilities
```
### Test Requirements from AGENTS.md
1. **Prioritize tests for**: model parsing, service logic, high-regression flows
2. **Auth/Home/Cache changes**: must include targeted regression tests
3. **Simple static UI changes**: may skip tests
4. **Test credentials**: use environment config, never hardcode
### Unit Testing
```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');
});
```
### Widget 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);
});
```
### Integration Testing
```dart
testWidgets('User can login with OTP', (tester) async {
await tester.pumpWidget(MyApp());
// Navigate to login
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(find.byKey(Key('email_field')), 'user@example.com');
await tester.tap(find.byKey(Key('send_otp_button')));
await tester.pumpAndSettle();
// Enter OTP
await tester.enterText(find.byKey(Key('otp_field')), '123456');
await tester.tap(find.byKey(Key('login_button')));
await tester.pumpAndSettle();
// Verify success
expect(find.text('Welcome'), findsOneWidget);
});
```
---
## Code Review Checklist
### Architecture
- [ ] Follows `features/<feature>/data/` and `features/<feature>/presentation/` structure
- [ ] Shared infrastructure in `data/` (not feature repositories/models)
- [ ] Reusable widgets in `shared/widgets/` (not feature-specific)
- [ ] Cross-feature code in `core/`
- [ ] No new second-level directories under `lib/`
### State Management
- [ ] Uses `ChangeNotifier` + immutable state
- [ ] State classes have `copyWith` method
- [ ] Singleton blocs via DI (not per-widget instances)
- [ ] Errors are logged before state changes
### UI
- [ ] Uses semantic colors from `Theme.of(context).colorScheme`
- [ ] Uses `AppSpacing` / `AppRadius` (no hardcoded values)
- [ ] Follows design system tokens
- [ ] Toast/Banner for user feedback (no `print()`)
### Data
- [ ] Repository pattern for data access
- [ ] `ApiProblem` for error handling
- [ ] Error codes mapped to l10n keys
- [ ] 401 handled via global callback
### Logging
- [ ] Logger initialized with module path (`features.<feature>.<component>`)
- [ ] All exceptions logged with `error`, `stackTrace`, and `message`
- [ ] No PII/tokens/passwords in logs
- [ ] Info logs only for milestones (login, logout, run completed)
### Testing
- [ ] Unit tests for bloc logic
- [ ] Widget tests for UI
- [ ] Integration tests for critical flows
- [ ] Mocks use `when/thenAnswer/thenReturn`
---
## High-Risk Modules Checklist
From AGENTS.md, these modules require extra attention:
### Auth
- [ ] `AuthBloc` is single source of truth
- [ ] 401 invalidation goes through global callback
- [ ] No feature-level token clearing or direct login navigation
### Home Message Viewport
- [ ] Auto-scroll/anchor restore is event-driven
- [ ] Viewport preserved during history prepend
- [ ] User reading position maintained
### Cache / Repository
- [ ] Reads/writes go through repository layer
- [ ] Cache keys/invalidation in repository (not UI/Bloc)
- [ ] Feature-scoped TTL policy defined in repository
- [ ] No per-screen/per-widget cache store instances
- [ ] Cross-feature access via app-level facade
---
## Common Mistakes
### ❌ Mutable State
```dart
// WRONG: Direct mutation
class AuthBloc extends ChangeNotifier {
AuthUser? user; // Mutable
void login() {
user = fetchUser(); // Direct mutation
notifyListeners();
}
}
```
**Right: Immutable state:**
```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();
}
}
```
### ❌ Missing Error Logging
```dart
// WRONG: No logging
try {
await operation();
} catch (e) {
state = ErrorState();
}
```
**Right: Log before state change:**
```dart
try {
await operation();
} catch (e, stackTrace) {
_logger.error(message: 'Operation failed', error: e, stackTrace: stackTrace);
state = ErrorState();
}
```
### ❌ Hardcoded Strings
```dart
// WRONG: User-facing string
Text('Login Failed')
```
**Right: Use l10n:**
```dart
Text(AppLocalizations.of(context)!.loginFailed)
```
### ❌ Bypassing Design System
```dart
// WRONG: Magic numbers
Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 8),
)
```
**Right: Use tokens:**
```dart
Padding(
padding: EdgeInsets.symmetric(
horizontal: AppSpacing.medium,
vertical: AppSpacing.small,
),
)
```
---
## Flutter-Specific Quality
### Widget Best Practices
- Use `const` constructors for immutable widgets
- Prefer `StatelessWidget` over `StatefulWidget` when possible
- Use `const` when creating widgets in build methods
- Avoid `print()` - use Logger instead
### Performance
- Use `ListView.builder` for long lists
- Avoid rebuilding expensive widgets unnecessarily
- Use `const` widgets to prevent rebuilds
- Use `RepaintBoundary` for complex animations
### Code Style
- Follow Dart style guide
- Use `flutter analyze` to catch issues
- Run `dart format .` before commits
- Follow naming conventions: `lowerCamelCase` for variables, `UpperCamelCase` for types
+398
View File
@@ -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);
});
```