agentsclimarketplace

Flutter development

Skill JaveedIshaq/ai-workflow-orchestrator/skills/ballee/flutter-development

Comprehensive Flutter development patterns for Ballee mobile app. Covers architecture, Riverpod 3.x state management, Supabase integration, error handling, performance optimization, and navigation.From its SKILL.md

Install
npx -y skills add JaveedIshaq/ai-workflow-orchestrator --skill flutter-development

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

  • 1 stars1 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

24.6 KB, as published. Nobody here has run it

Flutter Development Patterns

Comprehensive guide for building features in the Ballee mobile app.

Table of Contents

  1. Architecture Overview
  2. Module Structure
  3. Riverpod 3.x State Management
  4. Supabase Integration
  5. Error Handling
  6. Performance Optimization
  7. Navigation with GoRouter
  8. Code Generation

Architecture Overview

Ballee mobile uses a 3-layer architecture based on ApparenceKit:

┌─────────────────────────────────────────────────────────┐
│                   PRESENTATION LAYER                     │
│  Pages (UI) ←→ Providers/Notifiers ←→ Page State Models │
├─────────────────────────────────────────────────────────┤
│                     DOMAIN LAYER                         │
│         Repositories ←→ Domain Models (Freezed)          │
├─────────────────────────────────────────────────────────┤
│                      DATA LAYER                          │
│              APIs ←→ Entities (JSON serialization)       │
└─────────────────────────────────────────────────────────┘

Layer Responsibilities

LayerPurposeKey Classes
Data (API)Fetch data, handle serialization*Api, *Entity
DomainBusiness logic, transform entities to models*Repository, domain models
PresentationUI state, user interactions*Notifier, *PageState, Pages

Module Structure

Folder Layout

apps/mobile/lib/
├── core/                          # Shared utilities
│   ├── data/api/                  # Shared API clients, error types
│   ├── guards/                    # Route guards
│   ├── states/                    # Global state (user, auth)
│   ├── theme/                     # App theming
│   └── widgets/                   # Shared widgets
└── modules/                       # Feature modules
    └── <module_name>/
        ├── api/
        │   ├── <module>_api.dart
        │   └── entities/
        │       └── <entity>_entity.dart
        ├── domain/
        │   └── <model>.dart
        ├── repositories/
        │   └── <module>_repository.dart
        ├── providers/
        │   ├── <module>_notifier.dart
        │   └── models/
        │       └── <module>_page_state.dart
        └── ui/
            ├── <module>_page.dart
            ├── components/        # Smart widgets (use providers)
            └── widgets/           # Dumb widgets (pure Flutter)

Creating a New Module

# Example: Creating a "payments" module
mkdir -p lib/modules/payments/{api/entities,domain,repositories,providers/models,ui/{components,widgets}}

Riverpod 3.x State Management

Key Changes from 2.x

Riverpod 3.0 introduces breaking changes. See migration guide.

// OLD (Riverpod 2.x) - Don't use
@riverpod
EventsApi eventsApi(EventsApiRef ref) { ... }

// NEW (Riverpod 3.0) - Use this
@riverpod
EventsApi eventsApi(Ref ref) { ... }  // Unified Ref, no type parameter

Dependencies

# pubspec.yaml
dependencies:
  flutter_riverpod: ^2.6.0
  riverpod_annotation: ^2.6.0

dev_dependencies:
  riverpod_generator: ^2.6.0
  build_runner: ^2.4.0

Provider Types

1. Simple Provider (Dependency Injection)

// api/events_api.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'events_api.g.dart';

@riverpod
EventsApi eventsApi(Ref ref) {
  return EventsApi(client: Supabase.instance.client);
}

2. AsyncNotifier (Page State)

// providers/events_list_notifier.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'events_list_notifier.g.dart';

@riverpod
class EventsListNotifier extends _$EventsListNotifier {
  @override
  Future<EventsPageState> build() async {
    final repository = ref.read(eventsRepositoryProvider);
    final events = await repository.getOpenEvents();
    return EventsPageState(events: events);
  }

  /// Sync mutation
  void setFilter(EventFilter filter) {
    final current = state.valueOrNull;
    if (current == null) return;
    state = AsyncData(current.copyWith(filter: filter));
  }

