agentleFS
sonhaicodersonhaicoder/haiclaudeskill/skills/flutter-architecture/SKILL.md

flutter-architecture

Skill0 starsChanged 6 months agoMITView on GitHub

Flutter architecture skill — Riverpod + Dio + GoRouter + repository pattern + clean architecture. AUTO-TRIGGER khi: chạm file .dart (NOT mobile-design UI), file có `import 'package:riverpod`, `package:dio`, `package:go_router`, hoặc user nói "viết repository/service/state/notifier/route/interceptor/error handler" + "Flutter". Force apply 7 luật cứng: (1) immutable models with copyWith/Freezed, (2) Riverpod providers (NOT setState global), (3) Dio interceptor for auth+retry+log, (4) GoRouter type-safe + guards, (5) Result type cho error handling (NOT exceptions cross layers), (6) repository pattern (data sources behind interface), (7) feature-based folder structure. Distilled từ Flutter production patterns (lme-ui, lsite-mobile, itg-mobile). KHÔNG dùng cho: UI design (use mobile-design), iOS/Android native, React Native.

9 other projects use a version of this.

Adopted by

Most recently taken up by

Used with

Projects with it also use flutter-performance and dart-concurrency.

Other files people use alongside this one.

Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
  • zakariafSkill

    flutter-performance

    Enforces Flutter runtime performance — const subtrees, minimal rebuild scope via ref.watch(select), lazy ListView/GridView builders and slivers, sized image decode (cacheWidth/ResizeImage), heavy work off the UI isolate via compute/Isolate, surgical RepaintBoundary, dispose everything, and measurement in profile mode on a floor device. Use when optimizing UI, diagnosing jank or dropped frames, tuning long lists or images, reviewing rebuild/repaint scope, or when the task mentions const, select, ListView.builder, cacheWidth, compute, RepaintBoundary, AnimatedBuilder, DevTools, raster thread, or 60/120fps.

    Also in 4 projects that use this

  • thiennc-tesoglobalSkill

    dart-concurrency

    Design and debug Dart asynchronous infrastructure with Future, Stream, isolates, cancellation, and lifecycle-safe ownership. Use for races, stale results, background CPU work, stream leaks, or async error handling; not for routine form-local validation or submission unless its underlying ordering mechanism is the task.

    Also in 3 projects that use this

The file

We split this skill into the rules it gives an agent

1 rule. 0 are common, 0 are distinctive, and 1 is very specific to this project.

  • 0

    Common

    Most of the 8 edited versions we read keep these.

  • 0

    Distinctive

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use ref.watch (rebuild on change) vs ref.read (one-time read)

    line 109

“Common” is measured: kept by at least 6 in 10 of the 8 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Flutter Architecture Skill — Production Patterns

Triết lý: Flutter UI dễ, architecture mới khó. Skill này = state management + data flow + error handling + folder structure mà sẽ scale từ 1 màn → 100 màn không vỡ. Source: distilled từ lme-ui, lsite-mobile, itg-mobile production codebases.


1. AUTO-TRIGGER

DÙNG khi:
  ✓ Chạm file .dart KHÔNG phải UI widget (lib/data/, lib/domain/, lib/presentation/state/)
  ✓ File import: package:flutter_riverpod, package:dio, package:go_router, package:freezed_annotation
  ✓ User nói "viết repository/service/notifier/provider/route/interceptor/use case"
  ✓ User nói "state management/error handling/data layer/auth flow"
  ✓ Project có pubspec.yaml với riverpod/dio/go_router

KHÔNG dùng khi:
  ✗ UI widget styling/layout — dùng mobile-design hoặc lme-flutter
  ✗ iOS/Android native (Swift/Kotlin)
  ✗ React Native

2. BẢY LUẬT CỨNG

LUẬT 1 — Immutable models với Freezed

// ❌ SAI — mutable, hard to reason about
class User {
  String name;
  int age;
  User(this.name, this.age);
}

// ✅ ĐÚNG — Freezed
@freezed
class User with _$User {
  const factory User({
    required String id,
    required String name,
    required int age,
    @Default([]) List<String> roles,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

// Usage
final updated = user.copyWith(name: 'New Name');  // creates new instance

Rules:

  • Mọi data model: Freezed (@freezed) HOẶC manual immutable với copyWith
  • Equality auto (Freezed handles == and hashCode)
  • JSON serialization: json_serializable package
  • KHÔNG mutate model fields trực tiếp

LUẬT 2 — State qua Riverpod, KHÔNG setState global

// ❌ SAI — global state qua singleton
class AuthService {
  static User? currentUser;
}

// ✅ ĐÚNG — Riverpod provider
@riverpod
class AuthNotifier extends _$AuthNotifier {
  @override
  AsyncValue<User?> build() {
    return const AsyncValue.data(null);
  }

  Future<void> login(String email, String password) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      final user = await ref.read(authRepositoryProvider).login(email, password);
      return user;
    });
  }

