fj-flutter

Personal Flutter app architecture and development conventions. Use this skill whenever building, modifying, or reviewing Flutter/Dart code in a mobile app. Triggers include: creating new pages/features, adding models or services, setting up providers, configuring routes, writing API integration code, building reusable components, or setting up theming. Also use when the user asks about app architecture, state management patterns, or how to structure a new feature in a Flutter app.

fjlessing/agent-skills2 installsMITSynced Aug 27

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: fj-flutter
description: Personal Flutter app architecture and development conventions. Use this skill whenever building, modifying, or reviewing Flutter/Dart code in a mobile app. Triggers include: creating new pages/features, adding models or services, setting up providers, configuring routes, writing API integration code, building reusable components, or setting up theming. Also use when the user asks about app architecture, state management patterns, or how to structure a new feature in a Flutter app.
license: MIT
---

# FJ's Flutter App Development

This skill defines the architecture, patterns, and conventions I use in my Flutter mobile applications. Follow these guidelines when building or modifying features.

## Architecture Overview

The app follows a layered architecture with clear separation of concerns:

```
lib/
├── main.dart                    # App entry point, Firebase init, MultiProvider setup
├── layouts/                     # Scaffold wrappers (AppScaffold, OuterScaffold)
├── router/                      # GoRouter config, route definitions, deep linking
├── providers/                   # Global ChangeNotifier providers (state management)
├── services/                    # API communication, caching, business logic
├── models/                      # Data models with fromJson/toJson/copyWith
├── components/                  # Domain-specific reusable UI (auction cards, forms)
├── widgets/                     # Low-level utility widgets
├── pages/                       # Feature modules (each with own models/services/providers)
├── theme/                       # ThemeConstants, fonts, spacings, Material theme
├── utils/                       # API config, fetch utils, observers
└── assets/                      # SVGs, images, fonts
```

## Data Flow

```
API → Service → Provider → Widget
                    ↑
              CacheService (SQLite fallback)
```

Widgets never call services directly. Providers are the single source of truth for UI state.

---

## Models

### Structure

Use `json_serializable` for all new models. Add the `@JsonSerializable()` annotation, declare a `part` directive pointing to the generated `.g.dart` file, then run `build_runner` to produce the implementation. The generated functions (`_$ModelNameFromJson`, `_$ModelNameToJson`) live entirely in the `.g.dart` file — never write them manually.

```dart
import 'package:json_annotation/json_annotation.dart';

part 'auction.g.dart';  // generated file — do not edit by hand

@JsonSerializable()
class Auction {
  final int id;
  final String title;
  @JsonKey(name: 'auction_start_at')
  final DateTime auctionStartAt;
  final String? optionalField;  // optional fields go last in the constructor

  const Auction({
    required this.id,
    required this.title,
    required this.auctionStartAt,
    this.optionalField,  // optional (non-required) params always come after required ones
  });

  factory Auction.fromJson(Map<String, dynamic> json) => _$AuctionFromJson(json);
  Map<String, dynamic> toJson() => _$AuctionToJson(this);

  Auction copyWith({int? id, String? title}) {
    return Auction(id: id ?? this.id, title: title ?? this.title);
  }

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Auction && other.id == id && other.title == title;
  }

  @override
  int get hashCode => Object.hash(id, title);
}
```

Run code generation:

```bash
flutter pub run build_runner build --delete-conflicting-outputs
```

### Key Patterns

- Use `json_serializable` — not hand-written `fromJson`/`toJson` (legacy code predates this)
- **Required constructor params always come before optional ones** — `required` fields first, nullable/optional `this.field` last
- Before parsing, clean with `ApiFetchUtil.cleanJsonData()` — the API can return string `"null"` instead of actual null
- Use `@JsonKey(name: 'snake_case_key')` to map API field names to Dart conventions
- Handle type casting defensively via custom `JsonConverter` when the API is inconsistent (e.g., lat/lng as either string or double)
- Provide a `fromJsonReduced()` variant for list/paginated views that skip heavy fields
- Override `==` and `hashCode` comparing all relevant fields (not just `id`)
- Use `copyWith()` for immutable updates

