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.
npx -y skills add jahfaliabdulrahman-dev/hermes-skills --skill flutter-patternsAssembled 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-Skill | Content | Status |
|---|---|---|
flutter-anr-debugging | Signal 3 ANR, Hero tag collisions, scrollbar assertion, Air-Lock pattern | Absorbed — see Section 1 |
flutter-dialog-localization | Riverpod _translations map, zero hardcoded strings, ConsumerWidget conversion | Absorbed — see Section 2 |
flutter-dialog-transient-file-lifecycle | _didSave flag pattern for orphan vs. saved file cleanup in dialogs | Absorbed — see Section 3 |
flutter-textfield-clipping | Floating label clipping in RTL AlertDialogs, isDense + reduced contentPadding fix | Absorbed — see Section 4 |
flutter-widget-wrapping | InkWell wrapping, bracket spiral rule, Dismissible wrapping, static method t() access | Absorbed — see Section 5 |
flutter-input-hardening | InputSanitizers utility, Arabic-Indic digit sanitization, detectTextDirection, validators | Absorbed — see Section 6 |
flutter-image-lifecycle-atomic | Normalized ref-counted InvoiceImage lifecycle, SHA-256 dedup, soft-delete GC | Absorbed — see Section 7 |
flutter-ci-environment-drift | CI environment mismatch causing build failures, PATH/SDK resolution | Absorbed — see Section 8 |
ui-yield-protocol | Yield main thread 500ms before heavy I/O to prevent InputMethodManager ANR | Absorbed — see Section 9 |
flutter-genui-a2ui | Flutter GenUI SDK + A2UI protocol for agent-driven declarative UI (JSON, not eval) | Absorbed — see Section 10 |
riverpod-mutation-order | Riverpod "Mutate Before Snapshot" TOCTOU anti-pattern — duplicate messages, API rejections | Absorbed — see Section 11 |
flutter-llm-integration | Gemini prompt isolation, history filtering, two-paths collapse, defense-in-depth for LLM outputs | Absorbed — see Section 12 |
gorouter-stale-shellroute | GoRouter const NoTransitionPage inside ShellRoute prevents rebuild on query change | Absorbed — 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: nullon 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 explicitScrollController
Keyboard + I/O Thread Collision (Air-Lock Pattern):
- Heavy Isar writes with active text fields cause InputMethodManager deadlock
- Fix: Navigate to a clean
LoadingScreenbefore 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):
StatefulWidget→ConsumerStatefulWidgetStatelessWidget→ConsumerWidget- Pull
tviafinal t = ref.watch(settingsProvider).tinbuild()
Key pitfalls:
- Remove
constfrom any widget callingt()at runtime - Static methods can't access
ref— passWidgetRefas 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 nulldetectTextDirectionneedsonChanged: (_) => setState(() {})to trigger rebuildtextDirectionon 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:
- Never use
!on nullable fields — local variable + null check always - Never nest
writeTxn— inline refCount logic directly - Soft delete BEFORE physical delete — entity persists for GC
- 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 analyzepasses locally but fails on CIdart run build_runner buildfails 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:
| Scenario | Minimum Delay |
|---|---|
| Release build, warm start | 150ms |
| Debug build, warm start | 300ms |
| Debug build, first launch | 500ms |
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_buttons→WrapofElevatedButton/OutlinedButtonsummary_card→ FlutterCardwidgetquick_input_form→TextFieldwithInputDecorationbar_chart/pie_chart→fl_chartpackage
- 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
statebefore mutating it - Task providers that snapshot filter state before adding a new item
Sister Patterns
- LL-004:
isBusyguard in provider prevents duplicate network requests from rapid double-taps — same "prevent duplicates at provider level" philosophy - LL-007: Provider invalidation ordering — calling
ref.invalidatefrom 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: userin 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
- Navigate to
/chat?session=A→ keyValueKey('/chat?session=A') - Navigate to
/chat?session=B→ keyValueKey('/chat?session=B') - 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
ShellRoutepageBuilder useconst NoTransitionPage? And does the target screen read query/path parameters indidChangeDependencies()? Addkey: 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 errorsflutter 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.