  void logout() {
    state = const AsyncValue.data(null);
  }
}

// Widget consume
final auth = ref.watch(authNotifierProvider);

Rules:

  • Local widget state: useState / StatefulWidget (OK)
  • Cross-widget state: Riverpod provider
  • Global state: Riverpod provider (KHÔNG singleton, KHÔNG InheritedWidget tự build)
  • Async state: AsyncValue<T> (loading/data/error built-in)
  • Use ref.watch (rebuild on change) vs ref.read (one-time read)
  • Riverpod 2.0+: code generation với @riverpod annotation

LUẬT 3 — Dio interceptor cho auth + retry + log

final dioProvider = Provider<Dio>((ref) {
  final dio = Dio(BaseOptions(
    baseUrl: ref.read(envProvider).apiUrl,
    connectTimeout: const Duration(seconds: 10),
    receiveTimeout: const Duration(seconds: 30),
  ));

  // Auth interceptor — inject JWT
  dio.interceptors.add(InterceptorsWrapper(
    onRequest: (options, handler) async {
      final token = await ref.read(secureStorageProvider).getToken();
      if (token != null) {
        options.headers['Authorization'] = 'Bearer $token';
      }
      handler.next(options);
    },
    onError: (error, handler) async {
      // Handle 401 — refresh token
      if (error.response?.statusCode == 401) {
        final refreshed = await ref.read(authNotifierProvider.notifier).refresh();
        if (refreshed) {
          // Retry original request
          final clone = await dio.fetch(error.requestOptions);
          return handler.resolve(clone);
        } else {
          // Force logout
          ref.read(authNotifierProvider.notifier).logout();
        }
      }
      handler.next(error);
    },
  ));

  // Logging interceptor (debug only)
  if (kDebugMode) {
    dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
  }

  // Retry interceptor (network failures)
  dio.interceptors.add(RetryInterceptor(
    dio: dio,
    retries: 3,
    retryDelays: const [Duration(seconds: 1), Duration(seconds: 2), Duration(seconds: 4)],
  ));

  return dio;
});

Rules:

  • 1 Dio instance, configured qua provider
  • Auth interceptor inject JWT, handle 401 refresh
  • Log interceptor CHỈ trong kDebugMode
  • Retry với exponential backoff cho network errors
  • Timeout config: connect 10s, receive 30s (adjust theo use case)

LUẬT 4 — GoRouter type-safe + guards

@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute extends GoRouteData {
  const HomeRoute();
  @override
  Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}

@TypedGoRoute<OrderDetailRoute>(path: '/orders/:orderId')
class OrderDetailRoute extends GoRouteData {
  const OrderDetailRoute({required this.orderId});
  final String orderId;

  @override
  Widget build(BuildContext context, GoRouterState state) =>
      OrderDetailScreen(orderId: orderId);
}

// Auth guard
final routerProvider = Provider<GoRouter>((ref) {
  final auth = ref.watch(authNotifierProvider);

  return GoRouter(
    initialLocation: '/',
    redirect: (context, state) {
      final loggedIn = auth.value != null;
      final loggingIn = state.matchedLocation == '/login';

      if (!loggedIn && !loggingIn) return '/login';
      if (loggedIn && loggingIn) return '/';
      return null;  // no redirect
    },
    routes: $appRoutes,  // generated from @TypedGoRoute
  );
});

// Navigate type-safely
const OrderDetailRoute(orderId: '123').go(context);
const OrderDetailRoute(orderId: '123').push(context);

Rules:

  • Type-safe routes via go_router_builder codegen (@TypedGoRoute)
  • KHÔNG Navigator.pushNamed với string magic — dùng generated routes
  • Auth guard ở redirect — observe Riverpod auth state
  • Deep link supported automatically với GoRouter

LUẬT 5 — Result type cho error handling

// ❌ SAI — exceptions thrown across layers
Future<User> getUser(String id) async {
  final response = await dio.get('/users/$id');
  if (response.statusCode != 200) throw Exception('Failed');  // bubble up
  return User.fromJson(response.data);
}

