Files
Krow-workspace/.claude/skills/krow-mobile-architecture/SKILL.md
Achintha Isuru b31a615092 feat: Migrate staff profile features from Data Connect to V2 REST API
- Removed data_connect package from mobile pubspec.yaml.
- Added documentation for V2 profile migration status and QA findings.
- Implemented new session management with ClientSessionStore and StaffSessionStore.
- Created V2SessionService for handling user sessions via the V2 API.
- Developed use cases for cancelling late worker assignments and submitting worker reviews.
- Added arguments and use cases for payment chart retrieval and profile completion checks.
- Implemented repository interfaces and their implementations for staff main and profile features.
- Ensured proper error handling and validation in use cases.
2026-03-16 22:45:06 -04:00

32 KiB

name, description
name description
krow-mobile-architecture KROW mobile app Clean Architecture implementation including package structure, dependency rules, feature isolation, BLoC lifecycle management, session handling, and V2 REST API integration. Use this when architecting new mobile features, debugging state management issues, preventing prop drilling, managing BLoC disposal, implementing session stores, or setting up API repository patterns. 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 V2 API repository patterns
  • 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)             │
│  • core: API service, session management, device        │
│          services, utilities, extensions, base classes   │
└─────────────────┬───────────────────────────────────────┘
                  │ depends on
┌─────────────────▼───────────────────────────────────────┐
│                Domain (Stable Core)                     │
│  • Entities (data models with fromJson/toJson)          │
│  • Enums (shared enumerations)                          │
│  • 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 into features
  • Configure environment-specific settings (dev/stage/prod)
  • Initialize session management via V2SessionService

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 using ApiService with V2ApiEndpoints
  • Pages as StatelessWidget: Move state to BLoCs for better performance and testability
  • Feature-level domain is optional: Only needed when the feature has business logic (use cases, validators). Simple features can have just data/ + presentation/.

RESTRICTION: Features MUST NOT import other features. Communication happens via:

  • Shared domain entities
  • Session stores (StaffSessionStore, ClientSessionStore)
  • Navigation via Modular

2.3 Domain (apps/mobile/packages/domain)

Role: The stable, pure heart of the system

Responsibilities:

  • Define Entities (data models with fromJson/toJson for V2 API serialization)
  • Define Enums (shared enumerations in entities/enums/)
  • Define Failures (domain-specific error types)

Structure:

domain/
├── lib/
│   └── src/
│       ├── entities/
│       │   ├── user.dart
│       │   ├── staff.dart
│       │   ├── shift.dart
│       │   └── enums/
│       │       ├── staff_status.dart
│       │       └── order_type.dart
│       ├── failures/
│       │   ├── failure.dart        # Base class
│       │   ├── auth_failure.dart
│       │   └── network_failure.dart
│       └── core/
│           └── services/api_services/
│               └── base_api_service.dart
└── pubspec.yaml

Example Entity:

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,
  });

  factory Staff.fromJson(Map<String, dynamic> json) {
    return Staff(
      id: json['id'] as String,
      name: json['name'] as String,
      email: json['email'] as String,
      status: StaffStatus.values.byName(json['status'] as String),
    );
  }

  Map<String, dynamic> toJson() => {
    'id': id,
    'name': name,
    'email': email,
    'status': status.name,
  };

  @override
  List<Object?> get props => [id, name, email, status];
}

RESTRICTION:

  • NO Flutter dependencies (no import 'package:flutter/material.dart')
  • Only equatable for value equality
  • Pure Dart only
  • fromJson/toJson live directly on entities (no separate DTOs or adapters)

2.4 Core (apps/mobile/packages/core)

Role: Cross-cutting concerns, API infrastructure, session management, device services, and utilities

