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 withfromJson), 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
ApiClientcalls with string paths. The BFF exposes a generated retrofit client atlib/backend/openapi/(e.g.TypedBffClinicalClientvia thetypedBffClinicalProvider, with sub-clients likeclinicians,payments, etc.) plus typed request/response models underlib/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 parsingjson['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
ApiClientwhen the endpoint genuinely cannot be expressed through the generated client.
3. State Management: Riverpod¶
- Use
riverpodwithriverpod_generatorandbuild_runner. - Use
@riverpodannotation for all providers. - Pages/Widgets should be
ConsumerWidgetto accessref. - 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 forkeepAliveto 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 thekeepAliveworkaround entirely. - NEVER put business logic in UI widgets. Use UseCases or Providers.
- Guard
asyncgaps: after everyawaitin aState/ConsumerState, addif (!mounted) return;before touchingsetState,ref, or callbacks (the user may navigate away mid-call).
Workflow for implementing a feature¶
Implementing a new feature¶
- Plan: Identify the Domain entities and required API operations.
- Domain Layer: Create
Entity,Repository(abstract), andUseCase. - Data Layer: Create
Model(extends Entity, addsfromJson),RemoteDataSource(prefer the generated typed client, see "API Access" above, over rawApiClient), andRepositoryImpl. - Presentation Layer:
- Create Providers using
@riverpod. Use Class-Based Providers for user actions/controllers. - Create UI Widgets (
ConsumerWidget) usingFlutterFlowThemefor styling.
- Create Providers using
- Test: Write unit tests for UseCases/Repositories and widget tests for Pages.
- Build: Run
fvm flutter pub run build_runner build --delete-conflicting-outputsto generate Riverpod code.
Refactoring legacy code¶
- Identify: Locate the logic scattered in
api_calls.dartorFFAppState. - Extract: Move logic to a new Feature folder (Domain/Data layers).
- Replace: Replace the old
ApiCallusage with the newUseCasevia a Riverpod provider. - 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 widgetbuildmethods. A null assertion inbuildthrows at runtime and causes a grey error screen for the whole widget subtree. - For booleans that should be
falsewhen 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!insidebuild; extract to a local variable and null-check it instead.