### Response Wrapper Models

API responses are always wrapped in a standard envelope:

```dart
class AuctionsApiResponse {
  bool success;
  String message;
  AuctionsResponse auctionsResponse;

  factory AuctionsApiResponse.fromJson(Map<String, dynamic> data) {
    return AuctionsApiResponse(
      success: data["success"] ?? false,
      message: data["message"],
      auctionsResponse: AuctionsResponse.fromJson(data['response'] ?? {}),
    );
  }
}
```

The API returns `{ "success": bool, "message": string, "response": { ... } }`. Always wrap raw data in a typed response model.

### Query Models

Queries that go to the API are also models with `toJson()` and `copyWith()`:

```dart
class AuctionQueryData {
  final String startDate;
  final String endDate;
  final int perPage;  // -1 means fetch all (no pagination)
  int page = 1;
  bool appendResults = false;

  Map<String, dynamic> toJson() { /* ... */ }
  AuctionQueryData copyWith({/* ... */}) { /* ... */ }
}
```

---

## Services

Services handle HTTP communication and caching. They are **pure singletons** — no `ChangeNotifier`, no UI state. A service's job is to fetch, store, or transform data and return a typed result. Notifying the UI is always the provider's responsibility.

The singleton pattern is consistent across all services:

```dart
class MyFeatureService {
  // Singleton
  static final MyFeatureService _instance = MyFeatureService._internal();
  factory MyFeatureService() => _instance;
  MyFeatureService._internal();

  final ApiConfig _apiConfig = ApiConfig.internal();

  Future<MyFeatureResponse> fetchData(MyQueryData data) async {
    final response = await http.post(
      ApiConfig().buildUrl("my-endpoint"),
      body: jsonEncode(data.toJson()),
      headers: _apiConfig.apiHeaders(),
    );
    return MyFeatureResponse.fromJson(jsonDecode(response.body));
  }
}
```

### Conventions

- Services are singletons — use the factory + static instance pattern above
- Use `ApiConfig.internal()` for field-level instantiation (avoids triggering async user-agent init)
- Methods return typed response models (never raw `http.Response`)
- Use `ApiFetchUtil` for shared fetch logic, including heavy JSON parsing via `compute()`
- Services may delegate to `CacheService` for offline-first behaviour
- Explicitly handle 401/403 before parsing the body — the API may return non-JSON error pages
- Error handling lives in the response model (`check response.success`), not in try/catch in the service

### Example Service Method

```dart
Future<AuctionActionsResponse> setFavourite(int id) async {
  final response = await http.post(
    ApiConfig().buildUrl("auction/$id/favourite"),
    headers: _apiConfig.apiHeaders(),
  );
  if (response.statusCode == 401 || response.statusCode == 403) {
    return AuctionActionsResponse(success: false, message: "Unauthenticated.");
  }
  final result = AuctionActionsResponse.fromJson(jsonDecode(response.body));
  if (result.success) {
    await _cacheService.updateAuctionInteraction(auctionId: id, isFavourite: true);
  }
  return result;
}
```

### ApiConfig (Singleton)

`ApiConfig` is a singleton that manages the base URL, headers, and URL building:

```dart
class ApiConfig {
  static final ApiConfig _instance = ApiConfig._internalConstructor();
  factory ApiConfig() => _instance;
  ApiConfig._internalConstructor();
  ApiConfig.internal();  // For use without triggering async init

  Uri buildUrl(String path) => Uri.parse("$apiBase/$path");

  Map<String, String> apiHeaders({Map<String, String> overrides = const {}}) {
    // Content-Type, Accept, User-Agent, Bearer token from AppService
  }

  String formatDate(DateTime date) => DateFormat('yyyy-MM-dd').format(date);
}
```

Use `ApiConfig()` for the singleton factory, `ApiConfig.internal()` for lightweight access without user agent initialization.

### ApiFetchUtil

