All skills

Safe Dart and Flutter patterns for null safety, fixed state, async work, widgets, state tools, routes, HTTP, code generation, app layers, and tests.

  • 1 file
  • 7.9 KB
  • Updated 3 weeks ago
  • GitHub

Use this Skill: https://skilld.dev/gh/agenticluke/flutter-safe-patterns-plus/skill

This session only. Nothing lands on disk.

SKILL.md

≈39 tokens always: the name and description. ≈2k when used: this file.

Dart and Flutter Patterns

Original work by ECC. Credit goes to ECC.

When to Use

Use this skill when you:

  • Start a Flutter feature.
  • Write or review Dart code.
  • Pick BLoC, Riverpod, or Provider.
  • Add routes with GoRouter.
  • Call an API with Dio.
  • Store data on the device.
  • Add a WebView.
  • Use Freezed to make state classes.
  • Write unit or widget tests.
  • Split a large app into clear layers.

Core Rules

  1. Keep null safety on.
  2. Avoid !. Check for null first.
  3. Keep state fixed. Make new state instead of changing old state.
  4. Use const when all values are known.
  5. Split large widgets into small widget classes.
  6. Do not use helper methods to build large widget trees.
  7. Check context.mounted after await.
  8. Show loading, success, empty, and error states.
  9. Cancel work when a screen or provider is closed.
  10. Do not put secret keys in app code.
  11. Do not log tokens, passwords, or private user data.
  12. Test each state and each error path.

Pick One State Tool

Use one main state tool in each feature.

  • Use setState for small, local screen state.
  • Use Provider for simple shared state.
  • Use Riverpod for safe shared state and easy test setup.
  • Use Cubit when state changes are simple.
  • Use BLoC when each user action should be a named event.

Do not mix these tools without a clear need.

Null Safety

Prefer checks, ?., ??, and pattern matching.

String displayName(User? user) {
  if (user case User(name: final name) when name.isNotEmpty) {
    return name;
  }
  return 'Guest';
}

Use ! only when the value is proven to exist and the proof is close to that line.

Fixed State

Use sealed classes or Freezed. List all valid states.

sealed class LoadState<T> {
  const LoadState();
}

final class Loading<T> extends LoadState<T> {
  const Loading();
}

final class Success<T> extends LoadState<T> {
  const Success(this.data);

  final T data;
}

final class Empty<T> extends LoadState<T> {
  const Empty();
}

final class Failure<T> extends LoadState<T> {
  const Failure(this.error, [this.stackTrace]);

  final Object error;
  final StackTrace? stackTrace;
}

Do not change a list inside old state. Make a new list.

final nextItems = [...state.items, newItem];
emit(state.copyWith(items: nextItems));

Async Work

Run work at the same time only when the tasks do not depend on each other.

final results = await Future.wait([
  loadUser(),
  loadPosts(),
]);

Run tasks in order when one task needs the result of another.

Check that a widget still exists after await.

Future<void> save(BuildContext context) async {
  await repository.save();

  if (!context.mounted) return;
  Navigator.of(context).pop();
}

Catch errors at the right layer. Keep the first error and stack trace.

try {
  final data = await repository.load();
  emit(Success(data));
} catch (error, stackTrace) {
  emit(Failure(error, stackTrace));
}

Avoid starting the same request more than once. Disable the button or track the request state.

Widget Design

Make small widget classes. Pass only the data and actions they need.

class UserName extends StatelessWidget {
  const UserName({
    required this.name,
    super.key,
  });

  final String name;

  @override
  Widget build(BuildContext context) {
    return Text(name);
  }
}

Keep rebuilds small. Read state near the widget that needs it. Add keys to list rows when items can move.

Close controllers, streams, and timers in dispose.

Routes and Sign-In

Make route guards react when sign-in state changes. Avoid a loop between the login page and a locked page.

final router = GoRouter(
  refreshListenable: GoRouterRefreshStream(authCubit.stream),
  redirect: (context, state) {
    final signedIn = authCubit.state is AuthSignedIn;
    final onLogin = state.matchedLocation == '/login';

    if (!signedIn && !onLogin) {
      final from = Uri.encodeComponent(state.uri.toString());
      return '/login?from=$from';
    }

    if (signedIn && onLogin) {
      final from = state.uri.queryParameters['from'];
      return from == null ? '/' : Uri.decodeComponent(from);
    }

    return null;
  },
  routes: [
    // Add routes here.
  ],
);

Validate route values before use. Show a safe page when a route is missing or bad.

Dio and Token Refresh

Set time limits. Map network errors to app errors. Retry a request at most once after a token refresh.

Use one shared refresh job. This stops many failed calls from starting many refresh calls.

Do not retry these cases:

  • The request was canceled.
  • The user signed out.
  • The refresh call failed.
  • The request was already retried.
  • The server says the request is not safe to repeat.

Do not log request headers or bodies that may hold private data.

Local Data and WebViews

For local data:

  • Store tokens in secure storage.
  • Use plain storage only for safe settings.
  • Handle old data after an app update.
  • Clear private data at sign-out.

For WebViews:

  • Allow only trusted links.
  • Block unknown URL plans.
  • Check all messages from page code.
  • Do not turn on file access unless it is needed.
  • Send outside links to a safe browser when needed.

Error Handling

Show a kind message to the user. Keep full details for local debug work.

Add top-level error hooks for Flutter and async errors. Do not hide errors with an empty catch.

Use ErrorWidget.builder only as a last safe screen. It does not replace local error states.

Crash report tools must be set up by the app owner. Do not add them, send data, or make outside calls unless the user asks.

Testing

Test:

  • Loading, success, empty, and failure states.
  • Null and bad input.
  • A widget removed during await.
  • Route guard changes.
  • Token refresh success and failure.
  • The one-retry limit.
  • Request cancel steps.
  • Data upgrade steps.

Use fakes when a small fake is easy to read. Use mocks only when call checks matter.

For Riverpod widget tests, replace providers in ProviderScope.

await tester.pumpWidget(
  ProviderScope(
    overrides: [
      userRepositoryProvider.overrideWithValue(FakeUserRepository()),
    ],
    child: const MaterialApp(home: ProfilePage()),
  ),
);

Full Usage Example

Task: Load a profile with Riverpod and show every state.

final profileProvider =
    AsyncNotifierProvider<ProfileNotifier, User>(ProfileNotifier.new);

class ProfileNotifier extends AsyncNotifier<User> {
  @override
  Future<User> build() {
    return ref.read(userRepositoryProvider).loadProfile();
  }

  Future<void> reload() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(
      () => ref.read(userRepositoryProvider).loadProfile(),
    );
  }
}

class ProfilePage extends ConsumerWidget {
  const ProfilePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final profile = ref.watch(profileProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Profile')),
      body: profile.when(
        loading: () => const Center(
          child: CircularProgressIndicator(),
        ),
        error: (error, stackTrace) => Center(
          child: FilledButton(
            onPressed: () {
              ref.read(profileProvider.notifier).reload();
            },
            child: const Text('Try again'),
          ),
        ),
        data: (user) => UserName(name: user.name),
      ),
    );
  }
}

This example keeps state fixed, limits rebuilds, shows errors, and lets the user try again.

Final Check

Before you finish:

  • Run dart format.
  • Run flutter analyze.
  • Run the tests.
  • Check phone and tablet sizes.
  • Check light and dark themes.
  • Check slow network and no network.
  • Check screen reader labels.
  • Check that no secret or private data is logged.

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at 718276e. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 weeks ago.

Activeupdated 3 weeks ago
origin
ECC

README badge

README badge for agenticluke/flutter-safe-patterns-plus