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 offlutter_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.dartetc.) andapi_calls.dartare near-duplicates. Much offlutter_flow/is tri-plicated (both apps + shared package). - API:
swagger_parseris wired in the clinicians app (generates a typed client into the gitignoredlib/backend/openapi/from the BFF OpenAPI spec, consumed viaTypedBffClinicalClient). The hand-rolledapi_calls.dartfiles 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_commonsandperci-platform-frontend-sharedmerged) 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. NoFFAppState, noapi_calls.dart, noflutter_flow/runtime, nocustom_code/. - All backend access through the generated typed OpenAPI clients behind feature repositories.
- Typed
go_routerrouting owned per feature, guarded by an auth provider. - A shared
AppScaffoldpage 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.
- Ratify this roadmap — land this doc, fix the stale
FRONTEND_REFACTORING_GUIDE.mdlink in NEW_ARCHITECTURE_GUIDE.md. - "No new legacy" CI check (shipped — PPL-2958) —
scripts/frontend/legacy-guard.tscounts, per app: files referencingFFAppState(, endpoints inapi_calls.dart, routes influtter_flow/nav/nav.dart,createModel(call sites, and LOC under legacy directories, and compares them againstscripts/frontend/legacy-baseline.json. Thelegacy-guardjob influtter-pr-checks.ymlfails 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 withpnpm run legacy-guard -- --writeand commit it in the same PR. - Coverage ratchet — set
FLUTTER_MIN_COVERAGEto the current floor and bump it as migrations land (each migrated screen ships with widget tests). - 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.
- 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.
- Create
packages/perci_shared(real name replacing the FlutterFlow artefactperci_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). - Fold
ff_theme(one file) andff_commons(9 files) in; delete those packages. Pure mechanical moves + import rewrites, one PR each. - Move
perci-platform-frontend-sharedcontent across by directory, quarantining itsflutter_flow/,pages/,custom_code/copies underlegacy/rather than blessing them. - 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 inperci_shared/legacy/and point both apps at it. Cheap, high-visibility duplication win. - Merge
perci_platform_test_shared+integration_test_sharedinto oneperci_test_shareddev 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.
- Theme: re-express
ff_themeas a properThemeData+ThemeExtensionset inperci_shared/design_system/. Keep aFlutterFlowTheme-compatible facade so legacy screens keep compiling; new code usesTheme.of(context)/extensions. AppScaffoldbase 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 anAsyncValue, and analytics screen-view logging. Composition over inheritance: screens wrap themselves inAppScaffoldrather than extending a base class — keeps screens testable as plain widgets and avoids a fragile base-class hierarchy.- Component library: move the blessed shared widgets (
CustomDropdownField,PlainTextFieldWidgetfamily, buttons, cards) intodesign_system/, each with a widget test and apreviews.dartentry; delete the per-app copies. Add a lint/CI grep banning new hand-rolledDropdownButtonFormField/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.
- Wire
swagger_parsergeneration 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. - Move the
ApiClient/auth_interceptorcore intoperci_shared/core/network(one implementation instead of two). - Migrate endpoints in feature-sized bites: each PR moves one feature's calls from
api_calls.dartinto aRemoteDataSourceusing the typed client + repository, with unit tests. The Phase 0 counter enforces monotonic shrinkage. - Delete Firestore
backend/schema/*_record.dartclasses 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.
- Inventory the
FFAppStatefields and classify into slices: auth/session, user profile, entitlements, onboarding progress, UI flags. - One slice at a time: introduce a Riverpod provider (in
perci_sharedif 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 FirebasecurrentUserrestoration (PPL-2543). - Consolidate the duplicated
auth/custom_auth/stack into a singleperci_shared/features/authslice (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.
- Keep
go_router(already the underlying router) — this is a restructuring, not a rewrite. - 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 oldFFRoutewrapper coexists until its last route is gone. - 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.
- Replace
context.pushNamed('stringly-typed-name', queryParameters: ...)call sites with the typed API as each screen migrates. ShellRoutefor shared chrome: wrap the logged-in area inShellRoutes 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 2AppScaffoldand removes a whole class of per-page chrome duplication.- 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 addredirects 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.
- Priority order by churn × incident rate: use
git log --since=1year --name-onlyto rank legacy directories; migrate what the team touches most first (likelyappointments/,onboarding/, parts ofnew_a_p_p/), and leave dormant screens for last — some can be deleted instead of migrated. - 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.dartpair deleted, legacy counters down. - 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.
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.- 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.
- 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. - Fold anything genuinely still needed (a handful of utilities, icons) into
perci_shared/coreunder real names. - Delete
custom_code/,actions/, and theperci_shared/legacy/quarantine. - Final pass: remove the
FlutterFlowThemefacade 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.