7 Flutter App Patterns Worth Copying Into Your Next App

What a survey of my Riverpod + go_router app says to keep, and what to fix before you paste it
My test harness has a doc comment that says (in Korean) it initializes ProviderScope, ScreenUtilInit and EasyLocalization.
It does not wrap anything in EasyLocalization.
Screen tests that use it had been running without translations, and the helper's own comment said otherwise.
On 2026-10-03 I had an agent run a read-only survey of my pet-personality app, CAMI, to write down which patterns are worth carrying into a new Flutter app.
It read lib/, test/helpers/, the configs and CI.
It did not run builds or tests, so a few findings were re-read before they went into my notes.
The survey listed the harness as worth reusing and flagged the missing localization wrapper as its gap; the doc comment is what I saw when I re-read the file on 2026-10-12.
(The helper I'd copy first was also the one quietly lying about itself. Classic.)
So this list comes with receipts. Seven patterns, each with what the survey found and what I'd change when copying it. Every file quoted here was re-read on 2026-10-12.
1. One test harness per entry point
Screen tests in the app go through one of two WidgetTester extensions:
Future<void> pumpApp(
Widget home, {
List<Override> overrides = const [],
}) {
return pumpWidget(
_boilerplate(overrides, home: home),
);
}
pumpRouter(routerConfig:, overrides:) is the same thing for a GoRouter.
The shared _boilerplate builds the ProviderScope, a fixed ScreenUtilInit design size of 360 × 800, the theme and the MaterialApp.
A screen test then only states its providers, using fakes whose build() returns a value passed to the constructor:
class FakeAuthManager extends AuthManager {
FakeAuthManager(this._state);
final OAuthUser? _state;
@override
OAuthUser? build() => _state;
}
They go in with overrideWith, either as authManagerProvider.overrideWith(() => FakeAuthManager(null)) or as editEnvProvider.overrideWith(FakeEditEnv.new).
The survey counted 698 override uses under test/features on 2026-10-03.
This is where the lying doc comment from the top of the post lives.
The harness reads EasyLocalization.of(context) and falls back to Locale('ko') when there isn't one, and there never is one.
So the tests assert the source-language keys, which are Korean strings in this app.
A pet tab test expects '양육 환경 정보가 먼저 필요해요.', and it would pass whether or not that key exists in the translation files, because nothing is translating.
When I copy it, the harness gets wrapped in the localization widget too, so a test can actually catch a missing translation.
2. Platform-interface fakes instead of MethodChannel mocks
Plugins like url_launcher expose a *Platform interface with a settable instance.
The app implements that interface directly:
class FakeUrlLauncher extends Mock with MockPlatformInterfaceMixin implements UrlLauncherPlatform {
bool forceThrowException = false;
bool canLaunchResult = true;
bool launchUrlResult = true;
Uri? launchedUri;
A test then assigns UrlLauncherPlatform.instance = fakeLauncher; in setUp and flips fields per case.
In the email repository test, success, a false result and a thrown exception are three short tests:
test('returns false when launchUrl throws an exception', () async {
fakeLauncher.forceThrowException = true;
fakeLauncher.launchUrlResult = false;
final result = await repository.sendEmail(email: 'user@example.com', subject: 'Subject');
expect(result, isFalse);
expect(fakeLauncher.launchedUri, isNull);
});
This was the one pattern the survey marked as verified, because I had re-read FakeUrlLauncher while fixing that email test.
No MethodChannel mocks, the failure path is one boolean away, and I'd copy it as it is.
3. Inject the clock and the timer
lib/core/utils/time_injectables.dart defines two tiny interfaces, Scheduler and Clock:
abstract class Clock {
/// 현재 날짜와 시간을 반환합니다.
///
/// Returns: 현재 [DateTime] 인스턴스
DateTime now();
}
Constructors take them as optional arguments and default to the real ones:
Hs256JwtUtils({Clock? clock}) : _clock = clock ?? SystemClock();
The same shape shows up in the token manager, the circuit breaker, the logging strategy and an uploaded-keys cache.
On the test side, test/helpers/test_with_fake_time.dart wraps fake_async, so retry and polling logic run in virtual time.
Its elapse() even refuses to run outside a fake-time test, with a StateError that tells you which wrapper to use.
Production callers never notice the parameter, and tests never wait on a real timer. Like the platform fakes, it's one I'd copy without changes.
4. Guard mixins on typed routes
With TypedGoRoute and GoRouteData, an access rule can live on the route declaration itself:
mixin RequireAuth on GoRouteData {
@override
FutureOr<String?> redirect(BuildContext context, GoRouterState state) {
return context.shouldBeSignedIn(state);
}
}
A route that needs a signed-in user adds the mixin next to its generated one:
class MainRoute extends GoRouteData with $MainRoute, RequireAuth {
Next to the router sits lib/router/routes_access_matrix.csv, a table of routes with their route class and entry condition ("로그인 필요", sign-in required).
Two things undercut it, though.
First, nothing tests that table.
On 2026-10-12, the only other mention of it I could find in lib/, test/, docs/, the README and AGENTS.md was docs/onboarding/CONTRACTS.md.
It's documentation that can drift from the mixins without anything turning red.
Second, lib/router/typed_router.dart builds its GoRouter without a refreshListenable.
The guards ref.read the auth state, so logging out does not re-run the guard for the page you're already on.
A test that asserts the table against the route classes would close the first gap, and a refreshListenable tied to auth state would close the second.
5. Page-view analytics that ignore modals
Dialogs and sheets in the app are shown through mixins that give each overlay a route name:
String get overlayRouteName => 'dialog:$runtimeType';
(sheet:$runtimeType for bottom sheets.)
A custom NavigatorObserver then decides what counts as a page view.
Its doc comment is the whole spec:
/// - Logs only "page -> page" transitions (PageRoute -> PageRoute)
/// - Ignores overlay noise (PopupRoute, e.g. dialogs/bottom sheets) for pageView
/// - Still supports unnamed routes by generating a fallback identifier
/// - When a page transition is observed with an overlay as `from`, it uses the
/// last known page as the effective source (e.g. /main → modal → /chat logs /main → /chat)
By design, that bridge keeps /main → modal → /chat from recording the modal as the page the user came from.
Without it, your funnels would grow a "dialog" step that no product manager asked for.
The catch is that this app also registers the vendor's FirebaseAnalyticsObserver, right next to the custom one.
I haven't measured whether the two log the same screens, and that is the first thing I'd check before copying the pattern.
Two observers that both "log page views" are a double count.
6. One entrypoint, flavor from the build
The app has one main.dart and reads the flavor from a --dart-define:
static Flavor get flavor {
if (kIsWeb) {
return flavorKeys[appFlavor] ?? Flavor.development;
} else {
if (forceThrowsException) {
return flavorKeys[appFlavor] ?? (throw UnsupportedFlavorException(appFlavor));
} else {
return flavorKeys[appFlavor] ?? Flavor.development;
}
}
}
appFlavor comes from String.fromEnvironment('FLUTTER_APP_FLAVOR') and maps to an enum.
Except that forceThrowsException is @visibleForTesting and defaults to false.
So in a real build, a typo in the flavor name doesn't fail.
It silently becomes a development build.
Keep the mapping if you copy this, but make release builds throw on an unknown flavor. In this app the development flavor uses the development Firebase project, and I'd rather a release build crash on launch than quietly report there.
7. A strict analyzer, with one exception I'd undo
analysis_options.yaml keeps the full lint list with commented opt-outs, turns on strict-casts, strict-inference and strict-raw-types, and raises the unused_* family (unused_element, unused_import, unused_result and friends) to errors.
That's the part to copy.
The exception is lines 71-72:
# Some packages (e.g., go_router) require using context in routing.
use_build_context_synchronously: false
The rule's own summary on dart.dev (read 2026-10-12) is "Do not use BuildContext across asynchronous gaps."
Turning it off for the whole app to make routing calls quiet also turns it off for every other await.
I'd turn it back on and silence the routing cases one line at a time, instead of muting the rule.
Lessons from going back through my own app
The best patterns are the boring ones. Platform fakes and an injected clock have no clever parts, and the survey found nothing to fix in either.
The gaps are where two pieces were meant to meet. The harness and localization, the guards and logout, the route table and its tests, the flavor map and release builds. Each piece works alone, and the bug lives in the join.
Doc comments and tables drift.
Two of the gaps above are documentation that nothing checks: a doc comment that says EasyLocalization, and a CSV that says who can open which route.
If you're starting a Flutter app this month, the first three patterns are the ones I'd set up on day one, before the first screen test. And if you have a pattern you copy into every project, I'd like to see it in the comments. I'm always looking for an eighth.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.