  /// Async mutation with optimistic update
  Future<void> expressInterest(String eventId, InterestLevel level) async {
    // Optimistic update
    state = state.whenData((data) => data.copyWith(
      events: data.events.map((e) =>
        e.event.id == eventId ? e.copyWith(interestLevel: level) : e
      ).toList(),
    ));

    try {
      await ref.read(eventsRepositoryProvider).updateParticipation(eventId, level);
    } catch (e) {
      ref.invalidateSelf(); // Revert on error
      rethrow;
    }
  }

  /// Pull to refresh
  Future<void> refresh() async {
    ref.invalidateSelf();
    await future;
  }
}

3. Family Provider (Parameterized)

@riverpod
class EventDetailNotifier extends _$EventDetailNotifier {
  @override
  Future<EventDetail> build(String eventId) async {
    final repository = ref.read(eventsRepositoryProvider);
    final event = await repository.getById(eventId);
    if (event == null) throw NotFoundException('Event not found');
    return event;
  }
}

// Usage
ref.watch(eventDetailNotifierProvider('event-123'));

4. KeepAlive Provider (Global State)

@Riverpod(keepAlive: true)
class UserStateNotifier extends _$UserStateNotifier {
  @override
  Future<UserState> build() async {
    // Persists across navigation
    final auth = ref.watch(authStateNotifierProvider);
    // ...
  }
}

Page State Models (Freezed)

// providers/models/events_page_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'events_page_state.freezed.dart';

@freezed
class EventsPageState with _$EventsPageState {
  const factory EventsPageState({
    required List<EventWithParticipation> events,
    @Default(EventFilter.all) EventFilter filter,
    @Default('') String searchQuery,
    @Default(false) bool isRefreshing,
  }) = _EventsPageState;

  const EventsPageState._();

  /// Computed property
  List<EventWithParticipation> get filteredEvents {
    var result = events;
    if (filter == EventFilter.interested) {
      result = result.where((e) => e.isInterested).toList();
    }
    if (searchQuery.isNotEmpty) {
      result = result.where((e) =>
        e.event.title.toLowerCase().contains(searchQuery.toLowerCase())
      ).toList();
    }
    return result;
  }
}

enum EventFilter { all, interested, notResponded }

UI Consumption Patterns

class EventsListPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(eventsListNotifierProvider);

    return Scaffold(
      body: state.when(
        data: (data) => _EventsList(events: data.filteredEvents),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, _) => _ErrorWidget(
          error: error,
          onRetry: () => ref.invalidate(eventsListNotifierProvider),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // READ in callbacks, WATCH in build
          ref.read(eventsListNotifierProvider.notifier).refresh();
        },
        child: const Icon(Icons.refresh),
      ),
    );
  }
}

Listen for Side Effects

ref.listen(assignmentActionProvider, (previous, next) {
  next.whenOrNull(
    data: (_) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Success!')),
      );
      context.pop();
    },
    error: (error, _) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Error: $error')),
      );
    },
  );
});

Supabase Integration

Initialization

// main.dart
await Supabase.initialize(
  url: 'https://your-project.supabase.co',
  anonKey: 'your-anon-key',
  authOptions: const FlutterAuthClientOptions(
    authFlowType: AuthFlowType.pkce,
  ),
);

Entity Definition

// api/entities/event_entity.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'event_entity.freezed.dart';
part 'event_entity.g.dart';

@freezed
class EventEntity with _$EventEntity {
  const factory EventEntity({
    required String id,
    required String title,
    @JsonKey(name: 'start_date_time') required DateTime startDateTime,
    @JsonKey(name: 'end_date_time') DateTime? endDateTime,
    @JsonKey(name: 'production_id') String? productionId,
    String? status,
    // Nested relations
    ProductionEntity? productions,
    VenueEntity? venues,
  }) = _EventEntity;

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

API Class

// api/events_api.dart
class EventsApi {
  final SupabaseClient _client;

  EventsApi({required SupabaseClient client}) : _client = client;

  String get _userId => _client.auth.currentUser!.id;

  /// Fetch with joins
  Future<List<EventEntity>> getOpenEvents() async {
    final response = await _client
        .from('events')
        .select('''
          id, title, start_date_time, end_date_time, status,
          productions(id, name, choreographer),
          venues(id, name, city, country)
        ''')
        .eq('status', 'active')
        .gte('start_date_time', DateTime.now().toIso8601String())
        .order('start_date_time', ascending: true);

    return response.map((json) => EventEntity.fromJson(json)).toList();
  }

