agentsclimarketplace

Flutter architecture

Skill Lukk17/agent-standards/.agents/skills/flutter-architecture

Architects a Flutter application using the recommended layered approach (UI, Logic, Data). Use when structuring a new project or refactoring for scalability.From its SKILL.md

Install
npx -y skills add Lukk17/agent-standards --skill flutter-architecture

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

9.2 KB, ~2.0k tokens by cl100k_base, as published. Nobody here has run it

Architecting Flutter Applications


Contents


Core Architectural Principles

Design Flutter applications to scale by strictly adhering to the following principles:

  • Enforce Separation of Concerns: Decouple UI rendering from business logic and data fetching. Organize the codebase into distinct layers (UI, Logic, Data) and further separate by feature within those layers.
  • Maintain a Single Source of Truth (SSOT): Centralize application state and data in the Data layer. Ensure the SSOT is the only component authorized to mutate its respective data.
  • Implement Unidirectional Data Flow (UDF): Flow state downwards from the Data layer to the UI layer. Flow events upwards from the UI layer to the Data layer.
  • Treat UI as a Function of State: Drive the UI entirely via immutable state objects. Rebuild widgets reactively when the underlying state changes.

Rule: Do not use ChangeNotifier for application state. Use Riverpod AsyncNotifier/Notifier providers or BLoC Cubit classes.

Rule: Never model async state with nullable optional fields. Use AsyncValue<T> (Riverpod) or sealed state classes (BLoC):

// BLoC sealed state
sealed class UserState {}
class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState { final User user; UserLoaded(this.user); }
class UserError extends UserState { final String message; UserError(this.message); }

// Riverpod
final userProvider = AsyncNotifierProvider<UserNotifier, User>(UserNotifier.new);
class UserNotifier extends AsyncNotifier<User> {
  @override
  Future<User> build() => ref.watch(userRepositoryProvider).fetchUser();
}
// In widget: ref.watch(userProvider) returns AsyncValue<User>
// Use .when(data: ..., loading: ..., error: ...)

Structuring the Layers

Separate the application into 2 to 3 distinct layers depending on complexity. Restrict communication so that a layer only interacts with the layer directly adjacent to it.

1. UI Layer (Presentation)

  • Views (Widgets): Build reusable, lean widgets. Strip all business and data-fetching logic from the widget tree. Restrict widget logic to UI-specific concerns (e.g., animations, routing, layout constraints).
  • ViewModels / Providers / Cubits: Manage the UI state. Consume domain models from the Data/Logic layers and transform them into presentation-friendly formats. Expose state to the Views and handle user interaction events. Implement as Riverpod AsyncNotifier/Notifier providers or BLoC Cubit classes: not ChangeNotifier.

2. Logic Layer (Domain) - Conditional

  • If the application requires complex client-side business logic: Implement a Logic layer containing Use Cases or Interactors. Use this layer to orchestrate interactions between multiple repositories before passing data to the UI layer.
  • If the application is a standard CRUD app: Omit this layer. Allow ViewModels/providers to interact directly with Repositories.

3. Data Layer (Model)

  • Responsibilities: Act as the SSOT for all application data. Handle business data, external API consumption, event processing, and data synchronization.
  • Components: Divide the Data layer strictly into Repositories and Services.

Implementing the Data Layer

Services

  • Role: Wrap external APIs (HTTP servers, local databases, platform plugins).
  • Implementation: Write Services as stateless Dart classes. Do not store application state here.
  • Mapping: Create exactly one Service class per external data source.

Repositories

  • Role: Act as the SSOT for domain data.
  • Implementation: Consume raw data from Services. Handle caching, offline synchronization, and retry logic.
  • Transformation: Transform raw API/Service data into clean Domain Models formatted for consumption by ViewModels/providers.

Feature Implementation Workflow

Follow this sequential workflow when adding a new feature to the application.