Static utility class for shared fetch operations that avoids circular dependencies:

- `fetchAuctionsFromApi()` — raw POST with JSON body, parses in isolate via `compute()`
- `cleanJsonData()` — recursively removes string "null" values from API responses
- `convertBooleansToInts()` / `convertIntsToBooleans()` — SQLite storage helpers

Heavy JSON parsing uses `compute()` to run on a separate isolate and avoid blocking the UI thread.

---

## Providers (State Management)

The app uses the `provider` package with `ChangeNotifier` for state management.

### Registration

All global providers are registered in `main.dart` using `MultiProvider`:

```dart
MultiProvider(
  providers: [
    Provider<AuctionCalendarRouter>(create: (_) => _router),
    ChangeNotifierProvider<AppService>(create: (_) => appService),
    ChangeNotifierProvider<ScaffoldProvider>(create: (_) => scaffoldProvider),
    ChangeNotifierProvider<AuctionsProvider>(create: (_) => AuctionsProvider()),
    ChangeNotifierProvider<ProfileProvider>(create: (_) => ProfileProvider()),
    // ... more providers
  ],
  child: /* ... */,
)
```

### Provider Structure

```dart
class AuctionsProvider extends ChangeNotifier {
  bool isLoading = false;
  List<Auction> auctions = [];
  late AuctionsApiResponse apiResponse;
  late AuctionQueryData queryData;

  final auctionsService = AuctionsService();

  // Request generation tracking to cancel stale requests
  int _requestGeneration = 0;
  int? _activeFetchGeneration;

  Future<bool> fetchAuctions([AuctionQueryData? body]) async {
    final currentGeneration = ++_requestGeneration;
    _activeFetchGeneration = currentGeneration;

    isLoading = true;
    notifyListeners();

    apiResponse = await auctionsService.fetchAuctions(body ?? queryData);

    // Discard if a newer request has been issued
    if (_activeFetchGeneration != currentGeneration) return false;

    if (apiResponse.success) {
      if (body?.appendResults == true && body!.page > 1) {
        auctions.addAll(apiResponse.auctionsResponse.data);
      } else {
        auctions = apiResponse.auctionsResponse.data;
      }
    }

    isLoading = false;
    notifyListeners();
    return apiResponse.success;
  }
}
```

### Key Patterns

- **Request generation tracking**: Increment a counter per request; discard results if a newer request has been issued. This prevents race conditions in fast navigation.
- **Loading flags per concern**: `isLoading`, `isCalendarLoading`, `isLoadingRecommendedAuctions` — separate flags for independent UI areas
- **Late-initialized responses**: Use `late` for response objects that are only set after the first fetch
- **Silent mode**: Some actions (like setting favourites in a list) skip global loading state to avoid full-screen spinners
- **Pagination via `appendResults`**: When `page > 1` and `appendResults` is true, append to the list instead of replacing
- **`notifyListeners()`** is called both before (to show loading) and after (to show results)
- **`dispose()`** cancels pending requests, clears lists, and nulls references

### Accessing Providers in Widgets

```dart
// In initState or callbacks (no rebuild on change)
final provider = Provider.of<AuctionsProvider>(context, listen: false);

// In build (rebuilds when provider notifies)
final provider = Provider.of<AuctionsProvider>(context);

// Or use Consumer for scoped rebuilds
Consumer<AuctionsProvider>(
  builder: (context, provider, child) => /* ... */,
)
```

### Feature-Local Providers (Preferred)

If a provider is only needed within a specific feature or nested route subtree, register it locally rather than globally. This keeps the global provider tree lean and makes dependencies explicit.

Register locally by wrapping the subtree — a `ShellRoute` builder or a parent page widget are both good places:

```dart
// In a ShellRoute builder, or a parent widget's build method:
ChangeNotifierProvider<AuthProvider>(
  create: (_) => AuthProvider(),
  child: AuthPage(),
)
```