Responsibilities:

  • ApiService — HTTP client wrapper around Dio with consistent response/error handling
  • V2ApiEndpoints — All V2 REST API endpoint constants
  • DioClient — Pre-configured Dio with AuthInterceptor and IdempotencyInterceptor
  • AuthInterceptor — Automatically attaches Firebase Auth ID token to requests
  • IdempotencyInterceptor — Adds Idempotency-Key header to POST/PUT/DELETE requests
  • ApiErrorHandler mixin — Maps API errors to domain failures
  • SessionHandlerMixin — Handles auth state, token refresh, role validation
  • V2SessionService — Manages session lifecycle, replaces legacy DataConnectService
  • Session stores (StaffSessionStore, ClientSessionStore)
  • Device services (camera, gallery, location, notifications, storage, etc.)
  • Extension methods (NavigationExtensions, ListExtensions, etc.)
  • Base classes (UseCase, Failure, BlocErrorHandler)
  • Logger configuration
  • AppConfig — Environment-specific configuration (API base URLs, keys)

Structure:

core/
├── lib/
│   ├── core.dart                          # Barrel exports
│   └── src/
│       ├── config/
│       │   ├── app_config.dart            # Env-specific config (V2_API_BASE_URL, etc.)
│       │   └── app_environment.dart
│       ├── services/
│       │   ├── api_service/
│       │   │   ├── api_service.dart        # ApiService (get/post/put/patch/delete)
│       │   │   ├── dio_client.dart         # Pre-configured Dio
│       │   │   ├── inspectors/
│       │   │   │   ├── auth_interceptor.dart
│       │   │   │   └── idempotency_interceptor.dart
│       │   │   ├── mixins/
│       │   │   │   ├── api_error_handler.dart
│       │   │   │   └── session_handler_mixin.dart
│       │   │   └── core_api_services/
│       │   │       ├── v2_api_endpoints.dart
│       │   │       ├── core_api_endpoints.dart
│       │   │       ├── file_upload/
│       │   │       ├── signed_url/
│       │   │       ├── llm/
│       │   │       ├── verification/
│       │   │       └── rapid_order/
│       │   ├── session/
│       │   │   ├── v2_session_service.dart
│       │   │   ├── staff_session_store.dart
│       │   │   └── client_session_store.dart
│       │   └── device/
│       │       ├── camera/
│       │       ├── gallery/
│       │       ├── location/
│       │       ├── notification/
│       │       ├── storage/
│       │       └── background_task/
│       ├── presentation/
│       │   ├── mixins/
│       │   │   └── bloc_error_handler.dart
│       │   └── observers/
│       │       └── core_bloc_observer.dart
│       ├── routing/
│       │   └── routing.dart
│       ├── domain/
│       │   ├── arguments/
│       │   └── usecases/
│       └── utils/
│           ├── date_time_utils.dart
│           ├── geo_utils.dart
│           └── time_utils.dart
└── pubspec.yaml

RESTRICTION:

  • NO feature-specific logic
  • Core services are domain-neutral and reusable
  • All V2 API access goes through ApiService — never use raw Dio directly in features

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

String Definition:

  • Strings are defined in packages/core_localization/lib/src/l10n/en.i18n.json (English) and es.i18n.json (Spanish)
  • Both files MUST be updated together when adding/modifying strings
  • Generated output: strings.g.dart, strings_en.g.dart, strings_es.g.dart
  • Regenerate with: cd packages/core_localization && dart run slang

Feature Integration:

// CORRECT: Access via Slang's global `t` accessor
import 'package:core_localization/core_localization.dart';

Text(t.client_create_order.review.invalid_arguments)
Text(t.errors.order.creation_failed)

// FORBIDDEN: Hardcoded user-facing strings
Text('Invalid review arguments')  // Must use localized key
Text('Order created!')            // Must use localized key

RESTRICTION: ALL user-facing strings in the presentation layer (Text widgets, SnackBars, AppBar titles, hints, labels, error messages, dialogs) MUST use localized keys via t.<section>.<key>. No hardcoded English or Spanish strings.

BLoC Error Flow:

// BLoCs emit domain failures (not strings)
emit(AuthError(InvalidCredentialsFailure()));

