Back to blog
Technical

Surviving a 7-Phase Package Migration Across Two Flutter Apps

flutterriverpodmigrationdevops

I maintain two Flutter apps. BrainFit is a brain training RPG with a space theme -- 332 files in lib/, about 115,900 lines of Dart. BloomCard is a garden-themed flashcard app -- 329 files in lib/, roughly 36,000 lines. Both apps share the same dependency stack: Riverpod for state management, GoRouter for routing, Drift for the local database, Firebase for analytics, AdMob for monetization, RevenueCat for subscriptions, and Supabase for the backend.

In early March 2026, I looked at the output of flutter pub outdated and realized nearly every dependency in both projects was behind. Some were a patch version behind. Others were two major versions behind. The Riverpod ecosystem alone had jumped from 2.x to 3.x, bringing fundamental changes to how state notifiers work. GoRouter had gone from 15.1 to 17.1. AdMob had jumped from 5.3 to 7.0. Firebase core had moved from 3.13 to 4.5.

I had a choice: upgrade everything at once and spend days debugging a broken build, or plan a phased approach and keep the apps compilable at every step. I chose the latter. This post is about what that looked like in practice.

Why Phased Migration Matters

The temptation with package upgrades is to just bump everything, run flutter pub get, and start fixing whatever breaks. For a small app, this works fine. For two apps with a combined 660+ Dart files and roughly 152,000 lines of code, it is a recipe for disaster.

Here is why. When you upgrade 15 packages simultaneously and the build breaks, you have no idea which upgrade caused the failure. Was it the Riverpod annotation change that conflicts with the new build_runner? Was it the GoRouter API that changed its StatefulShellRoute signature? Was it the Firebase core version that pulled in an incompatible transitive dependency? You end up playing whack-a-mole across your entire codebase.

Phased migration solves this by making each upgrade step atomic. If Phase 3 breaks something, you know exactly which packages changed and can isolate the problem. If Phase 5 causes a test failure, you can diff against Phase 4 and see precisely what moved.

The other advantage is psychological. A big-bang migration feels like a wall. A seven-phase plan feels like a checklist. You can stop after Phase 3, ship a build, and come back to Phase 4 tomorrow. The app works at every checkpoint.

The Strategy: Risk-Ordered Phases

I sorted all outdated packages by two dimensions: risk of breaking changes and dependency coupling. Low-risk, isolated packages go first. High-risk, deeply coupled packages go last. The reasoning is simple: if an easy upgrade breaks something unexpected, you want to catch it before you are also fighting a Riverpod rewrite.

Here is the plan I ended up with for BrainFit. BloomCard followed a similar structure, consolidated into fewer phases because some of the same lessons applied.

Phase 1: Minor and Patch Updates

Packages: drift 2.25 to 2.28, drift_dev 2.25 to 2.28, purchases_flutter 9.12 to 9.13

These were minor version bumps with no API changes. The commit touched exactly two files: pubspec.yaml and pubspec.lock. No Dart code changed. All tests passed on the first try.

This might seem like a waste of a separate phase, but it served an important purpose: it validated that the basic upgrade workflow was working. Flutter's dependency resolver can sometimes fail in surprising ways due to transitive version constraints. Getting the easy ones out of the way first confirmed the toolchain was healthy.

One important note: I wanted to upgrade build_runner, freezed, json_serializable, and mockito in this phase too, but they were blocked. The existing riverpod_generator 2.x had a constraint on build ^2.0.0 that conflicted with newer versions of those packages. I had to defer them to Phase 7 when Riverpod itself would be upgraded. This is a real-world example of why phased planning matters -- you discover dependency conflicts early and can plan around them.

Phase 2: Low-Risk Major Bumps

Packages: connectivity_plus 6.1 to 7.0, device_info_plus 11.3 to 12.3, package_info_plus 8.3 to 9.0, flutter_lints 5.0 to 6.0

These were major version bumps, but for utility packages that rarely introduce breaking API changes. The _plus family of plugins (connectivity, device info, package info) tend to bump their major version for platform-channel restructuring, not for Dart API changes. Sure enough, zero code changes needed.

