Blog › ICP guides

Dart developer on retainer: Flutter BLoC, Riverpod, GoRouter, and Dart 3 sealed classes on monthly retainer

September 2, 2026 · ~22 min read

A Flutter e-commerce app had a scroll performance problem. The search results screen ran at 45 frames per second on a mid-range Android device, with visible stuttering during fast scrolls. Flutter DevTools showed the SearchResultsScreen rebuilding 47 times per scroll event — not per frame, per scroll event. The root cause was architectural: the screen’s top-level ConsumerWidget called ref.watch(searchQueryProvider), which updated on every keystroke from the parent search bar. Every keystroke triggered a full subtree rebuild, including each SearchResultCard that had no logical dependency on the query string changing. A Dart developer on monthly retainer identified the dependency chain in the first session using DevTools’ Rebuild Stats checkbox and traced it to three compounding issues: the provider watch at the wrong scope, the absence of const constructors on cards that received only primitive values, and a map thumbnail widget that repainted on every card hover state change because no RepaintBoundary isolated it.

The fix required three coordinated changes across two sessions. First, the SearchResultsScreen was split into a parent widget that watches searchQueryProvider for the search bar only, and a child ConsumerWidget that watches searchResultsProvider via select to receive only the results list updates. Second, SearchResultCard received const constructors since all its fields are primitive strings and integers — Flutter’s const recognition prevents the widget from being included in the rebuild count when its data has not changed between frames. Third, a RepaintBoundary was placed around the map thumbnail inside each card, isolating its repaint from the card’s hover state change. After these three changes, DevTools showed rebuild count per scroll event at 3: one for the search bar, one for the results list container, one for whichever card received updated data. Frame rate on the Samsung Galaxy A52 test device rose from 45 FPS to consistent 60 FPS during fast scroll.

No new feature shipped across those two sessions. The search screen displayed the same results in the same layout. What changed structurally was the widget dependency graph — provider watch scope was narrowed from the screen root to the specific subtree that needed each piece of state, const constructors communicated immutability to the Flutter framework so that it could skip rebuild checks for those widgets entirely, and RepaintBoundary told the compositor that the thumbnail layer was independent of the card’s paint layer. The Dart developer’s retainer invoice logged 11 hours across two sessions: DevTools profiling and ConsumerWidget refactor in session one, const constructor audit and RepaintBoundary placement and final DevTools verification in session two.

The BLoC and Riverpod work that retainers fund

Flutter state management architecture is the category of Dart retainer work that generates the most hours with the least visible output. A BLoC migration from scattered setState calls involves identifying every StatefulWidget that holds network state in instance variables, designing a sealed class hierarchy for each screen’s state (sealed class HomeState { const HomeState(); } final class HomeLoading extends HomeState { const HomeLoading(); } final class HomeLoaded extends HomeState { final List<Item> items; const HomeLoaded(this.items); } final class HomeError extends HomeState { final String message; const HomeError(this.message); }), writing Cubit or Bloc classes that emit these states in response to repository method calls, and wiring BlocProvider at the correct level in the widget tree so that the Bloc’s lifetime matches the screen’s lifetime rather than surviving navigation. The output visible to a client reviewing the pull request is new files and deleted setState calls. The invisible output is the elimination of a class of bugs where two unrelated StatefulWidget instances each make the same network call on initState because neither knows the other exists.

Riverpod 2.x migration from Riverpod 1.x is similarly invisible in its deliverable. The migration involves converting StateNotifier and StateNotifierProvider to Notifier and NotifierProvider for synchronous state, and AsyncNotifier and AsyncNotifierProvider for data-fetching state. Each AsyncNotifier exposes an AsyncValue<T> that the UI handles with when(data: ..., loading: ..., error: ...) — eliminating the manual isLoading boolean, data field, and error string that StateNotifier implementations typically carry as separate fields in a hand-rolled state class. The migration also involves auditing every ref.read and ref.watch call site to ensure that ref.read appears only in callbacks (not in build methods) and that ref.watch appears only in build or AsyncNotifier.build methods. A single misplaced ref.read inside a build method silently produces stale data — the widget does not rebuild when the provider updates because read does not establish a subscription. Catching these through code review is the primary value of a Riverpod advisory retainer.

