Skip to content

Frontend Refactoring Roadmap

A phased plan to take the two Flutter apps (perci-platform-members, perci-platform-clinicians) from the FlutterFlow-exported legacy structure to the target architecture already defined in flutter-development-standards.md and NEW_ARCHITECTURE_GUIDE.md, with a single shared package and minimal duplication. Every phase is a stream of small, independent PRs into develop — no feature freeze, no long-lived refactor branch.

Where we are today (measured 6 Jul 2026)

Area Members Clinicians
Total lib/ Dart LOC ~86k ~88k
Migrated features/ 13.2k LOC, 8 features 19.1k LOC, 7 features
Legacy page code main_pages/ 20.3k, onboarding/ 15.8k, appointments/ 7.8k, form/ 2.6k new_a_p_p/ 47k
FlutterFlow runtime flutter_flow/ 10.8k flutter_flow/ 10.5k
Hand-rolled API layer api_calls.dart 3.6k api_calls.dart 4.2k
Routes in flutter_flow/nav/nav.dart 75 48
  • State: FFAppState() still referenced in 62 files; Riverpod (@riverpod) in only 23 hand-written files.
  • Packages (5): perci-platform-frontend-shared (48k LOC — including its own copies of flutter_flow/ 9.7k, pages/ 7k, backend/ 7.4k, custom_code/ 6k), ff_theme (905 LOC, one file), ff_commons (850 LOC), plus two dev-only test packages (perci_platform_test_shared, integration_test_shared).
  • Duplication: 52 identical relative paths exist in both apps; at least 8 are byte-identical (FlutterFlow boilerplate, firebase_config.dart). The auth stack (auth/custom_auth/custom_auth_manager.dart etc.) and api_calls.dart are near-duplicates. Much of flutter_flow/ is tri-plicated (both apps + shared package).
  • API: swagger_parser is wired in the clinicians app (generates a typed client into the gitignored lib/backend/openapi/ from the BFF OpenAPI spec, consumed via TypedBffClinicalClient). The hand-rolled api_calls.dart files remain the main path.
  • Tests: 80 (members) + 68 (clinicians) + 23 (shared) test files; Patrol E2E harness in both apps; coverage ~11.5% (PPL-2637 initiative, target 80%).

Target end state

  • Two thin apps that contain only app-specific features and composition; everything reusable in one runtime shared package (ff_theme, ff_commons and perci-platform-frontend-shared merged) plus one dev-only test package (the two test packages merged). Test support stays a separate package deliberately — merging it into the runtime package would drag test-only dependencies into production builds.
  • Every screen lives in a features/<name>/{domain,data,presentation} slice with Riverpod state, per the existing standards doc. No FFAppState, no api_calls.dart, no flutter_flow/ runtime, no custom_code/.
  • All backend access through the generated typed OpenAPI clients behind feature repositories.
  • Typed go_router routing owned per feature, guarded by an auth provider.
  • A shared AppScaffold page shell so screens carry no navigation/loading/error boilerplate.
  • CI enforces the architecture (no new legacy references) and a ratcheting coverage floor.

Sequencing logic

Guardrails first (Phase 0) so the legacy cannot grow while we shrink it. Then the changes that make every subsequent migration cheaper (package consolidation, page scaffold, typed API). State and navigation come next because migrated screens need Riverpod session state and typed routes to land clean. The long tail of screen migrations runs last and in parallel with normal feature work. Each phase lists its exit criterion — a phase is "done" when the criterion is measurable in CI, not when the tickets close.


Phase 0 — Guardrails and baseline (small, do immediately)

