agentsclimarketplace

Flutter patterns

Skill jahfaliabdulrahman-dev/hermes-skills/skills/flutter-patterns

Production-grade agent skills for Flutter, DevOps & AI Governance — forged in real projects, governed by constitution, verified on device.

Install
npx -y skills add jahfaliabdulrahman-dev/hermes-skills --skill flutter-patterns

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

2 things to look at

  • 19 days oldThe repository was created 19 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
  • 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.

What its author says it does

Copied from the file, not written here

Class-level Flutter patterns: ANR debugging, dialog localization, input hardening, widget wrapping, CI drift, image lifecycle, GoRouter routing. Covers general Flutter UI/UX, platform-integration, and navigation patterns that aren't Isar-specific. For Isar-specific Flutter patterns, see flutter-isar-clean-arch-setup.

SKILL.md

21.6 KB, ~5.1k tokens by cl100k_base, as published. Nobody here has run it

Flutter General Patterns

Umbrella skill for general Flutter UI, platform-integration, debugging, and routing patterns. Covers all non-Isar-specific Flutter expertise accumulated across sessions.

Absorbed Sub-Skills (use via labeled sections below)

Sub-SkillContentStatus
flutter-anr-debuggingSignal 3 ANR, Hero tag collisions, scrollbar assertion, Air-Lock patternAbsorbed — see Section 1
flutter-dialog-localizationRiverpod _translations map, zero hardcoded strings, ConsumerWidget conversionAbsorbed — see Section 2
flutter-dialog-transient-file-lifecycle_didSave flag pattern for orphan vs. saved file cleanup in dialogsAbsorbed — see Section 3
flutter-textfield-clippingFloating label clipping in RTL AlertDialogs, isDense + reduced contentPadding fixAbsorbed — see Section 4
flutter-widget-wrappingInkWell wrapping, bracket spiral rule, Dismissible wrapping, static method t() accessAbsorbed — see Section 5
flutter-input-hardeningInputSanitizers utility, Arabic-Indic digit sanitization, detectTextDirection, validatorsAbsorbed — see Section 6
flutter-image-lifecycle-atomicNormalized ref-counted InvoiceImage lifecycle, SHA-256 dedup, soft-delete GCAbsorbed — see Section 7
flutter-ci-environment-driftCI environment mismatch causing build failures, PATH/SDK resolutionAbsorbed — see Section 8
ui-yield-protocolYield main thread 500ms before heavy I/O to prevent InputMethodManager ANRAbsorbed — see Section 9
flutter-genui-a2uiFlutter GenUI SDK + A2UI protocol for agent-driven declarative UI (JSON, not eval)Absorbed — see Section 10
riverpod-mutation-orderRiverpod "Mutate Before Snapshot" TOCTOU anti-pattern — duplicate messages, API rejectionsAbsorbed — see Section 11
flutter-llm-integrationGemini prompt isolation, history filtering, two-paths collapse, defense-in-depth for LLM outputsAbsorbed — see Section 12
gorouter-stale-shellrouteGoRouter const NoTransitionPage inside ShellRoute prevents rebuild on query changeAbsorbed — see Section 13

Section 1: ANR Debugging

Full content archived in references/anr-debugging.md

Quick Reference

Signal 3 ANR on Navigation — Choreographer skips frames, process killed.

  • Root cause (90% of IndexedStack apps): Hero tag collision — multiple FABs share default <default FloatingActionButton tag> → rendering pipeline crash
  • Fix: Set heroTag: null on ALL FABs inside IndexedStack pages
  • Why null? Disables Hero animation entirely — no tag lookup, no collision

Scrollbar Assertion Crash in Dialogs:

  • Scrollbar(thumbVisibility: true) without explicit controller → crash in dialogs
  • Fix: Remove thumbVisibility (platform default handles it) or provide explicit ScrollController

Keyboard + I/O Thread Collision (Air-Lock Pattern):

  • Heavy Isar writes with active text fields cause InputMethodManager deadlock
  • Fix: Navigate to a clean LoadingScreen before I/O — destroys widget tree, kills keyboard state
  • Sequence: extract payload → pushReplacement(LoadingScreen)await Future.delayed(500ms) → perform I/O

When to Use Air-Lock

  • First-launch setup wizards with Isar writes
  • Batch imports triggered from forms
  • Any I/O > 100ms from a screen with active TextFormFields