The only adjustment was in analysis_options.yaml. The new flutter_lints 6.0 introduced an unnecessary_underscores lint rule that flagged the __ pattern commonly used in Flutter callbacks (the "I don't care about this parameter" convention). I disabled that specific lint rather than rewriting dozens of callback signatures across the codebase. Pragmatism over purity.

Phase 3: Medium-Risk Major Bumps

Packages: google_sign_in 6.2 to 7.2, flutter_local_notifications 18.0 to 21.0, fl_chart 0.70 to 1.1

This is where actual code changes started. Three files needed modification:

google_sign_in moved from a constructor-based API (GoogleSignIn()) to a singleton pattern (GoogleSignIn.instance.authenticate()). The accessToken property was removed entirely. In supabase_auth_service.dart, I rewrote the Google sign-in flow to use the new authenticate method. The change was small but conceptually significant -- the plugin no longer gives you raw OAuth tokens.

flutter_local_notifications switched from positional parameters to named parameters across its core methods: initialize(), periodicallyShow(), and cancel(). This was a straightforward find-and-replace in notification_service.dart, but the kind of change that would be annoying to debug if it happened alongside ten other breaking changes.

fl_chart renamed tooltipRoundedRadius to tooltipBorderRadius and changed its type from double to BorderRadius. One line changed in the BQ overview chart widget. Small, but it would have been a confusing compilation error mixed in with bigger problems.

Phase 4: Firebase Ecosystem

Packages: firebase_core 3.13 to 4.5, firebase_analytics 11.6 to 12.1.3

I gave Firebase its own phase because Firebase packages have tight internal version coupling. If you upgrade firebase_core without upgrading firebase_analytics, the version resolver will complain about incompatible platform dependencies. By upgrading both together and nothing else, I isolated any Firebase-specific issues.

As it turned out, this phase required zero code changes. The Dart APIs were fully compatible. The major version bump was driven by native SDK updates under the hood. All 2,490 tests passed without modification.

Phase 5: AdMob 5.3 to 7.0

Packages: google_mobile_ads 5.3 to 7.0

A two-major-version jump for the ads SDK deserved its own phase. BrainFit has four ad-related service files: AdService for banners, InterstitialAdService for interstitials, RewardedAdService for rewarded ads, and ad configuration in AppConfig. BloomCard has similar ad infrastructure.

I was bracing for significant API changes -- new ad format classes, initialization changes, callback signature updates. The google_mobile_ads changelog between 5.x and 7.x listed several breaking changes around ad inspector APIs and mediation adapters.

In practice, none of the changes affected our usage. The core BannerAd, InterstitialAd, and RewardedAd classes maintained backward compatibility for the standard load-and-show patterns we use. The breaking changes were in advanced features (mediation, ad inspector) that we did not use. Zero Dart files changed. All 2,490 tests passed.

This was a pleasant surprise, but I still do not regret giving AdMob its own phase. If there had been breaking changes, debugging ad loading issues alongside GoRouter routing changes would have been miserable. Ads are particularly annoying to test because failures are often runtime-only and device-specific.

Phase 6: GoRouter 15.1 to 17.1

Packages: go_router 15.1 to 17.1

Another two-major-version jump. BrainFit uses GoRouter extensively: 32 routes organized under a StatefulShellRoute with four tabs (Home, Training, Social, Profile). BloomCard has a similar shell-based routing structure.

GoRouter 16.x and 17.x introduced changes to StatefulShellRoute parameters, the navigation observer API, and redirect behavior. I was particularly worried about the StatefulShellRoute changes because BrainFit's entire tab navigation depends on it. The analytics service also uses a GoRouter observer for screen tracking, which is another potential breakage point.

Once again, the actual migration required zero code changes. Our usage of StatefulShellRoute and GoRouterObserver happened to use the subset of the API that remained stable. Two files changed: pubspec.yaml and pubspec.lock. All 2,490 tests passed.

Looking back, this is a pattern worth noting. Many "major version" bumps in the Flutter ecosystem are driven by internal restructuring or by breaking changes in niche APIs. If your usage is conventional, you often get through major bumps unscathed. But you cannot know that until you try, which is exactly why phased migration is valuable. You try each upgrade in isolation and confirm it is safe before moving on.