// ✅ ĐÚNG — Result type
sealed class Result<T> {
  const Result();
}

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

class Failure<T> extends Result<T> {
  final AppError error;
  const Failure(this.error);
}

// Repository
Future<Result<User>> getUser(String id) async {
  try {
    final response = await dio.get('/users/$id');
    return Success(User.fromJson(response.data));
  } on DioException catch (e) {
    return Failure(_mapDioError(e));
  } catch (e, st) {
    logger.error('Unexpected', error: e, stackTrace: st);
    return Failure(AppError.unknown());
  }
}

// Use case / notifier
final result = await ref.read(userRepoProvider).getUser('123');
switch (result) {
  case Success(:final data):
    state = AsyncValue.data(data);
  case Failure(:final error):
    state = AsyncValue.error(error, StackTrace.current);
}

Rules:

  • Repository return Result<T> HOẶC throw typed exception (AppError)
  • KHÔNG bubble Dio/DB exceptions up to UI
  • AppError sealed class với specific cases (NetworkError, AuthError, ValidationError, ServerError)
  • UI layer pattern match → display correct message

LUẬT 6 — Repository pattern (data sources behind interface)

// Domain layer (no dependencies)
abstract class OrderRepository {
  Future<Result<List<Order>>> getOrders({int page = 1});
  Future<Result<Order>> getOrder(String id);
  Future<Result<Order>> createOrder(CreateOrderRequest request);
}

// Data layer (implementation)
class OrderRepositoryImpl implements OrderRepository {
  final Dio _dio;
  final OrderLocalDataSource _local;
  final OrderRemoteDataSource _remote;

  OrderRepositoryImpl(this._dio, this._local, this._remote);

  @override
  Future<Result<List<Order>>> getOrders({int page = 1}) async {
    // Try cache first
    final cached = await _local.getOrders();
    if (cached.isNotEmpty) {
      return Success(cached);
    }

    // Fetch remote
    final result = await _remote.getOrders(page: page);
    return result.map((orders) {
      _local.cacheOrders(orders);  // side-effect cache
      return orders;
    });
  }

  // ...
}

// Provider
final orderRepoProvider = Provider<OrderRepository>((ref) {
  return OrderRepositoryImpl(
    ref.read(dioProvider),
    ref.read(orderLocalDataSourceProvider),
    ref.read(orderRemoteDataSourceProvider),
  );
});

Rules:

  • Repository = interface in domain layer
  • Implementation in data layer
  • DataSource: chia local (Hive/SharedPrefs) vs remote (Dio)
  • Repository orchestrate: cache strategy, error mapping, transformation
  • Notifier/use case CHỈ depend vào repository interface

LUẬT 7 — Feature-based folder structure

lib/
├── main.dart
├── app.dart                          # MaterialApp.router với GoRouter
├── core/
│   ├── env/                          # Env config provider
│   ├── network/                      # Dio provider, interceptors
│   ├── error/                        # AppError sealed class
│   ├── result/                       # Result<T> type
│   ├── theme/                        # ThemeData
│   ├── router/                       # GoRouter config + guards
│   └── storage/                      # SecureStorage, Hive setup
├── features/
│   ├── auth/
│   │   ├── data/
│   │   │   ├── datasources/         # AuthRemoteDataSource, AuthLocalDataSource
│   │   │   ├── models/              # AuthDto, UserDto (Freezed + JSON)
│   │   │   └── repositories/        # AuthRepositoryImpl
│   │   ├── domain/
│   │   │   ├── entities/            # User entity (pure Dart)
│   │   │   ├── repositories/        # AuthRepository abstract
│   │   │   └── usecases/            # LoginUseCase, LogoutUseCase
│   │   └── presentation/
│   │       ├── providers/           # AuthNotifier (Riverpod)
│   │       ├── screens/             # LoginScreen, RegisterScreen
│   │       └── widgets/             # LoginForm, AuthButton
│   ├── orders/
│   │   ├── data/
│   │   ├── domain/
│   │   └── presentation/
│   └── ...
└── shared/
    ├── widgets/                      # Reusable: AppButton, AppTextField
    ├── extensions/                   # Context extensions, Date extensions
    └── constants/                    # AppColors (or use theme), AppDurations

Rules:

  • Feature-based, KHÔNG layer-based (avoid lib/screens/, lib/services/ flat)
  • Mỗi feature có data/domain/presentation
  • core/ cho cross-cutting concerns (network, theme, error)
  • shared/ cho widgets/utils dùng cross-feature
  • Riverpod providers gần consumer (presentation/providers/) thay vì global