// UI translates failures to localized messages
final message = ErrorTranslator.translate(failure, context.strings);

App Setup:

// 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(...),
  ),
)

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
    • Entities include fromJson/toJson for practical V2 API serialization
  2. UI Agnosticism: Features depend on design_system for UI and domain for logic

    • Features do NOT know about HTTP/Dio details
    • Backend changes don't affect feature implementation
  3. Data Isolation: Feature data/ layer depends on core for API access and domain for entities

    • RepoImpl uses ApiService with V2ApiEndpoints
    • Maps JSON responses to domain entities via Entity.fromJson()
    • Does NOT know about UI

Dependency Flow:

Apps → Features → Design System
                → Core Localization
                → Core → Domain

4. V2 API Service & Session Management

4.1 ApiService

Location: apps/mobile/packages/core/lib/src/services/api_service/api_service.dart

Responsibilities:

  • Wraps Dio HTTP methods (GET, POST, PUT, PATCH, DELETE)
  • Consistent response parsing via ApiResponse
  • Consistent error handling (maps DioException to ApiResponse with V2 error envelope)

Key Usage:

final ApiService apiService;

// GET request
final response = await apiService.get(
  V2ApiEndpoints.staffDashboard,
  params: {'date': '2026-01-15'},
);

// POST request
final response = await apiService.post(
  V2ApiEndpoints.staffClockIn,
  data: {'shiftId': shiftId, 'latitude': lat, 'longitude': lng},
);

4.2 DioClient & Interceptors

Location: apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart

Pre-configured with:

  • AuthInterceptor — Automatically attaches Firebase Auth ID token as Bearer token
  • IdempotencyInterceptor — Adds Idempotency-Key (UUID v4) to POST/PUT/DELETE requests
  • LogInterceptor — Logs request/response bodies for debugging

4.3 V2SessionService

Location: apps/mobile/packages/core/lib/src/services/session/v2_session_service.dart

Responsibilities:

  • Manages session lifecycle (initialize, refresh, invalidate)
  • Fetches session data from V2 API on auth state change
  • Populates session stores with user/role data
  • Provides session state stream for SessionListener

Key Method:

// Call once on app startup
V2SessionService.instance.initializeAuthListener(
  allowedRoles: ['STAFF', 'BOTH'],  // or ['CLIENT', 'BUSINESS', 'BOTH']
);

4.4 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:

// main.dart
runApp(
  SessionListener(  // Critical wrapper
    child: ModularApp(module: AppModule(), child: AppWidget()),
  ),
);

4.5 Repository Pattern with V2 API

Step 1: Define interface in feature domain:

// features/staff/profile/lib/src/domain/repositories/
abstract interface class ProfileRepositoryInterface {
  Future<Staff> getProfile(String id);
}

Step 2: Implement using ApiService with V2ApiEndpoints:

// features/staff/profile/lib/src/data/repositories_impl/
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
  final ApiService _apiService;

  ProfileRepositoryImpl({required ApiService apiService})
      : _apiService = apiService;

  @override
  Future<Staff> getProfile(String id) async {
    final response = await _apiService.get(
      V2ApiEndpoints.staffSession,
      params: {'staffId': id},
    );
    return Staff.fromJson(response.data as Map<String, dynamic>);
  }
}

Benefits of ApiService + interceptors:

  • AuthInterceptor auto-attaches Firebase Auth token
  • IdempotencyInterceptor prevents duplicate writes
  • Consistent error handling via ApiResponse
  • No manual token management in features

4.6 Session Store Pattern

After successful auth, populate session stores:

Staff App:

StaffSessionStore.instance.setSession(
  StaffSession(
    user: user,
    staff: staff,
    ownerId: ownerId,
  ),
);

Client App:

ClientSessionStore.instance.setSession(
  ClientSession(
    user: user,
    business: business,
  ),
);

Lazy Loading: If session is null, fetch from backend and update:

final session = StaffSessionStore.instance.session;
if (session?.staff == null) {
  final response = await apiService.get(V2ApiEndpoints.staffSession);
  final staff = Staff.fromJson(response.data as Map<String, dynamic>);
  StaffSessionStore.instance.setSession(
    session!.copyWith(staff: staff),
  );
}

5. Feature Isolation & Communication

Zero Direct Imports

// 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 + API
import 'package:design_system/design_system.dart';  // shared UI

Navigation: Typed Navigators with Safe Extensions

Safe Navigation Extensions (from core package):

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:

// 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:

// CORRECT
Modular.to.toStaffHome();
Modular.to.toShiftDetails(shiftId: '123');
Modular.to.popSafe();

// AVOID
Modular.to.navigate('/profile');  // No safety
Navigator.push(...);            // No Modular integration

Data Sharing Patterns

Features don't share state directly. Use:

  1. Domain Repositories: Centralized data sources via ApiService
  2. Session Stores: StaffSessionStore, ClientSessionStore for app-wide context
  3. Event Streams: If needed, via V2SessionService streams
  4. Navigation Arguments: Pass IDs, not full objects

6. App-Specific Session Management

Staff App

// main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  V2SessionService.instance.initializeAuthListener(
    allowedRoles: ['STAFF', 'BOTH'],
  );

  runApp(
    SessionListener(
      child: ModularApp(module: StaffAppModule(), child: StaffApp()),
    ),
  );
}

Session Store: StaffSessionStore

  • Fields: user, staff, ownerId
  • Lazy load: fetch from V2ApiEndpoints.staffSession if staff is null

Navigation:

  • Authenticated -> Modular.to.toStaffHome()
  • Unauthenticated -> Modular.to.toInitialPage()

Client App

// main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  V2SessionService.instance.initializeAuthListener(
    allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'],
  );

  runApp(
    SessionListener(
      child: ModularApp(module: ClientAppModule(), child: ClientApp()),
    ),
  );
}

Session Store: ClientSessionStore

  • Fields: user, business
  • Lazy load: fetch from V2ApiEndpoints.clientSession if business is null

Navigation:

  • Authenticated -> Modular.to.toClientHome()
  • Unauthenticated -> Modular.to.toInitialPage()

7. V2 API Repository Pattern

Problem: Without a consistent pattern, each feature handles HTTP differently.

Solution: Feature RepoImpl uses ApiService with V2ApiEndpoints, returning domain entities via Entity.fromJson().

Structure

Repository implementations live in the feature package:

features/staff/profile/
├── lib/src/
│   ├── domain/
│   │   └── repositories/
│   │       └── profile_repository_interface.dart   # Interface
│   ├── data/
│   │   └── repositories_impl/
│   │       └── profile_repository_impl.dart        # Implementation
│   └── presentation/
│       └── blocs/
│           └── profile_cubit.dart

Repository Interface

// profile_repository_interface.dart
abstract interface class ProfileRepositoryInterface {
  Future<Staff> getProfile();
  Future<void> updatePersonalInfo(Map<String, dynamic> data);
  Future<List<ProfileSection>> getProfileSections();
}

Repository Implementation

// profile_repository_impl.dart
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
  final ApiService _apiService;

  ProfileRepositoryImpl({required ApiService apiService})
      : _apiService = apiService;

  @override
  Future<Staff> getProfile() async {
    final response = await _apiService.get(V2ApiEndpoints.staffSession);
    final data = response.data as Map<String, dynamic>;
    return Staff.fromJson(data['staff'] as Map<String, dynamic>);
  }

  @override
  Future<void> updatePersonalInfo(Map<String, dynamic> data) async {
    await _apiService.put(
      V2ApiEndpoints.staffPersonalInfo,
      data: data,
    );
  }

  @override
  Future<List<ProfileSection>> getProfileSections() async {
    final response = await _apiService.get(V2ApiEndpoints.staffProfileSections);
    final list = response.data['sections'] as List<dynamic>;
    return list
        .map((e) => ProfileSection.fromJson(e as Map<String, dynamic>))
        .toList();
  }
}