  /// Single record
  Future<EventEntity?> getById(String id) async {
    final response = await _client
        .from('events')
        .select('*, productions(*), venues(*)')
        .eq('id', id)
        .maybeSingle();

    if (response == null) return null;
    return EventEntity.fromJson(response);
  }

  /// Insert/Update
  Future<void> upsertParticipation({
    required String eventId,
    required String interestLevel,
  }) async {
    await _client.from('event_participants').upsert({
      'event_id': eventId,
      'performer_id': _userId,
      'interest_level': interestLevel,
      'response_date': DateTime.now().toIso8601String(),
    }, onConflict: 'event_id,performer_id');
  }

  /// RPC function
  Future<void> acceptAssignment(String assignmentId) async {
    await _client.rpc('respond_to_casting_invitation', params: {
      'p_assignment_id': assignmentId,
      'p_response': 'accepted',
    });
  }
}

Repository (Entity → Domain)

// repositories/events_repository.dart
@riverpod
EventsRepository eventsRepository(Ref ref) {
  return EventsRepository(api: ref.read(eventsApiProvider));
}

class EventsRepository {
  final EventsApi _api;

  EventsRepository({required EventsApi api}) : _api = api;

  Future<List<Event>> getOpenEvents() async {
    final entities = await _api.getOpenEvents();
    return entities.map(Event.fromEntity).toList();
  }

  Future<Event?> getById(String id) async {
    final entity = await _api.getById(id);
    if (entity == null) return null;
    return Event.fromEntity(entity);
  }
}

Domain Model with Factory

// domain/event.dart
@freezed
class Event with _$Event {
  const factory Event({
    required String id,
    required String title,
    required DateTime startDateTime,
    DateTime? endDateTime,
    String? productionName,
    String? venueName,
    String? city,
    required EventStatus status,
  }) = _Event;

  const Event._();

  bool get isUpcoming => startDateTime.isAfter(DateTime.now());

  factory Event.fromEntity(EventEntity entity) {
    return Event(
      id: entity.id,
      title: entity.title,
      startDateTime: entity.startDateTime,
      endDateTime: entity.endDateTime,
      productionName: entity.productions?.name,
      venueName: entity.venues?.name,
      city: entity.venues?.city,
      status: EventStatus.fromString(entity.status),
    );
  }
}

Authentication

@Riverpod(keepAlive: true)
class AuthStateNotifier extends _$AuthStateNotifier {
  @override
  Stream<AuthState> build() {
    return Supabase.instance.client.auth.onAuthStateChange;
  }

  Future<AuthResponse> signInWithEmail(String email, String password) async {
    return await Supabase.instance.client.auth.signInWithPassword(
      email: email,
      password: password,
    );
  }

  Future<AuthResponse> signInWithApple() async {
    return await Supabase.instance.client.auth.signInWithOAuth(
      OAuthProvider.apple,
      redirectTo: 'com.ballee.app://login-callback',
    );
  }

  Future<void> signOut() async {
    await Supabase.instance.client.auth.signOut();
  }
}

Storage Uploads

Future<String> uploadProfilePhoto(File file) async {
  final userId = _client.auth.currentUser!.id;
  final extension = file.path.split('.').last;
  final path = '$userId/profile.$extension';

  await _client.storage.from('profile-photos').upload(
    path,
    file,
    fileOptions: const FileOptions(cacheControl: '3600', upsert: true),
  );

  return _client.storage.from('profile-photos').getPublicUrl(path);
}

Realtime Subscriptions

@riverpod
class AssignmentsRealtimeNotifier extends _$AssignmentsRealtimeNotifier {
  RealtimeChannel? _channel;

  @override
  Future<List<Assignment>> build() async {
    final assignments = await ref.read(assignmentsRepositoryProvider).getAll();
    _setupSubscription();
    ref.onDispose(() => _channel?.unsubscribe());
    return assignments;
  }

  void _setupSubscription() {
    final userId = Supabase.instance.client.auth.currentUser?.id;
    if (userId == null) return;

    _channel = Supabase.instance.client.channel('assignments:$userId')
      .onPostgresChanges(
        event: PostgresChangeEvent.all,
        schema: 'public',
        table: 'cast_assignments',
        filter: PostgresChangeFilter(
          type: PostgresChangeFilterType.eq,
          column: 'performer_id',
          value: userId,
        ),
        callback: (_) => ref.invalidateSelf(),
      )
      .subscribe();
  }
}

Error Handling

Result Pattern

Use the Result pattern to force explicit error handling. No exceptions escape to the UI.

// core/data/result.dart
sealed class Result<T> {
  const Result();