3. PATTERNS BẮT BUỘC

Async UI states

final ordersAsync = ref.watch(ordersNotifierProvider);

return ordersAsync.when(
  loading: () => const Center(child: CircularProgressIndicator()),
  error: (error, stack) => ErrorView(
    error: error,
    onRetry: () => ref.invalidate(ordersNotifierProvider),
  ),
  data: (orders) {
    if (orders.isEmpty) return const EmptyOrdersView();
    return ListView.builder(
      itemCount: orders.length,
      itemBuilder: (_, i) => OrderTile(order: orders[i]),
    );
  },
);

Mọi screen consume async data PHẢI handle 4 states: loading / error / empty / data.

Form validation

final emailFieldProvider = StateProvider.autoDispose<String>((_) => '');

class LoginScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final email = ref.watch(emailFieldProvider);
    final isValid = _validateEmail(email);

    return TextFormField(
      onChanged: (v) => ref.read(emailFieldProvider.notifier).state = v,
      validator: (v) => isValid ? null : 'Email không hợp lệ',
      decoration: const InputDecoration(labelText: 'Email'),
    );
  }
}

Pagination với pull-to-refresh + infinite scroll

@riverpod
class OrdersNotifier extends _$OrdersNotifier {
  int _currentPage = 1;
  bool _hasMore = true;
  final List<Order> _orders = [];

  @override
  Future<List<Order>> build() async {
    return _loadPage(1);
  }

  Future<List<Order>> _loadPage(int page) async {
    final repo = ref.read(orderRepoProvider);
    final result = await repo.getOrders(page: page);
    return switch (result) {
      Success(:final data) => data,
      Failure(:final error) => throw error,
    };
  }

  Future<void> loadMore() async {
    if (!_hasMore) return;
    _currentPage++;
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final newOrders = await _loadPage(_currentPage);
      _hasMore = newOrders.isNotEmpty;
      _orders.addAll(newOrders);
      return _orders;
    });
  }

  Future<void> refresh() async {
    _currentPage = 1;
    _orders.clear();
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => _loadPage(1));
  }
}

Dependency injection hierarchy

// Bottom layer: pure functions / config
final envProvider = Provider<Env>((ref) => Env.fromEnv());

// Network layer
final dioProvider = Provider<Dio>((ref) { ... });

// Storage layer
final secureStorageProvider = Provider<SecureStorage>((ref) { ... });

// Data sources
final orderRemoteDsProvider = Provider<OrderRemoteDataSource>((ref) {
  return OrderRemoteDataSource(ref.read(dioProvider));
});

// Repositories
final orderRepoProvider = Provider<OrderRepository>((ref) {
  return OrderRepositoryImpl(ref.read(orderRemoteDsProvider), ...);
});

// State / Notifiers (consume repos)
final ordersNotifierProvider = AsyncNotifierProvider<...>(...);

Hierarchy: env → network → storage → data sources → repos → notifiers → UI.


4. ERROR HANDLING

sealed class AppError {
  const AppError();
  String get userMessage;
}

class NetworkError extends AppError {
  final String detail;
  const NetworkError(this.detail);
  @override
  String get userMessage => 'Lỗi kết nối. Vui lòng thử lại.';
}

class AuthError extends AppError {
  final String reason;
  const AuthError(this.reason);
  @override
  String get userMessage => 'Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.';
}

class ValidationError extends AppError {
  final Map<String, String> fields;  // field name → error message
  const ValidationError(this.fields);
  @override
  String get userMessage => 'Dữ liệu không hợp lệ.';
}

class ServerError extends AppError {
  final int statusCode;
  final String message;
  const ServerError(this.statusCode, this.message);
  @override
  String get userMessage => message.isNotEmpty ? message : 'Lỗi máy chủ. Vui lòng thử lại sau.';
}

class UnknownError extends AppError {
  const UnknownError();
  @override
  String get userMessage => 'Đã có lỗi xảy ra.';
}