The strangler-fig precondition: stop new legacy code, make progress measurable, and put a safety net under the riskiest later phases.

  1. Ratify this roadmap — land this doc, fix the stale FRONTEND_REFACTORING_GUIDE.md link in NEW_ARCHITECTURE_GUIDE.md.
  2. "No new legacy" CI check (shipped — PPL-2958)scripts/frontend/legacy-guard.ts counts, per app: files referencing FFAppState(, endpoints in api_calls.dart, routes in flutter_flow/nav/nav.dart, createModel( call sites, and LOC under legacy directories, and compares them against scripts/frontend/legacy-baseline.json. The legacy-guard job in flutter-pr-checks.yml fails a PR if any counter increases and prints the burn-down table to the CI step summary. When a migration legitimately reduces a counter, regenerate the baseline with pnpm run legacy-guard -- --write and commit it in the same PR.
  3. Coverage ratchet — set FLUTTER_MIN_COVERAGE to the current floor and bump it as migrations land (each migrated screen ships with widget tests).
  4. E2E safety net — confirm Patrol journeys cover login, dashboard, booking, chat and video call in both apps before the auth/navigation phases touch them. Add missing journeys now.
  5. Golden baselines for the shared components that phases 2–3 will move.

Exit criterion: legacy counters visible and enforced in CI; core journeys under Patrol.

Phase 1 — One shared package

Consolidate five packages into two while imports are still mechanical to rewrite.

  1. Create packages/perci_shared (real name replacing the FlutterFlow artefact perci_library_9rk85z) with the target internal layout: core/ (network, auth session, DI), design_system/ (theme, tokens, base components), features/ (shared feature slices), legacy/ (quarantined FlutterFlow imports, to shrink).
  2. Fold ff_theme (one file) and ff_commons (9 files) in; delete those packages. Pure mechanical moves + import rewrites, one PR each.
  3. Move perci-platform-frontend-shared content across by directory, quarantining its flutter_flow/, pages/, custom_code/ copies under legacy/ rather than blessing them.
  4. Immediately delete the tri-plicated FlutterFlow boilerplate: the byte-identical files (animations.dart, flutter_flow_model.dart, flutter_flow_widgets.dart, form_field_controller.dart, internationalization.dart, random_data_util.dart, firebase_config.dart, …) exist in both apps and the shared package — keep one copy in perci_shared/legacy/ and point both apps at it. Cheap, high-visibility duplication win.
  5. Merge perci_platform_test_shared + integration_test_shared into one perci_test_shared dev package.

Exit criterion: packages/ contains exactly perci_shared and perci_test_shared; both apps compile against them; zero byte-identical files across apps.

Phase 2 — Design system and the base screen shell

The chunk that cuts per-screen boilerplate for every later migration.

  1. Theme: re-express ff_theme as a proper ThemeData + ThemeExtension set in perci_shared/design_system/. Keep a FlutterFlowTheme-compatible facade so legacy screens keep compiling; new code uses Theme.of(context)/extensions.
  2. AppScaffold base screen shell (the "base screen class"): one widget that owns app bar / bottom nav / drawer wiring, safe-area and web-inset handling (the PPL-2549 clamp), standard loading / error / empty states driven by an AsyncValue, and analytics screen-view logging. Composition over inheritance: screens wrap themselves in AppScaffold rather than extending a base class — keeps screens testable as plain widgets and avoids a fragile base-class hierarchy.
  3. Component library: move the blessed shared widgets (CustomDropdownField, PlainTextFieldWidget family, buttons, cards) into design_system/, each with a widget test and a previews.dart entry; delete the per-app copies. Add a lint/CI grep banning new hand-rolled DropdownButtonFormField/TextFormField.

Exit criterion: every newly migrated screen uses AppScaffold + design-system components; the two apps' components/ directories only contain app-specific widgets.

Phase 3 — Typed API layer

Kill the 7.8k LOC of hand-rolled api_calls.dart, endpoint by endpoint.

  1. Wire swagger_parser generation into the members app the way clinicians already has it (member BFF spec → lib/backend/openapi/), and add a CI step that regenerates and fails on drift so the client can't go stale.
  2. Move the ApiClient/auth_interceptor core into perci_shared/core/network (one implementation instead of two).
  3. Migrate endpoints in feature-sized bites: each PR moves one feature's calls from api_calls.dart into a RemoteDataSource using the typed client + repository, with unit tests. The Phase 0 counter enforces monotonic shrinkage.
  4. Delete Firestore backend/schema/*_record.dart classes that no live code path reads (several are duplicated across both apps and likely orphaned).

Exit criterion: api_calls.dart deleted in both apps; all data sources use generated clients.

Phase 4 — State: strangle FFAppState

62 files still reach into the global singleton; it is the main testability blocker.

  1. Inventory the FFAppState fields and classify into slices: auth/session, user profile, entitlements, onboarding progress, UI flags.
  2. One slice at a time: introduce a Riverpod provider (in perci_shared if both apps need it, else in the app's feature), migrate readers/writers, delete the field. Session/auth slice first — it unblocks Phase 5 route guards and fixes the class of bugs where login state is derived from Firebase currentUser restoration (PPL-2543).
  3. Consolidate the duplicated auth/custom_auth/ stack into a single perci_shared/features/auth slice (Descope session handling, token refresh, logout) with real unit tests. This is the highest-risk chunk in the plan: do it behind the Patrol safety net from Phase 0, and roll out app-by-app.

Exit criterion: FFAppState class deleted from both apps and the shared package.

Phase 5 — Navigation

Replace the FlutterFlow nav.dart monoliths (75 + 48 routes) with feature-owned typed routing.

  1. Keep go_router (already the underlying router) — this is a restructuring, not a rewrite.
  2. Introduce typed route definitions (route name + params class) grouped per feature, assembled into the app router in main.dart. Migrate route-group by route-group; the old FFRoute wrapper coexists until its last route is gone.
  3. Route guards read the Phase 4 auth provider (redirect logic in one place, unit-testable); preserve deep links and web URLs — add a route-table test that pins every public URL.
  4. Replace context.pushNamed('stringly-typed-name', queryParameters: ...) call sites with the typed API as each screen migrates.
  5. ShellRoute for shared chrome: wrap the logged-in area in ShellRoutes that own the bottom nav / side rail / app bar once, instead of every page rebuilding its own scaffolding — this is the routing-level counterpart of the Phase 2 AppScaffold and removes a whole class of per-page chrome duplication.
  6. Nest the route tree: today nearly all 75 + 48 routes are flat top-level paths (/personalDetails), so navigation-state restoration on web refresh cannot rebuild the parent stack. Restructure into nested routes whose URL encodes the hierarchy (/personalDetails/about-you/your-details/personal-details), and add redirects from every legacy flat path so existing deep links, bookmarks, and emailed URLs keep working — the route-table test from step 3 pins both the new tree and the legacy redirects.

Exit criterion: flutter_flow/nav/ deleted; route tables unit-tested (including legacy-path redirects); shared chrome owned by ShellRoutes; deep links verified by Patrol.

Phase 6 — Screen migration (the long tail, runs continuously)

The bulk of the work: ~90k LOC of legacy screens into features/ slices. This phase starts as soon as Phase 2 lands and runs in parallel with everything else and with normal product work.

  1. Priority order by churn × incident rate: use git log --since=1year --name-only to rank legacy directories; migrate what the team touches most first (likely appointments/, onboarding/, parts of new_a_p_p/), and leave dormant screens for last — some can be deleted instead of migrated.
  2. Chunk = one screen (or one small flow) = one PR = one PPL ticket. Definition of done: feature-slice structure, Riverpod state, AppScaffold, typed client, widget tests, old *_widget.dart/*_model.dart pair deleted, legacy counters down.
  3. Two-app screens: where members and clinicians have variant copies of the same screen, migrate into a shared feature slice with app-specific composition, not two migrations.
  4. new_a_p_p/ (47k LOC) is the largest blob: treat each of its subdirectories (appointment, calendar, members_list, member_details, learn_hub, …) as an independent mini-project.
  5. Boy-scout rule: any product ticket touching a legacy screen migrates it (or its touched part) as part of the ticket, when the screen is small enough.

Exit criterion: main_pages/, onboarding/, appointments/, form/, new_a_p_p/, components/ (legacy parts) empty and deleted.

Phase 7 — Delete the FlutterFlow runtime

Only possible once phases 3–6 have removed the consumers.

  1. As FF model pairs disappear, flutter_flow_model.dart, form_field_controller.dart, app_util.dart (.divide() etc.) lose their call sites — delete file by file.
  2. Fold anything genuinely still needed (a handful of utilities, icons) into perci_shared/core under real names.
  3. Delete custom_code/, actions/, and the perci_shared/legacy/ quarantine.
  4. Final pass: remove the FlutterFlowTheme facade from Phase 2; screens use the design system directly.

Exit criterion: zero references to flutter_flow anywhere; legacy/ deleted; Phase 0 counters all read 0 and the CI check is retired.


Ways of working

  • No refactor branch. Every chunk is a normal PPL-ticketed PR into develop, small enough to review in one sitting. Rulesets/required checks stay as-is.
  • Epic per phase in Jira; the Phase 0 burn-down counters are the progress report — no separate status doc to maintain.
  • Risk ordering inside every phase: mechanical moves first, behavioural changes behind the Patrol safety net, auth and navigation changes rolled out app-by-app (members and clinicians release independently).
  • Capacity model: phases 0–2 are a few weeks of focused work; phases 3–5 each a bounded project; phase 6 is a standing stream (e.g. 1–2 screen migrations per engineer per sprint plus the boy-scout rule) — at that rate the long tail clears in quarters, not weeks, and that is fine because the guardrails guarantee it only shrinks.