Only register a provider globally in `main.dart` when it genuinely needs to be accessible from multiple unrelated parts of the app (e.g., `AuctionsProvider`, `ProfileProvider`, `ScaffoldProvider`).

Feature providers live alongside their feature code:

```
pages/auth/providers/auth_provider.dart
pages/profile/providers/alerts_provider.dart
```

---

## Router (GoRouter)

### Structure

- `router.dart` — `AuctionCalendarRouter` class with GoRouter instance and redirect logic
- `routes.dart` — `AuctionCalendarRouteConfiguration` with the route tree
- `route_utils.dart` — `NamedRoutes` enum, path/title helpers, notification routing

### NamedRoutes Enum

Routes are defined as an enum with extensions for path, fullpath, and title:

```dart
enum NamedRoutes {
  splash, onboarding, auth, confirm, reset,
  calendar, day, week, month,
  search, searchResults,
  profile, alerts, favourites, listings,
  auction, auctionBySlug,
  // ...
}

extension RoutePathExtension on NamedRoutes {
  String get path { /* switch statement */ }
  String get fullpath { /* handles nested paths */ }
  String get title { /* display title for app bar */ }
  bool get showAppBar { /* some routes hide the app bar */ }
}
```

### Route Tree

Two `ShellRoute` groups:
1. **Open routes** (OuterScaffold): splash, onboarding, auth + children (confirm, reset)
2. **Authenticated routes** (AppScaffold): bottom nav and drawer, requires login

**Default to open routes.** A new page belongs in the authenticated ShellRoute only if it (A) requires profile or user-specific data, or (B) the prompt explicitly calls it out as authenticated. Don't assume auth is required just because a page calls an API — many pages work fine for guest/skipped-login users.

### Redirect Logic