GoRouter migration from Navigator 1.0 imperative navigation is the third structural category. An app with Navigator 1.0 push calls scattered across 23 widget files has no central description of its navigation graph — routes that require authentication are guarded by conditional logic duplicated in each widget’s onTap, deep link handling is implemented piecemeal per platform (Android intent-filter with getIntent().getData() in the main Activity, iOS application:openURL:options: in AppDelegate), and the back stack is managed imperatively with no way to restore it after a process kill. GoRouter centralizes these concerns: a single GoRouter instance holds the entire route tree, a redirect callback on the root route guards authentication by reading a Riverpod provider, and GoRouter.refresh called on auth state change reruns the redirect logic so that the router navigates to the login screen without the widget layer knowing. The output of the migration session is a router.dart file containing the full route graph and deleted push calls across 23 widget files. The invisible output is that deep links now work correctly and the authentication gate cannot be bypassed by crafting a push call sequence.

Dart 3.0 sealed classes, records, and pattern matching

Dart 3.0 introduced sealed classes, records, and exhaustive switch expressions in a single SDK version. A retainer engagement covering Dart 3.0 adoption typically spans six to fourteen hours spread across three work areas. First, sealed class migration: replacing abstract classes used as discriminated union bases with sealed class declarations so that Dart enforces exhaustive coverage in switch expressions at compile time — a case branch missing a variant becomes a compile error rather than a runtime StateError. Second, record adoption: replacing two-field Map<String, dynamic> return types from repository methods with typed records ((String name, int count)) so that destructuring at the call site uses compile-checked field access (final (name, count) = await repo.getSummary(id);) rather than map key strings. Third, pattern matching: converting if (result is Success) { final data = (result as Success).data; } chains to switch expressions with pattern bindings (switch (result) { case Success(:final data) => data, case Failure(:final message) => throw Exception(message) }) that the compiler verifies are exhaustive. The hours are in reading the existing code, identifying the conversion sites, understanding why certain abstract classes should remain abstract rather than sealed (because external packages extend them), and writing the migration.

Firebase FlutterFire integration is another category where retainer hours accumulate invisibly. FlutterFire wraps Firebase SDKs for Android and iOS and exposes Dart APIs, but platform-specific configuration (google-services.json placement, GoogleService-Info.plist placement, Firebase App Check provider selection, Crashlytics dSYM upload build phase, FCM entitlements for background notifications on iOS) lives outside the Dart layer. A retainer engagement covering FlutterFire typically involves: configuring Firebase App Check with the DeviceCheck provider on iOS and Play Integrity on Android so that Firestore security rules can verify that requests originate from legitimate app instances; wiring Crashlytics with FlutterError.onError and PlatformDispatcher.instance.onError so that both Flutter-layer and platform-layer exceptions appear in the Firebase console with the correct stack trace rather than as generic crashes; and implementing FCM background message handling with FirebaseMessaging.onBackgroundMessage which executes in a separate Dart isolate and therefore cannot access any state initialized in the main isolate’s runApp. Each of these issues is subtle enough that a developer encountering it for the first time spends three to eight hours on platform documentation and StackOverflow before reaching the correct configuration. A retainer developer who has done it before spends forty-five minutes.

Flutter testing: golden tests, integration tests, and patrol

Flutter’s testing infrastructure spans three layers, each with distinct retainer value. Golden tests using matchesGoldenFile catch visual regressions that unit tests and widget tests cannot detect: a theme color changed in a ThemeData constant breaks every screen that uses Theme.of(context).colorScheme.primary, but no unit test notices because no unit test renders a pixel. A retainer engineer who sets up golden tests runs them across light and dark theme variants, different text scale factors (0.8, 1.0, 1.35, 2.0), and multiple device sizes (iPhone SE viewport, Pixel 7 viewport, iPad viewport) to catch regressions in responsive layout calculations. The setup cost is eight to sixteen hours; the ongoing value is catching visual regressions before they reach production.

Integration tests with integration_test verify end-to-end flows on a real device or emulator, but they cannot interact with system UI dialogs: the iOS permission alert, the Android notification permission dialog, the biometric prompt. Patrol extends integration_test with a native automation layer that interacts with system UI using UIAutomator on Android and XCTest on iOS. A retainer engagement covering patrol typically involves writing tests for the three flows most likely to break on OS updates: camera permission grant on first launch, push notification permission on iOS 16+ (which changed the required alert presentation API), and biometric authentication with fallback to PIN. These tests run in CI against an emulator on each pull request, catching OS-specific regressions before they reach the App Store review queue.

How HourTab tracks Dart developer retainer hours

Dart developer retainers present a specific hour-visibility problem: a four-hour DevTools profiling session produces a DevTools screenshot and a commit that adds three const keywords and one RepaintBoundary. The commit diff is four lines. The invisible work is reading rebuild counts across twelve widget classes, understanding which provider watches are responsible for which rebuild chains, and reasoning about the RepaintBoundary placement options and their interaction with the compositor’s layer tree. A time log entry that says “Flutter performance work, 4h” is technically accurate and completely uninformative to a client who does not read widget rebuild graphs.