  bool get isSuccess => this is Ok<T>;
  bool get isError => this is Err<T>;

  T? get valueOrNull => switch (this) {
    Ok(value: final v) => v,
    Err() => null,
  };

  R when<R>({
    required R Function(T value) ok,
    required R Function(AppException error) err,
  }) {
    return switch (this) {
      Ok(value: final v) => ok(v),
      Err(error: final e) => err(e),
    };
  }
}

final class Ok<T> extends Result<T> {
  final T value;
  const Ok(this.value);
}

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

Exception Types

// core/data/exceptions.dart
sealed class AppException implements Exception {
  String get message;
}

class NetworkException extends AppException {
  @override
  final String message;
  NetworkException([this.message = 'Network error']);
}

class DatabaseException extends AppException {
  @override
  final String message;
  final String? code;
  DatabaseException(this.message, [this.code]);
}

class AuthException extends AppException {
  @override
  final String message;
  AuthException(this.message);
}

class NotFoundException extends AppException {
  @override
  final String message;
  NotFoundException([this.message = 'Not found']);
}

Repository with Result

class EventsRepository {
  final EventsApi _api;

  Future<Result<List<Event>>> getOpenEvents() async {
    try {
      final entities = await _api.getOpenEvents();
      return Ok(entities.map(Event.fromEntity).toList());
    } on PostgrestException catch (e) {
      return Err(DatabaseException(e.message, e.code));
    } on SocketException {
      return Err(NetworkException());
    } catch (e) {
      return Err(DatabaseException(e.toString()));
    }
  }

  Future<Result<Event>> getById(String id) async {
    try {
      final entity = await _api.getById(id);
      if (entity == null) return Err(NotFoundException('Event not found'));
      return Ok(Event.fromEntity(entity));
    } on PostgrestException catch (e) {
      return Err(DatabaseException(e.message));
    }
  }
}

Notifier with Result

@riverpod
class EventsListNotifier extends _$EventsListNotifier {
  @override
  Future<EventsPageState> build() async {
    final result = await ref.read(eventsRepositoryProvider).getOpenEvents();

    return result.when(
      ok: (events) => EventsPageState(events: events),
      err: (error) => throw error, // Let Riverpod handle as AsyncError
    );
  }
}

Performance Optimization

Build Method Best Practices

// BAD: Heavy work in build()
Widget build(BuildContext context) {
  final data = expensiveComputation(); // Called on every rebuild!
  return Text(data);
}

// GOOD: Pre-compute in provider
Widget build(BuildContext context) {
  final data = ref.watch(precomputedDataProvider);
  return Text(data);
}

Use const Constructors

// BAD: Creates new instance every rebuild
return SizedBox(height: 16);

// GOOD: Reuses cached instance
return const SizedBox(height: 16);

// Enable lint rule in analysis_options.yaml:
// prefer_const_constructors: true

Split Widgets to Minimize Rebuilds

// BAD: Entire widget rebuilds when counter changes
class BadExample extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(counterProvider);
    return Column(
      children: [
        const ExpensiveHeader(), // Rebuilds unnecessarily!
        Text('Count: $counter'),
        const ExpensiveFooter(), // Rebuilds unnecessarily!
      ],
    );
  }
}

// GOOD: Only CounterDisplay rebuilds
class GoodExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const ExpensiveHeader(), // Never rebuilds
        const CounterDisplay(),  // Only this rebuilds
        const ExpensiveFooter(), // Never rebuilds
      ],
    );
  }
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(counterProvider);
    return Text('Count: $counter');
  }
}

Avoid Widget Helper Methods

// BAD: No widget reuse, rebuilds every time
Widget _buildCard(Event event) {
  return Card(child: Text(event.title));
}

// GOOD: Extract to const widget class
class EventCard extends StatelessWidget {
  final Event event;
  const EventCard({required this.event, super.key});

  @override
  Widget build(BuildContext context) {
    return Card(child: Text(event.title));
  }
}

ListView Optimization

// BAD: Builds all items upfront
ListView(
  children: events.map((e) => EventCard(event: e)).toList(),
)