Section 2: Dialog Localization

Full content archived in references/dialog-localization.md

Quick Reference

Add translation key to _translations map in settings_provider.dart:

'key_name': {'en': 'English', 'ar': 'العربية'},

For standalone widgets needing t() (no ref in scope):

  • StatefulWidgetConsumerStatefulWidget
  • StatelessWidgetConsumerWidget
  • Pull t via final t = ref.watch(settingsProvider).t in build()

Key pitfalls:

  • Remove const from any widget calling t() at runtime
  • Static methods can't access ref — pass WidgetRef as parameter
  • Don't compose: '${t('notes')} (${t('cancel')})' — create dedicated key instead

Section 3: Dialog Transient File Lifecycle

Full content archived in references/dialog-transient-file-lifecycle.md

Quick Reference — The _didSave Flag Pattern

dispose() cannot distinguish "dialog cancelled" from "dialog saved then disposed." Both have transientPath != null. Without a flag, dispose() deletes files that were just saved.

mixin FileDialogLifecycle<T extends StatefulWidget> on State<T> {
  bool _didSave = false;  // THE CRITICAL FLAG

  void disposeFileLifecycle() {
    if (_didSave) return;  // Saved — skip orphan cleanup
    if (transientPath != null && transientPath != _originalPath) {
      _storage.deleteFile(transientPath!);  // Deletes orphan
    }
  }

  String? finalizeFilePath() {
    _didSave = true;  // BLOCKS dispose cleanup
    return transientPath;
  }

  void revertSaveConfirmation() {
    _didSave = false;  // On DB write failure
  }
}

Critical: Never use flutter_image_compress for local file persistence on Android 14+ — OS temp paths get GC'd during activity transitions. Use dart:io File.copy() instead.


Section 4: TextField Clipping in RTL AlertDialogs

Full content archived in references/textfield-clipping.md

Quick Reference

Floating label clips entered text in narrow RTL AlertDialogs (especially OutlineInputBorder).

TextFormField(
  decoration: InputDecoration(
    isDense: true,                          // ← Key fix
    contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
  ),
)

isDense: true reduces internal height so the floating label doesn't overlap the entered text.


Section 5: Widget Wrapping Patterns

Full content archived in references/widget-wrapping.md

Quick Reference

The Bracket Spiral Rule: When patch edits to a Flutter widget tree cause persistent bracket/indentation errors after 2-3 attempts — STOP patching, rewrite the file entirely. Symptoms: "Too many positional arguments," unexpected ) errors, balance += line.count('(') - line.count(')') tracking shows +1 overall.

InkWell Wrapping:

// WRONG — Container clips ripple
Container(child: InkWell(onTap: onTap, child: ...))

// CORRECT — InkWell wraps Container
InkWell(
  onTap: onTap,
  borderRadius: BorderRadius.circular(12),
  child: Container(...)
)

Dismissible Wrapping: Always add the extra closing paren for the outer Dismissible:

child: Dismissible(
  key: ValueKey(record.id),
  onDismissed: (_) => _delete(record),
  child: _MyCard(record: record),
),  // ← DON'T FORGET

Section 6: Input Hardening

Full content archived in references/input-hardening.md

Quick Reference — InputSanitizers Utility

class InputSanitizers {
  // Cost regex: requires digit before optional dot (not \d* which allows leading dot)
  static final costFormatter = FilteringTextInputFormatter.allow(
    RegExp(r'^\d+\.?\d{0,2}'),
  );

  // Arabic-Indic digit sanitization (٠-٩ → 0-9)
  static String sanitizeDigits(String input) {
    for (final e in _arabicDigits.entries) {
      input = input.replaceAll(e.key, e.value);
    }
    return input.replaceAll(',', '');
  }

  static TextDirection detectTextDirection(String text) {
    for (final rune in text.runes) {
      if (rune >= 0x0600 && rune <= 0x06FF) return TextDirection.rtl;
    }
    return TextDirection.ltr;
  }
}

Pitfalls:

  • \d*\.?\d{2} allows leading dot → double.tryParse(".5") returns null
  • detectTextDirection needs onChanged: (_) => setState(() {}) to trigger rebuild
  • textDirection on TextField must be set at build time — requires state trigger

Section 7: Image Lifecycle (Ref-Counted)

Full content archived in references/image-lifecycle-atomic.md

Quick Reference

Normalized InvoiceImage entity with reference counting — prevents phantom files, refCount leaks, nested transaction deadlocks:

@collection
class InvoiceImage {
  Id id = Isar.autoIncrement;
  late String relativePath;    // "invoices/invoice_xxx.jpg"
  late String contentHash;     // SHA-256, unique index → dedup
  late int refCount;           // shared reference count
  DateTime? deletedAt;          // soft delete
}

Critical rules:

  1. Never use ! on nullable fields — local variable + null check always
  2. Never nest writeTxn — inline refCount logic directly
  3. Soft delete BEFORE physical delete — entity persists for GC
  4. Dedup via SHA-256 hash, not path

Section 8: CI Environment Drift

Full content archived in references/ci-environment-drift.md

Quick Reference

Flutter CI builds fail due to environment mismatch (Flutter SDK version, Dart SDK, platform tools). Key symptoms:

  • flutter analyze passes locally but fails on CI
  • dart run build_runner build fails with version conflicts
  • Missing platform folders (ios/, android/) in CI container

Pre-flight verification in CI:

flutter doctor
flutter --version
dart --version
# Check .dart_tool/package_config.json exists

Section 9: UI-Yield Protocol

Full content archived in references/ui-yield-protocol.md

Quick Reference

ANR (Signal 3) when user action triggers heavy I/O while keyboard or animation is still active.

Future<void> _onSave() async {
  setState(() => _isLoading = true);           // 1. Lock UI first
  FocusScope.of(context).unfocus();            // 2. Dismiss keyboard
  await Future.delayed(Duration(milliseconds: 500));  // 3. YIELD
  await _executeDatabaseOperations();         // 4. Safe I/O
  if (mounted) Navigator.of(context).pop();    // 5. Navigate
}

Timing:

ScenarioMinimum Delay
Release build, warm start150ms
Debug build, warm start300ms
Debug build, first launch500ms

Use 500ms as default. await Future.delayed (not Timer) ensures main thread yields.


Section 10: GenUI & A2UI — Agent-Driven Dynamic UI

Full content archived in references/genui-a2ui.md

Quick Reference

What it is: Google's official Flutter GenUI SDK (beta, Dec 2025) + A2UI protocol. AI agents describe UIs via declarative JSON, Flutter renders from a pre-built widget catalog. No runtime code execution — app-store-safe.

Package: genui_a2ui on pub.dev

Key facts:

  • Agent sends JSON UI schema → Flutter renders from widget catalog
  • Declarative only — NO eval(), NO code generation
  • Widget catalog: summary_card, bar_chart, pie_chart, line_chart, action_buttons, quick_input_form
  • RTL/Arabic support via Flutter's built-in Directionality

When to use:

  • AI agent apps needing dynamic UI (not hardcoded screens)
  • Conversational interfaces with data visualization
  • Fintech dashboards composed by LLM based on user questions

When NOT to use:

  • Standard apps with fixed screens — use regular Flutter widgets
  • If you need runtime code execution — this is NOT that

Difficulty calibration: 5/10 for MVP dynamic UI (not 9/10). Prior assessment of "near impossible" was wrong — the JSON widget catalog approach is well-defined. The hard part is the agent prompt engineering (getting the LLM to output valid schema JSON), not the Flutter rendering.

Pitfalls:

  • Agent CANNOT invent new widget types — catalog must be pre-built
  • Each interaction round-trips: user tap → agent → new JSON. Manage latency.
  • Beta status — test thoroughly before production
  • Gemini CLAIMS "agent writes Flutter code at runtime" — this is FALSE and dangerous. Reality: agent outputs declarative JSON against a fixed widget catalog. No eval(). No code generation. Correct this misconception immediately if any AI claims otherwise.
  • Claude accurately identified GenUI/A2UI existence via official Google sources (developers.googleblog.com, docs.flutter.dev, pub.dev), while Hermes originally rated it 9/10 "impossible" — the correction is: it's 5/10 difficulty, JSON widget catalog, and app-store-safe by design.
  • ISSUE RESOLVED 2026-05-16: The 3-agent cross-validation (Gemini → Hermes → Claude) converged: GenUI/A2UI is viable. Gemini overstated it (code generation claim). Hermes understated it (near-impossible claim). Claude hit the accurate middle: declarative JSON from widget catalog.

Plan B — If GenUI SDK Fails: If the beta Flutter GenUI SDK has bugs (RTL, component failures, pub.dev issues):

  • Keep the SAME JSON schemas — don't throw away the work
  • Swap only the renderer to native Flutter widgets:
    • action_buttonsWrap of ElevatedButton/OutlinedButton
    • summary_card → Flutter Card widget
    • quick_input_formTextField with InputDecoration
    • bar_chart / pie_chartfl_chart package
  • The JSON schema becomes a pure logic/config layer; rendering is native
  • This is architecturally clean because A2UI separates "describe UI" from "render UI"

Section 11: Riverpod State Mutation Before Snapshot (TOCTOU)

Full analysis archived in references/riverpod-mutation-order.md

Quick Reference — The Anti-Pattern

Calling a snapshot/history builder AFTER mutating state via state.copyWith() causes the snapshot to include the just-added mutation. If a downstream layer also adds the same data, you get duplicates.

// WRONG — history includes the just-added user message
state = state.copyWith(
  messages: [...state.messages, userMessage, agentMessage],  // 1. MUTATE
  isStreaming: true,
);
final history = _buildHistory();  // 2. SNAPSHOT — too late! Includes userMessage
_repository.streamChatCompletion(message: msg, history: history);  // 3. Adds userMessage AGAIN
// → API receives two consecutive role:user → API rejection

// CORRECT — snapshot BEFORE mutation
final history = _buildHistory();  // 1. SNAPSHOT — immutable view of previous state
state = state.copyWith(
  messages: [...state.messages, userMessage, agentMessage],  // 2. MUTATE
  isStreaming: true,
);
_repository.streamChatCompletion(message: msg, history: history);  // 3. Clean
// → API receives one role:user → proper alternation

Detection Rule

Does any state.copyWith() precede a _buildHistory()-style snapshot call in the same method? If so, move the snapshot BEFORE the mutation.

Common Locations

  • ChatNotifier.sendMessage() — history builder for SSE API requests
  • Any notifier that builds API request context from state before mutating it
  • Task providers that snapshot filter state before adding a new item

Sister Patterns

  • LL-004: isBusy guard in provider prevents duplicate network requests from rapid double-taps — same "prevent duplicates at provider level" philosophy
  • LL-007: Provider invalidation ordering — calling ref.invalidate from within a provider creates circular dependencies — same class of "mutation order matters in Riverpod"

When to Suspect This Bug

  • API rejects with "Invalid argument" or 400 on message alternation
  • Duplicate user messages appear in chat UI but only one was typed
  • Debug logs show two consecutive role: user in the request body
  • The bug appeared after a _buildHistory() call was added to include conversation context

Section 12: LLM/Prompt Integration Patterns

Full analysis archived in references/llm-prompt-isolation.md

Quick Reference — Four Patterns

Pattern 1: System Prompt Isolation (Separate Prompts Per Purpose)

When a Flutter app uses the same LLM for multiple purposes (chat + classification + OCR), a SINGLE system prompt causes cascading failures. Adding a prohibition like "don't emit widgets" blocks the CLASSIFICATION call too.

// WRONG — one prompt controls everything
const _systemPrompt = 'لا ترسل أبداً compound_split_card.';  // kills classification!

// CORRECT — separate prompts per purpose
const _systemPrompt = 'عبر عن التصنيف بنص عادي.';  // chat only
const _classifyPrompt = '"{"widget": "compound_split_card", ...}"';  // classification only

// Dedicated method with its own prompt
Future<GeminiResponse> classifyTransaction(String text) async {
  final model = GenerativeModel(
    systemInstruction: Content.system(_classifyPrompt),  // ← DIFFERENT prompt
  );
  return model.generateContent([Content.text(text)]);
}

Rule: Every distinct LLM purpose (chat, classification, OCR, summarization) gets its OWN system prompt. Never share.

Pattern 2: Two-Paths Collapse (Single Authoritative Code Path)

When an LLM's main response can produce the same UI widget as a separate classification call, one path bypasses critical logic.

// WRONG — two paths produce identical confirm UI
if (response.widget != null) {
  showWidget(response.widget);           // Path 1: Gemini's widget (no classification data)
} else {
  final txResult = await classify(text);  // Path 2: App's classification (has data)
  showWidget(buildConfirmUI(txResult));   // ← Path 1 shows SAME UI without data!
}

