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.
Works with
---
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 localMore Mobile skills
animation-vocabulary
emilkowalski/skills
Reverse-lookup glossary that turns a vague description of a web animation or motion effect into its exact term ("the bouncy thing when a popover opens" → Pop in; "the iOS rubber-band scroll" → Rubber-banding). Use when the user asks "what's it called when…", or describes a motion effect without knowing its name and wants the right word to prompt an AI or designer with. For naming an effect, not designing or building one.
xcode-project-setup
firebase/agent-skills
Safely modifies Xcode projects (.pbxproj) to add Swift Packages and link files. Use this skill whenever an iOS project needs dependencies installed (e.g. Firebase, Alamofire).
cross-border-ecommerce
nexscope-ai/ecommerce-skills
Cross-border e-commerce expansion advisor. Scores target markets on 8 weighted dimensions (market size, ecommerce penetration, competition, regulatory complexity, logistics infrastructure, payment ecosystem, cultural distance, IP protection), compares 5 fulfillment models with cost and transit data, provides country-by-country tax/duty compliance guides (EU VAT/IOSS, UK VAT, US sales tax, CA GST, AU GST, JP consumption tax), maps local payment preferences by market, and builds a phased expansion roadmap. No API key required.