The router's `redirect` function checks app state in priority order:
1. If responding to a notification → allow navigation (no redirect)
2. Deep link handling (platform-specific)
3. Not loaded → splash
4. Not onboarded → onboarding
5. Awaiting confirmation → confirm page
6. Not logged in (and hasn't skipped) → auth
7. Already logged in visiting login → calendar

### Deep Linking

- **Android**: Checks `state.uri.host` for incoming intents
- **iOS**: Checks `scheme` + `host` + `path`
- Maps web URLs to app routes via `RouteUtils.getRouteByWebLink()`
- Unmatched URLs are launched externally via `ApiConfig().launchURL()`

### Navigation from Code

```dart
context.go(NamedRoutes.calendar.path);        // Replace current
context.goNamed(NamedRoutes.auction.name);    // By name
context.push(NamedRoutes.auction.fullpath);   // Push onto stack
```

---

## Pages (Feature Modules)

Each page/feature is a self-contained module:

```
pages/{feature}/
├── {feature}_page.dart    # Main page widget
├── models/                # Feature-specific models
├── components/            # Feature-specific UI components
├── providers/             # Feature-specific providers (if needed)
├── services/              # Feature-specific services (if needed)
└── views/                 # Sub-views or tabs
```

### Page Widget Pattern

```dart
class CalendarPage extends StatefulWidget {
  final int initialView;
  const CalendarPage({super.key, this.initialView = 0});

  @override
  State<CalendarPage> createState() => _CalendarPageState();
}

class _CalendarPageState extends State<CalendarPage> {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      // Fetch initial data after the first frame
      Provider.of<AuctionsProvider>(context, listen: false).fetchAuctionsByDay(queryData);
    });
  }

  @override
  Widget build(BuildContext context) {
    final provider = Provider.of<AuctionsProvider>(context);
    // Build UI from provider state
  }
}
```

Use `addPostFrameCallback` for initial data fetching — never call async work directly in `initState`.

---

## Theme

All theme constants live in `lib/theme/style.dart`:

### ThemeConstants

Static color palette with color maps for tints (200–700):

```dart
class ThemeConstants {
  static Color green = const Color.fromRGBO(70, 189, 166, 1);
  static Color orange = const Color.fromRGBO(243, 122, 38, 1);
  // ...

  static Map<int, Color> greenMap = {
    200: const Color.fromRGBO(237, 248, 246, 1),
    300: const Color.fromRGBO(200, 235, 228, 1),
    // ... 400, 500, 600, 700
  };

  static BoxShadow boxShadow = /* ... */;
  static RoundedRectangleBorder buttonBorder = /* ... */;
  static Duration animationDuration = const Duration(milliseconds: 300);
}
```

### Fonts and Spacings

```dart
class Fonts {
  static String get believe => "Believe";
  static String get urbanist => "Urbanist";
}

class Spacings {
  static EdgeInsets get textViewPadding => const EdgeInsets.symmetric(horizontal: 16.0);
  static EdgeInsets get cardPadding => const EdgeInsets.symmetric(horizontal: 24.0, vertical: 24.0);
}
```

### TextStyle Extensions

Chain style modifiers instead of creating separate styles:

```dart
extension TextStyleHelpers on TextStyle {
  TextStyle get medium => copyWith(fontWeight: FontWeight.w500);
  TextStyle get semibold => copyWith(fontWeight: FontWeight.w600);
  TextStyle get bold => copyWith(fontWeight: FontWeight.w700);
  TextStyle get green => copyWith(color: ThemeConstants.green);
  TextStyle get fontUrbanist => copyWith(fontFamily: Fonts.urbanist);
}

// Usage:
Theme.of(context).textTheme.bodyMedium?.semibold.green
```

### Material Theme

The app defines a full `ThemeData` via `auctionCalendarTheme()` — use Material 3, set color scheme from brand colors, and configure all widget themes (buttons, inputs, dialogs, chips, tabs) in one place.

---

## Components vs Widgets

- **`lib/widgets/`** — Low-level utilities with no domain knowledge (debug tools, generic helpers)
- **`lib/components/`** — Domain-aware reusable UI (auction cards, navigation bars, form fields)

Components may access providers directly. They often accept callbacks for parent coordination:

```dart
class AuctionListItem extends StatefulWidget {
  final Auction auction;
  final Function? refreshInList;  // Callback to parent for state refresh
  final bool cropImage;
  final int maxTitleLines;
}
```

---

## App Initialization (main.dart)

The startup sequence:

1. `WidgetsFlutterBinding.ensureInitialized()`
2. `Firebase.initializeApp()`
3. Register background message handler
4. `Future.wait()` — Firebase services + CacheService init in parallel
5. Wrap app in analytics widget (Clarity)
6. `AuctionCalendar` StatefulWidget creates singletons, sets up FCM listeners, creates router
7. `MultiProvider` wraps `MaterialApp.router`

### AppService (Special Case)

`AppService` is the one exception where a singleton also implements `ChangeNotifier`. This is intentional: the GoRouter needs a `Listenable` to trigger redirects when auth state changes, and `AppService` is that listenable. Do not follow this pattern for other services — it exists specifically to bridge the singleton auth state and the router's `refreshListenable`.

```dart
class AppService with ChangeNotifier {
  static final AppService _instance = AppService.internal();
  factory AppService() => _instance;

  bool _loginState = false;
  String? _apiToken;

  set loginState(bool state) {
    sharedPreferences.setBool(_loginKey, state);
    _loginState = state;
    notifyListeners();  // Triggers router redirect
  }
}
```

---

## Caching Strategy

- **Online-first**: Always try the API; fall back to SQLite cache when offline
- **Hybrid fetch**: Show cached data immediately, then update when fresh data arrives via callback
- **Cache keys**: Generated from query data + prefix for uniqueness
- **Background sync**: Configurable timers (30min cache TTL, 5min sync interval)
- **Interaction updates**: Cache is updated locally when user actions succeed (favourite, reminder)
- **Boolean conversion**: SQLite doesn't support booleans — use `convertBooleansToInts`/`convertIntsToBooleans`

---

## Code Style

### Imports

Always use full `package:` paths — never relative imports:

```dart
// Correct
import 'package:auctioncalendar/utils/api_config.dart';
import 'package:auctioncalendar/providers/auctions_provider.dart';

// Wrong
import '../../utils/api_config.dart';
```

### Comments

Keep comments short — one line maximum. Only comment the non-obvious. Don't add `// <-- ADD THIS` style annotations in generated code snippets; write clean code as if it were already in the project.

### Utility functions

Before adding a helper method to a widget, check if it already exists in a feature's `helpers/` directory or in `lib/helpers/`. Duplicating logic that already exists (e.g. date helpers in `lib/pages/calendar/views/helpers/calendar_helpers.dart`) leads to divergent behaviour.

---

## Helpers

Helper functions and data classes follow a two-level placement rule based on scope.

### Page-local helpers — `pages/{feature}/views/helpers/`

If a helper is only used within a single feature, place it in a `helpers/` folder next to that feature's views. Group related functions into a single static utility class named after the feature domain:

```dart
// lib/pages/calendar/views/helpers/calendar_helpers.dart
class CalendarHelpers {
  static const double heightPerMinute = 0.75;

  static bool isToday(DateTime date) {
    final now = DateTime.now();
    return now.year == date.year && now.month == date.month && now.day == date.day;
  }

  static bool isSameDay(DateTime a, DateTime b) {
    return a.year == b.year && a.month == b.month && a.day == b.day;
  }
}
```

Typedefs that belong to a single feature domain also live here:

```dart
typedef DateFilterCallback = void Function(DateTime startDate, DateTime endDate);
```

### Shared helpers — `lib/helpers/`

As soon as a helper is used (or is likely to be used) in more than one feature, or interacts with multiple widgets across the app, it moves to `lib/helpers/`. This is also the right home for shared data structures and configuration classes that don't belong in models:

```dart
// lib/helpers/tabs.dart
class TabData {
  final String title;
  final IconData icon;
  final Widget view;
  final List<UserType> restrictedTo;

  TabData({
    required this.title,
    required this.icon,
    required this.view,
    this.restrictedTo = const [UserType.buyer, UserType.auctioneer],
  });
}
```

### Decision guide

| Situation | Where it lives |
|---|---|
| Pure logic used only within one page/feature | `pages/{feature}/views/helpers/` |
| Logic or data class used across 2+ features | `lib/helpers/` |
| Private one-off calculation only in one widget | Private method on the widget's `State` class |
| Might grow to be shared in future | Start in `lib/helpers/` — it's easier to reference early than to move later |

Never duplicate a helper that already exists. Check `lib/helpers/` and the feature's own `helpers/` folder before writing a new function.

---

## Conventions Checklist

When adding a new feature:

1. Create the page module under `lib/pages/{feature}/`
2. Define models using `json_serializable` (`@JsonSerializable`, run `build_runner`); required constructor params before optional; add `copyWith`, `==` (all fields), `hashCode`
3. Create a service as a **singleton** (factory + static instance); return typed response models; never extend `ChangeNotifier`
4. Add a provider (or extend an existing one) with loading flags and request generation tracking
5. Register provider **locally** in the feature subtree unless it's needed app-wide; only add to `MultiProvider` in `main.dart` if truly global
6. Add route to `NamedRoutes` enum; place in **open** ShellRoute by default, authenticated ShellRoute only if user data is required or explicitly stated
7. Use `ThemeConstants` for all colors/styles — never hardcode `Color(...)` or `Colors.*` values
8. Use `addPostFrameCallback` for initial data fetching in pages
9. Handle 401/403 responses explicitly in services before parsing the body
10. Clean API JSON with `ApiFetchUtil.cleanJsonData()` in model factories
11. Always use full `package:appname/...` import paths — never relative imports
12. Keep comments concise (one line); never leave `// <-- ADD THIS` style annotations in code
13. Before writing a new helper function, check `lib/helpers/` and `pages/{feature}/views/helpers/` — prefer reuse over duplication; place new helpers in `lib/helpers/` if cross-feature, or in the feature's `helpers/` folder if local

More Mobile skills

← All Mobile skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY