Skip to content

Frontend development standards (Flutter apps)

How new features and refactors are built in the Flutter apps (perci-platform-members and perci-platform-clinicians). For templates and the detailed walk-through, see the New architecture guide.

Architecture: Feature-Based Clean Architecture

All new features and refactors MUST follow this structure. Do NOT use the legacy custom_code, backend/api_requests, or global FFAppState patterns for new logic.

1. Folder Structure

lib/features/{feature_name}/

  • domain/: Pure Dart. Entities, Repository Interfaces, Use Cases. NO Flutter dependencies (except basic ones if needed), NO JSON serialization.
  • data/: Implementation. DTOs (Models with fromJson), Data Sources (API calls), Repository Implementation.
  • presentation/: UI. Pages, Widgets, Riverpod Providers.

One class per file. Every entity, model, widget, and use case gets its own file named in snake_case after the class. Never define multiple classes in a single file. If a file grows multiple classes during implementation, split it before committing.

2. API Access: Prefer the Generated Client

  • ALWAYS prefer the generated, typed OpenAPI client over hand-written ApiClient calls with string paths. The BFF exposes a generated retrofit client at lib/backend/openapi/ (e.g. TypedBffClinicalClient via the typedBffClinicalProvider, with sub-clients like clinicians, payments, etc.) plus typed request/response models under lib/backend/openapi/models/.
  • In a RemoteDataSource, inject the relevant sub-client (e.g. ref.watch(typedBffClinicalProvider).clinicians) and call its typed methods, returning typed responses instead of parsing json['data'] by hand.
  • If the endpoint you need is missing from the generated client, regenerate it from the OpenAPI spec rather than hand-rolling the path. Only fall back to the raw ApiClient when the endpoint genuinely cannot be expressed through the generated client.

3. State Management: Riverpod

  • Use riverpod with riverpod_generator and build_runner.
  • Use @riverpod annotation for all providers.
  • Pages/Widgets should be ConsumerWidget to access ref.
  • Simple Data: Use functional providers (FutureProvider).
  • Complex Interactions: Use Class-Based Providers (@riverpod class ...) as Controllers for actions (e.g., upload, delete).
  • Prefer one notifier per page over a notifier per action. A single page-scoped notifier (@riverpod class ...) that owns the page's state and exposes its actions as methods is far less bug-prone than splitting each action into its own notifier. Per-action notifiers autodispose the moment a widget stops listening, so they lose state mid-flow and force you to reach for keepAlive to compensate, which is itself error-prone (stale state lingers, disposal timing becomes hard to reason about, and lifecycle bugs creep in). Keeping the page's state and actions in one notifier gives a single, predictable lifecycle tied to the page and avoids the keepAlive workaround entirely.
  • NEVER put business logic in UI widgets. Use UseCases or Providers.
  • Guard async gaps: after every await in a State/ConsumerState, add if (!mounted) return; before touching setState, ref, or callbacks (the user may navigate away mid-call).

Workflow for implementing a feature

Implementing a new feature

  1. Plan: Identify the Domain entities and required API operations.
  2. Domain Layer: Create Entity, Repository (abstract), and UseCase.
  3. Data Layer: Create Model (extends Entity, adds fromJson), RemoteDataSource (prefer the generated typed client, see "API Access" above, over raw ApiClient), and RepositoryImpl.
  4. Presentation Layer:
    • Create Providers using @riverpod. Use Class-Based Providers for user actions/controllers.
    • Create UI Widgets (ConsumerWidget) using FlutterFlowTheme for styling.
  5. Test: Write unit tests for UseCases/Repositories and widget tests for Pages.
  6. Build: Run fvm flutter pub run build_runner build --delete-conflicting-outputs to generate Riverpod code.

Refactoring legacy code

  1. Identify: Locate the logic scattered in api_calls.dart or FFAppState.
  2. Extract: Move logic to a new Feature folder (Domain/Data layers).
  3. Replace: Replace the old ApiCall usage with the new UseCase via a Riverpod provider.
  4. Verify: Ensure tests pass and the feature works in isolation.

Key references

  • Templates & guide: New architecture guide
  • Example feature: apps/perci-platform-members/lib/features/documents/
  • Core services: apps/perci-platform-members/lib/core/

Null Safety (Flutter / Dart)

  • Prefer ?. (null-aware access) over ! (null assertion) when reading nullable fields in widget build methods. A null assertion in build throws at runtime and causes a grey error screen for the whole widget subtree.
  • For booleans that should be false when a value is absent, use Dart type promotion via a local variable or a switch expression rather than ! with a guard:
// preferred — type promotion via switch
visible: switch (widget.memberData?.id) {
  final id? => someCondition(id),
  null => false,
},

// also acceptable — null-aware with fallback
widget.memberData?.name ?? '',
  • Avoid patterns like widget.foo!.bar! inside build; extract to a local variable and null-check it instead.