Feature Module Integration

// profile_module.dart
class ProfileModule extends Module {
  @override
  void binds(Injector i) {
    i.addLazySingleton<ProfileRepositoryInterface>(
      () => ProfileRepositoryImpl(apiService: i.get<ApiService>()),
    );

    i.addLazySingleton(
      () => GetProfileUseCase(
        repository: i.get<ProfileRepositoryInterface>(),
      ),
    );

    i.addLazySingleton(
      () => ProfileCubit(
        getProfileUseCase: i.get(),
      ),
    );
  }
}

BLoC Usage

class ProfileCubit extends Cubit<ProfileState> with BlocErrorHandler<ProfileState> {
  final GetProfileUseCase _getProfileUseCase;

  Future<void> loadProfile() async {
    emit(state.copyWith(status: ProfileStatus.loading));

    await handleError(
      emit: emit,
      action: () async {
        final profile = await _getProfileUseCase();
        emit(state.copyWith(status: ProfileStatus.loaded, profile: profile));
      },
      onError: (errorKey) => state.copyWith(status: ProfileStatus.error),
    );
  }
}

Benefits

  • No Duplication — Endpoint constants defined once in V2ApiEndpoints
  • Consistent AuthAuthInterceptor handles token attachment automatically
  • Idempotent WritesIdempotencyInterceptor prevents duplicate mutations
  • Domain Purity — Entities use fromJson/toJson directly, no mapping layers
  • Testability — Mock ApiService to test RepoImpl in isolation
  • Scalability — Add new endpoints to V2ApiEndpoints, implement in feature RepoImpl

8. Avoiding Prop Drilling: Direct BLoC Access

The Problem

Passing data through intermediate widgets creates maintenance burden:

// BAD: Prop drilling
ProfilePage(status: status)
  -> ProfileHeader(status: status)
    -> ProfileLevelBadge(status: status)  // Only widget that needs it

The Solution: BlocBuilder in Leaf Widgets

// 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

// GOOD: Singleton registration
i.addLazySingleton<ProfileCubit>(
  () => ProfileCubit(useCase1, useCase2),
);

// BAD: Creates new instance each time
i.add(ProfileCubit.new);

Step 2: Use BlocProvider.value()

// 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

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:

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
import 'package:staff_profile/staff_profile.dart';  // in another feature
  • Business logic in BLoC
on<LoginRequested>((event, emit) {
  if (event.email.isEmpty) {  // Use case responsibility
    emit(AuthError('Email required'));
  }
});
  • Direct HTTP/Dio in features (use ApiService)
final response = await Dio().get('https://api.example.com/staff');  // Use ApiService
  • Importing krow_data_connect (deprecated package)
import 'package:krow_data_connect/krow_data_connect.dart';  // Use krow_core instead
  • Global state variables
User? currentUser;  // Use SessionStore
  • Direct Navigator.push
Navigator.push(context, MaterialPageRoute(...));  // Use Modular
  • Hardcoded navigation
Modular.to.navigate('/profile');  // Use safe extensions
  • Hardcoded user-facing strings
Text('Order created successfully!');  // Use t.section.key from core_localization

Summary

The architecture enforces:

  • Clean Architecture with strict layer boundaries
  • Feature Isolation via zero cross-feature imports
  • V2 REST API integration via ApiService, V2ApiEndpoints, and interceptors
  • Session Management via V2SessionService, session stores, and SessionListener
  • Repository Pattern with feature-local RepoImpl using ApiService
  • 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 ApiService with V2ApiEndpoints for all backend access
  3. Domain entities use fromJson/toJson for V2 API serialization
  4. RepoImpl lives in the feature data/ layer, not a shared package
  5. Register BLoCs as singletons with .value()
  6. Use safe navigation extensions
  7. Avoid prop drilling with direct BLoC access
  8. Keep domain pure and stable

Architecture is not negotiable. When in doubt, refer to existing well-structured features or ask for clarification.