Phase 7: Riverpod 2.x to 3.x

Packages: flutter_riverpod 2.6 to 3.3, riverpod_annotation 2.6 to 4.0, riverpod_generator 2.6 to 4.0, plus the previously blocked packages: build_runner 2.4 to 2.12, drift 2.28 to 2.31, drift_dev 2.28 to 2.31, freezed 3.0 to 3.2, json_serializable 6.9 to 6.13, json_annotation 4.9 to 4.11, mockito 5.4 to 5.6

This was the big one. I saved Riverpod for last because it touches everything. State management is the nervous system of a Flutter app. Every screen, every provider, every piece of reactive state flows through Riverpod. In BrainFit alone, there are dozens of providers spread across 14 feature modules.

StateNotifier to Notifier Migration

The headline change in Riverpod 3.x is the deprecation of StateNotifier in favor of the new Notifier class. This is not just a rename -- the API surface is fundamentally different:

  • StateNotifier uses a constructor to set initial state and exposes a mutable state property
  • Notifier uses a build() method to compute initial state and integrates more naturally with code generation

In BrainFit, I had 10 classes across 4 files that needed migration: settings providers, senior mode provider, achievement popup provider, and the translations provider. Each one needed its class definition rewritten, its state initialization moved from the constructor to a build() method, and its provider declaration changed from StateNotifierProvider to NotifierProvider.

AsyncValue.valueOrNull to .value

Riverpod 3.x deprecated AsyncValue.valueOrNull in favor of just .value. This sounds trivial, but it affected 43 call sites across 20 files. Every screen that reads an async provider and accesses its value needed updating. Find-and-replace handled most of it, but each change needed a quick visual check to make sure the null-safety semantics were preserved.

Override Type References

The way you declare provider overrides in tests and in main.dart changed. Six test files and main.dart needed their override declarations updated. This was particularly tricky in main.dart where BrainFit uses provider overrides for screenshot mode (forcing Pro subscription active) and BETA_MODE staging builds.

Async Safety: ref.mounted Guards

The new Riverpod is stricter about accessing ref after a provider has been disposed. Several async _load methods in notifiers needed ref.mounted guards added after await points to prevent use-after-dispose errors. This is actually a good change -- it catches real bugs -- but it added friction to the migration.

Code Generation Rebuild

Because build_runner and riverpod_generator both upgraded, I had to regenerate all code-generated files. For Drift alone, this meant regenerating 15 DAO files. The generated code changed not because of schema changes but because the code generator itself had new output patterns.

The Aftermath

Phase 7 touched 49 files in BrainFit. It was by far the largest single commit in the migration. But because Phases 1 through 6 were already stable, I knew that any compilation error or test failure was caused by the Riverpod migration, not by some interaction with the Firebase upgrade or the GoRouter changes. That confidence made debugging dramatically faster.

The BloomCard Side

After completing all seven phases on BrainFit, I applied the same upgrades to BloomCard. Because the two apps share the same stack, I already knew which packages had breaking changes and which were drop-in replacements. BloomCard's migration was consolidated into five phases instead of seven, and the total time was roughly half of what BrainFit took.

BloomCard had its own quirks. The share_plus package migrated from Share.share() to SharePlus.instance.share(ShareParams()), which did not affect BrainFit (it does not use sharing). The purchases_flutter upgrade changed CustomerInfo access patterns to PurchaseResult.customerInfo. The SQLite package renamed db.dispose() to db.close().

After the package upgrades, BloomCard had 78 lint issues flagged by flutter analyze. These broke down into: 12 unused imports (leftover flutter_riverpod duplicates from the legacy import migration), 38 unnecessary_underscores warnings, 15 use_build_context_synchronously issues, and assorted smaller fixes. All were resolved in a dedicated cleanup pass.

flutter analyze: 52 Warnings to Zero

One of the most satisfying moments in this entire process was running flutter analyze on BrainFit after all seven phases were complete. Before the migration, there were 52 warnings and informational messages -- deprecated API usage, lint violations from the old package versions, and various code quality issues that had accumulated over months of feature development.