HourTab gives Dart developers a public retainer-hours URL they paste into the first message of every client Slack thread. The client opens the URL and sees the current burn-down without asking. The more important function for Flutter retainers is the work log: each logged session should name the screen (SearchResultsScreen), the tool used (Flutter DevTools Performance view, Rebuild Stats checkbox), the finding (47 rebuilds per scroll event tracing to ref.watch(searchQueryProvider) at screen root), the fix (ConsumerWidget refactor with select, const constructors on SearchResultCard, RepaintBoundary on map thumbnail), and the before/after metric (47 → 3 rebuilds per scroll, 45 FPS → 60 FPS on Samsung Galaxy A52). That entry is eleven words of advisory category plus a sentence of finding plus a sentence of fix plus a number. It takes three minutes to write after the session and makes the next client check-in a five-second read rather than a twenty-minute explanation of why the rebuild count matters.

The retainer model fits Flutter platform engineering because the work is continuous, not project-shaped. Dart SDK updates introduce new language features that require migration decisions (Dart 3.0 sealed classes, Dart 3.x macros). Flutter SDK updates change rendering behavior (Impeller shader compilation on iOS, Skia Graphite on Android) in ways that require profiling to detect. Firebase SDK updates break Crashlytics dSYM upload build phases. A project contract closes when the feature ships. A retainer stays open for the next SDK update, the next OS version, and the next rebuild count regression that only DevTools can diagnose.

Track Dart developer retainer hours without the status emails

HourTab gives Flutter consultants a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your work log becomes the proof of value that gets the retainer renewed.

See HourTab pricing →

FAQ: Dart developer retainers

What does a Dart developer on retainer typically do?

A Dart developer or Flutter architect on monthly retainer provides ongoing BLoC event/state architecture, Riverpod AsyncNotifier provider graph design, GoRouter declarative navigation, Dart 3.0 sealed class and record migration, platform channel implementation for native features, Flutter DevTools performance profiling, and golden and integration test coverage. The retainer covers the platform engineering between visible feature releases: state management refactors, SDK migration decisions, and performance investigations that produce no new screen but eliminate a class of bugs or improve frame rate.

What Flutter work is most underlogged in a retainer?

Widget rebuild optimization (using DevTools Rebuild Stats to identify over-watching providers, then narrowing with ConsumerWidget select), BLoC migration from setState (designing sealed state hierarchies and wiring BlocProvider at the correct widget tree scope), and GoRouter migration from Navigator 1.0 (centralizing route graph and authentication redirect logic) are the three most systematically underlogged categories. Each produces a small diff and a large behavioral change that only surfaces in DevTools metrics or crash logs rather than in a visible UI feature.

What are typical Dart developer retainer rates?

Entry-level Dart/Flutter developers (1–3 years, StatefulWidget, basic BLoC) bill at $75–$135/hr. Mid-level Flutter engineers (3–8 years, BLoC EventTransformer, Riverpod 2.x AsyncNotifier, GoRouter, DevTools profiling, Dart 3.0) bill at $125–$225/hr. Senior Flutter architects (8+ years, RenderObject protocol, dart:isolate, Dart FFI, custom CustomPainter, Flutter desktop platform channels) bill at $185–$350/hr. Firm rates run $155–$275/hr. Monthly retainer ranges: $4,000–$8,000/mo for advisory (15–30 hrs), $11,000–$22,000/mo for full-engagement (feature development plus architecture plus performance).

What should a Flutter developer retainer agreement include?

A Dart developer retainer agreement should specify platform scope (mobile only, web and desktop, or Dart server-side), state management scope (BLoC, Riverpod, or GetX removal), Dart/Flutter SDK version scope (stable channel maintenance, Impeller renderer migration, Dart 3.x record and sealed class adoption), testing scope (unit tests, golden tests, patrol integration tests), and IP ownership for BLoC hierarchies, Riverpod provider graphs, GoRouter configurations, and platform channel implementations. Hour logging should specify screen name, advisory category, DevTools tool used, finding, fix, and before/after metric.

How should Flutter developer retainer hours be logged?

Log each Flutter retainer session with: advisory category (BLoC migration, Riverpod refactor, GoRouter migration, DevTools profiling, golden test authoring, platform channel implementation, Dart null safety migration, sealed class adoption, FlutterFire configuration, patrol test authoring), screen or feature name, problem identified (with DevTools metric or crash log reference), fix applied (with specific API names: ConsumerWidget select, RepaintBoundary, const constructor, BlocProvider.value, AsyncNotifier.build), and before/after metric (rebuild count, frame rate, crash-free rate, test coverage percentage). Include the specific Android or iOS device model used for performance measurements.