Task Progress:

  • Step 1: Define Domain Models. Create immutable Dart classes representing the core data structures required by the feature.
  • Step 2: Implement Services. Create stateless Service classes to handle raw data fetching (e.g., HTTP GET/POST).
  • Step 3: Implement Repositories. Create Repository classes that consume the Services, handle caching, and return Domain Models.
  • Step 4: Implement ViewModels/Providers. Create Riverpod AsyncNotifier/Notifier providers or BLoC Cubit classes that consume the Repositories. Expose immutable state and define methods (commands) for user actions. Do not use ChangeNotifier.
  • Step 5: Implement Views. Create Flutter Widgets that bind to the provider/Cubit state and trigger methods on user interaction.
  • Step 6: Run Validator. Execute unit tests for Services, Repositories, and ViewModels/providers. Execute widget tests for Views.
    • Feedback Loop: Review test failures -> Fix logic/mocking errors -> Re-run tests until passing.

Examples

Data Layer: Service and Repository

// 1. Service (Stateless API Wrapper)
class UserApiService {
  final HttpClient _client;

  UserApiService(this._client);

  Future<Map<String, dynamic>> fetchUserRaw(String userId) async {
    final response = await _client.get('/users/$userId');
    return response.data;
  }
}

// 2. Domain Model (Immutable, generated by freezed — the demonstrated default)
@freezed
class User with _$User {
  const factory User({
    required String id,
    required String name,
  }) = _User;

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

// 3. Repository (SSOT & Data Transformer)
// The cache snapshot is repository-internal and replaced atomically, never
// exposed mutably. Ad-hoc mutable fields scattered through the data layer are
// the shared-mutable-state anti-pattern: keep any cache private and immutable.
class UserRepository {
  final UserApiService _apiService;
  User? _cachedUser;

  UserRepository(this._apiService);

  Future<User> getUser(String userId) async {
    final cached = _cachedUser;
    if (cached != null && cached.id == userId) {
      return cached;
    }

    final rawData = await _apiService.fetchUserRaw(userId);
    final user = User(id: rawData['id'], name: rawData['name']);

    _cachedUser = user; // Replace the cached snapshot atomically
    return user;
  }
}

UI Layer: Riverpod AsyncNotifier and View

// 4a. Riverpod provider (preferred)
// State is AsyncValue<User> — no nullable fields for loading/error
final userProvider = AsyncNotifierProvider<UserNotifier, User>(UserNotifier.new);

class UserNotifier extends AsyncNotifier<User> {
  @override
  Future<User> build() =>
      ref.watch(userRepositoryProvider).getUser('current');

  Future<void> reload(String userId) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(
      () => ref.read(userRepositoryProvider).getUser(userId),
    );
  }
}

// 5a. View consuming Riverpod provider
class UserProfileView extends ConsumerWidget {
  const UserProfileView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userProvider);
    return userAsync.when(
      loading: () => const CircularProgressIndicator(),
      error: (err, _) => Text('Error: $err'),
      data: (user) => Text('Hello, ${user.name}'),
    );
  }
}

UI Layer: BLoC Cubit and View

// 4b. BLoC Cubit (use for complex event-driven features)
// State is a sealed class — no nullable fields for loading/error
sealed class UserState {}
class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
  final User user;
  UserLoaded(this.user);
}
class UserError extends UserState {
  final String message;
  UserError(this.message);
}

class UserCubit extends Cubit<UserState> {
  UserCubit(this._repository) : super(UserInitial());
  final UserRepository _repository;

  Future<void> loadUser(String userId) async {
    emit(UserLoading());
    try {
      final user = await _repository.getUser(userId);
      emit(UserLoaded(user));
    } catch (e) {
      emit(UserError(e.toString()));
    }
  }
}

// 5b. View consuming BLoC Cubit
class UserProfileView extends StatelessWidget {
  const UserProfileView({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<UserCubit, UserState>(
      builder: (context, state) => switch (state) {
        UserInitial() => const SizedBox.shrink(),
        UserLoading() => const CircularProgressIndicator(),
        UserLoaded(:final user) => Text('Hello, ${user.name}'),
        UserError(:final message) => Text('Error: $message'),
      },
    );
  }
}

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 326,645. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.