// CORRECT — collapse to one authoritative path
// Main response: text only. Classification: the SOLE source of actionable widgets.
if (response.widget != null) {
  if (isTransactionWidget(response.widget)) { /* DROP — fall through */ }
  else { showWidget(response.widget); return; }
}
final txResult = await classify(text);  // ← ALWAYS reached for transactions
showWidget(buildConfirmUI(txResult));

Rule: If Gemini can produce the same UI through two different routes, delete one. Classification is the gatekeeper.

Pattern 3: History Filtering (Don't Feed Processed Items to the LLM)

LLMs see conversation history as unprocessed data. A confirmed/saved transaction from message #1 looks identical to a new transaction in message #2. The LLM has no way to know #1 was handled.

// WRONG — sends ALL user messages
final history = allMessages.where((m) => m.isUser).toList();

// CORRECT — filter out processed messages
final filtered = allMessages.where((m) {
  if (!m.isUser) return true;
  return !_confirmedMessageIds.contains(m.id);  // skip handled transactions
}).toList();

Rule: Before sending history to an LLM, strip user messages whose content has already been classified and acted upon.

Pattern 4: Defense-in-Depth (Prompt + Code Gate)

Never trust a single prompt constraint. Add a code-level gate that blocks LLM output even if the prompt fails.

// Layer 1: Prompt says "don't emit compound_split_card"
// Layer 2: Code gate — even if Layer 1 fails, this catches it
if (widgetType == 'compound_split_card' || widgetType == 'action_buttons') {
  // DROP — fall through to classification path
}

Rule: For every prompt-level constraint, add a code-level gate. Two layers > one.

When to Suspect These Bugs

  • Transaction confirmation fails with "classification not available" (Pattern 2)
  • Second transaction merges with first into compound split (Pattern 3)
  • Prompt fix X breaks feature Y (Pattern 1)
  • Same widget appears correctly sometimes but not others (Pattern 2)

Section 13: GoRouter — Stale Page Inside ShellRoute

Full analysis archived in references/gorouter-stale-shellroute.md

Quick Reference — The const NoTransitionPage Trap

When using pageBuilder with const NoTransitionPage inside a GoRouter ShellRoute, the SAME Page object is returned for every navigation regardless of query parameters. GoRouter compares old and new Page references — finding them identical, it never rebuilds the widget tree, so didChangeDependencies() never fires.

// ❌ WRONG — Same Page object always. Stale UI on session switch.
GoRoute(
  path: '/chat',
  pageBuilder: (context, state) => const NoTransitionPage(
    child: ChatScreen(),
  ),
),

// ✅ RIGHT — Unique key per URI forces rebuild on query change.
GoRoute(
  path: '/chat',
  pageBuilder: (context, state) => NoTransitionPage(
    key: ValueKey(state.uri.toString()),
    child: const ChatScreen(),
  ),
),

The Mechanics

  1. Navigate to /chat?session=A → key ValueKey('/chat?session=A')
  2. Navigate to /chat?session=B → key ValueKey('/chat?session=B')
  3. Flutter: different keys → different widgets → tree rebuild → didChangeDependencies() fires → _initChatIfNeeded() detects new session

Symptoms

  • Opening any session after the first shows the FIRST session's title and messages
  • didChangeDependencies() breakpoint never hits after first navigation
  • Data from server is correct (verified via curl) — pure Flutter routing issue

Detection Rule

Does a ShellRoute pageBuilder use const NoTransitionPage? And does the target screen read query/path parameters in didChangeDependencies()? Add key: ValueKey(state.uri.toString()).

What NOT to Touch

Leave any _lastSessionId guard in the target screen as defensive fallback — with the key fix, the screen rebuilds fresh per session, so the guard becomes harmless redundancy.

Verification (Mandatory)

  • flutter analyze: 0 new errors
  • flutter test: no regressions (widget tests don't exercise ShellRoute navigation)
  • Device smoke test: Open session A → back → open session B → verify B's title/messages appear (not A's). Repeat with session C. Tab switching (Chat ↔ Sessions) without opening a new session should preserve scroll/text state.

Keep looking

Skills are one crate of 328,083. 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.