Add a new "Background Tasks & WorkManager" section with checklist items for reviewing background task code (app background/killed behavior, doze/timing, minimum periodic interval, background location permissions, battery optimization, data passed to background isolates, failure handling, and task cleanup). Also clarify navigation guidance by allowing Navigator.push() when popping a dialog (note added to both Mobile Architecture skill and Mobile QA Analyst agent docs).
901 lines
27 KiB
Markdown
901 lines
27 KiB
Markdown
---
|
|
name: krow-mobile-architecture
|
|
description: KROW mobile app Clean Architecture implementation including package structure, dependency rules, feature isolation, BLoC lifecycle management, session handling, and Data Connect connectors pattern. Use this when architecting new mobile features, debugging state management issues, preventing prop drilling, managing BLoC disposal, implementing session stores, or setting up connector repositories. Essential for maintaining architectural integrity across staff and client apps.
|
|
---
|
|
|
|
# KROW Mobile Architecture
|
|
|
|
This skill defines the authoritative mobile architecture for the KROW platform. All code must strictly adhere to these principles to prevent architectural degradation.
|
|
|
|
## When to Use This Skill
|
|
|
|
- Architecting new mobile features
|
|
- Debugging state management or BLoC lifecycle issues
|
|
- Preventing prop drilling in UI code
|
|
- Managing session state and authentication
|
|
- Implementing Data Connect connector repositories
|
|
- Setting up feature modules and dependency injection
|
|
- Understanding package boundaries and dependencies
|
|
- Refactoring legacy code to Clean Architecture
|
|
|
|
## 1. High-Level Architecture
|
|
|
|
KROW follows **Clean Architecture** in a **Melos Monorepo**. Dependencies flow **inward** toward the Domain.
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ Apps (Entry Points) │
|
|
│ • apps/mobile/apps/client │
|
|
│ • apps/mobile/apps/staff │
|
|
│ Role: DI roots, navigation assembly, env config │
|
|
└─────────────────┬───────────────────────────────────────┘
|
|
│ depends on
|
|
┌─────────────────▼───────────────────────────────────────┐
|
|
│ Features (Vertical Slices) │
|
|
│ • apps/mobile/packages/features/client/* │
|
|
│ • apps/mobile/packages/features/staff/* │
|
|
│ Role: Pages, BLoCs, Use Cases, Feature Repositories │
|
|
└─────┬───────────────────────────────────────┬───────────┘
|
|
│ depends on │ depends on
|
|
┌─────▼────────────────┐ ┌───────▼───────────┐
|
|
│ Design System │ │ Core Localization│
|
|
│ • UI components │ │ • LocaleBloc │
|
|
│ • Theme/colors │ │ • Translations │
|
|
│ • Typography │ │ • ErrorTranslator│
|
|
└──────────────────────┘ └───────────────────┘
|
|
│ both depend on
|
|
┌─────────────────▼───────────────────────────────────────┐
|
|
│ Services (Interface Adapters) │
|
|
│ • data_connect: Backend integration, session mgmt │
|
|
│ • core: Extensions, base classes, utilities │
|
|
└─────────────────┬───────────────────────────────────────┘
|
|
│ both depend on
|
|
┌─────────────────▼───────────────────────────────────────┐
|
|
│ Domain (Stable Core) │
|
|
│ • Entities (immutable data models) │
|
|
│ • Failures (domain-specific errors) │
|
|
│ • Pure Dart only, zero Flutter dependencies │
|
|
└─────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Critical Rule:** Dependencies point INWARD only. Domain knows nothing about the outer layers.
|
|
|
|
## 2. Package Structure & Responsibilities
|
|
|
|
### 2.1 Apps (`apps/mobile/apps/`)
|
|
|
|
**Role:** Application entry points and DI roots
|
|
|
|
**Responsibilities:**
|
|
- Initialize Flutter Modular
|
|
- Assemble features into navigation tree
|
|
- Inject concrete implementations (from `data_connect`) into features
|
|
- Configure environment-specific settings (dev/stage/prod)
|
|
- Initialize session management
|
|
|
|
**Structure:**
|
|
```
|
|
apps/mobile/apps/staff/
|
|
├── lib/
|
|
│ ├── main.dart # Entry point, session initialization
|
|
│ ├── app_module.dart # Root module, imports features
|
|
│ ├── app_widget.dart # MaterialApp setup
|
|
│ └── src/
|
|
│ ├── navigation/ # Typed navigators
|
|
│ └── widgets/ # SessionListener wrapper
|
|
└── pubspec.yaml
|
|
```
|
|
|
|
**RESTRICTION:** NO business logic. NO UI widgets (except App and Main).
|
|
|
|
### 2.2 Features (`apps/mobile/packages/features/<APP>/<FEATURE>`)
|
|
|
|
**Role:** Vertical slices of user-facing functionality
|
|
|
|
**Internal Structure:**
|
|
```
|
|
features/staff/profile/
|
|
├── lib/
|
|
│ ├── src/
|
|
│ │ ├── domain/
|
|
│ │ │ ├── repositories/ # Repository interfaces
|
|
│ │ │ │ └── profile_repository_interface.dart
|
|
│ │ │ └── usecases/ # Application logic
|
|
│ │ │ └── get_profile_usecase.dart
|
|
│ │ ├── data/
|
|
│ │ │ └── repositories_impl/ # Repository concrete classes
|
|
│ │ │ └── profile_repository_impl.dart
|
|
│ │ └── presentation/
|
|
│ │ ├── blocs/ # State management
|
|
│ │ │ └── profile_cubit.dart
|
|
│ │ ├── pages/ # Screens (StatelessWidget preferred)
|
|
│ │ │ └── profile_page.dart
|
|
│ │ └── widgets/ # Reusable UI components
|
|
│ │ └── profile_header.dart
|
|
│ └── profile_feature.dart # Barrel file (public API only)
|
|
└── pubspec.yaml
|
|
```
|
|
|
|
**Key Principles:**
|
|
- **Presentation:** UI Pages and Widgets, BLoCs/Cubits for state
|
|
- **Application:** Use Cases (business logic orchestration)
|
|
- **Data:** Repository implementations (backend integration)
|
|
- **Pages as StatelessWidget:** Move state to BLoCs for better performance and testability
|
|
|
|
**RESTRICTION:** Features MUST NOT import other features. Communication happens via:
|
|
- Shared domain entities
|
|
- Session stores (`StaffSessionStore`, `ClientSessionStore`)
|
|
- Navigation via Modular
|
|
- Data Connect connector repositories
|
|
|
|
### 2.3 Domain (`apps/mobile/packages/domain`)
|
|
|
|
**Role:** The stable, pure heart of the system
|
|
|
|
**Responsibilities:**
|
|
- Define **Entities** (immutable data models using Data Classes or Freezed)
|
|
- Define **Failures** (domain-specific error types)
|
|
|
|
**Structure:**
|
|
```
|
|
domain/
|
|
├── lib/
|
|
│ └── src/
|
|
│ ├── entities/
|
|
│ │ ├── user.dart
|
|
│ │ ├── staff.dart
|
|
│ │ └── shift.dart
|
|
│ └── failures/
|
|
│ ├── failure.dart # Base class
|
|
│ ├── auth_failure.dart
|
|
│ └── network_failure.dart
|
|
└── pubspec.yaml
|
|
```
|
|
|
|
**Example Entity:**
|
|
```dart
|
|
import 'package:equatable/equatable.dart';
|
|
|
|
class Staff extends Equatable {
|
|
final String id;
|
|
final String name;
|
|
final String email;
|
|
final StaffStatus status;
|
|
|
|
const Staff({
|
|
required this.id,
|
|
required this.name,
|
|
required this.email,
|
|
required this.status,
|
|
});
|
|
|
|
@override
|
|
List<Object?> get props => [id, name, email, status];
|
|
}
|
|
```
|
|
|
|
**RESTRICTION:**
|
|
- NO Flutter dependencies (no `import 'package:flutter/material.dart'`)
|
|
- NO `json_annotation` or serialization code
|
|
- Only `equatable` for value equality
|
|
- Pure Dart only
|
|
|
|
### 2.4 Data Connect (`apps/mobile/packages/data_connect`)
|
|
|
|
**Role:** Interface Adapter for Backend Access
|
|
|
|
**Responsibilities:**
|
|
- Centralized connector repositories (see Data Connect Connectors Pattern section)
|
|
- Implement Firebase Data Connect service layer
|
|
- Map Domain Entities ↔ Data Connect generated code
|
|
- Handle Firebase exceptions → domain failures
|
|
- Provide `DataConnectService` with session management
|
|
|
|
**Structure:**
|
|
```
|
|
data_connect/
|
|
├── lib/
|
|
│ ├── src/
|
|
│ │ ├── services/
|
|
│ │ │ ├── data_connect_service.dart # Core service
|
|
│ │ │ └── mixins/
|
|
│ │ │ └── session_handler_mixin.dart
|
|
│ │ ├── connectors/ # Connector pattern (see below)
|
|
│ │ │ ├── staff/
|
|
│ │ │ │ ├── domain/
|
|
│ │ │ │ │ ├── repositories/
|
|
│ │ │ │ │ │ └── staff_connector_repository.dart
|
|
│ │ │ │ │ └── usecases/
|
|
│ │ │ │ │ └── get_profile_completion_usecase.dart
|
|
│ │ │ │ └── data/
|
|
│ │ │ │ └── repositories/
|
|
│ │ │ │ └── staff_connector_repository_impl.dart
|
|
│ │ │ ├── order/
|
|
│ │ │ └── shifts/
|
|
│ │ └── session/
|
|
│ │ ├── staff_session_store.dart
|
|
│ │ └── client_session_store.dart
|
|
│ └── krow_data_connect.dart # Exports
|
|
└── pubspec.yaml
|
|
```
|
|
|
|
**RESTRICTION:**
|
|
- NO feature-specific logic
|
|
- Connectors are domain-neutral and reusable
|
|
- All queries follow Clean Architecture (domain interfaces → data implementations)
|
|
|
|
### 2.5 Design System (`apps/mobile/packages/design_system`)
|
|
|
|
**Role:** Visual language and component library
|
|
|
|
**Responsibilities:**
|
|
- Theme definitions (`UiColors`, `UiTypography`)
|
|
- UI constants (`spacingL`, `radiusM`, etc.)
|
|
- Shared widgets (if reused across multiple features)
|
|
- Assets (icons, images, fonts)
|
|
|
|
**Structure:**
|
|
```
|
|
design_system/
|
|
├── lib/
|
|
│ └── src/
|
|
│ ├── ui_colors.dart
|
|
│ ├── ui_typography.dart
|
|
│ ├── ui_icons.dart
|
|
│ ├── ui_constants.dart
|
|
│ ├── ui_theme.dart # ThemeData factory
|
|
│ └── widgets/ # Shared UI components
|
|
│ └── custom_button.dart
|
|
└── assets/
|
|
├── icons/
|
|
└── images/
|
|
```
|
|
|
|
**RESTRICTION:**
|
|
- Dumb widgets ONLY (no state management)
|
|
- NO business logic
|
|
- Colors and typography are IMMUTABLE (no feature can override)
|
|
|
|
### 2.6 Core Localization (`apps/mobile/packages/core_localization`)
|
|
|
|
**Role:** Centralized i18n management
|
|
|
|
**Responsibilities:**
|
|
- Define all user-facing strings in `l10n/`
|
|
- Provide `LocaleBloc` for locale state management
|
|
- Export `TranslationProvider` for `context.strings` access
|
|
- Map domain failures to localized error messages via `ErrorTranslator`
|
|
|
|
**Feature Integration:**
|
|
```dart
|
|
// Features access strings
|
|
Text(context.strings.loginButton)
|
|
|
|
// BLoCs emit domain failures (not strings)
|
|
emit(AuthError(InvalidCredentialsFailure()));
|
|
|
|
// UI translates failures to localized messages
|
|
final message = ErrorTranslator.translate(failure, context.strings);
|
|
```
|
|
|
|
**App Setup:**
|
|
```dart
|
|
// App imports LocalizationModule
|
|
class AppModule extends Module {
|
|
@override
|
|
List<Module> get imports => [LocalizationModule()];
|
|
}
|
|
|
|
// Wrap app with providers
|
|
BlocProvider<LocaleBloc>(
|
|
create: (_) => Modular.get<LocaleBloc>(),
|
|
child: TranslationProvider(
|
|
child: MaterialApp.router(...),
|
|
),
|
|
)
|
|
```
|
|
|
|
### 2.7 Core (`apps/mobile/packages/core`)
|
|
|
|
**Role:** Cross-cutting concerns
|
|
|
|
**Responsibilities:**
|
|
- Extension methods (NavigationExtensions, ListExtensions, etc.)
|
|
- Base classes (UseCase, Failure, BlocErrorHandler)
|
|
- Logger configuration
|
|
- Result types for functional error handling
|
|
|
|
## 3. Dependency Direction Rules
|
|
|
|
1. **Domain Independence:** `domain` knows NOTHING about outer layers
|
|
- Defines *what* needs to be done, not *how*
|
|
- Pure Dart, zero Flutter dependencies
|
|
- Stable contracts that rarely change
|
|
|
|
2. **UI Agnosticism:** Features depend on `design_system` for UI and `domain` for logic
|
|
- Features do NOT know about Firebase or backend details
|
|
- Backend changes don't affect feature implementation
|
|
|
|
3. **Data Isolation:** `data_connect` depends on `domain` to know interfaces
|
|
- Implements domain repository interfaces
|
|
- Maps backend models to domain entities
|
|
- Does NOT know about UI
|
|
|
|
**Dependency Flow:**
|
|
```
|
|
Apps → Features → Design System
|
|
→ Core Localization
|
|
→ Data Connect → Domain
|
|
→ Core
|
|
```
|
|
|
|
## 4. Data Connect Service & Session Management
|
|
|
|
### 4.1 Session Handler Mixin
|
|
|
|
**Location:** `apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart`
|
|
|
|
**Responsibilities:**
|
|
- Automatic token refresh (triggered when <5 minutes to expiry)
|
|
- Firebase auth state listening
|
|
- Role-based access validation
|
|
- Session state stream emissions
|
|
- 3-attempt retry with exponential backoff (1s → 2s → 4s)
|
|
|
|
**Key Method:**
|
|
```dart
|
|
// Call once on app startup
|
|
DataConnectService.instance.initializeAuthListener(
|
|
allowedRoles: ['STAFF', 'BOTH'], // or ['CLIENT', 'BUSINESS', 'BOTH']
|
|
);
|
|
```
|
|
|
|
### 4.2 Session Listener Widget
|
|
|
|
**Location:** `apps/mobile/apps/<app>/lib/src/widgets/session_listener.dart`
|
|
|
|
**Responsibilities:**
|
|
- Wraps entire app to listen to session state changes
|
|
- Shows user-friendly dialogs for session expiration/errors
|
|
- Handles navigation on auth state changes
|
|
|
|
**Usage:**
|
|
```dart
|
|
// main.dart
|
|
runApp(
|
|
SessionListener( // ← Critical wrapper
|
|
child: ModularApp(module: AppModule(), child: AppWidget()),
|
|
),
|
|
);
|
|
```
|
|
|
|
### 4.3 Repository Pattern with Data Connect
|
|
|
|
**Step 1:** Define interface in feature domain:
|
|
```dart
|
|
// features/staff/profile/lib/src/domain/repositories/
|
|
abstract interface class ProfileRepositoryInterface {
|
|
Future<Staff> getProfile(String id);
|
|
}
|
|
```
|
|
|
|
**Step 2:** Implement using `DataConnectService.run()`:
|
|
```dart
|
|
// features/staff/profile/lib/src/data/repositories_impl/
|
|
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
|
final DataConnectService _service = DataConnectService.instance;
|
|
|
|
@override
|
|
Future<Staff> getProfile(String id) async {
|
|
return await _service.run(() async {
|
|
final response = await _service.connector
|
|
.getStaffById(id: id)
|
|
.execute();
|
|
return _mapToStaff(response.data.staff);
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
**Benefits of `_service.run()`:**
|
|
- ✅ Auto validates user is authenticated
|
|
- ✅ Refreshes token if <5 min to expiry
|
|
- ✅ Executes the query
|
|
- ✅ 3-attempt retry with exponential backoff
|
|
- ✅ Maps exceptions to domain failures
|
|
|
|
### 4.4 Session Store Pattern
|
|
|
|
After successful auth, populate session stores:
|
|
|
|
**Staff App:**
|
|
```dart
|
|
StaffSessionStore.instance.setSession(
|
|
StaffSession(
|
|
user: user,
|
|
staff: staff,
|
|
ownerId: ownerId,
|
|
),
|
|
);
|
|
```
|
|
|
|
**Client App:**
|
|
```dart
|
|
ClientSessionStore.instance.setSession(
|
|
ClientSession(
|
|
user: user,
|
|
business: business,
|
|
),
|
|
);
|
|
```
|
|
|
|
**Lazy Loading:** If session is null, fetch from backend and update:
|
|
```dart
|
|
final session = StaffSessionStore.instance.session;
|
|
if (session?.staff == null) {
|
|
final staff = await getStaffById(session!.user.uid);
|
|
StaffSessionStore.instance.setSession(
|
|
session.copyWith(staff: staff),
|
|
);
|
|
}
|
|
```
|
|
|
|
## 5. Feature Isolation & Communication
|
|
|
|
### Zero Direct Imports
|
|
|
|
```dart
|
|
// ❌ FORBIDDEN
|
|
import 'package:staff_profile/staff_profile.dart'; // in another feature
|
|
|
|
// ✅ ALLOWED
|
|
import 'package:krow_domain/krow_domain.dart'; // shared domain
|
|
import 'package:krow_core/krow_core.dart'; // shared utilities
|
|
import 'package:design_system/design_system.dart'; // shared UI
|
|
```
|
|
|
|
### Navigation: Typed Navigators with Safe Extensions
|
|
|
|
**Safe Navigation Extensions** (from `core` package):
|
|
```dart
|
|
extension NavigationExtensions on IModularNavigator {
|
|
/// Safely navigate with fallback to home
|
|
Future<void> safeNavigate(String route) async {
|
|
try {
|
|
await navigate(route);
|
|
} catch (e) {
|
|
await navigate('/home'); // Fallback
|
|
}
|
|
}
|
|
|
|
/// Safely push with fallback to home
|
|
Future<T?> safePush<T>(String route) async {
|
|
try {
|
|
return await pushNamed<T>(route);
|
|
} catch (e) {
|
|
await navigate('/home');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Safely pop with guard against empty stack
|
|
void popSafe() {
|
|
if (canPop()) {
|
|
pop();
|
|
} else {
|
|
navigate('/home');
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Typed Navigators:**
|
|
```dart
|
|
// apps/mobile/apps/staff/lib/src/navigation/staff_navigator.dart
|
|
extension StaffNavigator on IModularNavigator {
|
|
Future<void> toStaffHome() => safeNavigate(StaffPaths.home);
|
|
|
|
Future<void> toShiftDetails(String shiftId) =>
|
|
safePush('${StaffPaths.shifts}/$shiftId');
|
|
|
|
Future<void> toProfileEdit() => safePush(StaffPaths.profileEdit);
|
|
}
|
|
```
|
|
|
|
**Usage in Features:**
|
|
```dart
|
|
// ✅ CORRECT
|
|
Modular.to.toStaffHome();
|
|
Modular.to.toShiftDetails(shiftId: '123');
|
|
Modular.to.popSafe();
|
|
|
|
// ❌ AVOID
|
|
Modular.to.navigate('/home'); // No safety
|
|
Navigator.push(...); // No Modular integration (except when popping a dialog).
|
|
```
|
|
|
|
### Data Sharing Patterns
|
|
|
|
Features don't share state directly. Use:
|
|
|
|
1. **Domain Repositories:** Centralized data sources
|
|
2. **Session Stores:** `StaffSessionStore`, `ClientSessionStore` for app-wide context
|
|
3. **Event Streams:** If needed, via `DataConnectService` streams
|
|
4. **Navigation Arguments:** Pass IDs, not full objects
|
|
|
|
## 6. App-Specific Session Management
|
|
|
|
### Staff App
|
|
|
|
```dart
|
|
// main.dart
|
|
void main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
|
|
DataConnectService.instance.initializeAuthListener(
|
|
allowedRoles: ['STAFF', 'BOTH'],
|
|
);
|
|
|
|
runApp(
|
|
SessionListener(
|
|
child: ModularApp(module: StaffAppModule(), child: StaffApp()),
|
|
),
|
|
);
|
|
}
|
|
```
|
|
|
|
**Session Store:** `StaffSessionStore`
|
|
- Fields: `user`, `staff`, `ownerId`
|
|
- Lazy load: `getStaffById()` if staff is null
|
|
|
|
**Navigation:**
|
|
- Authenticated → `Modular.to.toStaffHome()`
|
|
- Unauthenticated → `Modular.to.toInitialPage()`
|
|
|
|
### Client App
|
|
|
|
```dart
|
|
// main.dart
|
|
void main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
|
|
DataConnectService.instance.initializeAuthListener(
|
|
allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'],
|
|
);
|
|
|
|
runApp(
|
|
SessionListener(
|
|
child: ModularApp(module: ClientAppModule(), child: ClientApp()),
|
|
),
|
|
);
|
|
}
|
|
```
|
|
|
|
**Session Store:** `ClientSessionStore`
|
|
- Fields: `user`, `business`
|
|
- Lazy load: `getBusinessById()` if business is null
|
|
|
|
**Navigation:**
|
|
- Authenticated → `Modular.to.toClientHome()`
|
|
- Unauthenticated → `Modular.to.toInitialPage()`
|
|
|
|
## 7. Data Connect Connectors Pattern
|
|
|
|
**Problem:** Without connectors, each feature duplicates backend queries.
|
|
|
|
**Solution:** Centralize all backend queries in `data_connect/connectors/`.
|
|
|
|
### Structure
|
|
|
|
Mirror backend connector structure:
|
|
|
|
```
|
|
data_connect/lib/src/connectors/
|
|
├── staff/
|
|
│ ├── domain/
|
|
│ │ ├── repositories/
|
|
│ │ │ └── staff_connector_repository.dart # Interface
|
|
│ │ └── usecases/
|
|
│ │ └── get_profile_completion_usecase.dart
|
|
│ └── data/
|
|
│ └── repositories/
|
|
│ └── staff_connector_repository_impl.dart # Implementation
|
|
├── order/
|
|
├── shifts/
|
|
└── user/
|
|
```
|
|
|
|
**Maps to backend:**
|
|
```
|
|
backend/dataconnect/connector/
|
|
├── staff/
|
|
├── order/
|
|
├── shifts/
|
|
└── user/
|
|
```
|
|
|
|
### Clean Architecture in Connectors
|
|
|
|
**Domain Interface:**
|
|
```dart
|
|
// staff_connector_repository.dart
|
|
abstract interface class StaffConnectorRepository {
|
|
Future<bool> getProfileCompletion();
|
|
Future<Staff> getStaffById(String id);
|
|
}
|
|
```
|
|
|
|
**Use Case:**
|
|
```dart
|
|
// get_profile_completion_usecase.dart
|
|
class GetProfileCompletionUseCase {
|
|
final StaffConnectorRepository _repository;
|
|
|
|
GetProfileCompletionUseCase({required StaffConnectorRepository repository})
|
|
: _repository = repository;
|
|
|
|
Future<bool> call() => _repository.getProfileCompletion();
|
|
}
|
|
```
|
|
|
|
**Data Implementation:**
|
|
```dart
|
|
// staff_connector_repository_impl.dart
|
|
class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
|
final DataConnectService _service;
|
|
|
|
@override
|
|
Future<bool> getProfileCompletion() async {
|
|
return _service.run(() async {
|
|
final staffId = await _service.getStaffId();
|
|
final response = await _service.connector
|
|
.getStaffProfileCompletion(id: staffId)
|
|
.execute();
|
|
|
|
return _isProfileComplete(response);
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### Feature Integration
|
|
|
|
**Step 1:** Feature registers connector repository:
|
|
```dart
|
|
// staff_main_module.dart
|
|
class StaffMainModule extends Module {
|
|
@override
|
|
void binds(Injector i) {
|
|
i.addLazySingleton<StaffConnectorRepository>(
|
|
StaffConnectorRepositoryImpl.new,
|
|
);
|
|
|
|
i.addLazySingleton(
|
|
() => GetProfileCompletionUseCase(
|
|
repository: i.get<StaffConnectorRepository>(),
|
|
),
|
|
);
|
|
|
|
i.addLazySingleton(
|
|
() => StaffMainCubit(
|
|
getProfileCompletionUsecase: i.get(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2:** BLoC uses it:
|
|
```dart
|
|
class StaffMainCubit extends Cubit<StaffMainState> {
|
|
final GetProfileCompletionUseCase _getProfileCompletionUsecase;
|
|
|
|
Future<void> loadProfileCompletion() async {
|
|
final isComplete = await _getProfileCompletionUsecase();
|
|
emit(state.copyWith(isProfileComplete: isComplete));
|
|
}
|
|
}
|
|
```
|
|
|
|
### Benefits
|
|
|
|
✅ **No Duplication** - Query implemented once, used by many features
|
|
✅ **Single Source of Truth** - Backend change → update one place
|
|
✅ **Reusability** - Any feature can use any connector
|
|
✅ **Testability** - Mock connector repo to test features
|
|
✅ **Scalability** - Easy to add connectors as backend grows
|
|
|
|
## 8. Avoiding Prop Drilling: Direct BLoC Access
|
|
|
|
### The Problem
|
|
|
|
Passing data through intermediate widgets creates maintenance burden:
|
|
|
|
```dart
|
|
// ❌ BAD: Prop drilling
|
|
ProfilePage(status: status)
|
|
→ ProfileHeader(status: status)
|
|
→ ProfileLevelBadge(status: status) // Only widget that needs it
|
|
```
|
|
|
|
### The Solution: BlocBuilder in Leaf Widgets
|
|
|
|
```dart
|
|
// ✅ GOOD: Direct BLoC access
|
|
class ProfileLevelBadge extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocBuilder<ProfileCubit, ProfileState>(
|
|
builder: (context, state) {
|
|
if (state.profile == null) return const SizedBox.shrink();
|
|
|
|
final level = _mapStatusToLevel(state.profile!.status);
|
|
return LevelBadgeUI(level: level);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Guidelines
|
|
|
|
1. **Leaf Widgets Access BLoC:** Widgets needing specific data should use `BlocBuilder`
|
|
2. **Container Widgets Stay Simple:** Parent widgets only manage layout
|
|
3. **No Unnecessary Props:** Don't pass data to intermediate widgets
|
|
4. **Single Responsibility:** Each widget has one reason to exist
|
|
|
|
**Decision Tree:**
|
|
```
|
|
Does this widget need data?
|
|
├─ YES, leaf widget → Use BlocBuilder
|
|
├─ YES, container → Use BlocBuilder in child
|
|
└─ NO → Don't add prop
|
|
```
|
|
|
|
## 9. BLoC Lifecycle & State Emission Safety
|
|
|
|
### The Problem: StateError After Dispose
|
|
|
|
When async operations complete after BLoC is closed:
|
|
```
|
|
StateError: Cannot emit new states after calling close
|
|
```
|
|
|
|
**Root Causes:**
|
|
1. Transient BLoCs created with `BlocProvider(create:)` → disposed prematurely
|
|
2. Multiple BlocProviders disposing same singleton
|
|
3. User navigates away during async operation
|
|
|
|
### The Solution: Singleton BLoCs + Safe Emit
|
|
|
|
#### Step 1: Register as Singleton
|
|
|
|
```dart
|
|
// ✅ GOOD: Singleton registration
|
|
i.addLazySingleton<ProfileCubit>(
|
|
() => ProfileCubit(useCase1, useCase2),
|
|
);
|
|
|
|
// ❌ BAD: Creates new instance each time
|
|
i.add(ProfileCubit.new);
|
|
```
|
|
|
|
#### Step 2: Use BlocProvider.value()
|
|
|
|
```dart
|
|
// ✅ GOOD: Reuse singleton
|
|
final cubit = Modular.get<ProfileCubit>();
|
|
BlocProvider<ProfileCubit>.value(
|
|
value: cubit,
|
|
child: MyWidget(),
|
|
)
|
|
|
|
// ❌ BAD: Creates duplicate
|
|
BlocProvider<ProfileCubit>(
|
|
create: (_) => Modular.get<ProfileCubit>(),
|
|
child: MyWidget(),
|
|
)
|
|
```
|
|
|
|
#### Step 3: Safe Emit with BlocErrorHandler
|
|
|
|
**Location:** `apps/mobile/packages/core/lib/src/presentation/mixins/bloc_error_handler.dart`
|
|
|
|
```dart
|
|
mixin BlocErrorHandler<S> on Cubit<S> {
|
|
void _safeEmit(void Function(S) emit, S state) {
|
|
try {
|
|
emit(state);
|
|
} on StateError catch (e) {
|
|
developer.log(
|
|
'Could not emit state: ${e.message}. Bloc may have been disposed.',
|
|
name: runtimeType.toString(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
```dart
|
|
class ProfileCubit extends Cubit<ProfileState> with BlocErrorHandler<ProfileState> {
|
|
Future<void> loadProfile() async {
|
|
emit(state.copyWith(status: ProfileStatus.loading));
|
|
|
|
await handleError(
|
|
emit: emit,
|
|
action: () async {
|
|
final profile = await getProfile();
|
|
emit(state.copyWith(status: ProfileStatus.loaded, profile: profile));
|
|
// ✅ Safe even if BLoC disposed
|
|
},
|
|
onError: (errorKey) => state.copyWith(status: ProfileStatus.error),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Pattern Summary
|
|
|
|
| Pattern | When to Use | Risk |
|
|
|---------|------------|------|
|
|
| Singleton + BlocProvider.value() | Long-lived features | Low |
|
|
| Transient + BlocProvider(create:) | Temporary widgets | Medium |
|
|
| Direct BlocBuilder | Leaf widgets | Low |
|
|
|
|
## 10. Anti-Patterns to Avoid
|
|
|
|
❌ **Feature imports feature**
|
|
```dart
|
|
import 'package:staff_profile/staff_profile.dart'; // in another feature
|
|
```
|
|
|
|
❌ **Business logic in BLoC**
|
|
```dart
|
|
on<LoginRequested>((event, emit) {
|
|
if (event.email.isEmpty) { // ← Use case responsibility
|
|
emit(AuthError('Email required'));
|
|
}
|
|
});
|
|
```
|
|
|
|
❌ **Direct Data Connect in features**
|
|
```dart
|
|
final response = await FirebaseDataConnect.instance.query(); // ← Use repository
|
|
```
|
|
|
|
❌ **Global state variables**
|
|
```dart
|
|
User? currentUser; // ← Use SessionStore
|
|
```
|
|
|
|
❌ **Direct Navigator.push**
|
|
```dart
|
|
Navigator.push(context, MaterialPageRoute(...)); // ← Use Modular
|
|
```
|
|
|
|
❌ **Hardcoded navigation**
|
|
```dart
|
|
Modular.to.navigate('/profile'); // ← Use safe extensions
|
|
```
|
|
|
|
## Summary
|
|
|
|
The architecture enforces:
|
|
- **Clean Architecture** with strict layer boundaries
|
|
- **Feature Isolation** via zero cross-feature imports
|
|
- **Session Management** via DataConnectService and SessionListener
|
|
- **Connector Pattern** for reusable backend queries
|
|
- **BLoC Lifecycle** safety with singletons and safe emit
|
|
- **Navigation Safety** with typed navigators and fallbacks
|
|
|
|
When implementing features:
|
|
1. Follow package structure strictly
|
|
2. Use connector repositories for backend access
|
|
3. Register BLoCs as singletons with `.value()`
|
|
4. Use safe navigation extensions
|
|
5. Avoid prop drilling with direct BLoC access
|
|
6. Keep domain pure and stable
|
|
|
|
Architecture is not negotiable. When in doubt, refer to existing well-structured features or ask for clarification.
|