Files
Krow-workspace/docs/MOBILE/01-architecture-principles.md

16 KiB

KROW Architecture Principles

This document is the AUTHORITATIVE source of truth for the KROW engineering architecture. All agents and engineers must adhere strictly to these principles. Deviations are interpreted as errors.

1. High-Level Architecture

The KROW platform follows a strict Clean Architecture implementation within a Melos Monorepo. Dependencies flow inwards towards the Domain.

graph TD
    subgraph "Apps (Entry Points)"
        ClientApp["apps/mobile/apps/client"]
        StaffApp["apps/mobile/apps/staff"]
    end

    subgraph "Features"
        ClientFeatures["apps/mobile/packages/features/client/*"]
        StaffFeatures["apps/mobile/packages/features/staff/*"]
    end

    subgraph "Services"
        DataConnect["apps/mobile/packages/data_connect"]
        DesignSystem["apps/mobile/packages/design_system"]
        CoreLocalization["apps/mobile/packages/core_localization"]
    end

    subgraph "Core Domain"
        Domain["apps/mobile/packages/domain"]
        Core["apps/mobile/packages/core"]
    end

    %% Dependency Flow
    ClientApp --> ClientFeatures & DataConnect & CoreLocalization
    StaffApp --> StaffFeatures & DataConnect & CoreLocalization

    ClientFeatures & StaffFeatures --> Domain
    ClientFeatures & StaffFeatures --> DesignSystem
    ClientFeatures & StaffFeatures --> CoreLocalization
    ClientFeatures & StaffFeatures --> Core

    DataConnect --> Domain
    DataConnect --> Core
    DesignSystem --> Core
    CoreLocalization --> Core
    Domain --> Core

    %% Strict Barriers
    linkStyle default stroke-width:2px,fill:none,stroke:gray

2. Repository Structure & Package Roles

2.1 Apps (apps/mobile/apps/)

  • Role: Application entry points and Dependency Injection (DI) roots.
  • Responsibilities:
    • Initialize Flutter Modular.
    • Assemble features into a navigation tree.
    • Inject concrete implementations (from data_connect) into Feature packages.
    • Configure environment-specific settings.
  • RESTRICTION: NO business logic. NO UI widgets (except App and Main).

2.2 Features (apps/mobile/packages/features/<APP_NAME>/<FEATURE_NAME>)

  • Role: Vertical slices of user-facing functionality.
  • Internal Structure:
    • domain/: Feature-specific Use Cases(always extend the apps/mobile/packages/core/lib/src/domain/usecases/usecase.dart abstract clas) and Repository Interfaces.
    • data/: Repository Implementations.
    • presentation/:
      • Pages, BLoCs, Widgets.
      • For performance make the pages as StatelessWidget and move the state management to the BLoC (always use a BlocProvider when providing the BLoC to the widget tree) or StatefulWidget to an external separate widget file.
  • Responsibilities:
    • Presentation: UI Pages, Modular Routes.
    • State Management: BLoCs / Cubits.
    • Application Logic: Use Cases.
  • RESTRICTION: Features MUST NOT import other features. Communication happens via shared domain events.

2.3 Domain (apps/mobile/packages/domain)

  • Role: The stable heart of the system. Pure Dart.
  • Responsibilities:
    • Entities: Immutable data models (Data Classes).
    • Failures: Domain-specific error types.
  • RESTRICTION: NO Flutter dependencies. NO json_annotation. NO package dependencies (except equatable).

2.4 Data Connect (apps/mobile/packages/data_connect)

  • Role: Interface Adapter for Backend Access (Datasource Layer).
  • Responsibilities:
    • Connectors: Centralized repository implementations for each backend connector (see 03-data-connect-connectors-pattern.md)
      • One connector per backend connector domain (staff, order, user, etc.)
      • Repository interfaces and use cases defined at domain level
      • Repository implementations query backend and map responses
    • Implement Firebase Data Connect connector and service layer
    • Map Domain Entities to/from Data Connect generated code
    • Handle Firebase exceptions and map to domain failures
    • Provide centralized DataConnectService with session management
  • RESTRICTION:
    • NO feature-specific logic. Connectors are domain-neutral and reusable.
    • All queries must follow Clean Architecture (domain → data layers)
    • See 03-data-connect-connectors-pattern.md for detailed pattern documentation

2.5 Design System (apps/mobile/packages/design_system)

  • Role: Visual language and component library.
  • Responsibilities:
    • UI components if needed. But mostly try to modify the theme file (apps/mobile/packages/design_system/lib/src/ui_theme.dart) so we can directly use the theme in the app, to use the default material widgets.
      • If not possible, and if that specific widget is used in multiple features, then try to create a shared widget in the apps/mobile/packages/design_system/widgets.
    • Theme definitions (Colors, Typography).
    • Assets (Icons, Images).
    • More details on how to use this package is available in the apps/mobile/docs/03-design-system-usage.md.
  • RESTRICTION:
    • CANNOT change colours or typography.
    • Dumb widgets only. NO business logic. NO state management (Bloc).
    • More details on how to use this package is available in the apps/mobile/docs/03-design-system-usage.md.

2.6 Core Localization (apps/mobile/packages/core_localization)

  • Role: Centralized language and localization management.
  • Responsibilities:
    • Define all user-facing strings in l10n/ with i18n tooling support
    • Provide LocaleBloc for reactive locale state management
    • Export TranslationProvider for BuildContext-based string access
    • Map domain failures to user-friendly localized error messages via ErrorTranslator
  • Feature Integration:
    • Features access strings via context.strings.<key> in presentation layer
    • BLoCs don't depend on localization; they emit domain failures
    • Error translation happens in UI layer (pages/widgets)
  • App Integration:
    • Apps import LocalizationModule() in their module imports
    • Apps wrap the material app with BlocProvider<LocaleBloc>() and TranslationProvider
    • Apps initialize MaterialApp with locale from LocaleState

2.7 Core (apps/mobile/packages/core)

  • Role: Cross-cutting concerns.
  • Responsibilities:
    • Extension methods.
    • Logger configuration.
    • Base classes for Use Cases or Result types (functional error handling).

3. Dependency Direction & Boundaries

  1. Domain Independence: apps/mobile/packages/domain knows NOTHING about the outer world. It defines what needs to be done, not how.
  2. UI Agnosticism: apps/mobile/packages/features depends on apps/mobile/packages/design_system for looks and apps/mobile/packages/domain for logic. It does NOT know about Firebase.
  3. Data Isolation: apps/mobile/packages/data_connect depends on apps/mobile/packages/domain to know what interfaces to implement. It does NOT know about the UI.

4. Data Connect Service & Session Management

All backend access is unified through DataConnectService with integrated 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 token <5 minutes to expiry)
    • Firebase auth state listening
    • Role-based access validation
    • Session state stream emissions
    • 3-attempt retry logic with exponential backoff on token validation failure
  • Key Method: initializeAuthListener(allowedRoles: [...]) - call once on app startup

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
  • Pattern: SessionListener(child: AppWidget())

4.3 Repository Pattern with Data Connect

  1. Interface First: Define abstract interface class <Name>RepositoryInterface in feature domain layer.
  2. Implementation: Use _service.run() wrapper that automatically:
    • Validates user is authenticated (if required)
    • Ensures token is valid and refreshes if needed
    • Executes the Data Connect query
    • Handles exceptions and maps to domain failures
  3. Session Store Population: On successful auth, session stores are populated:
    • Staff: StaffSessionStore.instance.setSession(StaffSession(...))
    • Client: ClientSessionStore.instance.setSession(ClientSession(...))
  4. Lazy Loading: If session is null, fetch data via getStaffById() or getBusinessById() and update store.

5. Feature Isolation & Cross-Feature Communication

  • Zero Direct Imports: import 'package:feature_a/...' is FORBIDDEN inside package:feature_b.
    • Exception: Shared packages like domain, core, and design_system are always accessible.
  • Navigation: Use Typed Navigators and Safe Navigation via Flutter Modular:
    • Safe Methods: ALWAYS use safeNavigate(), safePush(), popSafe(), and safePushNamedAndRemoveUntil() from NavigationExtensions.
    • Fallback: All safe methods automatically fall back to the Home page (Staff or Client) if the target route is invalid or the operation fails.
    • Typed Navigator Pattern: Prefer using typed methods on Modular.to (e.g., Modular.to.toShiftDetails(id)) which are implemented in ClientNavigator and StaffNavigator using these safe extensions.
    • Configuration: Routes defined in module.dart files; constants in paths.dart.
  • Data Sharing: Features do not share state directly. Shared data accessed through:
    • Domain Repositories: Centralized data sources (e.g., AuthRepository)
    • Session Stores: StaffSessionStore and ClientSessionStore for app-wide user context
    • Event Streams: If needed, via DataConnectService streams for reactive updates

6. App-Specific Session Management

Each app (staff and client) has different role requirements and session patterns:

6.1 Staff App Session

  • Location: apps/mobile/apps/staff/lib/main.dart
  • Initialization: DataConnectService.instance.initializeAuthListener(allowedRoles: ['STAFF', 'BOTH'])
  • Session Store: StaffSessionStore with StaffSession(user: User, staff: Staff?, ownerId: String?)
  • Lazy Loading: getStaffName() fetches via getStaffById() if session null
  • Navigation: On auth → Modular.to.toStaffHome(), on unauth → Modular.to.toInitialPage()

6.2 Client App Session

  • Location: apps/mobile/apps/client/lib/main.dart
  • Initialization: DataConnectService.instance.initializeAuthListener(allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'])
  • Session Store: ClientSessionStore with ClientSession(user: User, business: ClientBusinessSession?)
  • Lazy Loading: getUserSessionData() fetches via getBusinessById() if session null
  • Navigation: On auth → Modular.to.toClientHome(), on unauth → Modular.to.toInitialPage()

7. Data Connect Connectors Pattern

See 03-data-connect-connectors-pattern.md for comprehensive documentation on:

  • How connector repositories work
  • How to add queries to existing connectors
  • How to create new connectors
  • Integration patterns with features
  • Benefits and anti-patterns

Quick Reference:

  • All backend queries centralized in apps/mobile/packages/data_connect/lib/src/connectors/
  • One connector per backend connector domain (staff, order, user, etc.)
  • Each connector follows Clean Architecture (domain interfaces + data implementations)
  • Features use connector repositories through dependency injection
  • Results in zero query duplication and single source of truth

8. Prop Drilling Prevention & Direct BLoC Access

8.1 The Problem: Prop Drilling

Passing data through intermediate widgets creates maintenance headaches:

  • Every intermediate widget must accept and forward props
  • Changes to data structure ripple through multiple widget constructors
  • Reduces code clarity and increases cognitive load

Anti-Pattern Example:

// ❌ BAD: Drilling status through 3 levels
ProfilePage(status: status)
   ProfileHeader(status: status)
     ProfileLevelBadge(status: status)  // Only widget that needs it!

8.2 The Solution: Direct BLoC Access with BlocBuilder

Use BlocBuilder to access BLoC state directly in leaf widgets:

Correct Pattern:

// ✅ GOOD: ProfileLevelBadge accesses ProfileCubit directly
class ProfileLevelBadge extends StatelessWidget {
  const ProfileLevelBadge({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ProfileCubit, ProfileState>(
      builder: (context, state) {
        final Staff? profile = state.profile;
        if (profile == null) return const SizedBox.shrink();
        
        final level = _mapStatusToLevel(profile.status);
        return LevelBadgeUI(level: level);
      },
    );
  }
}

8.3 Guidelines for Avoiding Prop Drilling

  1. Leaf Widgets Get Data from BLoC: Widgets that need specific data should access it directly via BlocBuilder
  2. Container Widgets Stay Simple: Parent widgets like ProfileHeader only manage layout and positioning
  3. No Unnecessary Props: Don't pass data to intermediate widgets unless they need it for UI construction
  4. Single Responsibility: Each widget should have one reason to exist

Decision Tree:

Does this widget need data?
├─ YES, and it's a leaf widget → Use BlocBuilder
├─ YES, and it's a container → Use BlocBuilder in child, not parent
└─ NO → Don't add prop to constructor

9. BLoC Lifecycle & State Emission Safety

9.1 The Problem: StateError After Dispose

When async operations complete after a BLoC is closed, attempting to emit state causes:

StateError: Cannot emit new states after calling close

Root Causes:

  1. Transient BLoCs: BlocProvider(create:) creates new instance on every rebuild → disposed prematurely
  2. Singleton Disposal: Multiple BlocProviders disposing same singleton instance
  3. Navigation During Async: User navigates away while loadProfile() is still running

9.2 The Solution: Singleton BLoCs + Error Handler Defensive Wrapping

Step 1: Register as Singleton

// ✅ GOOD: ProfileCubit as singleton
i.addSingleton<ProfileCubit>(
  () => ProfileCubit(useCase1, useCase2),
);

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

Step 2: Use BlocProvider.value() for Singletons

// ✅ GOOD: Use singleton instance
ProfileCubit cubit = Modular.get<ProfileCubit>();
BlocProvider<ProfileCubit>.value(
  value: cubit,  // Reuse same instance
  child: MyWidget(),
)

// ❌ BAD: Creates duplicate instance
BlocProvider<ProfileCubit>(
  create: (_) => Modular.get<ProfileCubit>(),  // Wrong!
  child: MyWidget(),
)

Step 3: Defensive Error Handling in BlocErrorHandler Mixin

The BlocErrorHandler<S> mixin provides _safeEmit() wrapper:

Location: apps/mobile/packages/core/lib/src/presentation/mixins/bloc_error_handler.dart

void _safeEmit(void Function(S) emit, S state) {
  try {
    emit(state);
  } on StateError catch (e) {
    // Bloc was closed before emit - log and continue gracefully
    developer.log(
      'Could not emit state: ${e.message}. Bloc may have been disposed.',
      name: runtimeType.toString(),
    );
  }
}

Usage in Cubits/Blocs:

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));
      // ✅ If BLoC disposed before emit, _safeEmit catches StateError gracefully
    },
    onError: (errorKey) {
      return state.copyWith(status: ProfileStatus.error);
    },
  );
}

9.3 Pattern Summary

Pattern When to Use Risk
Singleton + BlocProvider.value() Long-lived features (Profile, Shifts, etc.) Low - instance persists
Transient + BlocProvider(create:) Temporary widgets (Dialogs, Overlays) Medium - requires careful disposal
Direct BlocBuilder Leaf widgets needing data Low - no registration needed

Remember:

  • Use singletons for feature-level cubits accessed from multiple pages
  • Use transient only for temporary UI states
  • Always wrap emit() in _safeEmit() via BlocErrorHandler mixin
  • Test navigation away during async operations to verify graceful handling