// Mapping from Dio
AppError _mapDioError(DioException e) {
  return switch (e.type) {
    DioExceptionType.connectionTimeout || DioExceptionType.receiveTimeout
      => const NetworkError('Timeout'),
    DioExceptionType.connectionError
      => const NetworkError('No internet'),
    DioExceptionType.badResponse => switch (e.response?.statusCode) {
        401 => AuthError(e.response?.data['detail'] ?? 'Unauthorized'),
        422 => ValidationError(_parseValidationErrors(e.response?.data)),
        >= 500 => ServerError(
            e.response?.statusCode ?? 500,
            e.response?.data['detail'] ?? '',
          ),
        _ => ServerError(
            e.response?.statusCode ?? 0,
            e.response?.data['detail'] ?? '',
          ),
    },
    _ => const UnknownError(),
  };
}

5. TESTING

// Unit test notifier với mocked repo
test('login success updates state', () async {
  final mockRepo = MockAuthRepository();
  when(() => mockRepo.login('test@example.com', 'pass'))
      .thenAnswer((_) async => Success(testUser));

  final container = ProviderContainer(overrides: [
    authRepositoryProvider.overrideWithValue(mockRepo),
  ]);

  final notifier = container.read(authNotifierProvider.notifier);
  await notifier.login('test@example.com', 'pass');

  expect(container.read(authNotifierProvider).valueOrNull, testUser);
});

// Widget test
testWidgets('LoginScreen shows error on auth failure', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        authNotifierProvider.overrideWith(() =>
          FakeAuthNotifier(initialState: const AsyncValue.error(
            AuthError('Wrong password'), StackTrace.empty,
          ))),
      ],
      child: const MaterialApp(home: LoginScreen()),
    ),
  );

  expect(find.text('Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.'), findsOneWidget);
});

6. ANTI-PATTERNS

✗ Sai✓ ĐúngLý do
setState cho global stateRiverpod providerTied to widget lifecycle
Multiple Dio instances1 Dio qua providerHard to add interceptor consistently
Navigator.pushNamed('/route')TypedGoRoute generatedType-safe + refactor-safe
Throw exception across layersResult<T> hoặc AppError typedUI never sees Dio/DB exceptions
Service singletonRiverpod providerTestable + scoped
Mutable model fieldsFreezed + copyWithPredictable state
print() debuglogger với levelFilter / disable production
HTTP call trong widgetNotifier + repositorySeparation of concerns
Same widget loading + data + errorwhen/match 4 statesUI predictable
Fetch trong initStateNotifier auto-buildProvider handles lifecycle
Hardcoded stringsl10n / app_en.arbi18n ready
Magic numbersConstants/ThemeMaintainable
Material Icons mọi nơiCustom icon set / LMEBrand consistency

7. SELF-CHECK TRƯỚC COMMIT

□ flutter analyze pass (no warnings)?
□ Mọi async function return Future<Result<T>> hoặc throw AppError typed?
□ Provider hierarchy đúng (no circular dep)?
□ ref.watch trong build, ref.read trong callback?
□ Dispose properly (autoDispose providers, controller dispose)?
□ Error states handled trong UI (when/match)?
□ Loading state có CircularProgressIndicator hoặc skeleton?
□ Empty state có user guidance?
□ JSON serialization tested (fromJson/toJson roundtrip)?
□ No print() — use logger?
□ No hardcoded strings (use l10n)?
□ No setState cho cross-widget state — use provider?

8. REFERENCE FILES

FileKhi nào đọc
references/riverpod-patterns.mdSpecific Riverpod patterns: family, autoDispose, listen, invalidate
references/dio-recipes.mdDio interceptors: auth refresh, retry, cache, multipart upload

9. STARTER pubspec.yaml DEPS

dependencies:
  flutter:
    sdk: flutter

  # State management
  flutter_riverpod: ^2.5.0
  riverpod_annotation: ^2.5.0

  # Network
  dio: ^5.4.0

  # Routing
  go_router: ^13.0.0

  # Storage
  flutter_secure_storage: ^9.0.0
  shared_preferences: ^2.2.0
  hive: ^2.2.3
  hive_flutter: ^1.1.0

  # Models
  freezed_annotation: ^2.4.0
  json_annotation: ^4.8.0

  # Utils
  logger: ^2.0.0
  intl: ^0.19.0

dev_dependencies:
  build_runner: ^2.4.0
  riverpod_generator: ^2.4.0
  freezed: ^2.4.0
  json_serializable: ^6.7.0
  go_router_builder: ^2.4.0
  mocktail: ^1.0.0
  flutter_test:
    sdk: flutter
# Generate code (Freezed + Riverpod + GoRouter)
flutter pub run build_runner build --delete-conflicting-outputs
# Watch mode
flutter pub run build_runner watch --delete-conflicting-outputs

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.