After the migration and cleanup, the count was zero. A clean analysis output. This was not just cosmetic. Many of those warnings were about deprecated APIs that would eventually be removed. By addressing them during the migration, I eliminated an entire category of future technical debt.

The Safety Net: 3,057 Tests

The single most important factor in the success of this migration was the test suite. BrainFit has 2,273 tests across 234 test files. BloomCard has 784 tests. Combined, that is 3,057 tests covering core business logic, database operations, state management, UI rendering, and end-to-end flows.

After every phase, I ran the full test suite. Not a subset, not just the files I changed -- the entire suite. This caught issues that manual testing would have missed. For example, the Riverpod migration surfaced a timing issue in widget tests where Drift database streams were not being properly cleaned up. The tests started flaking until I added explicit timer cleanup in the test teardown. Without the test suite, this would have shown up as intermittent crashes in production.

The test suite also gave me the confidence to move quickly. When Phase 5 (AdMob 5 to 7) passed all 2,490 tests with zero changes, I knew it was genuinely safe to commit and move on. Without tests, I would have spent hours manually testing ad loading on different devices, different network conditions, different account states.

BETA_MODE: Staging Validation

Before the migration, I had implemented a BETA_MODE flag for staging builds. When enabled, BETA_MODE unlocks Pro features and uses test ad IDs, allowing me to validate the full app experience without a real subscription or real ads. After completing all seven phases, I built a BETA_MODE APK and ran through the critical user journeys: onboarding, gameplay, daily missions, social features, purchases, and ad display.

This staging step caught zero additional issues (the test suite had already caught everything), but it was an essential confidence check before pushing to production. Some things -- like ad SDK initialization timing, in-app purchase flow completion, and Firebase Analytics event delivery -- are hard to fully test in a unit test environment.

Lessons Learned

Order by risk, not by convenience. It is tempting to start with the exciting upgrade (Riverpod 3!) but starting with boring, low-risk packages first catches toolchain issues early and builds momentum.

One concern per phase. Phases 4, 5, and 6 each had a single upgrade: Firebase, AdMob, and GoRouter respectively. This made debugging trivial. When the entire phase is "upgrade one thing," you know exactly what to look at if something breaks.

Dependency conflicts are information. When Phase 1 revealed that build_runner could not upgrade due to riverpod_generator constraints, that was not a setback -- it was valuable planning information. It told me Phase 7 needed to bundle those deferred packages together.

Tests are non-negotiable for large migrations. With 3,057 tests across both apps, every phase ended with a clear pass/fail signal. Without that coverage, a migration this large would have taken weeks of manual regression testing instead of a single focused day.

Major versions do not always mean breaking changes. Four out of seven phases required zero code changes despite involving major version bumps. The Flutter ecosystem tends to be conservative about Dart API breaks, with major versions often driven by platform SDK updates or internal restructuring. But you cannot know this without trying, which is why isolation matters.

Do the hardest app first. BrainFit is larger, more complex, and uses more features of each package. By migrating BrainFit first, I created a roadmap that made BloomCard's migration faster and more predictable.

Clean up after yourself. The migration itself is not done when the tests pass. Running flutter analyze and fixing every warning, removing every deprecated API call, and cleaning up every lint issue is what turns a migration into a genuine improvement. Going from 52 warnings to zero is the kind of quality gain that pays dividends for months.

Final Thoughts

Upgrading the entire dependency stack of two production Flutter apps sounds daunting. And honestly, it is -- if you try to do it all at once. But broken into seven phases, ordered by risk, validated by a strong test suite, and confirmed with staging builds, it becomes a methodical checklist rather than a stressful gamble.

The total migration touched roughly 100 files across both apps, upgraded 25+ packages, migrated core architectural patterns (StateNotifier to Notifier), and left both codebases cleaner than before. It took about a day of focused work. The phased approach did not just make it manageable -- it made it reliable.

If you are staring at a wall of outdated dependencies in your Flutter project, my advice is simple: do not try to eat the elephant in one bite. Sort by risk, upgrade one concern at a time, run your tests after every step, and keep the app buildable at every checkpoint. Your future self will thank you.

Surviving a 7-Phase Package Migration Across Two Flutter Apps