Install
openclaw skills install @ahmadhussain0335/mobile-flutterWrites idiomatic modern Dart 3 and Flutter: records, patterns and sealed classes, async and Streams, Isolate.run, small const widgets, keys, context.mounted after await, disposing controllers, Riverpod or Bloc, go_router, Pigeon platform channels, flavors, lints and unit, widget, golden and integration tests. Use when writing, refactoring or reviewing Dart or Flutter code, screens, providers, blocs, routes or tests.
openclaw skills install @ahmadhussain0335/mobile-flutterWrite Dart and Flutter code the way a senior Flutter engineer would: Dart 3 types that make illegal states unrepresentable, widgets that rebuild only what changed, async code that respects the widget lifecycle, and one consistent state management approach per app.
Map<String, dynamic> everywhere, is chains).analysis_options.yaml, flavors, --dart-define-from-file, Pigeon or the test pyramid.Not for: reviewing a whole MR (use mobile-code-review), a security audit (use mobile-security),
or choosing an architecture from scratch (use mobile-mvvm, mobile-mvi).
pubspec.yaml: SDK constraint, state management package, router, codegen (build_runner, freezed).analysis_options.yaml: which lint set is active.const widget classes. Check every await in UI code for a mounted check,
and every controller, subscription and timer for a dispose.dart format ., flutter analyze and flutter test. Fix every warning before handing over.The compiler flags every switch that misses a new subtype. No default, no stale branches.
// BAD: impossible combinations compile (isLoading && error != null)
class DeviceState { bool isLoading = false; Device? device; String? error; }
// GOOD
sealed class DeviceState {}
final class DeviceLoading extends DeviceState {}
final class DeviceReady extends DeviceState { DeviceReady(this.device); final Device device; }
final class DeviceFailed extends DeviceState { DeviceFailed(this.error); final Object error; }
Widget build(BuildContext context) => switch (state) {
DeviceLoading() => const CircularProgressIndicator(),
DeviceReady(:final device) => DeviceCard(device: device),
DeviceFailed(:final error) => ErrorView(error: error),
};
when guards for sub-cases: Connecting(:final attempt) when attempt > 3 => ...._ => to a switch over a sealed type. It hides the next missing case.// BAD: unchecked casts on untrusted JSON, crash on a wrong type
final lat = json['lat'] as double;
final lng = json['lng'] as double;
// GOOD: destructure and validate in one step
({double lat, double lng}) parseLocation(Map<String, Object?> json) {
if (json case {'lat': final double lat, 'lng': final double lng}) {
return (lat: lat, lng: lng);
}
throw const FormatException('Invalid location');
}
final (count, name) = pair();.mainAxisAlignment: .center) and null-aware elements ([a, ?maybeB]) when the
SDK constraint allows them and the codebase already uses them.// BAD: any String goes, device and user IDs are interchangeable
Future<void> connect(String deviceId, String userId);
// GOOD: zero-cost wrappers, mixing them up is a compile error
extension type const DeviceId(String value) {}
extension type const UserId(String value) {}
Future<void> connect(DeviceId device, UserId user);
| Modifier | Use for |
|---|---|
final class | Value and state classes; no subclassing or implementing outside the library |
abstract interface class | Repository and service contracts that are only implemented |
base class | Classes with invariants that subclasses must inherit, never re-implement |
sealed class | Closed hierarchies matched with switch |
mixin class | Rare; only when a type must be both a mixin and a class |
id as String
still works. Validate in a factory if the format matters.// BAD: single-subscription stream exposed to many listeners -> "Stream has already been listened to"
final _controller = StreamController<ScanResult>();
// GOOD: broadcast for fan-out, closed by the owner
final _results = StreamController<ScanResult>.broadcast();
Stream<ScanResult> get results => _results.stream;
Future<void> dispose() => _results.close();
async*).async* generators. Cancelling the subscription stops the generator at the next yield.unawaited(analytics.log(event));.// BAD: 5 MB JSON decoded on the UI isolate, frames drop
final data = jsonDecode(body);
// GOOD
final data = await Isolate.run(() => jsonDecode(body) as Map<String, Object?>);
BuildContext, widgets or large objects; everything captured is copied.Isolate.run is not available on web. Use compute in code that must run on web too.async alone does not move work off the UI thread. Only isolates do.// BAD: rebuilt with the parent every time, no const, no own Element
Widget _buildHeader() => Padding(padding: const EdgeInsets.all(16), child: Text(title));
// GOOD
class DeviceHeader extends StatelessWidget {
const DeviceHeader({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) =>
Padding(padding: const EdgeInsets.all(16), child: Text(title));
}
build is pure and cheap: no futures, no controllers, no I/O.late final _user = repo.fetchUser();), never inside FutureBuilder(future: ...) in build.MediaQuery.sizeOf(context) instead of MediaQuery.of(context).size.// BAD: stateful rows lose or swap state on reorder and delete
ListView.builder(itemBuilder: (context, i) => DeviceTile(device: devices[i]));
// GOOD
ListView.builder(
itemCount: devices.length,
itemBuilder: (context, i) => DeviceTile(key: ValueKey(devices[i].id), device: devices[i]),
);
ValueKey(id) for list items that can reorder, insert or delete. Never ValueKey(index).GlobalKey only for real cross-tree access (Form validation, a Navigator key for go_router).
It is expensive and a widget with a GlobalKey must not appear twice in the tree.build (GlobalKey() or UniqueKey()); the state resets on every rebuild.mounted after every await before using context or state// BAD: the widget may be gone; wrong navigator or a crash
onPressed: () async {
await repo.save(form);
context.pop();
},
// GOOD
onPressed: () async {
await repo.save(form);
if (!context.mounted) return;
context.pop();
},
State, use if (!mounted) return; before setState or context.if (!ref.mounted) return;. In a Cubit, if (isClosed) return;.use_build_context_synchronously enabled; it is in flutter_lints.// BAD: created in build, never disposed, text lost on rebuild
Widget build(BuildContext context) => TextField(controller: TextEditingController());
// GOOD
final _controller = TextEditingController();
late final StreamSubscription<int> _sub;
Timer? _poll;
@override
void initState() {
super.initState();
_sub = widget.battery.listen(_onLevel);
}
@override
void dispose() {
_poll?.cancel();
_sub.cancel();
_controller.dispose();
super.dispose();
}
TextEditingController, ScrollController, PageController, FocusNode, AnimationController,
StreamSubscription, Timer, ChangeNotifiers the State created.late final _sub = stream.listen(...): it subscribes lazily, possibly for the first time in dispose.Riverpod:
// BAD: read in build never rebuilds; watch in a callback rebuilds for nothing
final todos = ref.read(todosProvider);
onPressed: () => ref.watch(todosProvider.notifier).add(todo),
// GOOD
final todos = ref.watch(todosProvider); // build: rebuild on change
onPressed: () => ref.read(todosProvider.notifier).add(todo), // callbacks: one-off read
ref.listen(todosProvider, (prev, next) { /* snackbar, navigation */ });
Bloc:
// BAD: side effect inside builder, runs on every rebuild
BlocBuilder<LoginBloc, LoginState>(builder: (context, state) {
if (state is LoginSuccess) context.go('/home');
return const LoginForm();
})
// GOOD
BlocListener<LoginBloc, LoginState>(
listenWhen: (previous, current) => current is LoginSuccess,
listener: (context, state) => context.go('/home'),
child: const LoginForm(),
)
autoDispose (default with @riverpod codegen). Narrow rebuilds with
ref.watch(provider.select((s) => s.count)).Cubit for simple method-driven state, Bloc when events need transformers (debounce, restartable).// BAD: auth check copied into every screen, the protected screen flashes before it runs
@override
void initState() {
super.initState();
if (!auth.isLoggedIn) WidgetsBinding.instance.addPostFrameCallback((_) => context.go('/login'));
}
// GOOD: one redirect, re-evaluated whenever auth notifies
GoRouter(
refreshListenable: auth,
redirect: (context, state) {
final atLogin = state.matchedLocation == '/login';
if (!auth.isLoggedIn) return atLogin ? null : '/login';
if (atLogin) return '/';
return null;
},
routes: [...],
);
redirect returns null for "stay". Always guard the target route to avoid redirect loops.assetlinks.json, autoVerify) and Universal Links (Associated Domains
entitlement) are platform config. Call out every manifest, Info.plist and entitlement change.go_router_builder are optional; adopt them app-wide or not at all.// BAD: shipped in the binary, readable with `strings`, obfuscation does not hide it
const apiKey = String.fromEnvironment('PAYMENT_SECRET_KEY');
// GOOD: only non-secret config at build time
const apiBaseUrl = String.fromEnvironment('API_BASE_URL');
// flutter run --flavor staging --dart-define-from-file=config/staging.json
--flavor selects the Android product flavor and the Xcode scheme; both must exist natively.String.fromEnvironment only works in a const context. Fail fast if a required value is empty.--obfuscate --split-debug-info=<dir>; keep the symbol files to symbolize crashes.// BAD: stringly typed, untyped Map, typos fail at runtime
final level = await const MethodChannel('device').invokeMethod('getBatery');
// GOOD: pigeons/device_api.dart, generated Dart, Kotlin and Swift stay in sync
@HostApi()
abstract class DeviceApi {
DeviceInfo getDeviceInfo();
@async
bool requestPairing(String deviceId);
}
# BAD: default template, never touched, casts from dynamic unchecked
include: package:flutter_lints/flutter.yaml
# GOOD
include: package:flutter_lints/flutter.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
linter:
rules:
- prefer_const_constructors
- prefer_final_locals
- unawaited_futures
- discarded_futures
- cancel_subscriptions
- close_sinks
- avoid_dynamic_calls
very_good_analysis is a reasonable stricter alternative. Pick one set, do not mix.// ignore: needs a reason comment next to it.package:logging or a logger behind an interface, not print. Never log tokens or device IDs.// BAD: real time, slow and flaky
await Future<void>.delayed(const Duration(seconds: 2));
expect(cubit.state, 2);
// GOOD: virtual time
fakeAsync((async) {
final values = <int>[];
countdown(2).listen(values.add);
async.elapse(const Duration(seconds: 3));
expect(values, [2, 1, 0]);
});
integration_test flows.pumpAndSettle times out on endless animations (a looping progress indicator). Use pump(duration).setState at the top of a screen for a value one small widget shows. Push state down or use a listenable.Widget _buildX() helper methods in place of widget classes.! on values from JSON, platform channels or route parameters.catch (e) {} or catching Error (StateError, TypeError) to keep going; those are bugs.late fields that may never be initialized on some path.dynamic and Map<String, dynamic> leaking past the data layer. Map to typed models at the edge.BuildContext, a widget or a controller.Opacity animated every frame; use FadeTransition or AnimatedOpacity.RepaintBoundary sprinkled everywhere "for performance" without a profile showing it helps.--dart-define, .env assets or source._as or !StreamController closed; broadcast only where there are many listenersIsolate.run or computeconst constructors; build has no side effects or futuresValueKey(id) on reorderable list items; no keys created in buildmounted / context.mounted / ref.mounted / isClosed checked after each awaitref.watch in build, ref.read in callbacks, side effects in ref.listen or BlocListenerflutter analyze clean with strict lints; dart format appliedflutter test passesmobile-code-review -- review a Flutter MR against these rulesmobile-security -- secure storage, certificate pinning, deep link and logging auditsmobile-mvvm, mobile-mvi -- architecture behind Riverpod notifiers and Blocsmobile-design-patterns -- repository, adapter and strategy patterns in Dartmobile-kotlin, mobile-swift -- native side of Pigeon channels and pluginsmobile-ci-cd -- flavors, signing, obfuscation symbols and golden tests in CImobile-ble -- Bluetooth Low Energy in Flutter and on the native sidemobile-testing -- cross-platform test strategy and goldensmobile-performance -- DevTools profiling, raster and UI thread jank