// GOOD: Lazy builds only visible items
ListView.builder(
  itemCount: events.length,
  itemBuilder: (context, index) => EventCard(event: events[index]),
)

// BEST: With keys for efficient updates
ListView.builder(
  itemCount: events.length,
  itemBuilder: (context, index) => EventCard(
    key: ValueKey(events[index].id),
    event: events[index],
  ),
)

RepaintBoundary for Complex Widgets

// Isolate expensive repaints
RepaintBoundary(
  child: ComplexAnimatedWidget(),
)

Navigation with GoRouter

Basic Setup

// router.dart
final goRouterProvider = Provider<GoRouter>((ref) => generateRouter());

GoRouter generateRouter() {
  return GoRouter(
    initialLocation: '/',
    navigatorKey: navigatorKey,
    errorBuilder: (context, state) => const PageNotFound(),
    routes: [
      GoRoute(
        name: 'home',
        path: '/',
        builder: (context, state) => const UserInfosGuard(
          fallbackRoute: '/onboarding',
          child: BottomMenu(),
        ),
      ),
      GoRoute(
        name: 'event_detail',
        path: '/events/:eventId',
        builder: (context, state) => EventDetailPage(
          eventId: state.pathParameters['eventId']!,
        ),
      ),
      GoRoute(
        name: 'assignment_detail',
        path: '/assignments/:assignmentId',
        builder: (context, state) => AssignmentDetailPage(
          assignmentId: state.pathParameters['assignmentId']!,
        ),
      ),
    ],
  );
}

Navigation Methods

// Navigate to route
context.go('/events/123');

// Push onto stack (can go back)
context.push('/events/123');

// Go back
context.pop();

// Replace current route
context.replace('/home');

// With named route
context.goNamed('event_detail', pathParameters: {'eventId': '123'});

Type-Safe Routes (Optional)

For compile-time safety, use go_router_builder:

# pubspec.yaml
dev_dependencies:
  go_router_builder: ^2.4.0
// routes.dart
part 'routes.g.dart';

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

@TypedGoRoute<EventDetailRoute>(path: '/events/:eventId')
class EventDetailRoute extends GoRouteData {
  final String eventId;
  const EventDetailRoute({required this.eventId});

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

// Usage - compile-time safe!
EventDetailRoute(eventId: '123').go(context);

Deep Link Configuration

iOS (Info.plist):

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>com.ballee.app</string>
    </array>
  </dict>
</array>

Android (AndroidManifest.xml):

<intent-filter>
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="com.ballee.app" android:host="login-callback" />
</intent-filter>

Code Generation

Run Build Runner

cd apps/mobile

# One-time build
dart run build_runner build --delete-conflicting-outputs

# Watch mode (development)
dart run build_runner watch --delete-conflicting-outputs

Generated Files

AnnotationGenerated
@freezed*.freezed.dart
@JsonSerializable / @freezed with JSON*.g.dart
@riverpod*.g.dart

Import Conventions

Always use package imports:

// CORRECT
import 'package:apparence_kit/modules/events/domain/event.dart';

// INCORRECT
import '../domain/event.dart';

Naming Conventions

TypeConventionExample
Filessnake_caseevents_list_page.dart
ClassesPascalCaseEventsListPage
VariablescamelCaseeventsList
ConstantsSCREAMING_SNAKEMAX_EVENTS
Entities*EntityEventEntity
APIs*ApiEventsApi
Repositories*RepositoryEventsRepository
Notifiers*NotifierEventsListNotifier
Page States*PageStateEventsPageState

Quick Reference

Creating a New Feature

  1. Create entity in api/entities/
  2. Create API methods in api/<module>_api.dart
  3. Create domain model in domain/
  4. Create repository in repositories/
  5. Create page state in providers/models/
  6. Create notifier in providers/
  7. Create page in ui/
  8. Add route to router.dart
  9. Run dart run build_runner build --delete-conflicting-outputs

Checklist

  • Entities use @freezed and @JsonKey for snake_case
  • Domain models have fromEntity factory
  • Repositories return Result<T> or handle errors
  • Notifiers use @riverpod with unified Ref
  • Page states have computed properties
  • UI uses state.when() for loading/error/data
  • const constructors used everywhere possible
  • Widgets split to minimize rebuilds

Keep looking

Skills are one crate of 627,801. 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.