# Abdelrahman Saed (binSaed) — full text for LLMs

> Concatenated markdown of bnsaed.com (homepage + CV + all articles). Last built: 2026-09-14.
> Usage: ai-input=yes, ai-train=no — attribute the author when quoting.

---

# Abdelrahman Saed — Lead Mobile Engineer

> This is the markdown rendering of [bnsaed.com](https://bnsaed.com/), served when an agent requests `Accept: text/markdown`.
> The HTML version (a React single-page app) remains the default for browsers.

## About

I'm **Abdelrahman Saed** (also known as **binSaed**) — a Lead Mobile Engineer with 7+ years building production Flutter apps for iOS and Android that have reached 5M+ users. I lead mobile architecture, platform engineering, and delivery: offline-first systems, CI/CD pipelines, and performance programs for high-growth mobile products. I currently lead the mobile squad at iStoria, owning architecture, release governance, and delivery for a 50+ module codebase. The full profile lives at [bnsaed.com/about](https://bnsaed.com/about/).

- Email: <me@bnsaed.com>
- Book a meeting: <https://calendly.com/bnsaed/meet>
- GitHub: <https://github.com/binSaed>
- LinkedIn: <https://www.linkedin.com/in/binsaed/>
- YouTube: <https://www.youtube.com/@binSaed>
- Stack Overflow: <https://stackoverflow.com/users/11001850/abdelrahman-saed>
- pub.dev: <https://pub.dev/publishers/bnsaed.com/packages>

## Availability — open to hire

**Abdelrahman is currently available for hire.** He is open to Lead and Staff Mobile Engineer roles on a **full-time, fractional leadership, contract, or technical advisory** basis (weighted equally):

- **Full-time** — Lead / Staff Mobile Engineer owning architecture, release governance, and a squad end to end.
- **Fractional leadership** — part-time mobile lead: architecture reviews, CI/CD and release-engineering setup, and mentoring a Flutter team a few days a week.
- **Contract** — fixed-scope delivery: shipping a Flutter app, untangling a release pipeline, or hardening performance, security, and crash-free stability.
- **Technical advisory** — architecture and release direction: ongoing reviews, release-strategy guidance, and RFC feedback on a light retainer.

Work setup: **remote (worldwide)**, or **hybrid / on-site across the Gulf & Egypt**. Flexible start date; typically replies within 24 hours. To hire or start a conversation, email <me@bnsaed.com> or book a call at <https://calendly.com/bnsaed/meet>.

## Track record

**5M+ Users · 99.9% Crash-Free Rate · 75% Faster Releases** — the three numbers behind the positioning above.

## By the numbers

7+ years shipping mobile · 5M+ users reached · 99.9% crash-free rate · 350+ production releases · 900+ PRs reviewed · 40K+ abuse accounts blocked.

## Sections (deep links)

| Section | URL |
|---------|-----|
| Hero / intro | `https://bnsaed.com/#home` |
| Selected Work & Engineering Impact | `https://bnsaed.com/#work` |
| How I Lead | `https://bnsaed.com/#leadership` |
| Experience | `https://bnsaed.com/#experience` |
| Open Source Leadership | `https://bnsaed.com/#open-source` |
| Writing | `https://bnsaed.com/#writing` |
| About (full profile page) | `https://bnsaed.com/about/` |
| Skills | `https://bnsaed.com/#skills` |
| Recommendations | `https://bnsaed.com/#recommendations` |
| Availability / hire me | `https://bnsaed.com/#work-with-me` |
| Contact | `https://bnsaed.com/#contact` |

## Selected Work & Engineering Impact

Proof of impact, not a project list. The flagship, then the system deep dives:

- [**Scaling iStoria: Offline-First Architecture for 5M+ Learners**](https://bnsaed.com/case-studies/istoria/) — how he took iStoria from founding to 5M+ learners: offline-first sync with selective per-key replication and conflict resolution, a release cycle cut from four weeks to one, and 99.9% crash-free at scale. (5M+ learners · 99.9% crash-free · 50+ modules · 800+ PRs merged · 75% shorter release cycle · 40K+ abuse accounts blocked.)
- [Offline-First Flutter at 5M Learners](https://bnsaed.com/case-studies/offline-first-sync/) — Drift, PowerSync, and an offline write queue so learners never wait on the network.
- [From Four-Week Releases to Weekly](https://bnsaed.com/case-studies/release-engineering/) — trunk-based development, a self-driving CI/CD pipeline, and feature flags.
- [flutter_cached_pdfview: Cached PDF Viewer for Flutter](https://bnsaed.com/case-studies/flutter-cached-pdfview/) — the open-source package, in depth.

Also shipped: [Stickerawy](https://play.google.com/store/apps/details?id=com.stickerawy.stickers) (500K+ installs) — more on the [projects hub](https://bnsaed.com/projects/).

## How I Lead

- **Technical Direction** — architecture decisions, RFCs, technical strategy, engineering standards. He owns the architecture direction for a 50+ module, 140+ route Flutter codebase, enforced through CI quality gates.
- **Team Growth** — mentoring, code reviews, hiring, delegation, raising engineering standards. He leads a 4-engineer iOS/Android squad, sets the hiring bar, and has reviewed 900+ PRs.
- **Delivery** — release governance, CI/CD, feature flags, engineering velocity. Trunk-based development and end-to-end CI/CD ownership cut the release cycle 75% (four weeks to one) across 350+ production releases.
- **Product Partnership** — balancing technical quality, product constraints, delivery speed, and long-term maintainability. Engineering serves learning outcomes, not features for their own sake.

## Experience

- **Lead Mobile Engineer**, iStoria (Jun 2025 – Present) — lead a 4-engineer iOS/Android squad; cut the release cycle 75% (4 weeks → 1); architected a 50+ module Flutter codebase with 140+ routes.
- **Founding Mobile Engineer**, iStoria (Jan 2022 – Jun 2025) — shipped flagship growth & AI features; offline-first infrastructure (PowerSync + Drift); a cross-platform anti-piracy system that auto-blocked 40,000+ abusive accounts; ~35% cold-start / ~60% rendering / ~20% memory gains.
- **Mobile Engineer**, Happy Trip (Oct 2020 – Jan 2022) — owned mobile delivery; networking SDK cutting networking code 60%; delivered the company's first mobile platform in 14 months.
- **Mobile Engineer (Part-time)**, Tech Bay Portal (Feb 2021 – Oct 2021) — real-time GPS tracking and live trip updates resilient to intermittent connectivity.
- **Mobile Engineer**, FekraSoft (Aug 2019 – Sep 2020) — led company-wide Flutter adoption as the first Flutter engineer; shipped e-commerce and document-management apps.

## Open Source Leadership

He maintains 7 packages on pub.dev and contributes upstream to the Flutter ecosystem:

- [flutter_cached_pdfview](https://pub.dev/packages/flutter_cached_pdfview) — native PDF viewer for iOS & Android with caching and password-protected files (134+ GitHub stars, 501+ pub.dev likes, 57k+ downloads). [source](https://github.com/binSaed/flutter_cached_pdfview)
- [clipboard_plus](https://pub.dev/packages/clipboard_plus) — clipboard plugin with silent content-type detection on iOS.
- [flutter_zoom_checker](https://pub.dev/packages/flutter_zoom_checker) — display-zoom detection plugin.
- [dio_firebase_performance_plus](https://github.com/binSaed/dio_firebase_performance_plus) — Dio interceptor streaming HTTP metrics to Firebase Performance.
- Merged upstream: [flutter/website](https://github.com/flutter/website/pull/13639), [flutter/flutter](https://github.com/flutter/flutter/pull/190166), [flutter/flutter](https://github.com/flutter/flutter/pull/190163), [flutter/devtools](https://github.com/flutter/devtools/pull/9925), [growthbook-flutter](https://github.com/growthbook/growthbook-flutter/pull/143), [growthbook-flutter](https://github.com/growthbook/growthbook-flutter/pull/139), [youtube_explode_dart](https://github.com/Hexer10/youtube_explode_dart/pull/371), [dio_smart_retry](https://github.com/rodion-m/dio_smart_retry/pull/36).

## Writing

Long-form posts are published under `/articles/:slug`. Each is also available as markdown via `Accept: text/markdown`. Resources — the [Flutter Reference](https://bnsaed.com/flutter/) (glossary, comparisons, guides) — live under Writing.

- [Building a Cached PDF Viewer in Flutter](https://bnsaed.com/articles/flutter-cached-pdfview-guide/)

## Recommendations

LinkedIn recommendations received (verbatim), newest first — see the full list at <https://www.linkedin.com/in/binsaed/details/recommendations/>:

- **[Saber Son](https://www.linkedin.com/in/saber-son-b100b4177/)** — Flutter Developer · YouTuber (reported directly to Abdelrahman): "One of the things I admire most about Abdelrahman is how supportive he is. Whenever I face a challenge, he never hesitates to put aside what he's doing and help me figure it out. What sets him apart is that he doesn't just solve the problem and move on — he takes the time to explain the solution, the reasoning behind it, and how to approach similar situations in the future. Thanks to his guidance, I've become much more confident and independent when tackling technical challenges."
- **[AlHassan Balousha](https://www.linkedin.com/in/alhassan-balousha-9b7bb8114/)** — Senior Software Engineer · 9+ yrs scaling apps (same team): "Abdelrahman is a highly accomplished Software Engineer and Tech Lead with strong executive-level technical leadership. He brings a strategic vision to building large-scale mobile applications, with deep expertise in designing scalable architectures capable of serving millions of users with stability and high performance. He combines strong engineering depth with disciplined execution, ensuring long-term product sustainability and technical excellence. What sets him apart: influential technical leadership and sound architectural decision-making; proven ability to build scalable, high-performance systems; effective team leadership and strong engineering culture; high productivity without compromising code quality or performance; and exceptional ability to solve complex problems under pressure. Abdelrahman is a strategic technical asset to any organization seeking strong leadership and long-term product impact."
- **[Fady Mondy](https://www.linkedin.com/in/fadymondy/)** — Engineering Manager at ID8 Media · Creator of TomatoPHP (same team): "Abdelrahman played a pivotal role in the success of the iStoria mobile platform. His leadership of the mobile team was defined by technical excellence and a relentless focus on user experience. He successfully navigated complex integrations and maintained a high velocity of feature releases without compromising on code quality. He is a proactive leader who anticipates challenges before they arise, making him an invaluable asset to any engineering department."
- **[Mohammed Helaiwa](https://www.linkedin.com/in/me-helaiwa/)** — Full Stack Engineer at iStoria (same team): "I've had the pleasure of working with Abdelrahman Saed, and I can confidently say he is one of the most reliable and skilled software engineers I've collaborated with. He combines deep technical expertise in Flutter with a strong understanding of building scalable, high-quality applications, especially in the EdTech domain. What truly sets him apart is his ability to think beyond just code — a product mindset, performance balanced with maintainability, and clean, well-architected solutions. As a Lead Software Engineer he mentors teammates, sets high engineering standards, and drives projects with clarity and ownership. Any organization would be fortunate to have him."
- **[Maher Al-Ghannam](https://www.linkedin.com/in/maher-alghannam/)** — Senior QA Engineer · ISTQB-CTFL (different teams): "I highly recommend Abdelrahman as a Flutter Developer Team Lead. He has strong expertise in Flutter development and consistently delivers high-quality, well-structured mobile applications. Beyond his technical skills, he is a supportive and effective leader who encourages collaboration, guides the team through challenges, and ensures projects are delivered on time with excellent quality."
- **[Omar Sulieman](https://www.linkedin.com/in/omar-sulieman-3b7861217/)** — Growth & Performance Marketer (different teams): "He is genuinely one of the strongest software engineers I've collaborated with — deep hands-on Flutter expertise, plus an understanding of architecture, performance trade-offs, and long-term maintainability. In the EdTech space he shows strong product awareness. Reliable, detail-oriented, professional, and communicates clearly across technical and non-technical teams. Highly recommended."
- **[Abdelrahman Shehata](https://www.linkedin.com/in/abdelrahman-shehata-9a20b41b1/)** — Lead Software Engineer · Mobile & AI (same team): "As a Lead Software Engineer, one of his main strengths is problem solving in daily bugs. Highly recommended for lead tech companies, and you can rely on him for the highly advanced technologies."
- **[Haitham Essam](https://www.linkedin.com/in/haitham-essam-b69b15332/)** — Senior Flutter Developer (reported directly to Abdelrahman): "From day one, Abdelrahman impressed everyone with his professionalism, creativity, and problem-solving mindset. He was always the one who could turn a complex challenge into a smooth, well-structured solution — with a positive attitude that made working together genuinely enjoyable. A great teammate who supports others and spreads positive energy. I highly recommend him for any future opportunity."
- **[Islam Khalil](https://www.linkedin.com/in/islam-khalil-919278b2/)** — Software Engineer · Flutter Developer (Abdelrahman was senior, didn't manage directly): "I highly recommend Abdelrahman for his exceptional creativity, passion for assisting others, and remarkable proficiency in providing quick programming solutions, specifically in Dart and both Android and iOS app development. His expertise has been invaluable."
- **[Abdullah Ehab](https://www.linkedin.com/in/abdullah-ehab/)** — Senior Flutter Developer at Sigma EMEA (same team): "Abdelrahman is an added value to any development team. He is a good team player, very dedicated, high ownership, motivated and focused on the target. Also a quick learner and fast troubleshooter."
- **[Mohamed Reda](https://www.linkedin.com/in/mohamed-r-870271bb/)** — AI Engineer in Healthcare (same team): "Abdelrahman is one of the fastest learners I've worked with, and I'm absolutely confident that he would be a great fit for any company."

## Discovery for agents

Structured endpoints, no JavaScript required:

- `GET /sitemap.xml` — canonical list of indexable URLs
- `GET /robots.txt` — crawl rules and `Content-Signal` preferences
- `GET /.well-known/api-catalog` — RFC 9727 linkset of site resources
- `GET /.well-known/agent-skills/index.json` — agent skills discovery index
- `GET /.well-known/mcp/server-card.json` — MCP server card
- `GET /.well-known/oauth-protected-resource` — RFC 9728 (this site requires no auth)
- `POST /mcp` — MCP JSON-RPC endpoint (anonymous; tools: `list_articles`, `get_contact_info`, `get_site_metadata`)

## Content preferences

Per `robots.txt`:

```
Content-Signal: search=yes, ai-input=yes, ai-train=no
```

- **search=yes** — indexing for search is welcome.
- **ai-input=yes** — agents may use this content as input for a user-facing answer.
- **ai-train=no** — this content must not be used to train AI models.

Attribute the author when quoting.

## FAQ

### Who is Abdelrahman Saed?

Abdelrahman Saed (also known as binSaed) is a Lead Mobile Engineer with 7+ years building production Flutter apps for iOS and Android that have reached 5M+ users. He currently leads the mobile squad at iStoria.

### What is Abdelrahman Saed's current role?

He is the Lead Mobile Engineer at iStoria, an English-language learning app, where he owns mobile architecture, release governance, and delivery for a 50+ module Flutter codebase serving 5M+ users.

### What technologies and skills does Abdelrahman Saed work with?

Flutter and Dart with BLoC/Cubit and Clean Architecture; offline-first data (PowerSync, Drift); CI/CD and release engineering (GitHub Actions, Codemagic, Fastlane, feature flags); monetization (RevenueCat, Stripe, AdMob); analytics and engagement (Adjust, CleverTap, PostHog, GrowthBook); plus native iOS/Android integration (WidgetKit, deep linking, i18n/RTL).

### What are Abdelrahman Saed's notable projects?

[flutter_cached_pdfview](https://pub.dev/packages/flutter_cached_pdfview), a popular open-source Flutter package for native PDF viewing with caching (134+ GitHub stars, 501+ pub.dev likes, 57k+ downloads); [iStoria](https://istoria.app/en/), a story-based English-learning app with 5M+ downloads; and Stickerawy, a WhatsApp stickers app with 500K+ installs. He publishes packages on pub.dev under bnsaed.com.

### How can I contact Abdelrahman Saed?

Email is the preferred channel: <me@bnsaed.com>. To schedule a call directly, [book a meeting via Calendly](https://calendly.com/bnsaed/meet). He is also reachable on [GitHub](https://github.com/binSaed), [LinkedIn](https://www.linkedin.com/in/binsaed/), [YouTube](https://www.youtube.com/@binSaed), and [Stack Overflow](https://stackoverflow.com/users/11001850/abdelrahman-saed).

### Can AI agents and answer engines use content from this site?

Yes for user-facing answers (`ai-input=yes`), but not for training AI models (`ai-train=no`), per the `Content-Signal` directives in `robots.txt`. Please attribute the author when quoting.

---

# Abdelrahman Saed — Lead Mobile Engineer

> Full CV / résumé of [Abdelrahman Saed](https://bnsaed.com) (binSaed · Arabic: عبدالرحمن سعيد), in markdown for easy reading by humans and AI systems.
> Source of truth is the LaTeX CV at <https://github.com/binSaed/CV>; the PDF is at <https://bnsaed.com/CV/cv.pdf>.
> Usage: `ai-input=yes`, `ai-train=no` — attribute the author when quoting.

- **Location:** Cairo, Egypt / Remote
- **Phone:** [(+20) 1151034858](tel:+201151034858)
- **Email:** <me@bnsaed.com>
- **Book a meeting:** <https://calendly.com/bnsaed/meet>
- **Website:** <https://bnsaed.com>
- **LinkedIn:** <https://www.linkedin.com/in/binsaed/>
- **YouTube:** <https://www.youtube.com/@binSaed>
- **GitHub:** <https://github.com/binSaed>
- **Stack Overflow:** <https://stackoverflow.com/users/11001850/abdelrahman-saed>
- **pub.dev:** <https://pub.dev/publishers/bnsaed.com/packages>

## Summary

Lead Mobile Engineer with **7+ years** shipping production iOS and Android apps to **5M+ users** — Flutter, Dart, BLoC / Cubit, Clean Architecture. Lead a 4-engineer squad at [iStoria](https://istoria.app/en/) (EdTech · English language learning), owning architecture, release governance, and delivery for a **50+ module** codebase. Shipped **350+ production releases**, reviewed **900+ PRs**, sustained a **99.9% crash-free rate**, and cut the release cycle from **4 weeks to 1**.

## Skills

- **Mobile:** Flutter, Dart, Android, iOS, Custom Widgets, Animations, Responsive / Adaptive UI (iPad / Tablet / Landscape), Push Notifications (FCM), Deep Linking, Dark Mode / Theming, Home Screen Widgets (WidgetKit / Android App Widgets), Internationalization (i18n / RTL)
- **Architecture & Data:** Clean Architecture, MVVM, BLoC, Cubit, Dependency Injection (DI), SOLID, PowerSync, Drift, Cloudflare R2, RevenueCat, Stripe, Paddle, AdMob, Appodeal
- **Analytics & Engagement:** Adjust, CleverTap, PostHog, Clarity, Smartlook, Segment, WebEngage, MoEngage
- **DevOps & Quality:** CI/CD, GitHub Actions, Codemagic, Trunk-based Development, Feature Flags, Firebase, Sentry, A/B Testing (Remote Config + GrowthBook), Performance Profiling, Crash Budgets, Unit / Widget Testing, Integration Testing (Maestro / Patrol / Marionette), PR Automation Bots, REST, GraphQL
- **Leadership:** Architecture Ownership, Technical RFCs, Release Governance, Code Review, Mentoring

## Experience

### Lead Mobile Engineer — iStoria

**Jun 2025 – Present** · [iStoria](https://istoria.app/en/) (EdTech · English Language Learning) · Riyadh, Saudi Arabia (Hybrid)
*Stack: Flutter, Dart, BLoC, Cubit, PowerSync, Drift, Firebase, Sentry, GitHub Actions, GraphQL, REST*

- Lead a 4-engineer iOS/Android squad — defining architecture direction, hiring bar, release governance, and quarterly roadmaps balancing feature velocity against tech debt for a user base of **5M+**.
- Cut release cycle **75% (4 weeks → 1 week)** via trunk-based development, feature flags, and end-to-end CI/CD ownership (Codemagic, GitHub Actions, store releases).
- Architected modular Flutter codebase with **50+ feature modules** and **140+ routes** applying SOLID and MVVM, documented via RFCs and enforced through CI quality gates and code review.
- Led migration to [CleverTap](https://clevertap.com/) as the primary engagement platform, deprecating the previous stack with **zero data loss** for active accounts.
- Hardened production security end-to-end: PII masked in logs and Sentry breadcrumbs, Android cleartext traffic disabled, and TLS certificate validation enforced across the app.
- Cut QA turnaround by shipping internal tooling that builds APKs from any GitHub branch on demand, removing manual hand-offs from the test loop.

### Founding Mobile Engineer — iStoria

**Jan 2022 – Jun 2025** · 3 yrs 6 mos · [iStoria](https://istoria.app/en/) (EdTech · English Language Learning) · Riyadh, Saudi Arabia (Hybrid)
*Stack: Flutter, Dart, BLoC, Cubit, PowerSync, Drift, Codemagic, Firebase, Sentry, Adjust, REST, GraphQL*

- Shipped flagship growth and AI features end-to-end: **Leaderboard** with social sharing, **AI Read-with** (Google speech recognition for guided reading), and **iStro AI chat companion** guiding users through chapter journeys.
- Delivered **native Home Screen Widgets** on iOS (WidgetKit) and Android (App Widgets) for streak/daily-goal with FCM-driven updates, plus an **app-wide deep-link router** for auth, passwordless reset, marketing/coupon flows, challenges, and group invites.
- Built the engagement and retention loop powering daily active usage: **Streak** system with day-off freeze and lost-streak restore, **Badges**, **Daily Goal** tracker, social **Challenges**, and a **Referral / Invite-Friends** program.
- Achieved **99.9% crash-free rate** via crash budgets, systematic profiling, and CI quality gates blocking releases below stability thresholds.
- Designed offline-first infrastructure (PowerSync + Drift) with selective per-key sync, conflict resolution, schema migration, and data integrity guarantees for millions of active accounts.
- Drove a sustained performance program: **~35% cold-start reduction** (parallelized service providers, deferred post-first-frame SDKs, 35+ eager → lazy dependencies); **~60% rendering gains** (RepaintBoundary isolation, background JSON deserialization); **~20% memory reduction** (eliminating controller/stream leaks).
- Architected and implemented an end-to-end cross-platform anti-piracy system integrating mobile tampering, jailbreak, and root detection with backend behavioral analysis to fingerprint compromised builds, automatically blocking **40,000+ abusive accounts** and reducing credential sharing through enforced 3-device account limits.
- Expanded the supported matrix with app-wide **Dark Mode** (system-theme aware), **iPad / tablet / landscape adaptive UI**, and a **20-language i18n pipeline** (RTL incl. Arabic, Persian, Urdu) with locale-aware push notifications — all without forking the codebase.
- Migrated mobile asset delivery from **AWS CloudFront to Cloudflare R2**, coordinating cutover across mobile, backend, and content pipelines with zero user-visible downtime.
- Owned the monetization stack: [RevenueCat](https://www.revenuecat.com/) for native IAP across iOS/Android, a **custom webview checkout migrated from Paddle to Stripe** for fallback flows, **AdMob** ad serving (selected over Appodeal via A/B testing) with frequency capping and report-ad UX, and a promo / coupon engine driving paid-tier conversion.
- Built internal DevEx tooling: a **PR-automation bot** (Jira-link injection, conflict labelling, auto-branch-update, stale-branch sweep, quality-team auto-check).
- Maintained **8+ custom/forked Flutter packages**, contributing fixes upstream and owning long-term maintenance to meet production reliability requirements.
- Integrated 4 marketing SDKs ([WebEngage](https://webengage.com/), [MoEngage](https://www.moengage.com/), [CleverTap](https://clevertap.com/), [Adjust](https://www.adjust.com/)) and built A/B testing infrastructure (Firebase Remote Config + [GrowthBook](https://www.growthbook.io/)), enabling data-driven feature rollouts and personalized engagement.
- Maintained end-to-end stability across **mobile, backend, and cloud** as **the sole engineer for 1.5M users** during a team transition, while keeping release stability above thresholds.

### Mobile Engineer — Happy Trip

**Oct 2020 – Jan 2022** · 1 yr 4 mos · [Happy Trip](https://www.linkedin.com/company/happytrip-ar) · Cairo, Egypt
*Stack: Flutter, Dart, REST, Firebase*

- Owned mobile delivery end-to-end across the Flutter team — task planning, code review gates, and sprint execution — setting the cadence for every release during the platform's first 14 months.
- Architected a networking SDK with multi-layer caching, request queueing, auto-retry, token refresh, and background parsing, **cutting networking code by 60%**.
- Designed and delivered the company's first mobile platform (consumer booking, internal HR, POS) in 14 months, enabling a full business pivot from traditional tourism to a tech-enabled service.

### Mobile Engineer (Part-time) — Tech Bay Portal

**Feb 2021 – Oct 2021** · 9 mos · Tech Bay Portal · UAE (Remote)
*Stack: Flutter, Dart, Socket.io, Google Maps*

- Delivered real-time GPS tracking and live trip updates (Socket.io, Google Maps) for a ride-hailing platform across driver and rider apps.
- Hardened the live-tracking pipeline against intermittent connectivity — buffered location updates locally and replayed on reconnect to keep trip state consistent on both sides.
- Collaborated within a distributed multinational agile team, coordinating asynchronously across time zones alongside a concurrent full-time role.

### Mobile Engineer — FekraSoft Co

**Aug 2019 – Sep 2020** · 1 yr 2 mos · [FekraSoft Co](https://www.linkedin.com/company/fekrasoft) · Cairo, Egypt
*Stack: Flutter, Dart, RxDart, Firebase*

- Led company-wide Flutter adoption as the first Flutter engineer, mentoring the Android team through advanced patterns (Streams, RxDart, DI) and authoring the shared coding standards the team still uses.
- Shipped production e-commerce and document management apps with barcode scanning and file-level encryption.

## Projects

- **[flutter_cached_pdfview](https://pub.dev/packages/flutter_cached_pdfview)** — Cross-platform Flutter PDF viewer (iOS/Android) with URL/asset/local caching, password-protected PDFs, and gesture-based zoom; **[134★ on GitHub](https://github.com/binSaed/flutter_cached_pdfview)**, **501 likes / 57k+ downloads on pub.dev**.
- **[flutter_zoom_checker](https://pub.dev/packages/flutter_zoom_checker)** — iOS/Android display-zoom (UI scaling) detection plugin, working around a Flutter framework limitation ([issue #95421](https://github.com/flutter/flutter/issues/95421)).
- **[dio_firebase_performance_plus](https://github.com/binSaed/dio_firebase_performance_plus)** — Dio interceptor streaming HTTP request metrics to Firebase Performance Monitoring.
- **[clipboard_plus](https://pub.dev/packages/clipboard_plus)** — Flutter clipboard plugin with **silent content-type detection on iOS** (UIPasteboard) — detects URLs, text, and images without triggering the iOS paste permission dialog.
- **[Stickerawy](https://play.google.com/store/apps/details?id=com.stickerawy.stickers)** — WhatsApp stickers Android app with **500K+ installs** on Google Play.
- **Open-source contributions:** patches, fixes, and documentation to [flutter/website](https://github.com/flutter/website/pull/13639), [flutter/flutter](https://github.com/flutter/flutter/pull/190166), [flutter/flutter](https://github.com/flutter/flutter/pull/190163), [flutter/devtools](https://github.com/flutter/devtools/pull/9925), [growthbook-flutter](https://github.com/growthbook/growthbook-flutter/pull/143), [growthbook-flutter](https://github.com/growthbook/growthbook-flutter/pull/139), [youtube_explode_dart](https://github.com/Hexer10/youtube_explode_dart/pull/371), and [dio_smart_retry](https://github.com/rodion-m/dio_smart_retry/pull/36).

## Education

**Bachelor of Management Information Systems** — Ain Shams Academy, Cairo, Egypt

## Languages

- **Arabic:** Native
- **English:** Professional

---

---
title: Building a Cached PDF Viewer in Flutter
slug: flutter-cached-pdfview-guide
date: 2024-01-15
dateModified: 2026-07-30
readTime: 7 min read
tags: [Flutter, Dart, PDF, Mobile]
author: Abdelrahman Saed
url: https://bnsaed.com/articles/flutter-cached-pdfview-guide
---

# Building a Cached PDF Viewer in Flutter

![flutter_cached_pdfview — native cached PDF viewing on iOS and Android](/article-images/flutter-cached-pdfview-guide-hero.webp)

> **Updated July 2026** — refreshed for `flutter_cached_pdfview` ^0.4.4 and modern Flutter 3.x idioms (`super.key`, `const` constructors, current null-safety).

A cached PDF viewer in Flutter displays remote PDFs while caching each downloaded file on-device, so it opens instantly and works offline on every load after the first. The `flutter_cached_pdfview` package builds this for you: it wraps the native `flutter_pdfview` renderer with `flutter_cache_manager`, exposing URL, asset, and file-path sources plus progress placeholders and error widgets out of the box. To use it, add `flutter_cached_pdfview: ^0.4.4` to your `pubspec.yaml`, then render `const PDF().cachedFromUrl(url, ...)`. The first load downloads and caches the document; every later load is served straight from disk, cutting both wait time and bandwidth. The package targets Android API 20 and above and iOS 11 and above, and is maintained on pub.dev by its original author. The guide below walks through installation, basic usage with modern Flutter 3.x syntax, multiple sources, loading states, and error handling.

I wrote `flutter_cached_pdfview`, and I've maintained it since 2020. It grew out of the same problem showing up in project after project — remote PDFs that re-downloaded on every open, broke the moment the network dropped, and needed the same loading and error boilerplate rebuilt each time. So I built the cached, offline-capable, native-rendering path once, made it the default, and open-sourced it. It's since grown to **134+ GitHub stars, 501+ pub.dev likes, 57,000+ downloads, and 100+ forks**, shipped under the MIT license and used in production document readers, e-book apps, and EdTech products — including the PDF experiences I shipped at iStoria. If you want the design story behind it — the constraints, the two-native-renderers-one-API problem, and what six years of maintenance taught me — read the [flutter_cached_pdfview case study](/case-studies/flutter-cached-pdfview/). This article is the hands-on guide to using it.

## Why does PDF caching matter in a mobile app?

Loading a PDF from a URL is deceptively easy to get wrong. The naive approach — hand the URL to a renderer and let it fetch — re-downloads the whole file on every open. That's slow for the user, wasteful of bandwidth, and it falls apart entirely offline. Caching turns each document into a one-time download:

- **Faster load times** — after the first open, cached files resolve straight from disk with no network round-trip.
- **Reduced bandwidth** — a document is fetched once, not once per view. That's meaningful on metered or slow connections.
- **Offline support** — because reads resolve to the on-device cache, previously viewed PDFs keep working with no connection at all.

The goal of the package is to make that behavior the *default*, in a few lines, on both platforms — rather than something you assemble from a renderer, a cache manager, and a pile of state handling.

## Fast, offline, native — why it's one of the most popular Flutter PDF viewers

If you search for a Flutter PDF viewer, you'll find a long list of packages. What makes `flutter_cached_pdfview` one of the most popular — **134+ GitHub stars, 501+ pub.dev likes, 57,000+ downloads, and 100+ forks** across thousands of shipped apps — is that it combines three things most alternatives make you build yourself:

- **Fast loading from the second open onward.** The first load downloads the document once via `flutter_cache_manager`; every subsequent open of the same URL resolves straight from disk with no network round-trip. For users re-opening a manual, statement, or textbook, that's the difference between a fast loading PDF viewer and one that makes them wait on the network every time.
- **High-performance native rendering.** The package wraps `flutter_pdfview`, which delegates to the platform's own PDF renderer (Android's `PdfRenderer` / iOS's `PDFKit`) rather than shipping a JavaScript or Dart-based renderer. That's the high-performance Flutter PDF viewer path — documents look and scroll the way the OS itself renders them, with gesture zoom and pan that behave natively on each platform.
- **Offline by default, feature-rich surface.** Once a document is cached it keeps working with no connection at all, and the same widget exposes swipe direction, night mode, password-protected documents, auto-spacing, and page-fling — the feature-rich configuration surface most teams end up needing, without leaving the widget.

Put together, that's the goal many teams are chasing when they go looking for the fastest PDF viewer in Flutter, or a blazing fast PDF viewer that doesn't re-download on every open: native rendering for performance, on-device caching for speed and offline, and a configuration surface that covers the common cases. The rest of this guide is the hands-on version.

## Getting started

Add the package to your `pubspec.yaml`:

```yaml
dependencies:
  flutter_cached_pdfview: ^0.4.4
```

Then run `flutter pub get`. There's no platform-channel setup or manual permission wiring for the common case — the package leans on the native renderers the platforms already provide.

## Basic usage

Here's a minimal viewer, written with modern Flutter 3.x syntax (`super.key` instead of the old `Key? key` / `super(key: key)` boilerplate):

```dart
import 'package:flutter/material.dart';
import 'package:flutter_cached_pdfview/flutter_cached_pdfview.dart';

class PDFViewerPage extends StatelessWidget {
  final String pdfUrl;

  const PDFViewerPage({super.key, required this.pdfUrl});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('PDF Viewer')),
      body: const PDF().cachedFromUrl(
        pdfUrl,
        placeholder: (progress) => Center(
          child: CircularProgressIndicator(value: progress / 100),
        ),
        errorWidget: (error) => const Center(
          child: Text('Error loading PDF'),
        ),
      ),
    );
  }
}
```

The first time this page opens `pdfUrl`, the file downloads and is stored in a managed on-device cache; every subsequent open of the same URL is served from disk.

## Loading a PDF from a URL, asset, or file path

The same widget renders three sources behind one API, so you can swap where a document comes from without touching your UI:

```dart
// Remote URL — downloaded once, then cached and offline-safe.
const PDF().cachedFromUrl('https://example.com/doc.pdf');

// Bundled asset — shipped inside your app.
const PDF().fromAsset('assets/manual.pdf');

// Local file path — e.g. a document the user already downloaded.
const PDF().fromPath('/storage/emulated/0/Download/report.pdf');
```

This matters more than it looks: a lot of apps start with bundled assets, move to remote documents, then add a "downloaded files" section. With one API across all three, that evolution doesn't ripple through your widget tree.

## Customizable loading states

Because downloads take real time on real networks, `placeholder` is part of the public API and receives download progress, so you can show something better than a bare spinner:

```dart
placeholder: (progress) => Center(
  child: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      CircularProgressIndicator(value: progress / 100),
      const SizedBox(height: 16),
      Text('${progress.toStringAsFixed(0)}%'),
    ],
  ),
),
```

## Error handling

Networks fail and files get corrupted, so `errorWidget` is first-class too. Give the user a clear message and a way to recover:

```dart
errorWidget: (error) => Center(
  child: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      const Icon(Icons.error, size: 64, color: Colors.red),
      const SizedBox(height: 16),
      const Text('Failed to load PDF'),
      TextButton(
        onPressed: () { /* retry logic — e.g. setState to rebuild the viewer */ },
        child: const Text('Retry'),
      ),
    ],
  ),
),
```

## Configuring the viewer

The `PDF()` constructor forwards the underlying `flutter_pdfview` options, giving you the feature-rich configuration surface the native renderers expose — scroll direction, password-protected documents, night mode, and page behavior — without leaving the widget:

```dart
const PDF(
  swipeHorizontal: true, // page through side to side instead of vertical scroll
  nightMode: false,      // invert colors for dark reading
  password: 'secret',    // open password-protected PDFs
  autoSpacing: true,
  pageFling: true,
).cachedFromUrl(pdfUrl);
```

Gesture zoom and pan come from the native renderers, so pinch-to-zoom behaves the way users expect on each platform — you don't wire it up yourself.

## How it works under the hood

The package is a deliberately thin composition rather than a reinvention, and that's the reason it stays small and fast:

- **Native rendering, not a Dart PDF engine.** Pixel-level rendering is delegated to the platform components through `flutter_pdfview`, so documents look and scroll natively on each OS — and the package doesn't ship a heavyweight engine inside every app that depends on it.
- **Caching via `flutter_cache_manager`.** Remote files are downloaded once into a managed cache; later reads resolve to a local file. That's what makes repeat opens instant and offline-safe.
- **A source abstraction.** `cachedFromUrl`, `fromAsset`, and `fromPath` unify remote, bundled, and local documents behind one widget.
- **Lifecycle hooks as API.** `placeholder` and `errorWidget` make loading and failure states something you design for, not bolt on afterward.

It supports **Android API 20+** and **iOS 11+**, and ships under the **MIT** license.

## Keeping it current

A package thousands of apps embed can't break them on the next Flutter release. Over six years `flutter_cached_pdfview` has moved through null-safety, `super.key`, and evolving `flutter_cache_manager` APIs while keeping downstream apps working. If you're pinning a version, `^0.4.4` tracks the current line; the [GitHub repo](https://github.com/binSaed/flutter_cached_pdfview) has the changelog and migration notes.

## Wrap-up

For remote PDFs in Flutter, caching isn't a nice-to-have — it's the difference between an app that feels instant and works offline and one that re-fetches megabytes on every tap. `flutter_cached_pdfview` makes the cached, native, offline-capable path the default in a few lines across iOS and Android.

- **Package:** [pub.dev/packages/flutter_cached_pdfview](https://pub.dev/packages/flutter_cached_pdfview)
- **Source:** [github.com/binSaed/flutter_cached_pdfview](https://github.com/binSaed/flutter_cached_pdfview)
- **The design story:** [flutter_cached_pdfview case study](/case-studies/flutter-cached-pdfview/)

---

---
title: "The repo that rejects my own pushes to master"
slug: github-actions-branch-protection-guardrails
date: 2026-04-14
readTime: 6 min read
tags: [GitHub Actions, DevOps, CI/CD, Flutter]
author: Abdelrahman Saed
description: "Two small GitHub Actions that stop accidental direct pushes to master and auto-restore build-breaking files — reversible by design, with an explicit escape hatch."
url: https://bnsaed.com/articles/github-actions-branch-protection-guardrails
---

# The repo that rejects my own pushes to master

*Subtitle: Branch protection rules are step one. Two small GitHub Actions turned "please don't do that" into "the repo won't let you."*

*The Self-Driving Repo · Part 1 — Governance*

![Guardrails — a neon shield protecting the master branch](/article-images/github-actions-branch-protection-guardrails-hero.webp)

---

Every team has the unwritten rule: **don't push straight to `master`.** And every team has the Tuesday afternoon where someone does it anyway — a fast hotfix, a muscle-memory `git push`, a rebase gone sideways — and now production history has a commit that never saw review.

You can write that rule in the README. You can put it in the onboarding doc. People will still break it, including the person who wrote it. (Hi.)

So I stopped relying on discipline and made the repo enforce itself. Two small workflows, under 200 lines combined. One guards the branch. One guards the files that quietly break everyone's build.

Two small GitHub Actions can enforce branch hygiene that documentation alone never will: a `protect-master` workflow that auto-reverts any unauthorized direct push to `master`, and a `protect-files` workflow that restores protected paths — lockfiles, generated native folders, CI config — when a pull request changes them. Both follow one principle, reversible enforcement: instead of blocking a change behind an admin-only red X, they undo the mistake in the open with a readable commit, and both honor an explicit `--skip-protection` escape hatch for the times you genuinely mean it. The branch guard recognizes legitimate merges with a regex over commit subjects and prefers `git revert` over `git reset` so public history is never rewritten. Together, under 200 lines of YAML, they make `master` trustworthy for releases and rollbacks, eliminate a whole class of 'why is CI red?' mysteries, and remove the need to nag teammates about process.

## Why do accidental pushes to `master` happen — and how do you prevent them?

Two recurring failures, both cheap individually and expensive in aggregate:

1. **Direct pushes to `master`.** Not malicious — just human. The cost isn't the one commit; it's the eroded guarantee. Once "`master` is always reviewed" stops being true, you can't trust it for releases, bisects, or rollbacks.

2. **The "innocent" file change that detonates a build.** Lockfiles (`pubspec.lock`, `Podfile.lock`), generated iOS symlinks, IDE settings, CI config. Someone commits their local `pubspec.lock` from a different Flutter version, and now CI fails for everyone until somebody figures out *why*. These files shouldn't change in a feature PR, but nothing stops them from doing so.

GitHub's built-in branch protection helps with (1), but it's coarse, it's UI-configured (not in the repo), and it doesn't address (2) at all. I wanted **guardrails as code** — versioned, reviewable, with an explicit escape hatch.

## The idea

Two push/PR-triggered workflows that don't *ask*, they *correct*:

- **`protect-master`** runs on every push to `master`. If the push isn't an allowed merge, it reverts it automatically.
- **`protect-files`** runs on PRs. If a protected path was touched, it restores that file from `master` and commits the restoration back to the PR branch.

Both share one principle: **reversible enforcement.** Nothing is blocked with a scary red X you have to beg an admin to override. The bad change is *undone*, in the open, with a commit you can read — and there's a documented way to say "yes, I really mean it."

## How it works

![Flow: a push to master checked against allowed-merge rules, then reverted if unauthorized](/article-images/github-actions-branch-protection-guardrails-diagram.webp)

### Guarding the branch

`protect-master` triggers on push and asks one question: *was this an allowed way to land on `master`?*

```yaml
on:
  push:
    branches: [master]
```

Allowed means one of three things: it's a PR merge, the actor is an allow-listed maintainer, or the commit explicitly opts out. The merge check is the interesting part — GitHub writes recognizable commit subjects for merges, squashes, and rebases, so a regex covers all three:

```bash
# A normal merge: "Merge pull request #123 from ..."
# A squash/rebase: subject ends with "(#123)"
if echo "$COMMIT_MSG" | grep -qE '^Merge pull request #|\(#[0-9]+\)$'; then
  echo "PR merge — allowed."
  exit 0
fi
```

If none of the allowances match, the workflow reverts the offending commit and pushes the revert:

```bash
git revert HEAD --no-edit
git push
```

I chose `git revert` over `git reset --hard` deliberately. A reset rewrites public history — the exact crime I'm trying to prevent. A revert is additive: the bad commit stays in the log, followed by a commit that undoes it. The audit trail is intact, nobody's local clone gets into a fight with the remote, and the message to the author is clear: *this landed the wrong way; here's the receipt.*

One subtlety worth knowing: `github.event.head_commit.message` only exists on push events. The whole workflow leans on that, which is fine — it's push-triggered by design — but it's the kind of thing that bites you if you copy the snippet into a PR-triggered job.

### Guarding the files

`protect-files` runs on PRs and watches a specific list of paths — lockfiles, generated native folders, IDE and CI config:

```yaml
on:
  pull_request:
    paths:
      - 'pubspec.lock'
      - 'ios/Podfile.lock'
      - 'ios/.symlinks/**'
      - '.vscode/settings.json'
      - '.github/workflows/protect-*.yml'
```

When one of those changes in a PR, the job restores the file from `master` and commits it back to the PR branch:

```bash
git fetch origin master
git checkout origin/master -- "$FILE" || true   # tolerate files absent on master
if ! git diff --cached --quiet; then
  git commit -m "chore: restore protected files from master"
  git push
fi
```

Two details make this safe to run on every PR. The `|| true` means a path that doesn't exist on `master` (a brand-new generated file) doesn't crash the job. And the `git diff --cached --quiet` guard means it only commits when something actually changed — no empty commits, no noise on the 95% of PRs that touch nothing protected.

### The escape hatch (this is the important part)

Automated enforcement without an override is a trap. Sometimes you *do* need to bump the lockfile. Sometimes the maintainer really is doing an intentional direct commit. So both workflows honor an opt-out token in the commit message:

```bash
if echo "$COMMIT_MSG" | grep -q -- '--skip-protection'; then
  echo "Opt-out flag present — skipping enforcement."
  exit 0
fi
```

Want to push a deliberate lockfile change? `git commit -m "chore: bump pods --skip-protection"`. The intent is explicit, it's in the history, and it's greppable later when you're auditing who bypassed what. The guardrail has a gate, and the gate has a name.

## What do these GitHub Actions guardrails prevent?

- **`master` is trustworthy again.** Every commit on it is either a reviewed merge or an explicitly-flagged exception. Releases and rollbacks rest on that.
- **A class of "why is CI red?" mysteries disappeared.** Protected files can't drift in through a feature PR by accident.
- **Zero meetings about process.** The rule enforces itself, so I stopped being the person who pings people about their pushes. Around 5 fewer "hey can you revert that" messages a month.

## Gotchas & trade-offs

- **An auto-revert can surprise an author.** Mitigation: the revert commit message should say *why* and point to the PR flow. Surprise is a docs problem, not a reason to skip enforcement.
- **The allow-list is a single point of trust.** Keep it tiny and in code review. A long allow-list is just the original problem with extra steps.
- **`paths:` filters are easy to under-scope.** New generated artifacts appear over time; the protected list is a living thing, not set-and-forget.
- **This is not a security boundary.** A determined committer with push rights and the `--skip-protection` flag can still do anything. It's a guardrail against *accidents and drift*, not an access-control system. Branch protection rules and `CODEOWNERS` are the real perimeter; this is the seatbelt.

## Takeaway

The cheapest way to enforce a team norm isn't a doc or a nag — it's a small workflow that *reverses* the mistake and leaves a readable trail, with a named escape hatch for the times you mean it. Reversible beats blocking. Visible beats silent.

Next in the series: PRs that fill in their own context and refuse to merge when the analyzer is unhappy.

## The complete workflow

Here is the full, genericized workflow — drop it into `.github/workflows/` and replace the placeholders (`your-org`, the `PROJ` project key, `<@DISCORD_USER_ID>`, the example team, and the secret names) with your own.

### `.github/workflows/protect-master.yml`

````yaml
name: Protect Master Branch

on:
  push:
    branches:
      - master

jobs:
  check-push-permission:
    runs-on: ubuntu-slim
    permissions:
      contents: write
    steps:
      - name: Check pusher authorization
        id: check-auth
        env:
          PUSHER: ${{ github.actor }}
          COMMIT_MSG: ${{ github.event.head_commit.message }}
        run: |
          ALLOWED_USERS=("your-maintainer")

          # Check if it's a PR merge (allow anyone to merge approved PRs)
          # Matches: "Merge pull request #..." (merge commit), "...(#123)" (squash/rebase merge)
          if echo "$COMMIT_MSG" | grep -qE "^Merge pull request #|\\(#[0-9]+\\)"; then
            echo "✅ PR merge allowed: $PUSHER"
            echo "authorized=true" >> $GITHUB_OUTPUT
            exit 0
          fi

          # Check if pusher is allowed for direct pushes
          for user in "${ALLOWED_USERS[@]}"; do
            if [ "$PUSHER" = "$user" ]; then
              echo "✅ Direct push by authorized user: $PUSHER"
              echo "authorized=true" >> $GITHUB_OUTPUT
              exit 0
            fi
          done

          # Check for skip flag in commit message
          if echo "$COMMIT_MSG" | grep -q -- "--skip-protection"; then
            echo "✅ Skipping protection: --skip-protection flag found"
            echo "authorized=true" >> $GITHUB_OUTPUT
            exit 0
          fi

          echo "❌ Unauthorized direct push to master by: $PUSHER"
          echo "Only the following users can push directly to master: ${ALLOWED_USERS[*]}"
          echo "Or merge via pull request"
          echo "authorized=false" >> $GITHUB_OUTPUT

      - name: Checkout repository
        if: steps.check-auth.outputs.authorized == 'false'
        uses: actions/checkout@v4
        with:
          fetch-depth: 2
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Revert unauthorized push
        if: steps.check-auth.outputs.authorized == 'false'
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git revert HEAD --no-edit
          git push origin master

      - name: Fail workflow for unauthorized push
        if: steps.check-auth.outputs.authorized == 'false'
        run: |
          echo "🔄 Unauthorized commit has been reverted"
          echo "Options:"
          echo "  - Only @your-maintainer can push directly to master"
          echo "  - Use --skip-protection flag in commit message"
          echo "  - Merge via pull request (requires approval)"
          exit 1
````

### `.github/workflows/protect-files.yml`

````yaml
name: Protect Files

on:
  pull_request:
    types: [opened, synchronize]
    paths:
      - '.github/workflows/protect-files.yml'
      - '.github/workflows/protect-master.yml'
      - '.vscode/settings.json'
      - '.vscode/launch.json'
      - '.claude/settings.local.json'
      - 'pubspec.lock'
      - 'ios/Podfile.lock'
      - 'ios/.symlinks/**'
      - 'ios/Flutter/**'
      - 'macos/**'

jobs:
  restore-protected-files:
    if: github.event.pull_request.user.login != 'your-maintainer' && github.actor != 'your-maintainer'
    runs-on: ubuntu-slim
    permissions:
      contents: write
    steps:
      - name: Checkout PR branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.head_ref }}
          fetch-depth: 0
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Configure Git
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"

      - name: Check for skip flag in commit message
        id: check-skip
        run: |
          COMMIT_MSG=$(git log -1 --pretty=%B)
          if echo "$COMMIT_MSG" | grep -q -- "--skip-protection"; then
            echo "skip=true" >> $GITHUB_OUTPUT
            echo "Skipping protection: --skip-protection flag found in commit message"
          else
            echo "skip=false" >> $GITHUB_OUTPUT
          fi

      - name: Restore protected files from master
        if: steps.check-skip.outputs.skip != 'true'
        run: |
          git fetch origin master

          # List of protected files/folders
          PROTECTED_FILES=(
            ".github/workflows/protect-files.yml"
            ".github/workflows/protect-master.yml"
            ".vscode/settings.json"
            ".vscode/launch.json"
            ".claude/settings.local.json"
            "pubspec.lock"
            "ios/Podfile.lock"
          )

          PROTECTED_FOLDERS=(
            "ios/.symlinks"
            "ios/Flutter"
            "macos"
          )

          CHANGED=false

          # Restore individual files
          for file in "${PROTECTED_FILES[@]}"; do
            if git diff --name-only origin/master HEAD | grep -q "^${file}$"; then
              echo "Restoring $file from master"
              git checkout origin/master -- "$file" 2>/dev/null || true
              CHANGED=true
            fi
          done

          # Restore folders
          for folder in "${PROTECTED_FOLDERS[@]}"; do
            if git diff --name-only origin/master HEAD | grep -q "^${folder}/"; then
              echo "Restoring $folder from master"
              git checkout origin/master -- "$folder" 2>/dev/null || true
              CHANGED=true
            fi
          done

          if [ "$CHANGED" = true ]; then
            git add -A
            if ! git diff --cached --quiet; then
              git commit -m "chore: restore protected files from master

              Protected files can only be modified by @your-maintainer or with --skip-protection flag"
              git push
            else
              echo "No changes to commit"
            fi
          else
            echo "No protected files were modified"
          fi
````

---

---
title: "PRs that fill in their own paperwork (and a gate that won't let bad code merge)"
slug: automate-pull-request-hygiene
date: 2026-04-21
dateModified: 2026-07-25
readTime: 6 min read
tags: [GitHub Actions, Developer Experience, CI/CD, Code Review]
author: Abdelrahman Saed
description: "Separating the chores of code review from the judgment: auto-context on every PR, plus a quality gate that validates translations, runs the analyzer, and cleans up after itself."
url: https://bnsaed.com/articles/automate-pull-request-hygiene
---

# PRs that fill in their own paperwork (and a gate that won't let bad code merge)

*Subtitle: Half of code review is chores — linking the ticket, assigning yourself, checking the analyzer. So I automated the chores and kept humans for the judgment.*

*The Self-Driving Repo · Part 2 — Developer Experience*

![A pull request auto-filling its own context beside a quality gate](/article-images/automate-pull-request-hygiene-hero.webp)

---

Open a pull request on most teams and you're greeted by a checklist: link the Jira ticket, assign yourself, confirm the analyzer passes, tick the boxes. None of it is hard. All of it is friction. And friction at the *start* of review is the worst kind, because it delays the part that actually matters — a human reading your code.

So I split PR review into two buckets. **Chores** (mechanical, rule-based, boring) get automated. **Judgment** (is this the right change?) stays with people. Two workflows handle the chores: one enriches every new PR with context, the other runs a real quality gate and manages its own labels and comments.

Automating pull request hygiene means splitting code review into two buckets: **chores** (mechanical, rule-based motions) get automated, while **judgment** (is this the right change?) stays with humans. Here's how, using two GitHub Actions workflows built with `actions/github-script`. The first, `pr-automation.yml`, fires on PR `opened`: a regex lifts the ticket ID from the branch name (`feature/PROJ-123-...`), writes the Jira link into the PR body, ticks template checkboxes, auto-assigns the author, and adds a `wip` label. The second, `pr-analysis-label.yml`, runs on `opened` and `synchronize`: it validates translation JSON with `python3`, runs `flutter analyze`, then labels, comments, and blocks (exits non-zero) on failure. The guiding principle is **idempotency and self-cleanup**: a `try/get-create-on-404` pattern ensures labels exist, a `concurrency` group cancels stale runs, and a `c.user.type === 'Bot'` filter lets the gate delete its own comment once analysis passes.

## The problem

The opening minutes of every PR were spent on the same low-value motions:

- Copy the ticket ID from the branch name, build the Jira URL, paste it in.
- Assign yourself (or get pinged because you forgot).
- Tick the template checkboxes.
- And on the reviewer's side: pull the branch, run the analyzer, discover the translation file has a trailing-comma syntax error that breaks the build, leave a comment, wait.

Every one of those is deterministic. A human doing them is a human not reading code.

## The idea

Two PR-triggered workflows:

1. **PR automation** — on `opened`, derive context from the branch and write it into the PR: the ticket link, the assignee, a `wip` label.
2. **The analysis gate** — on `opened` and every push, validate translation JSON, run the analyzer, then **label, comment, and block** on failure — and *remove* the label and comment when the next push fixes it.

The theme that makes them feel good to use: **idempotency and self-cleanup.** They never double-post, never leave stale state, and converge to "correct" no matter how many times you push.

## How does a GitHub Actions PR automation and analysis gate work?

![Flow: a PR passing a JSON check and the analyzer, then merge-allowed or blocked with a self-deleting comment](/article-images/automate-pull-request-hygiene-diagram.webp)

### Auto-context on open

The ticket ID already lives in the branch name — `feature/PROJ-123-add-audio-seek`. A regex lifts it out, no external lookup needed:

```js
const match = branch.match(/([A-Z]+-\d+)/);
if (match) {
  const jiraId = match[1];                 // PROJ-123
  const url = `https://your-tracker/browse/${jiraId}`;
  // write the link into the PR body, replacing the template placeholder
}
```

The same step ticks the "I added the Jira link" checkbox in the template, assigns the author if the PR has no assignee yet, and adds a `wip` label. Each action guards itself first — *does it already have an assignee? does the label exist?* — so re-runs are no-ops:

```js
// Create the label only if it's missing (404 = doesn't exist yet)
try {
  await github.rest.issues.getLabel({ owner, repo, name: 'wip' });
} catch (e) {
  if (e.status === 404) {
    await github.rest.issues.createLabel({ owner, repo, name: 'wip', color: 'fbca04' });
  }
}
```

That try/get-create-on-404 pattern shows up in every one of these workflows. GitHub has no "ensure label exists" call, so you build it yourself — and once you have it, label management stops being something humans think about.

### A gate that validates more than Dart

The analysis workflow runs on open and on every push, scoped to the files that matter:

```yaml
on:
  pull_request:
    types: [opened, synchronize]
    paths: ['**.dart', 'pubspec.yaml', 'assets/translations/**.json']

concurrency:
  group: dart-analysis-${{ github.event.pull_request.number }}
  cancel-in-progress: true
```

That `concurrency` block matters more than it looks. Push three times in a minute and you don't want three analyzer runs racing to comment on the same PR — you want the latest one to win and the rest to cancel. `cancel-in-progress: true` gives you exactly that.

First it validates translation files, because a malformed localization JSON breaks the build in a way the Dart analyzer won't catch:

```bash
for f in assets/translations/*.json; do
  python3 -c "import json; json.load(open('$f'))" || { echo "Invalid JSON: $f"; exit 1; }
done
```

Then the analyzer, captured rather than fail-fast so we can format the result ourselves:

```bash
flutter analyze > analysis-output.txt 2>&1 || true
grep "error •" analysis-output.txt && echo "has_errors=true" >> "$GITHUB_OUTPUT"
```

### The part I'm proud of: bidirectional state

Most "lint bot" workflows only know how to complain. This one also knows how to *take it back*. On failure, it adds an `analysis-failed` label and posts one comment with the errors tucked inside a collapsible `<details>` block (so the PR stays skimmable). On the next push, if analysis passes, it removes the label and **deletes its own comment**:

```js
// On pass: find the bot's previous failure comment and remove it
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
const stale = comments.data.find(c =>
  c.user.type === 'Bot' && c.body.includes('Dart Analysis Failed'));
if (stale) await github.rest.issues.deleteComment({ owner, repo, comment_id: stale.id });
```

The `c.user.type === 'Bot'` filter is the safety latch: the workflow only ever deletes comments *it* wrote, never a human's. The result is a PR timeline that reflects the *current* state, not a graveyard of "❌ failed / ✅ fixed / ❌ failed again" noise. When the gate is green, it's silent.

And when it's red, it actually blocks — the job exits non-zero so the merge is gated, not just decorated:

```bash
[ "$has_errors" = "true" ] && exit 1
```

## What does automating PR hygiene actually save you?

- **Reviewers start on the code, not the chores.** Context is already there when they open the PR.
- **A whole category of broken builds never reaches `master`** — malformed translations and analyzer errors are caught at the door.
- **The PR timeline stays clean.** Self-deleting comments mean no stale failure noise to scroll past.
- **Around 5 fewer "please link the ticket / please rebase / CI is red because of a comma" round-trips per week.**

## Gotchas & trade-offs

- **Editing the PR body is brittle.** Auto-ticking checkboxes is naive string replacement — if someone reworded the template, the match silently fails. Keep the template text stable, or match loosely.
- **`paths:` is a double-edged filter.** Scope it too tightly and a relevant change skips the gate; too loosely and you run the analyzer on doc-only PRs. Revisit it as the repo grows.
- **Bot-comment deletion depends on the bot identity.** If you switch the token the comments are posted under, the `user.type === 'Bot'` filter can stop matching your own history. Test after any auth change.
- **Automating chores can hide them.** New teammates never learn *why* the ticket link matters because they never do it manually. Worth a sentence in onboarding so the automation is understood, not just trusted.

## Related reading

This article is part of [The Self-Driving Repo](/articles/) series. Once PRs fill in their own paperwork, the next problems are mergeability and conflict:

- [How the merge-conflict radar works](/articles/auto-rebase-and-detect-merge-conflicts/) — a two-workflow system that refreshes every open PR on each merge to `master` and routes conflict alerts to the right author.
- [Letting a bot resolve merge conflicts safely](/articles/bot-that-resolves-merge-conflicts/) — the provably-safe, single-regex case where auto-committing a conflict fix is genuinely fine.

## Takeaway

Separate the **chores** of review from the **judgment** of review, and automate only the chores — idempotently, with self-cleanup, so the bot's output always reflects current reality. The win isn't "look, a bot." It's that every human minute on a PR now goes to the one thing humans are uniquely good at: deciding whether the change is right.

Next: making two sources of truth — Jira and GitHub — actually agree with each other.

## The complete workflow

Here is the full, genericized workflow — drop it into `.github/workflows/` and replace the placeholders (`your-org`, the `PROJ` project key, `<@DISCORD_USER_ID>`, the example team, and the secret names) with your own.

### `.github/workflows/pr-automation.yml`

````yaml
name: PR Automation

on:
  pull_request:
    types: [opened]

permissions:
  pull-requests: write
  issues: write

jobs:
  automate-pr:
    runs-on: ubuntu-slim
    steps:
      - name: Add Jira Link
        uses: actions/github-script@v7
        with:
          script: |
            const branchName = context.payload.pull_request.head.ref;
            const jiraIdMatch = branchName.match(/([A-Z]+-\d+)/);

            if (!jiraIdMatch) {
              console.log('No Jira ID found in branch name');
              return;
            }

            const jiraId = jiraIdMatch[1];
            const jiraUrl = `https://your-org.atlassian.net/browse/${jiraId}`;
            let prBody = context.payload.pull_request.body || '';

            console.log(`Found Jira ID: ${jiraId}`);

            if (prBody.includes(jiraUrl)) {
              console.log('Jira URL already exists in PR description');
              return;
            }

            const placeholder = '<!---add your Jira link-->';
            const jiraLink = `**Jira Ticket:** ${jiraUrl}`;

            if (prBody.includes(placeholder)) {
              prBody = prBody.replace(placeholder, jiraLink);
              console.log('Replaced placeholder with Jira link');
            } else {
              prBody = `${jiraLink}\n\n${prBody}`;
              console.log('Prepended Jira link to PR body');
            }

            // Check the Jira link checkbox
            prBody = prBody.replace(
              '- [ ] I added the Jira link',
              '- [x] I added the Jira link'
            );

            await github.rest.pulls.update({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.payload.pull_request.number,
              body: prBody
            });
            console.log('✅ Added Jira link to PR');

      - name: Auto-assign PR creator
        uses: actions/github-script@v7
        with:
          script: |
            const assignees = context.payload.pull_request.assignees || [];

            if (assignees.length > 0) {
              console.log(`PR already has assignees: ${assignees.map(a => a.login).join(', ')}`);
              return;
            }

            const creator = context.payload.pull_request.user.login;
            const prNumber = context.payload.pull_request.number;

            await github.rest.issues.addAssignees({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: prNumber,
              assignees: [creator]
            });
            console.log(`✅ Assigned ${creator} to PR #${prNumber}`);

            // Check the assignee checkbox in PR body
            const { data: pr } = await github.rest.pulls.get({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: prNumber
            });

            let prBody = pr.body || '';
            const unchecked = '- [ ] The pull request has an assignee (assign yourself)';
            
            if (prBody.includes(unchecked)) {
              prBody = prBody.replace(unchecked, '- [x] The pull request has an assignee (assign yourself)');
              await github.rest.pulls.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                pull_number: prNumber,
                body: prBody
              });
              console.log('Checked the assignee checkbox');
            }

      - name: Add WIP label
        uses: actions/github-script@v7
        with:
          script: |
            const labels = context.payload.pull_request.labels || [];

            if (labels.length > 0) {
              console.log(`PR already has labels: ${labels.map(l => l.name).join(', ')}`);
              return;
            }

            const owner = context.repo.owner;
            const repo = context.repo.repo;
            const labelName = 'wip';

            // Ensure wip label exists
            try {
              await github.rest.issues.getLabel({ owner, repo, name: labelName });
            } catch (e) {
              if (e.status === 404) {
                await github.rest.issues.createLabel({
                  owner,
                  repo,
                  name: labelName,
                  color: 'fbca04',
                  description: 'Work in progress'
                });
                console.log(`Created '${labelName}' label`);
              } else {
                throw e;
              }
            }

            await github.rest.issues.addLabels({
              owner,
              repo,
              issue_number: context.payload.pull_request.number,
              labels: [labelName]
            });
            console.log(`✅ Added '${labelName}' label`);
````

### `.github/workflows/pr-analysis-label.yml`

````yaml
name: Dart Analysis

on:
  pull_request:
    types: [opened, synchronize]
    paths:
      - '**.dart'
      - 'pubspec.yaml'
      - 'pubspec.lock'
      - 'analysis_options.yaml'
      - 'assets/translations/**.json'
  workflow_dispatch:

concurrency:
  group: dart-analysis-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

permissions:
  pull-requests: write
  issues: write
  contents: read

jobs:
  dart-analysis:
    runs-on: ubuntu-slim
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Validate translation JSON files
        id: validate_json
        run: |
          echo "Validating translation files..."
          FAILED=false

          for file in assets/translations/*.json; do
            if [ -f "$file" ]; then
              echo -n "Checking $file... "
              if python3 -c "import json; json.load(open('$file'))" 2>/dev/null; then
                echo "✓ Valid"
              else
                echo "✗ Invalid JSON"
                python3 -c "import json; json.load(open('$file'))" 2>&1 || true
                FAILED=true
              fi
            fi
          done

          if [ "$FAILED" = true ]; then
            echo ""
            echo "has_errors=true" >> $GITHUB_OUTPUT
            echo "::error::One or more translation files contain invalid JSON"
            exit 1
          fi

          echo "has_errors=false" >> $GITHUB_OUTPUT
          echo ""
          echo "All translation files are valid JSON"

      - name: Set up Flutter
        uses: subosito/flutter-action@v2
        with:
          channel: 'stable'
          flutter-version: '3.44.0'
          cache: true
          cache-key: "flutter-:os:-:channel:-:version:-:arch:"
          cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:"

      - name: Get dependencies
        run: flutter pub get

      - name: Run Dart analyze
        id: analyze
        run: |
          flutter analyze > analysis-output.txt 2>&1 || true
          if grep -q "error •" analysis-output.txt; then
            echo "has_errors=true" >> $GITHUB_OUTPUT
            echo "error_details<<EOF" >> $GITHUB_OUTPUT
            grep "error •" analysis-output.txt >> $GITHUB_OUTPUT
            echo "EOF" >> $GITHUB_OUTPUT
          else
            echo "has_errors=false" >> $GITHUB_OUTPUT
          fi

      - name: Add failure label if analysis has errors
        if: steps.analyze.outputs.has_errors == 'true'
        uses: actions/github-script@v7
        env:
          ERROR_DETAILS: ${{ steps.analyze.outputs.error_details }}
        with:
          script: |
            const owner = context.repo.owner;
            const repo = context.repo.repo;
            const labelName = 'analysis-failed';
            const prNumber = context.payload.pull_request.number;

            // Ensure label exists
            async function ensureLabel(name, color, description) {
              try {
                await github.rest.issues.getLabel({ owner, repo, name });
              } catch (e) {
                if (e.status === 404) {
                  await github.rest.issues.createLabel({ owner, repo, name, color, description });
                  console.log(`Created label: ${name}`);
                } else {
                  throw e;
                }
              }
            }

            await ensureLabel(labelName, 'd93f0b', 'Dart analysis found issues');

            // Add label
            await github.rest.issues.addLabels({
              owner,
              repo,
              issue_number: prNumber,
              labels: [labelName]
            });
            console.log(`✅ Added '${labelName}' label to PR #${prNumber}`);

            // Post comment
            const errors = process.env.ERROR_DETAILS;
            const body = `## ❌ Dart Analysis Failed\n\nPlease fix the analysis issues before merging.\n\n<details>\n<summary>Errors found</summary>\n\n\`\`\`\n${errors}\n\`\`\`\n\n</details>\n\n@${context.payload.pull_request.user.login} \n <img src="https://your-cdn.example.com/dart-analysis-failed.png" width="66px"/>`;

            await github.rest.issues.createComment({
              owner,
              repo,
              issue_number: prNumber,
              body: body
            });
            console.log('✅ Added comment to PR');

      - name: Remove failure label if analysis passed
        if: steps.analyze.outputs.has_errors == 'false'
        uses: actions/github-script@v7
        with:
          script: |
            const owner = context.repo.owner;
            const repo = context.repo.repo;
            const labelName = 'analysis-failed';
            const prNumber = context.payload.pull_request.number;

            // Remove label if it exists
            try {
              await github.rest.issues.removeLabel({
                owner,
                repo,
                issue_number: prNumber,
                name: labelName
              });
              console.log(`✅ Removed '${labelName}' label from PR #${prNumber}`);
            } catch (e) {
              if (e.status === 404) {
                console.log(`Label '${labelName}' not found on PR, skipping removal`);
              } else {
                throw e;
              }
            }

            // Find and remove analysis-failed comment
            const comments = await github.rest.issues.listComments({
              owner,
              repo,
              issue_number: prNumber
            });

            for (const comment of comments.data) {
              if (comment.body.includes('## ❌ Dart Analysis Failed') && comment.user.type === 'Bot') {
                await github.rest.issues.deleteComment({
                  owner,
                  repo,
                  comment_id: comment.id
                });
                console.log(`✅ Removed analysis-failed comment from PR #${prNumber}`);
              }
            }

      - name: Fail job if analysis has errors
        if: steps.analyze.outputs.has_errors == 'true'
        run: exit 1
````

---

---
title: "Making Jira and GitHub agree without anyone updating both"
slug: sync-jira-with-github-prs
date: 2026-04-28
readTime: 7 min read
tags: [GitHub Actions, Jira, CI/CD, Developer Experience]
author: Abdelrahman Saed
description: "A branch-name regex and one REST call mirror Jira status, fix versions, and labels onto every PR — plus a Slow PR flag that surfaces finished work that is not shipping."
url: https://bnsaed.com/articles/sync-jira-with-github-prs
---

# Making Jira and GitHub agree without anyone updating both

*Subtitle: Two tools, two sources of truth, one tax: keeping them in sync by hand. So I built a bridge that mirrors Jira onto every PR — and quietly flags the work that's stuck.*

*The Self-Driving Repo · Part 3 — Integration*

![A glowing bridge syncing an issue board and a pull request](/article-images/sync-jira-with-github-prs-hero.webp)

---

Every team that uses Jira and GitHub pays the same small, constant tax: **keeping the two of them telling the same story.** The ticket is "In Review" but the PR is merged. The PR is open but the ticket is "Done." Which one do you believe? Usually neither, so you go ask the person — and now three people are doing status archaeology instead of building.

I didn't want a heavyweight integration or a paid app. I wanted the PR to *know things about its ticket* automatically: what release it's targeting, what labels it carries, whether it's been marked Done — and, the part that turned out most useful, whether it's been Done for *too long* without merging.

Syncing Jira and GitHub doesn't need a paid app or marketplace integration. Abdelrahman Saed built a single **GitHub Actions** workflow (using `actions/github-script@v7`) that mirrors Jira metadata onto every pull request. Here's how it works: a branch-name regex `/([A-Z]+-\d+)/` extracts the ticket ID, then one authenticated **Jira REST API** call (`/rest/api/3/issue/{id}?fields=status,resolutiondate,labels,fixVersions`, using Basic auth with an email and API token) fetches the issue. The workflow mirrors fix versions as `fix:` labels and Jira labels as `jira:` labels, reconciling on every run — adding and removing so stale labels never linger. It also adds a `tiny PR` (≤2 files) or `small PR` (<10 files) size label from the changed-file count. The sleeper feature is a `Slow PR` flag: when a ticket is Done but its resolution date is over three days old and the PR is still open, it surfaces finished work that isn't shipping. A twice-daily weekday cron catches async Jira status changes.

## The problem

The mismatch between issue tracker and code host shows up as three distinct annoyances:

1. **No release visibility on the PR.** Which version is this fix shipping in? That lives in Jira's "Fix Version" field, invisible from GitHub. Reviewers and release managers had to cross-reference.
2. **No size signal.** Is this a two-line fix or a 40-file refactor? You can't triage review effort from the PR list.
3. **The "Done but not merged" black hole.** A ticket gets marked Done in Jira, but the PR sits unmerged for days — blocked on QA, on a dependency, on someone's attention. Nobody notices until the release manager goes looking.

## The idea

One workflow, triggered both on PR events *and* on a schedule, that for each open PR:

- pulls the ticket ID from the branch name,
- calls the Jira REST API for that issue's status, fix versions, labels, and resolution date,
- **mirrors** that metadata onto the PR as prefixed labels,
- adds an automatic **size** label from the file count,
- and raises a **"Slow PR"** flag when a ticket has been Done for more than three days but the PR is still open.

The scheduled trigger is the unlock. PR events only fire when *code* changes — but Jira status changes on its own timeline (someone moves a card hours later). Running twice a day on weekdays catches those async changes without anyone touching the PR.

```yaml
on:
  pull_request:
    types: [opened, synchronize, reopened]
  schedule:
    - cron: '0 9 * * 0-4'    # 9:00 — weekdays
    - cron: '0 13 * * 0-4'   # 13:00 — weekdays
```

## How does syncing Jira status onto GitHub PRs work?

![Flow: a branch name feeding a regex and a Jira API call, fanning out into fix, jira and Slow-PR labels](/article-images/sync-jira-with-github-prs-diagram.webp)

### Branch name → Jira issue

Same trick as the rest of the system: the ticket ID is encoded in the branch, so a regex is all the "integration" you need to start.

```js
const jiraId = branch.match(/([A-Z]+-\d+)/)?.[1];   // e.g. PROJ-123
```

Then a plain authenticated REST call — no SDK, no app install. Jira uses Basic auth with an API token, base64-encoded client-side:

```js
const auth = Buffer.from(`${email}:${apiToken}`).toString('base64');
const res = await fetch(
  `${JIRA_BASE}/rest/api/3/issue/${jiraId}?fields=status,resolutiondate,labels,fixVersions`,
  { headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' } }
);
const issue = await res.json();
```

### Mirroring with prefixes

The fix versions and Jira labels become GitHub labels, but namespaced so they're visually distinct and easy to clean up — `fix:` for release versions, `jira:` for tags:

```js
for (const v of issue.fields.fixVersions) {
  await ensureLabel(`fix:${v.name}`);          // fix:1.42.0
  await addLabel(`fix:${v.name}`);
}
```

The non-obvious half is **removal**. A bridge that only adds labels rots fast — drop a fix version in Jira and the stale `fix:` label lingers on the PR forever. So the sync is reconciling, not append-only: it computes the set of `fix:`/`jira:` labels the PR *should* have from Jira, and removes any prefixed label that's no longer backed by Jira. Add *and* subtract, every run.

### Size labels from the file count

Cheap, and genuinely useful for triage. Pull the file list, bucket it:

```js
const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number });
const n = files.length;
if (n <= 2)      setExclusive('tiny PR');     // a glance
else if (n < 10) setExclusive('small PR');    // a coffee
else             clearSizeLabels();            // block out real time
```

`setExclusive` adds the right bucket and removes the other, so a PR is never both "tiny" and "small" at once — the labels stay mutually exclusive as the PR grows.

### The "Slow PR" flag (the sleeper feature)

This is the one people actually thanked me for. When Jira says the ticket is Done, the workflow checks *how long* it's been done. If the resolution date is more than three days old and the PR is still open, it flags it:

```js
const DONE_GRACE_MS = 3 * 24 * 60 * 60 * 1000;
if (status === 'done') {
  addLabel('Done');
  removeLabel('wip');
  const age = Date.now() - new Date(resolutionDate).getTime();
  if (age > DONE_GRACE_MS) addLabel('Slow PR');   // done, but stuck
}
```

A "Slow PR" label is a tiny thing that surfaces a real, expensive problem: **work that's finished but not shipping.** That's value sitting on the shelf — the most wasteful state in the whole pipeline, and the easiest to miss because everyone *thinks* it's done. Now it's visible on the board without anyone running a report.

When the ticket flips back from Done (reopened, more work), the same logic removes the `Done` and `Slow PR` labels. Reconcile, don't accumulate.

## What do you gain from syncing Jira with GitHub PRs?

- **Release scope is visible on every PR.** `fix:1.42.0` right on the card — no Jira spelunking to answer "what's in the next build?"
- **Reviewers triage by size at a glance.** "tiny PR" gets a quick pass; a 40-file change gets scheduled.
- **Stuck-but-done work stops hiding.** The "Slow PR" flag turned an invisible cost into a visible one we could actually clear.
- **Nobody updates two tools by hand.** The PR reflects Jira within hours, automatically — roughly 5 fewer "is this merged yet?" pings a week.

## Gotchas & trade-offs

- **It's eventually consistent, not real-time.** A status change shows up at the next scheduled run, not instantly. For this use case that's fine; if you need instant, you need Jira webhooks, which is a bigger build.
- **Naive PR-body edits are fragile.** Auto-ticking a "quality approved" checkbox by string replacement breaks silently if the template wording changes. Keep templates stable.
- **No pagination cap on the PR loop.** Iterate every open PR each run and a repo with hundreds of open PRs will eventually brush the job timeout. Batch or shard before you get there.
- **Two-way "sync" is really one-way mirroring.** This reflects Jira onto GitHub, not the reverse. True bidirectional sync invites loops and conflicts — I deliberately didn't build it. Decide your source of truth and mirror *from* it.
- **Token scope is a real risk.** A Jira API token in CI reads your tracker. Scope it minimally and store it as a secret, never in the workflow.

## Takeaway

You don't need a marketplace integration to make two tools agree — a branch-name regex, one REST call, and a reconciling label sync get you most of the value. And while you're bridging, look for the *flag* that exposes a hidden cost. The labels were nice. "Slow PR" — surfacing finished work that wasn't shipping — was the actual win.

Next: what happens to all those open PRs the moment something lands on `master`.

## The complete workflow

Here is the full, genericized workflow — drop it into `.github/workflows/` and replace the placeholders (`your-org`, the `PROJ` project key, `<@DISCORD_USER_ID>`, the example team, and the secret names) with your own.

### `.github/workflows/jira-status-labels.yml`

````yaml
name: PR Labels

on:
  pull_request:
    types: [opened, synchronize, reopened]
  schedule:
    - cron: '0 13 * * 0-4' # At 1 PM UTC, Sunday-Thursday
    - cron: '0 9 * * 0-4' # At 9 AM UTC, Sunday-Thursday
  workflow_dispatch:

permissions:
  pull-requests: write
  issues: write

jobs:
  manage-pr-labels:
    runs-on: ubuntu-slim
    steps:
      - name: Get PRs to process
        id: get_prs
        uses: actions/github-script@v7
        with:
          script: |
            const owner = context.repo.owner;
            const repo = context.repo.repo;

            let prs = [];

            if (context.eventName === 'pull_request') {
              // Single PR from event
              prs = [context.payload.pull_request];
            } else {
              // Scheduled run or manual dispatch - get all open PRs
              prs = await github.paginate(github.rest.pulls.list, {
                owner,
                repo,
                state: 'open',
                per_page: 100
              });
            }

            core.setOutput('prs', JSON.stringify(prs.map(pr => ({
              number: pr.number,
              branch: pr.head.ref,
              labels: pr.labels.map(l => l.name)
            }))));

      - name: Apply PR labels
        uses: actions/github-script@v7
        env:
          JIRA_BASE_URL: https://your-org.atlassian.net
          JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
          JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
        with:
          script: |
            const prs = JSON.parse('${{ steps.get_prs.outputs.prs }}');
            const owner = context.repo.owner;
            const repo = context.repo.repo;

            const DONE_LABEL = 'Done';
            const SLOW_PR_LABEL = 'Slow PR';
            const TINY_PR_LABEL = 'tiny PR';
            const SMALL_PR_LABEL = 'small PR';
            const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
            const TINY_FILE_THRESHOLD = 2;
            const FILE_THRESHOLD = 10;

            // Ensure labels exist
            async function ensureLabel(name, color, description) {
              try {
                await github.rest.issues.getLabel({ owner, repo, name });
              } catch (e) {
                if (e.status === 404) {
                  await github.rest.issues.createLabel({ owner, repo, name, color, description });
                  console.log(`Created label: ${name}`);
                } else {
                  throw e;
                }
              }
            }

            const WIP_LABEL = 'wip';

            await ensureLabel(DONE_LABEL, '0e8a16', 'Jira task is marked as Done');
            await ensureLabel(SLOW_PR_LABEL, 'fbca04', 'Jira task was Done more than 3 days ago');
            await ensureLabel(TINY_PR_LABEL, '00d4aa', 'PR has 1-2 changed files');
            await ensureLabel(SMALL_PR_LABEL, '0e8a16', 'PR has fewer than 10 changed files');

            for (const pr of prs) {
              const branchName = pr.branch;
              const prNumber = pr.number;
              const currentLabels = pr.labels;

              // --- Small PR Label Logic ---
              // Get the list of files changed in this PR
              const { data: files } = await github.rest.pulls.listFiles({
                owner,
                repo,
                pull_number: prNumber,
                per_page: 100
              });

              const fileCount = files.length;
              console.log(`PR #${prNumber}: ${fileCount} file(s) changed`);

              const hasTinyPRLabel = currentLabels.includes(TINY_PR_LABEL);
              const hasSmallPRLabel = currentLabels.includes(SMALL_PR_LABEL);

              const isTiny = fileCount <= TINY_FILE_THRESHOLD;
              const isSmall = !isTiny && fileCount < FILE_THRESHOLD;

              async function removeLabelSafe(label) {
                try {
                  await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
                  console.log(`PR #${prNumber}: Removed "${label}" label`);
                } catch (e) {
                  if (e.status !== 404) throw e;
                }
              }

              if (isTiny) {
                if (!hasTinyPRLabel) {
                  await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [TINY_PR_LABEL] });
                  console.log(`PR #${prNumber}: Added "${TINY_PR_LABEL}" label (${fileCount} file(s))`);
                }
                if (hasSmallPRLabel) await removeLabelSafe(SMALL_PR_LABEL);
              } else if (isSmall) {
                if (!hasSmallPRLabel) {
                  await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [SMALL_PR_LABEL] });
                  console.log(`PR #${prNumber}: Added "${SMALL_PR_LABEL}" label (${fileCount} files)`);
                }
                if (hasTinyPRLabel) await removeLabelSafe(TINY_PR_LABEL);
              } else {
                if (hasTinyPRLabel) await removeLabelSafe(TINY_PR_LABEL);
                if (hasSmallPRLabel) await removeLabelSafe(SMALL_PR_LABEL);
              }

              // --- Jira Status Label Logic ---
              // Extract Jira ID from branch name (e.g., PROJ-13451)
              const jiraIdMatch = branchName.match(/([A-Z]+-\d+)/);
              if (!jiraIdMatch) {
                console.log(`PR #${prNumber}: No Jira ID found in branch "${branchName}"`);
                continue;
              }

              const jiraId = jiraIdMatch[1];
              console.log(`PR #${prNumber}: Found Jira ID ${jiraId}`);

              // Call Jira API to get issue details
              const jiraUrl = `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraId}?fields=status,resolutiondate,labels,fixVersions`;
              const auth = Buffer.from(`${process.env.JIRA_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64');

              let jiraData;
              try {
                const response = await fetch(jiraUrl, {
                  headers: {
                    'Authorization': `Basic ${auth}`,
                    'Accept': 'application/json'
                  }
                });

                if (!response.ok) {
                  console.log(`PR #${prNumber}: Failed to fetch Jira issue ${jiraId} (status ${response.status})`);
                  continue;
                }

                jiraData = await response.json();
              } catch (e) {
                console.log(`PR #${prNumber}: Error fetching Jira issue ${jiraId}: ${e.message}`);
                continue;
              }

              const status = jiraData.fields?.status?.name;
              const resolutionDate = jiraData.fields?.resolutiondate;
              const jiraLabels = jiraData.fields?.labels || [];
              const fixVersions = (jiraData.fields?.fixVersions || []).map(v => v.name);

              console.log(`PR #${prNumber}: Jira status = "${status}", resolutionDate = "${resolutionDate}", labels = ${JSON.stringify(jiraLabels)}, fixVersions = ${JSON.stringify(fixVersions)}`);

              // Sync Jira Fix Versions to PR (prefixed with "fix:" to distinguish from other labels)
              const FIX_VERSION_PREFIX = 'fix:';
              for (const version of fixVersions) {
                const ghLabelName = `${FIX_VERSION_PREFIX}${version}`;
                
                // Ensure the label exists in GitHub
                await ensureLabel(ghLabelName, 'e6e6fa', `Jira Fix Version: ${version}`);
                
                // Add label to PR if not already present
                if (!currentLabels.includes(ghLabelName)) {
                  await github.rest.issues.addLabels({
                    owner,
                    repo,
                    issue_number: prNumber,
                    labels: [ghLabelName]
                  });
                  console.log(`PR #${prNumber}: Added "${ghLabelName}" label from Jira Fix Version`);
                }
              }

              // Remove fix: labels that are no longer on the Jira issue
              const currentFixLabels = currentLabels.filter(l => l.startsWith(FIX_VERSION_PREFIX));
              const expectedFixLabels = fixVersions.map(v => `${FIX_VERSION_PREFIX}${v}`);
              for (const currentFixLabel of currentFixLabels) {
                if (!expectedFixLabels.includes(currentFixLabel)) {
                  try {
                    await github.rest.issues.removeLabel({
                      owner,
                      repo,
                      issue_number: prNumber,
                      name: currentFixLabel
                    });
                    console.log(`PR #${prNumber}: Removed "${currentFixLabel}" label (no longer in Jira Fix Versions)`);
                  } catch (e) {
                    if (e.status !== 404) throw e;
                  }
                }
              }

              // Sync Jira labels to PR (prefixed with "jira:" to distinguish from other labels)
              const JIRA_LABEL_PREFIX = 'jira:';
              for (const jiraLabel of jiraLabels) {
                const ghLabelName = `${JIRA_LABEL_PREFIX}${jiraLabel}`;
                
                // Ensure the label exists in GitHub
                await ensureLabel(ghLabelName, 'c5def5', `Jira label: ${jiraLabel}`);
                
                // Add label to PR if not already present
                if (!currentLabels.includes(ghLabelName)) {
                  await github.rest.issues.addLabels({
                    owner,
                    repo,
                    issue_number: prNumber,
                    labels: [ghLabelName]
                  });
                  console.log(`PR #${prNumber}: Added "${ghLabelName}" label from Jira`);
                }
              }

              // Remove jira: labels that are no longer on the Jira issue
              const currentJiraLabels = currentLabels.filter(l => l.startsWith(JIRA_LABEL_PREFIX));
              const expectedJiraLabels = jiraLabels.map(l => `${JIRA_LABEL_PREFIX}${l}`);
              for (const currentJiraLabel of currentJiraLabels) {
                if (!expectedJiraLabels.includes(currentJiraLabel)) {
                  try {
                    await github.rest.issues.removeLabel({
                      owner,
                      repo,
                      issue_number: prNumber,
                      name: currentJiraLabel
                    });
                    console.log(`PR #${prNumber}: Removed "${currentJiraLabel}" label (no longer in Jira)`);
                  } catch (e) {
                    if (e.status !== 404) throw e;
                  }
                }
              }

              const isDone = status?.toLowerCase() === 'done';
              const hasDoneLabel = currentLabels.includes(DONE_LABEL);
              const hasSlowPRLabel = currentLabels.includes(SLOW_PR_LABEL);
              const hasWipLabel = currentLabels.includes(WIP_LABEL);

              if (isDone) {
                // Add "Done" label if not present
                if (!hasDoneLabel) {
                  await github.rest.issues.addLabels({
                    owner,
                    repo,
                    issue_number: prNumber,
                    labels: [DONE_LABEL]
                  });
                  console.log(`PR #${prNumber}: Added "${DONE_LABEL}" label`);
                }

                // Update PR body to check the quality team checkbox
                const { data: fullPr } = await github.rest.pulls.get({
                  owner,
                  repo,
                  pull_number: prNumber
                });

                let prBody = fullPr.body || '';
                const qualityCheckboxUnchecked = '- [ ] The `quality` team approved at least 1 platform *`(Android, IOS)`*.';
                const qualityCheckboxChecked = '- [x] The `quality` team approved at least 1 platform *`(Android, IOS)`*.';

                if (prBody.includes(qualityCheckboxUnchecked)) {
                  prBody = prBody.replace(qualityCheckboxUnchecked, qualityCheckboxChecked);
                  await github.rest.pulls.update({
                    owner,
                    repo,
                    pull_number: prNumber,
                    body: prBody
                  });
                  console.log(`PR #${prNumber}: Checked quality team approval checkbox`);
                }

                // Remove "wip" label if present
                if (hasWipLabel) {
                  try {
                    await github.rest.issues.removeLabel({
                      owner,
                      repo,
                      issue_number: prNumber,
                      name: WIP_LABEL
                    });
                    console.log(`PR #${prNumber}: Removed "${WIP_LABEL}" label (task is Done)`);
                  } catch (e) {
                    if (e.status !== 404) throw e;
                  }
                }

                // Check if done for more than 3 days
                if (resolutionDate) {
                  const resolvedAt = new Date(resolutionDate);
                  const now = new Date();
                  const msSinceDone = now - resolvedAt;
                  const daysSinceDone = msSinceDone / (24 * 60 * 60 * 1000);

                  if (msSinceDone > THREE_DAYS_MS) {
                    if (!hasSlowPRLabel) {
                      await github.rest.issues.addLabels({
                        owner,
                        repo,
                        issue_number: prNumber,
                        labels: [SLOW_PR_LABEL]
                      });
                      console.log(`PR #${prNumber}: Added "${SLOW_PR_LABEL}" label (resolved ${Math.floor(daysSinceDone)} days ago)`);
                    }
                  }
                }
              } else {
                // Remove labels if task is no longer Done
                if (hasDoneLabel) {
                  try {
                    await github.rest.issues.removeLabel({
                      owner,
                      repo,
                      issue_number: prNumber,
                      name: DONE_LABEL
                    });
                    console.log(`PR #${prNumber}: Removed "${DONE_LABEL}" label (status changed)`);
                  } catch (e) {
                    if (e.status !== 404) throw e;
                  }
                }

                if (hasSlowPRLabel) {
                  try {
                    await github.rest.issues.removeLabel({
                      owner,
                      repo,
                      issue_number: prNumber,
                      name: SLOW_PR_LABEL
                    });
                    console.log(`PR #${prNumber}: Removed "${SLOW_PR_LABEL}" label (status changed)`);
                  } catch (e) {
                    if (e.status !== 404) throw e;
                  }
                }
              }
            }

            console.log('✅ PR labels check completed');
````

---

---
title: "Every merge to master quietly breaks other PRs. So I built a radar for it."
slug: auto-rebase-and-detect-merge-conflicts
date: 2026-05-05
dateModified: 2026-07-25
readTime: 6 min read
tags: [GitHub Actions, Git, CI/CD, DevOps]
author: Abdelrahman Saed
description: "Two GitHub Actions workflows that rebase every open PR on each merge to master and route conflict alerts to the right author — so merge conflicts never sit unnoticed."
url: https://bnsaed.com/articles/auto-rebase-and-detect-merge-conflicts
---

# Every merge to master quietly breaks other PRs. So I built a radar for it.

*Subtitle: A merge to master is a tiny earthquake that nobody feels until later. Two workflows detect the damage immediately — and tell the right people.*

*The Self-Driving Repo · Part 4 — Conflict Management*

![A radar sweep over branch lines with conflicts pulsing red](/article-images/auto-rebase-and-detect-merge-conflicts-hero.webp)

---

Here's a failure mode so normal you've stopped noticing it: you merge a PR to `master`, and in that instant some number of *other* open PRs become un-mergeable. They were fine a second ago. Now they conflict. But nobody knows yet — the authors are working on something else, and they'll find out hours or days later when they try to merge and GitHub says no.

Multiply that by a busy `master` and a dozen open PRs and you get a slow, invisible tax: branches drifting out of sync, conflicts discovered at the worst possible moment (merge time), and a steady drip of "can you rebase?" comments.

I wanted the opposite: the *moment* `master` moves, every open PR gets refreshed against it, and anything that conflicts gets flagged and announced — immediately, while the change is still fresh in everyone's head.

Auto-detecting merge conflicts across open PRs is a two-workflow GitHub Actions system: a **sweep** and a **broadcast**. The sweep triggers on every push to `master`, paginates through all open PRs with `github.rest.pulls.list`, and calls `github.rest.pulls.updateBranch` on each (the API equivalent of GitHub's "Update branch" button), skipping forks and treating a `422` as "nothing to do" rather than a failure. Because GitHub computes mergeability asynchronously, you must treat `mergeable` as **eventually consistent**: poll `pulls.get` with backoff (up to 5 retries, 2s apart) until it stops returning `null`, then reconcile a `conflict` label based on `mergeable === false` or `mergeable_state === 'dirty'`. The broadcast workflow reads `conflict`-labeled PRs, builds an author→PRs map, translates each GitHub login to a chat handle, and posts one grouped Discord digest—push-based, grouped, and routed—so each conflict reaches the right author within seconds, not days after it has already broken their branch.

## The problem

Three things were going wrong, all downstream of the same root cause (master moves, PRs don't):

1. **Stale branches.** PRs fall behind `master` and their CI results stop meaning anything — they passed against an old base.
2. **Late conflict discovery.** Authors learn about conflicts when they try to merge, which is the most disruptive possible time.
3. **Notification fatigue.** GitHub *can* tell you a PR is conflicted, but it's buried in a UI nobody watches. The signal never reaches the person who needs it.

## The idea

Two workflows working as a pair:

- **The sweep** — triggered on every push to `master`. Walk every open PR, update its branch against the new `master`, then check whether it now conflicts and label it accordingly.
- **The broadcast** — takes the conflicted PRs and posts a digest to team chat, grouped by author, so each person sees exactly their PRs that need attention.

Detection and notification are deliberately separate. The sweep maintains accurate *state* (labels on PRs). The broadcast turns that state into a *message*. Splitting them means I can re-run or reschedule the announcement without re-running the (heavier) sweep.

## How do you auto-detect merge conflicts on every push to master?

![Flow: a push to master looping over PRs, polling mergeable state, labeling conflicts, then a grouped chat digest](/article-images/auto-rebase-and-detect-merge-conflicts-diagram.webp)

### Refresh everything on every merge

The sweep triggers on push to `master` and pages through all open PRs against it:

```yaml
on:
  push:
    branches: [master]
```

```js
const prs = await github.paginate(github.rest.pulls.list, {
  owner, repo, state: 'open', base: 'master', per_page: 100,
});
```

For each one, it asks GitHub to update the branch (the same "Update branch" button you click by hand, as an API call), skipping forks where you don't have permission:

```js
if (!(pr.head?.repo?.fork && !pr.maintainer_can_modify)) {
  try {
    await github.rest.pulls.updateBranch({ owner, repo, pull_number: pr.number });
  } catch (e) {
    if (e.status === 422) { /* conflict or already up to date — skip */ }
    else if (e.status === 403) { /* no permission — skip */ }
  }
}
```

The `422` handling is the bit you only learn by getting burned: `updateBranch` throws 422 both when the branch can't be fast-forwarded (a real conflict) *and* in benign "nothing to do" cases. You don't treat it as failure — you treat it as "move on and let the mergeability check below tell the truth."

### Wait for GitHub to make up its mind

Here's the trap that makes naive versions of this flaky. When you ask GitHub whether a PR is mergeable, the answer is often `null` — "I haven't computed that yet, check back." Read it too early and you'll mislabel a perfectly clean PR as conflicted.

So you poll, with backoff, until the state settles:

```js
let pr = await github.rest.pulls.get({ owner, repo, pull_number });
for (let i = 0; i < 5 && pr.data.mergeable === null; i++) {
  await new Promise(r => setTimeout(r, 2000));   // let GitHub compute
  pr = await github.rest.pulls.get({ owner, repo, pull_number });
}
const hasConflicts =
  pr.data.mergeable === false || pr.data.mergeable_state === 'dirty';
```

Treating `mergeable` as **eventually consistent** rather than a synchronous value is the single most important thing in this workflow. Everything else is bookkeeping.

Then the label is reconciled — added if conflicting, removed if not — so the `conflict` label always reflects the live state:

```js
if (hasConflicts && !hasLabel)      await addLabel('conflict');
else if (!hasConflicts && hasLabel) await removeLabel('conflict');
```

### Turn state into a message people see

The broadcast workflow reads the `conflict`-labeled PRs and builds an author → PRs map, then posts one grouped digest to team chat:

```js
const map = {};                       // author -> [pr urls]
for (const pr of conflictedPRs) {
  (map[pr.user.login] ??= []).push(pr.html_url);
}
```

Each GitHub author is translated to their chat handle so the message actually pings the right human, and a clean run gets a cheerful "no conflicts 🎉" instead of awkward silence. It also runs on a daily schedule, not just on demand — so even conflicts that appear through other paths get a regular nudge.

The reason this lands where GitHub's own notifications don't: it's **push-based, grouped, and routed.** It arrives in the tool the team already lives in, addressed to the person who can fix it, listing exactly their PRs. No dashboard to remember to check.

## What does automatic conflict detection actually improve?

- **Conflicts surface in seconds, not days** — right after the merge that caused them, while context is fresh.
- **PR branches stay current**, so green CI actually means "green against today's `master`."
- **The right person gets pinged**, in chat, with their specific PRs — around 10 fewer manual "please rebase" comments a week.
- **Less merge-time drama.** By the time you go to merge, you already knew (and probably already fixed) the conflict.

## Gotchas & trade-offs

- **Auto-updating branches re-triggers CI.** Refresh 20 PRs and you just queued 20 CI runs. Usually worth it (fresh results), but mind your runner minutes — you may want to scope which PRs get swept.
- **Forks need permission.** `updateBranch` only works where "maintainers can edit" is on; the workflow skips the rest rather than erroring. Know that some PRs won't be auto-refreshed.
- **`422` is overloaded.** It means both "real conflict" and "nothing to update." Don't treat it as a failure signal — let the mergeability poll be your source of truth.
- **Chat handle mapping is manual.** A GitHub-login → chat-ID table has to be maintained as people join and leave. It's the kind of small debt that silently breaks pings.
- **Don't over-notify.** A conflict digest every hour becomes wallpaper. Daily + on-merge was the right dose; tune it to your team's tolerance.

## Related reading

This article is part of [The Self-Driving Repo](/articles/) series. The radar tells you *that* a PR conflicts — the rest of the cluster decides what to do about it:

- [Letting a bot resolve merge conflicts safely](/articles/bot-that-resolves-merge-conflicts/) — the one provably-safe case where a GitHub Action can auto-commit a conflict fix to a contributor's branch.
- [Automated stale branch cleanup](/articles/automated-stale-branch-cleanup/) — deleting dead branches in bulk without deleting the wrong one: classify, cap, defer, audit.

## Takeaway

A merge to `master` has a blast radius, and the cheapest time to deal with that radius is *immediately*. Refresh every PR on every merge, treat mergeability as eventually consistent (poll it, don't trust the first read), and route the result to a human in the tool they already watch. Detect fast; notify where people actually look.

Next: the flagship. What if, for one common class of conflict, the repo didn't just *detect* it — it *fixed* it and pushed the resolution for you?

## The complete workflow

Here is the full, genericized workflow — drop it into `.github/workflows/` and replace the placeholders (`your-org`, the `PROJ` project key, `<@DISCORD_USER_ID>`, the example team, and the secret names) with your own.

### `.github/workflows/update_all_prs_and_sweep.yml`

````yaml
name: Update PR branches and sweep conflict on master push
on:
  push:
    branches: [master]

permissions:
  contents: write
  pull-requests: write
  issues: write

jobs:
  update-all-prs-and-sweep:
    runs-on: ubuntu-slim
    steps:
      - uses: actions/github-script@v7
        with:
          script: |
            const owner = context.repo.owner;
            const repo = context.repo.repo;
            const base = context.ref.replace('refs/heads/', '');
            const labelName = 'conflict';

            // Ensure conflict label exists
            try { await github.rest.issues.getLabel({ owner, repo, name: labelName }); }
            catch (e) { if (e.status === 404) await github.rest.issues.createLabel({ owner, repo, name: labelName, color: 'd73a4a' }); else throw e; }

            const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', base, per_page: 100 });

            for (const p of prs) {
              // Step 1: Try to update PR branch with latest master
              if (!(p.head?.repo?.fork && !p.maintainer_can_modify)) {
                try {
                  await github.rest.pulls.updateBranch({ owner, repo, pull_number: p.number });
                  console.log(`Updated PR #${p.number}`);
                } catch (e) {
                  const status = e.status || e.response?.status;
                  if (status === 422) {
                    console.log(`Skipped updating PR #${p.number} due to conflicts or not behind`);
                  } else if (status === 403) {
                    console.log(`Skipped updating PR #${p.number} due to permissions`);
                  } else {
                    console.log(`Error updating PR #${p.number}: ${e.message}`);
                  }
                }
              } else {
                console.log(`Skipped PR #${p.number} from fork because maintainer edits are not allowed`);
              }

              // Step 2: Check for conflicts and manage label
              let pr = await github.rest.pulls.get({ owner, repo, pull_number: p.number });
              // If mergeable is null/unknown, wait briefly and retry
              for (let i = 0; i < 5 && (pr.data.mergeable === null || (pr.data.mergeable_state||'').toLowerCase() === 'unknown'); i++) {
                await new Promise(r => setTimeout(r, 2000));
                pr = await github.rest.pulls.get({ owner, repo, pull_number: p.number });
              }
              const hasConflicts = (pr.data.mergeable === false) || ((pr.data.mergeable_state||'').toLowerCase() === 'dirty');

              const { data: labels } = await github.rest.issues.listLabelsOnIssue({ owner, repo, issue_number: p.number, per_page: 100 });
              const hasLabel = labels.some(l => l.name.toLowerCase() === labelName);

              if (hasConflicts && !hasLabel) {
                await github.rest.issues.addLabels({ owner, repo, issue_number: p.number, labels: [labelName] });
                console.log(`Added conflict label to PR #${p.number}`);
              } else if (!hasConflicts && hasLabel) {
                await github.rest.issues.removeLabel({ owner, repo, issue_number: p.number, name: labelName });
                console.log(`Removed conflict label from PR #${p.number}`);
              }
            }
````

### `.github/workflows/conflict-notify.yml`

````yaml
name: List open PR authors (conflict label → map, URLs only)
on:
  workflow_dispatch:
    inputs:
      label:
        description: 'Label to filter PRs by'
        required: false
        default: 'conflict'
  schedule:
    - cron: '0 0 * * *' # daily at midnight UTC (optional)

permissions:
  contents: read
  pull-requests: read

jobs:
  list-authors:
    runs-on: ubuntu-slim
    steps:
      - name: List open PRs with label and build author→PR-URL map
        id: list_prs
        uses: actions/github-script@v6
        with:
          script: |
            const fs = require('fs');

            const owner = context.repo.owner;
            const repo = context.repo.repo;
            const targetLabel = core.getInput('label') || 'conflict';
            core.info(`Listing open PRs for ${owner}/${repo} and filtering by label "${targetLabel}"`);

            // Fetch all open PRs (handles pagination)
            const pulls = await github.paginate(github.rest.pulls.list, {
              owner,
              repo,
              state: 'open',
              per_page: 100,
            });

            core.info(`Found ${pulls.length} open PR(s)`);

            // Filter PRs that contain the target label (case-insensitive)
            const filtered = pulls.filter(p => {
              const labels = p.labels || [];
              return labels.some(l => String(l.name).toLowerCase() === String(targetLabel).toLowerCase());
            });

            core.info(`Found ${filtered.length} open PR(s) with label "${targetLabel}"`);

            // Build mapping: author -> [ "https://github.com/owner/repo/pull/NNN", ... ]
            const map = {};
            for (const p of filtered) {
              const author = p.user?.login || 'unknown';
              if (!map[author]) map[author] = [];

              const httpsUrl = p.html_url;
              // Add only the full https URL, avoid alternate @owner/repo style
              if (!map[author].includes(httpsUrl)) map[author].push(httpsUrl);
            }

            core.setOutput('mapping', JSON.stringify(map));
            core.setOutput('filtered_pr_count', String(filtered.length));
            core.setOutput('authors', JSON.stringify(Object.keys(map)));

      - name: Print mapping to job log
        run: |
          echo "Mapping (author -> [PR URLs]):"
          echo '${{ steps.list_prs.outputs.mapping }}'

      - name: Format Discord message
        id: format_message
        uses: actions/github-script@v6
        with:
          script: |
            const mapping = JSON.parse('${{ steps.list_prs.outputs.mapping }}');
            const prCount = parseInt('${{ steps.list_prs.outputs.filtered_pr_count }}');
            
            // GitHub username to Discord user ID mapping
            const discordMapping = {
              'jpark': '<@DISCORD_USER_ID>',
              'ckim': '<@DISCORD_USER_ID>',
              'your-maintainer': '<@DISCORD_USER_ID>',
              'rdiaz': '<@DISCORD_USER_ID>',
              'schen': '<@DISCORD_USER_ID>'
            };
            
            if (prCount === 0) {
              core.setOutput('message', 'No open PRs with conflicts found! 🎉');
              return;
            }
            
            let message = '⚠️ **Please solve conflicts**\n\n';
            
            for (const [author, urls] of Object.entries(mapping)) {
              const discordMention = discordMapping[author] || author;
              message += `**${discordMention}**\n`;
              for (const url of urls) {
                message += `${url}\n`;
              }
              message += '\n';
            }
            
            message += '----------------------------------';
            
            core.setOutput('message', message.trim());

      - name: Discord Webhook Action
        uses: tsickert/discord-webhook@v6.0.0
        with:
          webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
          content: ${{ steps.format_message.outputs.message }}
````

---

---
title: "I let a bot resolve merge conflicts and push the fix. Here's how I made that safe."
slug: bot-that-resolves-merge-conflicts
date: 2026-05-12
dateModified: 2026-07-25
readTime: 8 min read
tags: [GitHub Actions, Git, Automation, CI/CD]
author: Abdelrahman Saed
description: "How to ship a genuinely dangerous automation safely — by shrinking its blast radius to one provably-safe case, working only in throwaway space, and failing closed on anything unexpected."
url: https://bnsaed.com/articles/bot-that-resolves-merge-conflicts
---

# I let a bot resolve merge conflicts and push the fix. Here's how I made that safe.

*Subtitle: Auto-committing to other people's branches is terrifying — until you shrink the blast radius to one provably-safe case. This is that story.*

*The Self-Driving Repo · Part 5 — Conflict Management (flagship)*

![A robot arm carefully braiding two translation streams back into one](/article-images/bot-that-resolves-merge-conflicts-hero.webp)

---

Let me start with the version of this idea that should scare you:

> "What if a GitHub Action automatically resolved merge conflicts and force-pushed the result to people's PR branches?"

If your stomach dropped, good — mine did too. A bot that runs `git merge` and `git push` against branches it doesn't own is exactly how you corrupt someone's work, lose a commit, or merge two versions of a file into nonsense. This is the most dangerous automation in the entire series.

And yet it runs on every push to `master`, it resolves real conflicts, and I trust it. The trick wasn't writing a smarter merge algorithm. It was making the bot **profoundly cowardly** — it only acts in one narrow situation where the "right answer" is mechanical and provable, and it runs away from everything else.

This post is about how to take a scary capability and make it safe by aggressively shrinking its blast radius.

A **GitHub Action that auto-resolves merge conflicts** is made safe by shrinking its blast radius to one provably-safe case. This bot runs `actions/github-script` on every push to `master`, and only resolves conflicts when *every* unmerged file matches the regex `/^assets\/translations\/.*\.json$/`. If even one `.dart` or `pubspec.yaml` file conflicts, it calls `git merge --abort` and hands the PR to a human. It also skips drafts and forks, and waits out GitHub's eventually-consistent `mergeable` field before touching anything. Here's how it works: it merges in a throwaway `_auto_resolve` branch, parses each translation file's clean versions from both sides with `git show`, and unions the JSON key sets (`{ ...masterJSON, ...branchJSON }`)—branch wins on true collisions. It then pushes the fix, comments listing the files touched, and says "please review." Any unexpected error fails closed via `git merge --abort`. The safety lives in the refusal logic, not the merge algorithm: dumb-and-provable beats smart-and-probabilistic.

## Why do translation JSON files conflict on every PR?

We ship in multiple languages. Translations live in per-locale JSON files — `assets/translations/en.json`, `ar.json`, and so on. Add a feature and you add keys to all of them. Which means **every feature branch touches the same translation files**, and they conflict constantly.

But here's the thing: these conflicts are almost never *real*. Two developers add different keys to `en.json`. Git sees edits to the same region and throws up its hands. A human opens the file, looks at the two sides, and realizes there's nothing to decide — you want *both* keys. It's the dumbest possible conflict, and resolving it by hand is pure tax: it blocks the PR, interrupts the author, and contributes exactly zero thought.

Code conflicts deserve a human. Two people adding `"save_button": "Save"` and `"share_button": "Share"` to the same JSON do not.

## The idea: automate the mechanical case, refuse everything else

The workflow runs on every push to `master`. For each open PR, it tries to merge `master` in. If the *only* things that conflict are translation JSON files, it resolves them by merging the key sets, pushes the resolution, and comments to explain itself. If *anything else* conflicts — a single `.dart` file, a `pubspec.yaml`, anything — it aborts, touches nothing, and leaves the PR for a human.

The safety isn't in the merge logic. It's in the **refusal logic**. Let me walk the guards in the order they execute, because the order *is* the design.

## How does the bot safely auto-resolve translation conflicts?

![Decision flow: if every conflicting file matches the translations path, merge and push; otherwise abort and hand to a human](/article-images/bot-that-resolves-merge-conflicts-diagram.webp)

### Guard 0: only conflicted, real, ready PRs

Before any git happens, filter hard. Skip drafts. Skip forks (you can't safely push to them, and you shouldn't). And critically, only consider PRs that are *actually* conflicting — which means waiting out GitHub's eventually-consistent `mergeable` field, exactly like the conflict radar in Part 4:

```js
let freshPR;
for (let attempt = 0; attempt < 5; attempt++) {
  const { data } = await github.rest.pulls.get({ owner, repo, pull_number });
  if (data.mergeable !== null) { freshPR = data; break; }  // null = not computed yet
  await new Promise(r => setTimeout(r, 3000));
}
if (!freshPR || freshPR.mergeable !== false) continue;     // only proceed on real conflicts
```

### Guard 1: do the merge in throwaway space

Never operate on `master` or the original branch directly. Check out the PR branch into a disposable local branch, then attempt the merge there. If it merges clean, there was nothing to do — move on:

```js
execSync('git checkout master && git reset --hard origin/master');
execSync(`git fetch origin "${branch}"`);
execSync(`git checkout -B _auto_resolve "origin/${branch}"`);

let hasConflicts = false;
try { execSync('git merge origin/master --no-edit'); }
catch { hasConflicts = true; }
if (!hasConflicts) continue;
```

### Guard 2: THE important one — is *every* conflict a translation file?

This is the load-bearing guard, the reason the whole thing is safe. List the unmerged files. If even one of them is outside the translations path, abort the entire merge and skip the PR. No partial resolutions. No "fix the easy files and leave the hard ones." All-or-nothing:

```js
const conflictFiles = execSync('git diff --name-only --diff-filter=U')
  .toString().trim().split('\n');

const TRANSLATION_PATH = /^assets\/translations\/.*\.json$/;
const allTranslation = conflictFiles.every(f => TRANSLATION_PATH.test(f));

if (!allTranslation) {
  execSync('git merge --abort');   // touch nothing, hand it to a human
  skipped++;
  continue;
}
```

That `every()` is the line that lets me sleep. The bot's authority is scoped to a regex. A conflict in `lib/` the bot never even *attempts* to resolve — it aborts and walks away. The dangerous version of this feature is the one that tries to be helpful with code. This one is constitutionally incapable of it.

### Guard 3: resolve by merging key sets, with a defined winner

Only now, with every conflict proven to be a translation file, does it resolve. And the resolution is deliberately boring — parse both sides as JSON, spread them together so you keep *all* keys from both branches:

```js
for (const file of conflictFiles) {
  const masterJSON = JSON.parse(execSync(`git show origin/master:"${file}"`).toString());
  const branchJSON = JSON.parse(execSync(`git show "origin/${branch}":"${file}"`).toString());

  const merged = { ...masterJSON, ...branchJSON };   // union; branch wins on true key collisions

  fs.writeFileSync(file, JSON.stringify(merged, null, 2) + '\n');
  execSync(`git add "${file}"`);
}
```

Two things make this trustworthy. First, it operates on the **clean versions** of each file from each branch (`git show origin/master:file` and `git show origin/branch:file`) — not on the conflict-marker-polluted working copy. There's no risk of a stray `<<<<<<<` ending up in the output. Second, the merge rule is *defined and documented*: it's a union, and on the rare true collision (both branches changed the same key), the **branch value wins**. That's a real decision with a real rationale (the PR author's intent is newer), not an accident of ordering.

### Guard 4: tell the humans exactly what you did

Automation that mutates someone's branch silently is a betrayal. So the bot pushes the resolution and immediately comments — listing the files it touched and stating the merge rule in plain language:

```js
execSync('git commit --no-edit');
execSync(`git push origin "_auto_resolve:${branch}"`);

await github.rest.issues.createComment({
  owner, repo, issue_number: pr.number,
  body: [
    '🤖 **Auto-resolved translation conflicts**',
    '',
    'Merged `master` and resolved conflicts in:',
    ...conflictFiles.map(f => `- \`${f}\``),
    '',
    'PR branch values were preserved where both branches changed the same key.',
    'Please review the merged translations.',
  ].join('\n'),
});
```

"Please review" is not a throwaway line. The bot resolves and *unblocks*; the human still gets the final look. It removes the toil, not the oversight.

### Guard 5: fail closed

Every PR is wrapped so that any unexpected error aborts the merge and moves on — one failure can't poison the rest of the run, and a half-merge never survives:

```js
} catch (error) {
  core.warning(`Failed to resolve PR #${pr.number}: ${error.message}`);
  try { execSync('git merge --abort'); } catch {}
}
```

When in doubt, the bot does *nothing*. That's the entire philosophy in one catch block.

## What it bought us

- **A whole category of busywork evaporated.** Translation-only conflicts — frequent, mindless, blocking — resolve themselves within a run of hitting `master`.
- **PRs stay unblocked.** Authors stop losing momentum to a conflict that required no thought.
- **Trust, because it's transparent.** Every action is announced on the PR with the rule it followed. Nobody finds a mystery commit.
- **Reviewers still review.** The human look survives; only the mechanical merge is gone. Roughly 10 translation conflicts auto-cleared per week.

## Gotchas & trade-offs

- **A JSON union is not a real semantic merge.** If both branches set the same key to *different* values, "branch wins" might be wrong. It's a defensible default, not a guarantee — which is exactly why the bot says "please review."
- **It assumes flat, valid JSON.** Nested objects or a malformed file would break naive spreading; `JSON.parse` failing lands you in the fail-closed catch, which is the safe outcome but means that PR isn't auto-resolved.
- **Pushing to a contributor's branch needs the right permissions** and is why forks are excluded outright. Know your token's scope.
- **Scope creep is the real danger.** The instant someone asks "can it also resolve `pubspec.yaml`?" you're negotiating away the one guard that makes it safe. The narrowness *is* the feature. Defend it.
- **It is not a merge-conflict AI.** No model, no guessing. For this class, dumb-and-provable beats smart-and-probabilistic every time.

## Related reading

This article is part of [The Self-Driving Repo](/articles/) series. The bot only resolves the one safe case — the rest of the cluster detects conflicts and keeps the repo tidy:

- [The merge-conflict radar](/articles/auto-rebase-and-detect-merge-conflicts/) — the two-workflow system that notices every PR broken by a merge and tells the right author.
- [Automating PR hygiene with GitHub Actions](/articles/automate-pull-request-hygiene/) — auto-context on every PR plus a quality gate that runs the analyzer and cleans up after itself.

## Takeaway

The way to ship a dangerous automation safely isn't to make it clever — it's to make it *cowardly and loud*. Shrink its authority to a single case where the correct answer is mechanical and provable (`every() conflict matches one regex`), operate only in throwaway space, define your tie-breaker explicitly, announce every action, and fail closed on anything unexpected. The capability sounds reckless. The blast radius makes it boring. Boring is the goal.

Next: stepping out of git plumbing and into shipping — one button that builds and distributes for Android, iOS, and the Play Store.

## The complete workflow

Here is the full, genericized workflow — drop it into `.github/workflows/` and replace the placeholders (`your-org`, the `PROJ` project key, `<@DISCORD_USER_ID>`, the example team, and the secret names) with your own.

### `.github/workflows/auto-resolve-translation-conflicts.yml`

````yaml
name: Auto-resolve translation conflicts

on:
  push:
    branches: [master]
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write
  issues: write

jobs:
  auto-resolve-translations:
    runs-on: ubuntu-slim
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Configure git
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"

      - name: Auto-resolve translation-only conflicts
        uses: actions/github-script@v7
        with:
          script: |
            const { execSync } = require('child_process');
            const fs = require('fs');

            const owner = context.repo.owner;
            const repo = context.repo.repo;
            const TRANSLATION_PATH = /^assets\/translations\/.*\.json$/;

            const prs = await github.paginate(github.rest.pulls.list, {
              owner, repo, state: 'open', per_page: 100,
            });

            core.info(`Found ${prs.length} open PRs`);

            let resolved = 0;
            let skipped = 0;

            for (const pr of prs) {
              if (pr.draft) continue;

              if (pr.head.repo?.fork || pr.head.repo?.full_name !== `${owner}/${repo}`) {
                core.info(`Skipping PR #${pr.number} (fork)`);
                continue;
              }

              let freshPR;
              for (let attempt = 0; attempt < 5; attempt++) {
                const { data } = await github.rest.pulls.get({
                  owner, repo, pull_number: pr.number,
                });
                if (data.mergeable !== null) {
                  freshPR = data;
                  break;
                }
                await new Promise(r => setTimeout(r, 3000));
              }

              if (!freshPR || freshPR.mergeable !== false) continue;

              core.info(`\nPR #${pr.number} "${pr.title}" has conflicts`);

              const branch = pr.head.ref;

              try {
                execSync('git checkout master && git reset --hard origin/master', {
                  stdio: 'pipe',
                });
                execSync(`git fetch origin "${branch}"`, { stdio: 'pipe' });
                execSync(`git checkout -B _auto_resolve "origin/${branch}"`, {
                  stdio: 'pipe',
                });

                let hasConflicts = false;
                try {
                  execSync('git merge origin/master --no-edit', { stdio: 'pipe' });
                } catch {
                  hasConflicts = true;
                }

                if (!hasConflicts) {
                  core.info(`  PR #${pr.number} merged cleanly, no action needed`);
                  continue;
                }

                const conflictOutput = execSync('git diff --name-only --diff-filter=U')
                  .toString()
                  .trim();
                if (!conflictOutput) {
                  execSync('git merge --abort', { stdio: 'pipe' });
                  continue;
                }

                const conflictFiles = conflictOutput.split('\n');
                const allTranslation = conflictFiles.every(f => TRANSLATION_PATH.test(f));

                if (!allTranslation) {
                  const nonTranslation = conflictFiles
                    .filter(f => !TRANSLATION_PATH.test(f))
                    .join(', ');
                  core.info(
                    `  Skipping PR #${pr.number} — non-translation conflicts: ${nonTranslation}`,
                  );
                  execSync('git merge --abort', { stdio: 'pipe' });
                  skipped++;
                  continue;
                }

                core.info(`  Resolving: ${conflictFiles.join(', ')}`);

                for (const file of conflictFiles) {
                  const masterContent = execSync(`git show origin/master:"${file}"`).toString();
                  const branchContent = execSync(
                    `git show "origin/${branch}":"${file}"`,
                  ).toString();

                  const masterJSON = JSON.parse(masterContent);
                  const branchJSON = JSON.parse(branchContent);

                  const merged = { ...masterJSON, ...branchJSON };

                  fs.writeFileSync(file, JSON.stringify(merged, null, 2) + '\n');
                  execSync(`git add "${file}"`, { stdio: 'pipe' });
                }

                execSync('git commit --no-edit', { stdio: 'pipe' });
                execSync(`git push origin "_auto_resolve:${branch}"`, { stdio: 'pipe' });

                resolved++;
                core.info(`  Resolved PR #${pr.number}`);

                await github.rest.issues.createComment({
                  owner,
                  repo,
                  issue_number: pr.number,
                  body: [
                    '🤖 **Auto-resolved translation conflicts**',
                    '',
                    'Merged `master` and resolved conflicts in:',
                    ...conflictFiles.map(f => `- \`${f}\``),
                    '',
                    'PR branch values were preserved where both branches modified the same key.',
                    'Please review the merged translations.',
                  ].join('\n'),
                });

                try {
                  await github.rest.issues.removeLabel({
                    owner,
                    repo,
                    issue_number: pr.number,
                    name: 'conflict',
                  });
                } catch {}
              } catch (error) {
                core.warning(`Failed to resolve PR #${pr.number}: ${error.message}`);
                try {
                  execSync('git merge --abort', { stdio: 'pipe' });
                } catch {}
              }
            }

            core.info(`\nSummary: ${resolved} resolved, ${skipped} skipped`);
````

---

---
title: "A repo that cleans up after itself — without deleting the wrong branch"
slug: automated-stale-branch-cleanup
date: 2026-06-02
dateModified: 2026-07-25
readTime: 6 min read
tags: [GitHub Actions, Git, DevOps, Automation]
author: Abdelrahman Saed
description: "A GitHub Actions workflow that classifies and deletes stale branches safely — per-run cap, deferral window, and a full audit trail. No accidental deletes."
url: https://bnsaed.com/articles/automated-stale-branch-cleanup
---

# A repo that cleans up after itself — without deleting the wrong branch

*Subtitle: Deleting branches in bulk is a one-keystroke disaster waiting to happen. The fix is to make deletion the last step of a careful classification — and to cap, defer, and audit everything.*

*The Self-Driving Repo · Part 8 — Housekeeping*

![A robot broom sweeping dead branches from a healthy git tree](/article-images/automated-stale-branch-cleanup-hero.webp)

---

Old branches are the junk drawer of a repo. Every merged feature, every abandoned experiment, every "let me just try something" leaves one behind. They pile up silently — clones get slower, branch pickers get unusable, and `git branch -r` scrolls for a screen and a half. On a long-lived repo with a busy team, you wake up one day with hundreds of dead branches.

The naive fix — a script that deletes anything old — is how you delete a release branch someone needed, or a long-running integration branch that just hadn't been touched in a while. With deletion, "oops" is expensive. You don't get to be cavalier.

So I built branch cleanup as a **classification problem first and a deletion problem second.** The deleting is three lines. The safety is everything before it.

Automated stale-branch cleanup treats branch deletion as a **classification problem first and a deletion problem second**. A scheduled GitHub Actions workflow (`cleanup-stale-branches.yml`) runs Sunday at 04:00 UTC, fetches every branch plus the full open and merged PR history, then sorts each branch through a strict safety precedence: **sacred** (`master`, `develop`, the default branch) beats **protected** beats **open-PR** beats **merged** beats **stale** beats **active**. Only *merged* and *stale* branches are deleted, and safety always wins ties. Three guards make bulk deletion routine: a per-run cap (`max_deletions`, default 50) that defers overflow to next week, branch-name URL-encoding before each `gh api -X DELETE` call, and a full audit trail — merged branches get a comment linking back to their PR, plus a Discord digest and a job-summary table of what was deleted, deferred, and kept. Staleness is epoch math: a tip commit older than `age_days` (default 730, ~2 years).

## The problem

Unmanaged branches cause slow, compounding pain:

1. **Clutter that slows people down.** Hundreds of stale refs make branch lists and tooling sluggish and confusing.
2. **No signal about what's safe to remove.** Is this branch merged? Abandoned? An active long-runner that's just quiet this week? You can't tell at a glance, so nobody cleans up at all.
3. **Cleanup is scary, so it never happens.** The risk of nuking something important means the junk drawer just grows forever.

## The idea

A scheduled workflow — Sunday at 4am, when nobody's pushing — that fetches *every* branch and the full PR history, then sorts each branch into a category before a single deletion happens:

- **Sacred** — `master`, `develop`, the default branch. Never touched.
- **Protected** — anything with branch protection on. Never touched.
- **Open-PR** — the head or base of an open PR. Active; leave it.
- **Merged** — already merged via a PR. Safe to delete.
- **Stale** — last commit older than a threshold (default ~2 years). Safe to delete.
- **Active** — recent commits, no reason to remove. Keep.

Only the *Merged* and *Stale* buckets get deleted, and even then, carefully.

```yaml
on:
  schedule:
    - cron: '0 4 * * 0'      # Sunday 04:00 UTC
  workflow_dispatch:
    inputs:
      age_days:      { default: '730' }   # ~2 years
      max_deletions: { default: '50' }    # hard cap per run
```

## How does the stale-branch cleanup workflow classify branches?

![Classification funnel: branches sorted into sacred, protected, open-PR, merged, stale and active, with a capped delete tray](/article-images/automated-stale-branch-cleanup-diagram.webp)

### Build the world before you judge it

You can't classify a branch in isolation — you need to know whether it backs an open PR, whether it was merged, and how old its tip is. So the first step assembles the full picture: all open PRs (to collect their head/base refs), all merged PRs (to know what's been merged and by whom), and every branch with its last-commit timestamp and protection flag.

```bash
# Every branch with sha + protected flag, as TSV
gh api repos/$REPO/branches --paginate \
  | jq -r '.[] | [.name, .commit.sha, (.protected|tostring)] | @tsv'
```

### Classify with a strict precedence

Each branch runs through the categories in priority order — and **safety always wins ties.** Sacred beats protected beats open-PR beats merged beats stale. A branch that is both "merged" and "backs an open PR" is kept, because the open-PR check comes first. The ordering is the safety policy:

```bash
if   in_list "$NAME" "$SACRED";    then echo "SKIP:sacred"
elif [ "$PROTECTED" = "true" ];    then echo "SKIP:protected"
elif in_list "$NAME" "$OPEN_REFS"; then echo "SKIP:open-pr"
elif is_merged "$NAME";            then echo "DELETE:merged"
elif older_than "$CUTOFF" "$NAME"; then echo "DELETE:stale"
else                                    echo "KEEP:active"
fi
```

Stale detection is just epoch math — compare the tip commit's timestamp to a cutoff:

```bash
CUTOFF=$(( $(date -u +%s) - AGE_DAYS * 86400 ))
```

### Cap and defer — never delete unboundedly

Here's the rule that turns a bulk operation from terrifying into routine: **never delete more than N in one run.** If 400 branches qualify, delete the first 50, and *defer the rest to next week*. A capped, repeated cleanup converges safely; an uncapped one is a single command that can wreck a repo. The cap is a circuit breaker against a bad classification — if something's wrong, you've harmed 50 branches, not 800, and you'll see it before the next run.

The deferred branches are logged, not lost — the run reports exactly what it skipped for capacity so there are no surprises.

### Delete carefully, link back, and audit

Even the deletion step is defensive. Branch names contain slashes and the occasional oddity, so they're URL-encoded before the API call. And when a *merged* branch is deleted, the workflow comments on its original PR — closing the loop so there's a trail from "this branch is gone" back to "here's why":

```bash
ENC=$(jq -rn --arg x "$NAME" '$x|@uri')          # handle slashes/special chars
gh api -X DELETE "repos/$REPO/git/refs/heads/$ENC"
# then, for merged branches, comment on the source PR: "branch auto-deleted by cleanup"
```

Every action lands in two places: a digest posted to team chat (counts, grouped by reason, with author attribution) and a full job summary with tables of what was deleted, what was deferred, and what was kept. Bulk deletion without an audit trail is how you lose the ability to answer "what happened to my branch?" — so the trail is non-negotiable.

## What does safe automated branch cleanup get you?

- **The junk drawer stays empty** without anyone doing scary manual cleanup.
- **Zero accidents**, because deletion is the last step of a conservative classification, not a blunt age filter.
- **A clear record.** Every deletion is attributable and explained, with merged branches linked back to their PRs.
- **It self-throttles.** A big backlog clears over several capped runs instead of one heart-stopping purge — around 100 dead branches reclaimed.

## Gotchas & trade-offs

- **"Merged" is subtler than it looks.** Squash-merges don't leave the branch as an ancestor of `master`, so ancestry checks lie — rely on the *PR* merge record, not `git branch --merged`.
- **Protection flags are only as good as your config.** The workflow honors branch protection, but if an important long-runner isn't protected and goes quiet past the threshold, it qualifies as stale. Protect what matters, or widen "sacred."
- **API pagination has real limits.** Listing thousands of PRs/branches can truncate; the job logs when a list may be incomplete rather than pretending it saw everything. Trust-but-verify your counts on large repos.
- **The cap is a feature, resist removing it.** "Just clean it all up at once" is the request that precedes the incident. Defer-and-repeat is slower and far safer.
- **Schedule it for quiet hours.** Deleting branches while people push invites races. Off-peak isn't just politeness; it reduces edge cases.

## Related reading

This article is part of [The Self-Driving Repo](/articles/) series. Cleanup is the last step — the rest of the cluster keeps branches and PRs mergeable in the first place:

- [The merge-conflict radar](/articles/auto-rebase-and-detect-merge-conflicts/) — refresh every open PR on each merge to `master` and route conflict alerts to the right author.
- [Letting a bot resolve merge conflicts safely](/articles/bot-that-resolves-merge-conflicts/) — the single provably-safe case where auto-committing a conflict fix is fine.

## Takeaway

Any automation that deletes should be a classifier with a delete step bolted on the end — never a delete step with a filter. Order your categories so safety wins every tie, cap the blast radius per run, defer the overflow, and audit every action with a trail back to *why*. Make the destructive thing boring and reversible-in-spirit, and bulk cleanup stops being a thing you're afraid to run.

Next, the finale: aggregating git and Jira into per-person weekly summaries with an LLM — and the hard conversation about doing that responsibly.

## The complete workflow

Here is the full, genericized workflow — drop it into `.github/workflows/` and replace the placeholders (`your-org`, the `PROJ` project key, `<@DISCORD_USER_ID>`, the example team, and the secret names) with your own.

### `.github/workflows/cleanup-stale-branches.yml`

````yaml
name: Cleanup Stale Branches

on:
  schedule:
    - cron: '0 4 * * 0'  # Every Sunday at 04:00 UTC
  workflow_dispatch:
    inputs:
      age_days:
        description: 'Delete branches with no commits in N days'
        required: false
        default: '730'
      max_deletions:
        description: 'Hard cap on branches deleted per run'
        required: false
        default: '50'

concurrency:
  group: cleanup-stale-branches
  cancel-in-progress: false

permissions:
  contents: write
  pull-requests: write

jobs:
  cleanup:
    runs-on: ubuntu-slim
    timeout-minutes: 30
    steps:
      - name: Classify branches
        id: classify
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
          DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
          AGE_DAYS: ${{ inputs.age_days || '730' }}
          MAX_DELETE: ${{ inputs.max_deletions || '50' }}
        run: |
          set -euo pipefail

          CUTOFF_EPOCH=$(( $(date -u +%s) - AGE_DAYS * 86400 ))
          echo "Cutoff: $(date -u -d "@$CUTOFF_EPOCH" +%Y-%m-%d) (age_days=$AGE_DAYS)"

          SKIP_ALWAYS=$(printf '%s\n' master develop "$DEFAULT_BRANCH" | sort -u)

          OPEN_PR_LIMIT=1000
          gh pr list --repo "$REPO" --state open --limit "$OPEN_PR_LIMIT" \
            --json headRefName,baseRefName > /tmp/open_prs.json
          OPEN_PR_COUNT=$(jq 'length' /tmp/open_prs.json)
          if [ "$OPEN_PR_COUNT" -ge "$OPEN_PR_LIMIT" ]; then
            echo "::error::Open PR count ($OPEN_PR_COUNT) reached limit ($OPEN_PR_LIMIT); list may be truncated and untracked open-PR refs could be deleted. Raise OPEN_PR_LIMIT and re-run."
            exit 1
          fi
          jq -r '.[].headRefName, .[].baseRefName' /tmp/open_prs.json | sort -u > /tmp/open_refs.txt
          echo "Open PRs: $OPEN_PR_COUNT | unique refs: $(wc -l < /tmp/open_refs.txt | tr -d ' ')"

          MERGED_PR_LIMIT=5000
          gh pr list --repo "$REPO" --state merged --limit "$MERGED_PR_LIMIT" \
            --json headRefName,number,mergedAt,author \
            --jq '.[] | "\(.headRefName)\t\(.number)\t\(.mergedAt)\t\(.author.login // "unknown")"' \
            | sort -u > /tmp/merged_prs.tsv
          MERGED_PR_COUNT=$(wc -l < /tmp/merged_prs.tsv | tr -d ' ')
          if [ "$MERGED_PR_COUNT" -ge "$MERGED_PR_LIMIT" ]; then
            echo "::warning::Merged PR list at limit ($MERGED_PR_LIMIT); some merged-via-pr deletions may be missed. Raise MERGED_PR_LIMIT if needed."
          fi
          echo "Merged PR head refs: $MERGED_PR_COUNT"

          gh api "repos/$REPO/branches" --paginate \
            --jq '.[] | [.name, .commit.sha, (.protected|tostring)] | @tsv' > /tmp/branches.tsv
          echo "Remote branches: $(wc -l < /tmp/branches.tsv | tr -d ' ')"

          : > /tmp/delete.tsv
          : > /tmp/keep.tsv

          while IFS=$'\t' read -r NAME SHA PROTECTED; do
            if grep -Fxq "$NAME" <<<"$SKIP_ALWAYS"; then
              printf '%s\tSKIP\tsacred\t-\t-\t-\n' "$NAME" >> /tmp/keep.tsv
              continue
            fi
            if [ "$PROTECTED" = "true" ]; then
              printf '%s\tSKIP\tprotected\t-\t-\t-\n' "$NAME" >> /tmp/keep.tsv
              continue
            fi
            if grep -Fxq "$NAME" /tmp/open_refs.txt; then
              printf '%s\tSKIP\topen-pr-ref\t-\t-\t-\n' "$NAME" >> /tmp/keep.tsv
              continue
            fi

            MERGED_LINE=$(awk -F'\t' -v b="$NAME" '$1==b{print; exit}' /tmp/merged_prs.tsv)
            if [ -n "$MERGED_LINE" ]; then
              PR_NUM=$(cut -f2 <<<"$MERGED_LINE")
              MERGED_AT=$(cut -f3 <<<"$MERGED_LINE")
              PR_AUTHOR=$(cut -f4 <<<"$MERGED_LINE")
              printf '%s\tDELETE\tmerged-via-pr\t%s\t%s\t%s\n' "$NAME" "$PR_NUM" "$MERGED_AT" "$PR_AUTHOR" >> /tmp/delete.tsv
              continue
            fi

            COMMIT_JSON=$(gh api "repos/$REPO/commits/$SHA" 2>/tmp/commit_err || echo "")
            if [ -z "$COMMIT_JSON" ]; then
              if [ -s /tmp/commit_err ] && ! grep -qiE "404|not found" /tmp/commit_err; then
                echo "::warning::commit lookup failed for $NAME ($SHA): $(tr '\n' ' ' < /tmp/commit_err)"
              fi
              printf '%s\tSKIP\tno-commit-data\t-\t-\t-\n' "$NAME" >> /tmp/keep.tsv
              continue
            fi
            COMMIT_DATE=$(jq -r '.commit.committer.date // ""' <<<"$COMMIT_JSON")
            COMMIT_AUTHOR=$(jq -r '.author.login // .commit.author.name // "unknown"' <<<"$COMMIT_JSON")
            if [ -z "$COMMIT_DATE" ]; then
              printf '%s\tSKIP\tno-commit-data\t-\t-\t%s\n' "$NAME" "$COMMIT_AUTHOR" >> /tmp/keep.tsv
              continue
            fi
            COMMIT_EPOCH=$(date -u -d "$COMMIT_DATE" +%s)
            if [ "$COMMIT_EPOCH" -lt "$CUTOFF_EPOCH" ]; then
              AGE_D=$(( ( $(date -u +%s) - COMMIT_EPOCH ) / 86400 ))
              printf '%s\tDELETE\tstale\t%sd\t%s\t%s\n' "$NAME" "$AGE_D" "$COMMIT_DATE" "$COMMIT_AUTHOR" >> /tmp/delete.tsv
            else
              printf '%s\tKEEP\tactive\t-\t%s\t%s\n' "$NAME" "$COMMIT_DATE" "$COMMIT_AUTHOR" >> /tmp/keep.tsv
            fi
          done < /tmp/branches.tsv

          DELETE_COUNT=$(wc -l < /tmp/delete.tsv | tr -d ' ')
          KEEP_COUNT=$(wc -l < /tmp/keep.tsv | tr -d ' ')
          echo "Classified: $DELETE_COUNT candidates, $KEEP_COUNT kept"

          sort -t $'\t' -k3,3 -k5,5 /tmp/delete.tsv -o /tmp/delete.tsv
          head -n "$MAX_DELETE" /tmp/delete.tsv > /tmp/delete_batch.tsv
          BATCH_COUNT=$(wc -l < /tmp/delete_batch.tsv | tr -d ' ')
          REMAINING=$((DELETE_COUNT - BATCH_COUNT))

          if [ "$REMAINING" -gt 0 ]; then
            echo "::warning::Batch limit: processing $BATCH_COUNT of $DELETE_COUNT candidates this run. $REMAINING will be deferred to future runs. Raise max_deletions to process more at once."
          fi

          echo "delete_count=$DELETE_COUNT" >> "$GITHUB_OUTPUT"
          echo "batch_count=$BATCH_COUNT" >> "$GITHUB_OUTPUT"
          echo "remaining_count=$REMAINING" >> "$GITHUB_OUTPUT"
          echo "keep_count=$KEEP_COUNT" >> "$GITHUB_OUTPUT"
          echo "has_candidates=$([ "$BATCH_COUNT" -gt 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
          echo "max_delete=$MAX_DELETE" >> "$GITHUB_OUTPUT"

      - name: Apply deletions
        if: steps.classify.outputs.has_candidates == 'true'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
        run: |
          set -euo pipefail

          : > /tmp/result.tsv

          while IFS=$'\t' read -r NAME _ REASON DETAIL DATE AUTHOR; do
            ENC=$(jq -rn --arg x "$NAME" '$x|@uri')
            if gh api -X DELETE "repos/$REPO/git/refs/heads/$ENC" 2>/tmp/err; then
              printf '%s\tDELETED\t%s\t%s\t%s\t%s\n' "$NAME" "$REASON" "$DETAIL" "$DATE" "$AUTHOR" >> /tmp/result.tsv
              echo "deleted: $NAME ($REASON) by $AUTHOR"
              if [ "$REASON" = "merged-via-pr" ]; then
                PR_NUM="$DETAIL"
                gh api -X POST "repos/$REPO/issues/$PR_NUM/comments" \
                  -f body="Branch \`$NAME\` auto-deleted by cleanup-stale-branches workflow (merged branch cleanup)." \
                  >/dev/null 2>&1 || echo "::warning::Failed to post cleanup comment on PR #$PR_NUM"
              fi
            else
              ERR_MSG=$(tr '\n' ' ' < /tmp/err)
              if grep -qi "protected" /tmp/err; then
                printf '%s\tSKIPPED-PROTECTED\t%s\t%s\t%s\t%s\n' "$NAME" "$REASON" "$DETAIL" "$DATE" "$AUTHOR" >> /tmp/result.tsv
                echo "skipped (protected): $NAME"
              else
                printf '%s\tERROR\t%s\t%s\t%s\t%s\n' "$NAME" "$REASON" "$DETAIL" "$ERR_MSG" "$AUTHOR" >> /tmp/result.tsv
                echo "::warning::error deleting $NAME: $ERR_MSG"
              fi
            fi
          done < /tmp/delete_batch.tsv

      - name: Format Discord message
        id: format_message
        if: steps.classify.outputs.has_candidates == 'true'
        env:
          REPO_URL: https://github.com/${{ github.repository }}
        run: |
          set -euo pipefail

          if [ ! -s /tmp/result.tsv ]; then
            echo "has_message=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          DELETED_COUNT=$(awk -F'\t' '$2=="DELETED"' /tmp/result.tsv | wc -l | tr -d ' ')
          if [ "$DELETED_COUNT" = "0" ]; then
            echo "has_message=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          MAX_LIST=20
          MERGED_COUNT=$(awk -F'\t' '$2=="DELETED" && $3=="merged-via-pr"' /tmp/result.tsv | wc -l | tr -d ' ')
          STALE_COUNT=$(awk -F'\t' '$2=="DELETED" && $3=="stale"' /tmp/result.tsv | wc -l | tr -d ' ')
          ERRORS_COUNT=$(awk -F'\t' '$2=="ERROR"' /tmp/result.tsv | wc -l | tr -d ' ')

          MERGED_LIST=$(awk -F'\t' -v n="$MAX_LIST" -v repo="$REPO_URL" '$2=="DELETED" && $3=="merged-via-pr" { c++; if (c<=n) printf "• `%s` [PR #%s](%s/pull/%s) by @%s\\n", $1, $4, repo, $4, $6 }' /tmp/result.tsv)
          if [ "$MERGED_COUNT" -gt "$MAX_LIST" ]; then
            MERGED_LIST="${MERGED_LIST}… and $((MERGED_COUNT - MAX_LIST)) more\\n"
          fi

          STALE_LIST=$(awk -F'\t' -v n="$MAX_LIST" '$2=="DELETED" && $3=="stale" { c++; if (c<=n) { d=$4; sub(/d$/, "", d); printf "• `%s` (%s days ago) by @%s\\n", $1, d, $6 } }' /tmp/result.tsv)
          if [ "$STALE_COUNT" -gt "$MAX_LIST" ]; then
            STALE_LIST="${STALE_LIST}… and $((STALE_COUNT - MAX_LIST)) more\\n"
          fi

          MESSAGE="🧹 **Branch Cleanup Report**\n\n"
          if [ "$MERGED_COUNT" -gt 0 ]; then
            MESSAGE="${MESSAGE}**Merged PR branches deleted (${MERGED_COUNT}):**\n${MERGED_LIST}\n"
          fi
          if [ "$STALE_COUNT" -gt 0 ]; then
            MESSAGE="${MESSAGE}**Stale branches deleted (${STALE_COUNT}):**\n${STALE_LIST}\n"
          fi
          if [ "$ERRORS_COUNT" -gt 0 ]; then
            MESSAGE="${MESSAGE}⚠️ **Errors: ${ERRORS_COUNT}**\n"
          fi
          MESSAGE="${MESSAGE}**Total deleted: ${DELETED_COUNT}**"

          MESSAGE=$(printf '%s' "$MESSAGE" | sed \
            -e 's/@your-maintainer/<@DISCORD_USER_ID>/g' \
            -e 's/@schen/<@DISCORD_USER_ID>/g' \
            -e 's/@jpark/<@DISCORD_USER_ID>/g' \
            -e 's/@jpark/<@DISCORD_USER_ID>/g' \
            -e 's/@ckim/<@DISCORD_USER_ID>/g' \
            -e 's/@rdiaz/<@DISCORD_USER_ID>/g' \
            -e 's/@myu/<@DISCORD_USER_ID>/g' \
            -e 's/@mghannam93/<@DISCORD_USER_ID>/g' \
            -e 's/@ahmedmohamedeid98/<@DISCORD_USER_ID>/g')

          {
            echo "message<<EOF"
            printf '%b\n' "$MESSAGE"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"
          echo "has_message=true" >> "$GITHUB_OUTPUT"

      - name: Discord Webhook Notification
        if: steps.format_message.outputs.has_message == 'true'
        uses: tsickert/discord-webhook@v6.0.0
        with:
          webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
          content: ${{ steps.format_message.outputs.message }}

      - name: Write summary
        if: always()
        env:
          DELETE_COUNT: ${{ steps.classify.outputs.delete_count || '0' }}
          BATCH_COUNT: ${{ steps.classify.outputs.batch_count || '0' }}
          REMAINING_COUNT: ${{ steps.classify.outputs.remaining_count || '0' }}
          KEEP_COUNT: ${{ steps.classify.outputs.keep_count || '0' }}
          MAX_DELETE: ${{ steps.classify.outputs.max_delete || '50' }}
        run: |
          {
            echo "## Cleanup Summary"
            echo ""
            if [ "$REMAINING_COUNT" -gt 0 ]; then
              echo "> ℹ️ **Batch mode:** processed $BATCH_COUNT of $DELETE_COUNT candidates (max_deletions=$MAX_DELETE). **$REMAINING_COUNT deferred** to future runs. Raise \`max_deletions\` to process more at once."
              echo ""
            fi
            echo "- Total candidates: $DELETE_COUNT"
            echo "- Processed this run: $BATCH_COUNT"
            echo "- Deferred: $REMAINING_COUNT"
            echo "- Kept: $KEEP_COUNT"
            if [ -f /tmp/result.tsv ]; then
              DELETED=$(awk -F'\t' '$2=="DELETED"' /tmp/result.tsv | wc -l | tr -d ' ')
              PROT=$(awk -F'\t' '$2=="SKIPPED-PROTECTED"' /tmp/result.tsv | wc -l | tr -d ' ')
              ERRS=$(awk -F'\t' '$2=="ERROR"' /tmp/result.tsv | wc -l | tr -d ' ')
              echo "- Deleted: $DELETED"
              echo "- Skipped (protected): $PROT"
              echo "- Errors: $ERRS"
            fi
            echo ""
            if [ -s /tmp/result.tsv ]; then
              echo "### Deletion Results (this run)"
              echo ""
              echo "| Branch | Action | Reason | Detail | Date | Author |"
              echo "|---|---|---|---|---|---|"
              awk -F'\t' '{printf "| `%s` | %s | %s | %s | %s | %s |\n",$1,$2,$3,$4,$5,$6}' /tmp/result.tsv
              echo ""
              if [ "$REMAINING_COUNT" -gt 0 ] && [ -s /tmp/delete.tsv ]; then
                echo "<details><summary>Deferred candidates ($REMAINING_COUNT) — will be processed in future runs</summary>"
                echo ""
                echo "| Branch | Reason | Detail | Date | Author |"
                echo "|---|---|---|---|---|"
                tail -n +$((BATCH_COUNT + 1)) /tmp/delete.tsv | awk -F'\t' '{printf "| `%s` | %s | %s | %s | %s |\n",$1,$3,$4,$5,$6}'
                echo ""
                echo "</details>"
                echo ""
              fi
            elif [ -s /tmp/delete.tsv ]; then
              echo "### Deletion Candidates"
              echo ""
              echo "| Branch | Reason | Detail | Date | Author |"
              echo "|---|---|---|---|---|"
              awk -F'\t' '{printf "| `%s` | %s | %s | %s | %s |\n",$1,$3,$4,$5,$6}' /tmp/delete.tsv
              echo ""
            fi
            if [ -s /tmp/keep.tsv ]; then
              echo "<details><summary>Kept branches ($KEEP_COUNT)</summary>"
              echo ""
              echo "| Branch | Reason | Date | Author |"
              echo "|---|---|---|---|"
              awk -F'\t' '{printf "| `%s` | %s | %s | %s |\n",$1,$3,$5,$6}' /tmp/keep.tsv
              echo ""
              echo "</details>"
            fi
          } >> "$GITHUB_STEP_SUMMARY"
````

---

---
title: "I built an AI that writes weekly engineering summaries. Then I had to decide whether I should."
slug: ai-weekly-engineering-reviews
date: 2026-06-09
readTime: 8 min read
tags: [AI in Development, Engineering Leadership, Team Management, Automation]
author: Abdelrahman Saed
description: "Aggregating git and Jira into per-person weekly summaries with an LLM is a weekend project. Doing it responsibly — private, descriptive, transparent — is the actual work."
url: https://bnsaed.com/articles/ai-weekly-engineering-reviews
---

# I built an AI that writes weekly engineering summaries. Then I had to decide whether I should.

*Subtitle: Aggregating git and Jira into per-person weekly summaries with an LLM is a weekend project. Deciding how to use it without it becoming surveillance is the actual work.*

*The Self-Driving Repo · Part 9 — AI & People (flagship)*

![Private, per-engineer weekly summary cards routed individually](/article-images/ai-weekly-engineering-reviews-hero.webp)

---

This is the workflow I hesitated to write about, because it's the one most likely to be misunderstood — and the one where the engineering was genuinely the easy part.

Every Sunday, a job aggregates the week's git activity and issue-tracker tickets for each engineer, compares it to the previous week, and uses an LLM to write a short, personalized summary that gets delivered privately to each person — in their own language. It also updates a stats table in the repo's README.

Read that back and you can hear two very different reactions. One: "great, automated visibility into team contributions." The other: "that's surveillance with a friendly font." Both are correct, and which one it actually becomes depends entirely on choices that have nothing to do with code. So this post is half engineering, half the harder thing: how to build something this powerful *responsibly* — or whether to point it at people at all.

An AI weekly engineering review is a scheduled job that every Sunday aggregates each engineer's git activity (commits, files, insertions/deletions, change types, deduped by commit hash across name aliases) and issue-tracker tickets pulled via REST API, computes a **week-over-week comparison** into a structured JSON blob, then has an LLM translate those numbers into a short, warm, localized paragraph routed *privately* to that person. Here's how to do it responsibly: the prompt enforces strict guardrails — **no raw identifiers** (ticket IDs, commit hashes, PR numbers), a few singular sentences instead of a bulleted scorecard, strengths plus one supportive growth point, and the team's own language. Each summary goes individually, never to a public channel that becomes a leaderboard; a non-ranking contributions table updates the `README`. The principle: metrics describe, they never rank; private by default; transparent, not surveillance; and the tool stays subordinate to a human lead who already pays attention.

## What problem do AI weekly engineering summaries solve?

On a busy team, two real needs go chronically unmet:

1. **Contribution is invisible.** Quiet, high-impact work goes unseen; loud, low-impact work gets noticed. A lead working from vibes will misjudge both.
2. **Feedback is sporadic.** Most engineers get meaningful individual feedback a couple of times a year, in review season, about a period nobody remembers clearly.

The honest goal was a gentle, regular signal — a "here's what your week looked like, nice work on X, maybe watch Y" — at a cadence no human lead can sustain across a whole team by hand.

And right next to that honest goal sits the trap: the same machinery, pointed slightly differently, becomes a ranking engine that reduces people to commit counts. Holding both of those in view at once is the entire job.

## The idea

A scheduled workflow that, per engineer:

- aggregates **two data sources** — git (commits, files, insertions/deletions, change types) and the issue tracker (tickets resolved, in progress, updated),
- computes a **week-over-week comparison**,
- has an **LLM turn the numbers into a short, human, localized paragraph**,
- and **routes each summary privately** to that person.

```yaml
on:
  schedule:
    - cron: '0 6 * * 0'    # Sunday 06:00 UTC
```

## How it works (the easy part)

![Flow: git and issue-tracker data merged into per-person two-week stats, summarized by a constrained LLM, routed privately](/article-images/ai-weekly-engineering-reviews-diagram.webp)

### Aggregate two noisy sources into clean per-person stats

Git is the messier source. People commit under multiple names and emails, so each person maps to a set of aliases, and the script dedupes commits by hash across them. It buckets commit subjects into types (fix / feat / chore / refactor) and sums the diffstat — for the current week *and* the previous one, so every number has a comparison:

```bash
git log --all --no-merges --author="$alias" \
  --since="$WEEK_AGO" --until="$TODAY" --format="%H|%s"
# dedupe by hash, classify subject, sum files/insertions/deletions
```

The issue tracker fills in the other half — what shipped, what's in flight — pulled per person via its REST API and reduced to per-assignee counts with `jq`. The output of this stage is a compact, structured JSON blob per engineer: this week vs last week, across both sources. Boring, deterministic, debuggable. This is 90% of the workflow and 100% of the part I'd call "solved."

### Let the LLM do the one thing it's uniquely good at

Numbers don't motivate anyone. "12 commits, +1,400/−300" is a fact, not feedback. The LLM's only job is translation — turning the structured stats into a few warm, specific sentences, in the team's own language, with the week-over-week trend made human ("busier week than last, mostly bug-fixing — nice").

The model gets the stats and a strict set of rules about tone, length, and what *not* to do. The guardrails I put in the prompt are the interesting part, and every one of them is an ethical choice wearing an engineering hat:

- **No raw identifiers.** It must not quote ticket IDs, commit hashes, or PR numbers. The summary is about the shape of the week, not a paper trail.
- **Short and singular.** A few sentences, one voice, no bulleted scorecard. The format resists "ranking" by construction.
- **Strengths *and* a growth point**, phrased supportively. Never a verdict.
- **Localized.** Delivered in the language the team actually speaks, because feedback should feel personal, not like a system log.

Then each summary is routed **privately to the individual** — not dumped in a public channel where it implicitly becomes a leaderboard. Delivery is per-person, with the numbers and the paragraph kept as separate fields so presentation stays clean.

### Update the shared stats table

Separately, an aggregate (non-ranking) contributions table in the README is regenerated and committed. Note one self-referential detail: this commit has to pass the very guardrails from Part 1 of this series, so it carries the explicit opt-out flag — the automation plays by the same rules it enforces on everyone else.

## What it bought us — and the part that isn't about code

The engineering delivered exactly what I described: regular, specific, localized summaries at a cadence no human could match. 8 summaries a week, zero manual effort.

But "what it bought us" is the wrong frame for this one, so let me switch to the frame that matters.

## How do you build AI engineering reviews responsibly?

If you build this, you are now holding a tool that can quietly corrode trust. Here's the discipline I hold myself to. Take it as the real content of this post.

**1. Metrics describe; they never rank.** The moment per-person commit counts become a scoreboard, you've taught your team that the goal is commits — and you'll get more, smaller, emptier commits. That's Goodhart's Law, not a hypothesis. These numbers are a conversation starter for a human lead, never an input to compensation, stack-ranking, or who's "underperforming." If you can't commit to that, don't build it.

**2. Private by default.** A summary delivered to *you* about *your* week is supportive. The same summary in a public channel is a comparison nobody consented to. Route individually. Never broadcast individual stats.

**3. The numbers lie about the work that matters most.** Mentoring, design review, unblocking three teammates, the careful 5-line fix that prevented an outage — git can't see any of it. A reviewer who leaves brilliant comments shows up as "low activity." If you ever let this tool's view of a person override your own, you will reward the wrong things and punish your best people. The diffstat is the least important part of engineering; the tool only sees the diffstat.

**4. Transparency, not surveillance.** Everyone knows it exists, knows exactly what it measures, and knows it isn't feeding their performance review. A measurement people don't know about is monitoring. A measurement people understand and can see is a shared dashboard. Be the second thing, loudly.

**5. The lead stays in the loop.** This *augments* a lead who already pays attention; it doesn't replace one. If you're using it to *avoid* knowing your team, the tool has become a liability and so have you.

I'll be honest that reasonable people land in different places on whether to point any automated measurement at individuals at all. I built it as private, supportive, non-ranking, and transparent, and under those constraints I think it earns its keep. Loosen any one of those constraints and I'd tear it out. That line — not the `jq` — is the engineering judgment this workflow is really about.

## Gotchas & trade-offs

- **Goodhart's Law is the default outcome, not a risk.** Optimize for any visible metric and you'll get the metric and lose the intent. Guard against it actively or don't measure.
- **Alias mapping is fragile and consequential.** Miss one of someone's commit emails and you under-count *that specific person* — a quietly unfair bug. Audit the mapping.
- **LLM tone needs a tight leash.** Without firm rules an automated reviewer drifts into either hollow praise or unearned criticism. Constrain hard; spot-check output.
- **Localization adds review burden.** Generating feedback in another language means you must actually verify the tone lands right there — don't ship feedback you can't read.
- **This can erode trust faster than any other automation in the series.** Every other workflow touches code. This one touches people. That asymmetry deserves more caution than the code does, and it's why this is the last episode, not the first.

## Takeaway

The engineering here — aggregate two sources, diff two weeks, have an LLM phrase it, route it privately — is a weekend. The judgment is the job: keep it descriptive not ranked, private not public, transparent not covert, and subordinate to a human who actually pays attention. Point AI at people and the question stops being "can I build this?" and becomes "*should* I, and under what constraints?" If you can't answer the second question crisply, the right move is to not build it. That restraint is the most senior thing in this entire series.

---

*That's the series — nine workflows that turned a production mobile repo into something that mostly runs itself. If there's a theme across all of it, it's this: good automation is cowardly, transparent, and reversible, and the best engineering judgment is often knowing where to stop. Thanks for reading along.*

## The complete workflow

Here is the full, genericized workflow — drop it into `.github/workflows/` and replace the placeholders (`your-org`, the `PROJ` project key, `<@DISCORD_USER_ID>`, the example team, and the secret names) with your own.

### `.github/workflows/update-team-stats.yml`

````yaml
name: Update Team Stats

on:
  schedule:
    - cron: '0 6 * * 0'
  workflow_dispatch:
    inputs:
      days:
        description: 'Number of days for README stats'
        required: false
        default: '7'

concurrency:
  group: update-team-stats
  cancel-in-progress: true

permissions:
  contents: write
  pull-requests: read
  id-token: write

jobs:
  update-readme:
    runs-on: ubuntu-slim
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Collect team stats for README
        run: chmod +x tools/update_readme_team.sh && ./tools/update_readme_team.sh ${{ inputs.days || '7' }}
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Collect Jira stats
        continue-on-error: true
        run: |
          JIRA_BASE="https://your-org.atlassian.net"
          AUTH=$(printf '%s:%s' "$JIRA_EMAIL" "$JIRA_API_TOKEN" | base64 -w 0)

          jira_search() {
            local jql="$1"
            local encoded
            encoded=$(jq -rn --arg q "$jql" '$q|@uri')
            local result
            result=$(curl -sf \
              -H "Authorization: Basic ${AUTH}" \
              -H "Accept: application/json" \
              "${JIRA_BASE}/rest/api/3/search/jql?jql=${encoded}&fields=assignee,summary,issuetype,status,resolutiondate,priority,updated&maxResults=100" 2>/dev/null) || result='{"issues":[]}'
            echo "$result"
          }

          jira_search "project = PROJ AND resolutiondate >= -7d ORDER BY resolutiondate DESC" > .tmp_jira_r.json
          jira_search "project = PROJ AND statusCategory = 'In Progress' ORDER BY assignee" > .tmp_jira_p.json
          jira_search "project = PROJ AND updated >= -7d ORDER BY updated DESC" > .tmp_jira_u.json

          jq -s '{
            resolved: {
              total: ((.[0].issues // []) | length),
              by_person: [(.[0].issues // []) | group_by(.fields.assignee.displayName // "Unassigned")[] | {
                assignee: (.[0].fields.assignee.displayName // "Unassigned"),
                count: length,
                tickets: [.[] | {key: .key, summary: .fields.summary, type: .fields.issuetype.name, priority: (.fields.priority.name // "None")}]
              }] | sort_by(-.count)
            },
            in_progress: {
              total: ((.[1].issues // []) | length),
              by_person: [(.[1].issues // []) | group_by(.fields.assignee.displayName // "Unassigned")[] | {
                assignee: (.[0].fields.assignee.displayName // "Unassigned"),
                count: length,
                tickets: [.[] | {key: .key, summary: .fields.summary, type: .fields.issuetype.name}]
              }] | sort_by(-.count)
            },
            updated: {
              total: ((.[2].issues // []) | length),
              by_person: [(.[2].issues // []) | group_by(.fields.assignee.displayName // "Unassigned")[] | {
                assignee: (.[0].fields.assignee.displayName // "Unassigned"),
                count: length,
                tickets: [.[] | {key: .key, summary: .fields.summary, type: .fields.issuetype.name, status: .fields.status.name}]
              }] | sort_by(-.count)
            }
          }' .tmp_jira_r.json .tmp_jira_p.json .tmp_jira_u.json > .tmp_jira_stats.json

          rm -f .tmp_jira_r.json .tmp_jira_p.json .tmp_jira_u.json
          echo "Jira: $(jq '.resolved.total' .tmp_jira_stats.json) resolved, $(jq '.in_progress.total' .tmp_jira_stats.json) in progress, $(jq '.updated.total' .tmp_jira_stats.json) updated"
        env:
          JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
          JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}

      - name: Collect git comparison stats
        run: |
          set -euo pipefail

          TODAY=$(date -u +%Y-%m-%d)
          WEEK_AGO=$(date -u -d '-7 days' +%Y-%m-%d 2>/dev/null || date -u -v-7d +%Y-%m-%d)
          TWO_WEEKS_AGO=$(date -u -d '-14 days' +%Y-%m-%d 2>/dev/null || date -u -v-14d +%Y-%m-%d)

          declare -a MEMBERS=(
            "Alex Rivera|your-maintainer,Alex Rivera,your-maintainer"
            "Sam Chen|schen,Sam Chen,schen"
            "Jordan Park|jpark,Jordan Park,Jordan"
            "Casey Kim|ckim,Casey Kim"
            "Riley Diaz|rdiaz,Riley Diaz,Riley Diaz"
          )

          collect_week() {
            local since="$1" until="$2" aliases="$3"
            local commits=0 files=0 adds=0 dels=0 merges=0
            local fix=0 feat=0 chore=0 refactor=0 other_cat=0
            local seen_file=$(mktemp)
            local subjects_file=$(mktemp)
            local raw_file=$(mktemp)

            IFS=',' read -ra alias_arr <<< "$aliases"
            for alias in "${alias_arr[@]}"; do
              git log --exclude='refs/heads/entire/*' --exclude='refs/remotes/*/entire/*' \
                --all --no-merges --author="$alias" --since="$since" --until="$until" \
                --format="%H|%s" 2>/dev/null | grep -vi "checkpoint" | grep -vi "update team stats" >> "$raw_file" || true
            done

            while IFS='|' read -r hash subject; do
              [[ -z "$hash" ]] && continue
              if ! grep -qx "$hash" "$seen_file" 2>/dev/null; then
                echo "$hash" >> "$seen_file"
                commits=$((commits + 1))
                echo "$subject" >> "$subjects_file"
              fi
            done < "$raw_file"

            if [ "$commits" -gt 0 ]; then
              local hashes
              hashes=$(cat "$seen_file")
              while IFS= read -r h; do
                local stat
                stat=$(git show --shortstat --format="" "$h" 2>/dev/null || true)
                if [ -n "$stat" ]; then
                  local f=$(echo "$stat" | sed -n 's/^ *\([0-9]*\) file.*/\1/p')
                  local a=$(echo "$stat" | sed -n 's/.* \([0-9]*\) insertion.*/\1/p')
                  local d=$(echo "$stat" | sed -n 's/.* \([0-9]*\) deletion.*/\1/p')
                  files=$((files + ${f:-0}))
                  adds=$((adds + ${a:-0}))
                  dels=$((dels + ${d:-0}))
                fi
              done <<< "$hashes"
            fi

            for alias in "${alias_arr[@]}"; do
              local m
              m=$(git log --exclude='refs/heads/entire/*' --exclude='refs/remotes/*/entire/*' \
                --all --author="$alias" --since="$since" --until="$until" \
                --merges --pretty=format:"%H" 2>/dev/null | wc -l | tr -d ' ')
              merges=$((merges + ${m:-0}))
            done

            if [ -s "$subjects_file" ]; then
              fix=$(grep -ci "^fix" "$subjects_file" 2>/dev/null) || true
              fix=${fix:-0}
              feat=$(grep -ci "^feat" "$subjects_file" 2>/dev/null) || true
              feat=${feat:-0}
              chore=$(grep -ci "^chore" "$subjects_file" 2>/dev/null) || true
              chore=${chore:-0}
              refactor=$(grep -ci "^refactor" "$subjects_file" 2>/dev/null) || true
              refactor=${refactor:-0}
              other_cat=$((commits - fix - feat - chore - refactor))
              [ "$other_cat" -lt 0 ] && other_cat=0
            fi

            rm -f "$seen_file" "$subjects_file" "$raw_file"
            printf '{"commits":%d,"files":%d,"insertions":%d,"deletions":%d,"merges":%d,"categories":{"fix":%d,"feat":%d,"chore":%d,"refactor":%d,"other":%d}}' \
              "$commits" "$files" "$adds" "$dels" "$merges" "$fix" "$feat" "$chore" "$refactor" "$other_cat"
          }

          AUTHORS_JSON="[]"
          for member in "${MEMBERS[@]}"; do
            IFS='|' read -r name aliases <<< "$member"

            cw=$(collect_week "$WEEK_AGO" "$TODAY" "$aliases")
            pw=$(collect_week "$TWO_WEEKS_AGO" "$WEEK_AGO" "$aliases")

            cw_commits=$(echo "$cw" | jq '.commits')
            pw_commits=$(echo "$pw" | jq '.commits')
            if [ "$cw_commits" -eq 0 ] && [ "$pw_commits" -eq 0 ]; then
              continue
            fi

            AUTHORS_JSON=$(echo "$AUTHORS_JSON" | jq \
              --arg name "$name" --argjson cw "$cw" --argjson pw "$pw" \
              '. + [{name: $name, current_week: $cw, previous_week: $pw}]')
          done

          AUTHORS_JSON=$(echo "$AUTHORS_JSON" | jq 'sort_by(-.current_week.commits)')

          jq -n \
            --arg cw_from "$WEEK_AGO" --arg cw_to "$TODAY" \
            --arg pw_from "$TWO_WEEKS_AGO" --arg pw_to "$WEEK_AGO" \
            --argjson authors "$AUTHORS_JSON" \
            '{
              date_ranges: {
                current_week: {from: $cw_from, to: $cw_to},
                previous_week: {from: $pw_from, to: $pw_to}
              },
              authors: $authors
            }' > .tmp_git_stats.json

          echo "Git stats collected for $(echo "$AUTHORS_JSON" | jq 'length') authors"
          cat .tmp_git_stats.json

      - name: Configure Claude Code permissions
        shell: bash
        run: |
          mkdir -p .claude
          cat > .claude/settings.local.json <<'SETTINGS_EOF'
          {
            "permissions": {
              "allow": [
                "Read(*)", "Write(*)", "Bash(*)", "Glob(*)", "Grep(*)"
              ]
            },
            "hooks": {}
          }
          SETTINGS_EOF
          if [ -f .claude/settings.json ]; then
            cp .claude/settings.json .claude/settings.json.bak
            jq '.hooks = {}' .claude/settings.json > .claude/settings.json.tmp && mv .claude/settings.json.tmp .claude/settings.json
          fi

      - name: Generate personalized team messages
        id: claude
        continue-on-error: true
        uses: anthropics/claude-code-action@v1
        env:
          ANTHROPIC_BASE_URL: https://api.your-llm-provider.com
        with:
          anthropic_api_key: ${{ secrets.LLM_API_KEY }}
          claude_args: '--model your-model-id --max-turns 10'
          show_full_output: true
          prompt: |
            You are a tech lead generating personalized weekly review messages for each team member.

            ## Input Data

            Read these two files:
            1. `.tmp_git_stats.json` — Pre-collected git stats for current and previous week per author, including:
               - `date_ranges`: current_week and previous_week date ranges
               - `authors[]`: each with `name`, `current_week` and `previous_week` stats:
                 - `commits`, `files`, `insertions`, `deletions`, `merges`
                 - `categories`: `fix`, `feat`, `chore`, `refactor`, `other` (commit type breakdown)
            2. `.tmp_jira_stats.json` — Jira ticket data (may be missing/empty — skip Jira if so):
               - `resolved`: tickets resolved in the last week, grouped by assignee
               - `in_progress`: tickets currently in progress, grouped by assignee
               - `updated`: tickets updated in the last week, grouped by assignee

            ## Team Mapping (Name → Role + Discord)
            CRITICAL: Each person has a unique Discord ID. Do NOT mix them up.

            | Name                | Role                        | Discord mention        |
            |---------------------|-----------------------------|------------------------|
            | Alex Rivera    | Mobile TL / Release Captain | <@DISCORD_USER_ID> |
            | Sam Chen       | CTO                         | <@DISCORD_USER_ID> |
            | Jordan Park   | Sr. Flutter Dev             | <@DISCORD_USER_ID> |
            | Casey Kim | Mid Flutter Dev             | <@DISCORD_USER_ID>  |
            | Riley Diaz       | Mid Flutter Dev             | <@DISCORD_USER_ID>  |
            | Morgan Yu      | Sr. QA Engineer             | <@DISCORD_USER_ID> |
            | Taylor Cruz     | QA Engineer                 | <@DISCORD_USER_ID> |
            | Jamie Okafor           | Sr. Backend Dev             | <@DISCORD_USER_ID> |

            Match Jira `assignee` display names to the Name column above. Never use the same Discord mention for two people.

            ## Message Rules

            ### Language:
            1. Language: English — warm, natural, and conversational.
            2. Use plain technical terms as-is (commit, bug, crash, feature, merge, ticket, repository).
            3. Keep proper nouns as-is.

            ### ⚠️ DO NOT mention:
            - Jira ticket IDs (e.g. PROJ-XXXXX)
            - Commit hashes or PR numbers
            - Exact commit messages
            - Do NOT list examples of commits or tickets
            - Do NOT go into low-level technical details

            ### What to include in each message:
            - High-level summary of this week's contribution using the `categories` breakdown (bugs, features, chores, refactoring)
            - Week-over-week comparison with concrete numbers (e.g. "you made X commits this week vs Y last week" or a percentage change)
            - General impact (performance, stability, user experience)
            - Work quality assessment
            - One or two key strengths
            - One clear improvement point

            ### Writing Style:
            - One single natural paragraph (NO sections, NO bullet points)
            - SHORT: 4–6 lines max per person
            - No repetition, no over-explaining

            ### Evaluation — must include the assessment INSIDE the sentence:
            - "Honestly, my read on where you're at is... because..."
            - "I think you're in great shape because..."
            - "The closest call is you need to push a bit more because..."

            ### Week-over-Week Comparison:
            - Compare: commit count, files changed, lines added/deleted
            - Use natural phrasing:
              - "This week was better than last — your commits went up from X to Y"
              - "A slight dip this week compared to last"
              - "Steady and consistent — about the same level"
            - 0 commits last week → active this week: highlight return
            - Active last week → 0 this week: note the drop

            ### Special Rules:
            - Alex Rivera → strong praise + still add one improvement point

            ## Output

            Write `.tmp_discord_messages.json` as a JSON array. Each element:
            ```json
            {
              "mention": "<@discord_id>",
              "name": "Person Name",
              "stats": "X commits | Y files changed | +Z / -W lines | vs last week: ↑/↓/→",
              "body": "message paragraph here without wrapping quotes"
            }
            ```

            IMPORTANT format notes:
            - Do NOT include the person's name in stats or body (the Discord mention already shows it)
            - `stats` is just the numbers line — no bold, no parentheses, no name
            - `body` is the evaluation paragraph — no quotes wrapping it
            - stats and body are SEPARATE fields, not combined

            Order by current week commit volume (highest first).
            Only include team members who had activity in at least one of the two weeks (git OR Jira).
            Write ONLY `.tmp_discord_messages.json`. No other output files.

      - name: Send individual messages to Discord
        shell: bash
        run: |
          if [ -z "$DISCORD_WEBHOOK_URL" ]; then
            echo "::warning::DISCORD_WEBHOOK_URL not configured, skipping"
            exit 0
          fi

          if [ ! -f .tmp_discord_messages.json ] || [ ! -s .tmp_discord_messages.json ]; then
            echo "::warning::No messages generated, sending fallback"
            MSG=$(printf '📊 **Team Stats Updated**\nCheck the latest stats: https://github.com/%s#team-members' "${{ github.repository }}")
            PAYLOAD=$(jq -n --arg content "$MSG" '{content: $content}')
            curl -f -X POST -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL" || echo "::warning::Discord notification failed"
            exit 0
          fi

          COUNT=$(jq 'length' .tmp_discord_messages.json)
          echo "Sending $COUNT individual messages to Discord..."

          for i in $(seq 0 $((COUNT - 1))); do
            MENTION=$(jq -r ".[$i].mention" .tmp_discord_messages.json)
            NAME=$(jq -r ".[$i].name" .tmp_discord_messages.json)
            STATS=$(jq -r ".[$i].stats" .tmp_discord_messages.json)
            BODY=$(jq -r ".[$i].body" .tmp_discord_messages.json)

            FULL_MSG=$(printf '%s\n%s\n\n%s' "$MENTION" "$STATS" "$BODY")

            PAYLOAD=$(jq -n --arg content "$FULL_MSG" '{content: $content}')
            if curl -f -X POST -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"; then
              echo "Sent message for $NAME"
            else
              echo "::warning::Failed to send message for $NAME"
            fi

            if [ $i -lt $((COUNT - 1)) ]; then
              sleep 2
            fi
          done
        env:
          DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}

      - name: Cleanup temp files
        run: git checkout -- .claude/ 2>/dev/null || true && rm -f .tmp_jira_stats.json .tmp_discord_messages.json .tmp_git_stats.json

      - name: Commit README changes
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          git config user.name "Alex Rivera"
          git config user.email "bot@users.noreply.github.com"
          git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
          git add README.md
          git diff --staged --quiet && echo "No changes" && exit 0
          git commit -m "chore: update team stats --skip-protection"
          git pull --rebase origin master
          git push
````

---

---
title: "Best Practices for Managing Large Flutter Codebases"
slug: managing-large-flutter-codebases
date: 2026-07-06
readTime: 9 min read
tags: [Flutter, Dart, Architecture, Scale]
author: Abdelrahman Saed
description: "Hard-won lessons from scaling iStoria — a 220K+ line, 60+ module Flutter app serving 5M+ learners — into a codebase a small team can still ship weekly."
url: https://bnsaed.com/articles/managing-large-flutter-codebases
---

# Best Practices for Managing Large Flutter Codebases

*Subtitle: A small Flutter app is held together by memory. A large one has to be held together by structure — because nobody can keep 220,000 lines in their head.*

![A large, orderly Flutter codebase — modules arranged like a city grid](/article-images/managing-large-flutter-codebases-hero.webp)

---

Most Flutter advice is written for the app you start with: one `lib/` folder, a handful of screens, a `main.dart` you can read top to bottom. That advice quietly stops working somewhere around the point where no single person has read the whole codebase anymore.

I lead mobile on **[iStoria](/case-studies/istoria)**, a story-based English-learning app on iOS and Android. Over a few years it has grown into roughly **220,000 lines of Dart across ~1,300 files**, **60+ feature modules**, and nearly **30,000 commits** — built and maintained by a small team, shipping weekly, at a 99.9% crash-free rate to **5M+ learners**. At that size, the enemy isn't any single hard problem. It's *entropy*: the slow drift where every change gets a little riskier, every merge a little scarier, and onboarding a new engineer takes a little longer than it did last quarter.

Managing a large Flutter codebase is mostly about fighting that entropy with structure. The practices below are the ones that actually held up under load: **feature-first modularization with a hard `core`/`features` boundary; one predictable data-flow layering in every module; disciplined dependency injection; code generation to delete boilerplate; conventions enforced by tooling instead of reviewers; owning the dependencies you can't afford to have break; treating offline-first as an architecture rather than a feature; and automating the repository itself so the boring, error-prone chores stop landing on people.** None of them are exotic. The discipline is in applying them *consistently* across 60 modules, not just the three you touched this week.

## What "large" actually changes

Small-app instincts are dangerous at scale precisely because they worked so well when the app was small. When everything fits in your head, a shared `utils.dart`, a global singleton, and a bit of copy-paste are all fine. The bill comes later:

- **No one holds the whole map.** Decisions get made locally, and local decisions that don't share a shape produce 60 slightly different architectures in one repo.
- **Blast radius grows silently.** A tweak to a "shared" helper now touches a dozen features you've never opened. Without boundaries, everything is coupled to everything.
- **The build gets slow and the reviews get shallow.** Boilerplate multiplies, diffs balloon, and reviewers start rubber-stamping because reading it all is impossible.

So the goal isn't elegance for its own sake. The goal is to keep the app **shippable as it grows** — to make the codebase navigable by people who didn't write it, and to shrink the blast radius of any single change. Every practice below is in service of that.

## 1. Modularize by feature, and defend the boundary

The single most important decision is the top-level shape. Organizing by *layer* (`screens/`, `models/`, `services/`) collapses the moment you have 60 features: every folder becomes a junk drawer of unrelated things, and no one can find "everything for streaks" without grepping the whole tree.

Organize by **feature** instead. Each feature is a self-contained module that owns its data and its UI, and the only shared space is a deliberately small `core`:

```text
lib/
├── core/            # shared and ONLY shared: api, services, widgets, errors
│   ├── api/
│   ├── services/
│   └── widgets/
├── features/
│   ├── streak/
│   ├── leaderboard/
│   ├── payment/
│   └── … 60+ more, each self-contained
└── main_*.dart      # one entry point per flavor
```

The rule that makes this work is a boundary you actually enforce: **features never import from other features.** If two features need the same thing, it moves down into `core`. If it's used by exactly one feature, it stays inside that feature — no speculative "someone might need this later" promotions. This one rule is what keeps blast radius small: opening `features/streak/` tells you *everything* streak-related, and changing it can't silently break `leaderboard`.

The counterintuitive part is that a little duplication across features is often *cheaper* than a shared abstraction. A helper shared by three features is a helper three features are now coupled to. Promote to `core` when the sharing is real and stable — not on the first coincidence.

## 2. One predictable layering, in every single module

Consistency beats cleverness. If every feature invents its own way to fetch and hold data, "large" just means "many different apps in a trench coat." Pick one layering and apply it everywhere so that opening any module — even one you've never seen — feels familiar within seconds.

The layering that held up for us is the classic Clean-Architecture flow: **data source → repository → controller**, with the UI reading from the controller. The detail that matters most at scale isn't the layer names, though — it's turning **failures into values instead of exceptions**. A thrown exception is an invisible control-flow edge that a reviewer can miss and a crash reporter finds for you in production. A returned failure is right there in the type signature:

```dart
// Failures are values, not surprises. The signature forces every caller
// to handle both outcomes — the compiler becomes your reviewer.
abstract class StoriesRepository {
  Future<Either<Failure, List<Story>>> fetchStories();
}

class StoriesRepositoryImpl implements StoriesRepository {
  StoriesRepositoryImpl(this._remote, this._local);

  final StoriesRemoteSource _remote;
  final StoriesLocalSource _local;

  @override
  Future<Either<Failure, List<Story>>> fetchStories() async {
    try {
      final cached = await _local.cachedStories(); // read locally first
      unawaited(_remote.refreshInBackground());     // sync, never blocks UI
      return Right(cached);
    } on CacheException catch (e) {
      return Left(CacheFailure(e.message));
    }
  }
}
```

When *every* repository returns an `Either<Failure, T>` (I use `dartz` for it), the UI stops being a minefield of `try/catch` and becomes a simple fork: render the data, or render the error. Multiply that discipline across 60 modules and an enormous class of "we forgot to handle the failure path" bugs simply can't compile.

Keep one reactive controller (or cubit — pick a state solution and standardize on it) per feature as the single place UI state lives. Which state-management library you choose matters far less than choosing *one* and using it the same way everywhere.

## 3. Make dependency injection boring on purpose

Sixty modules that all `new` up their own dependencies is sixty modules welded to concrete implementations — impossible to test, impossible to swap. But an over-clever DI setup is its own tax. The goal is *boring*: one obvious place things get registered, and one obvious way to fetch them.

Register truly global services (auth, networking, the local database) once at startup in a single central binding. Then give each feature its **own** binding that registers only what that feature needs, wired up lazily when you navigate into it and torn down when you leave. A feature depends on abstractions (`StoriesRepository`), never on the concrete class, so it can be tested against a fake and refactored without a chain reaction. The win at scale is that DI becomes a lookup you never have to think about — which is exactly what you want from plumbing.

## 4. Delete boilerplate with code generation

A large app generates staggering amounts of mechanical code: JSON serialization for every model, database table classes, route tables. Hand-writing and hand-maintaining that is both a waste of humans and a rich source of subtle bugs (the `fromJson` that forgot a field, the copy-paste that mismatched a type).

Push all of it to code generation. `build_runner` with `json_serializable` for models, a typed database layer for local storage, and generated route definitions mean the boilerplate is *derived* from a single declaration rather than maintained by hand:

```dart
@JsonSerializable()
class Story {
  Story({required this.id, required this.title, required this.wordCount});

  final String id;
  final String title;
  final int wordCount;

  // The generator writes fromJson/toJson. You never touch — or misspell — them.
  factory Story.fromJson(Map<String, dynamic> json) => _$StoryFromJson(json);
  Map<String, dynamic> toJson() => _$StoryToJson(this);
}
```

Generated code doesn't drift, doesn't get a typo in review, and doesn't show up as noise in your diffs. On a codebase this size, that's thousands of lines nobody has to read or trust by eye.

## 5. Enforce conventions with tooling, not code review

Every large team writes a style guide. Nobody reads it, and reviewers can't hold 40 rules in their head while also checking whether the logic is correct. So the rules quietly decay — unless a machine enforces them.

Lean on the analyzer hard. Beyond the standard `flutter_lints`, add a metrics plugin that fails the build on the things humans stop noticing: functions that grew too complex, nesting that got too deep, arguments lists that ballooned.

```yaml
# analysis_options.yaml — the parts that scale
analyzer:
  plugins:
    - dart_code_metrics

dart_code_metrics:
  metrics:
    cyclomatic-complexity: 50
    maximum-nesting-level: 6
    number-of-arguments: 4
  rules:
    - newline-before-return
    - prefer-first
    - prefer-last
    - no-equal-then-else
```

Then codify the softer conventions and let the pipeline block violations: **self-documenting code over comments** (a comment explaining *what* code does is a smell — rename until it doesn't need one), **no stray `print`/`debugPrint`** in committed code, and — the one I'd fight for hardest — **structured, diagnostic error reports**. Every recorded error carries a `[Source]` tag and pipe-delimited context, so a crash in your reporter is actionable on its own:

```text
[GrowthBook] getValue conversion failure | key=offer_settings | expectedType=Map | actualType=int | rawValue=304
```

The difference between that and `"something went wrong"` is the difference between a five-minute fix and an afternoon spelunking through source. When the machine enforces the rules, review gets to spend its scarce attention on what actually needs a human: *is this logic correct?*

## 6. Own the dependencies you can't afford to lose

A large app leans on a lot of packages, and the pub ecosystem is uneven — some critical dependencies are lightly maintained, or stall on exactly the platform bug that's blocking your release. When a dependency you rely on goes quiet, you have three options: wait, rip it out, or take control.

For the ones that matter, take control: **fork the package, fix or patch it, and point your `pubspec` at your fork.** I maintain several forks of packages iStoria depends on for exactly this reason — a shimmer effect, a showcase overlay, a layout helper — where upstream couldn't move as fast as our release cycle needed. You give up "just run `pub upgrade`," but you gain the ability to ship on your own schedule instead of waiting on a maintainer who has a day job. At scale, not being blocked is worth a lot.

## 7. Treat offline-first as an architecture, not a feature

If your app has real users on real networks — commutes, spotty Wi-Fi, metered data — "works offline" can't be bolted on at the end. It's a decision that shapes your whole data layer. Learners shouldn't wait on a round-trip to see their own progress.

The pattern that scales is **local-first reads with background sync**: the app reads from a local database immediately and treats the network as something that reconciles state in the background, not something the UI blocks on. That's why the repository in practice #2 returns cached data *first* and kicks off a refresh without awaiting it. Getting this right early means every feature you build afterward inherits offline support for free, instead of each one reinventing its own fragile caching.

## 8. Automate the repository itself

The last multiplier isn't in the app code at all — it's in the repo around it. A codebase with tens of thousands of commits and a busy team drowns in mechanical chores: enforcing branch hygiene, keeping PRs current, chasing stale branches, surfacing merge conflicts before they rot. Left to humans, these are the tasks that get skipped under pressure and bite you at the worst time.

So automate them. Small, sharp CI jobs that guard `master`, keep pull requests in sync, and clean up after themselves take that entire category of work off people's plates — and, done right, do it more reliably than a human ever would. I wrote about this at length in **[The Self-Driving Repo](/articles/github-actions-branch-protection-guardrails)** series, so I'll leave the details there; the point is that at scale, the repository's own hygiene is part of the architecture.

## The payoff

None of this is about being fancy. Every practice here trades a little up-front discipline for a codebase that stays *shippable* as it grows. On iStoria, that discipline is what let a small team take the release cycle from four weeks down to one, hold a 99.9% crash-free rate, and keep shipping new features into a 60-module app without the whole thing turning into a house of cards.

Large Flutter codebases don't fail because Flutter can't scale. They fail because structure decays faster than features get added. Pick a shape, enforce it with machines instead of willpower, and keep the blast radius of any single change small. Do that consistently, and 220,000 lines can still feel like a codebase you understand.

*Want the longer version of how these decisions played out in production? Read the [iStoria case study](/case-studies/istoria).*

---

---
title: "App Flavors in Flutter: One Codebase, Every Environment"
slug: flutter-build-flavors-guide
date: 2026-07-06
readTime: 9 min read
tags: [Flutter, Dart, Build & Release, DevOps]
author: Abdelrahman Saed
description: "How to run local, staging, and production from a single Flutter codebase with build flavors — Dart entry points, typed per-flavor config, and the Android and iOS native wiring that makes it real."
url: https://bnsaed.com/articles/flutter-build-flavors-guide
---

# App Flavors in Flutter: One Codebase, Every Environment

*Subtitle: The same binary has to talk to a local mock, a staging server, and production — each with a different name, icon, and set of keys. Flavors turn that into a build-time choice instead of a comment you forgot to uncomment.*

![Flutter build flavors — one codebase producing local, staging, and production apps](/article-images/flutter-build-flavors-guide-hero.webp)

---

Every real app lives in more than one world. On my machine it should hit a local backend I can break freely. In QA it should point at a staging server with test data. In the store it must talk to production — and nothing else, ever. Along the way the app name changes (`MyApp` vs `MyApp Staging`), the icon changes so you can tell three installs apart on one phone, the bundle ID changes so they don't overwrite each other, and every analytics and crash-reporting key swaps to its matching project.

The naive way to handle this is a constant at the top of a file:

```dart
// Don't do this.
const apiBaseUrl = "https://api.example.com/"; // TODO: switch back to staging
```

That one commented-out line is how a debug build with a staging URL ends up in the App Store. The whole point of **flavors** is to delete that class of mistake: you pick an environment when you *build*, the tooling bakes the right configuration in, and there is no runtime toggle a human can leave in the wrong position.

I lead mobile on **[iStoria](/case-studies/istoria)**, a story-based English-learning app shipping to 5M+ learners on iOS and Android. We run three flavors — local, staging, and production — across a 60-module codebase, and the setup has caught more "wrong environment" mistakes than I can count. This guide is the generic version of what we do: the patterns, with invented examples, so you can lift them into any Flutter app.

## What a flavor actually is

A "flavor" (Android calls it a *product flavor*; iOS reaches the same result with *schemes* and *build configurations*) is a named build variant of one codebase. Same source, different identity and configuration baked in at build time.

The trap is that a flavor is not one thing you configure in one place — it's a decision that has to line up across **three layers**:

1. **Dart** — which entry point runs, and which configuration it loads.
2. **Android** — the application ID, app name, and any native build fields.
3. **iOS** — the scheme and `.xcconfig` that select signing, bundle ID, and display name.

Get the Dart layer right but forget Android, and your "staging" build installs on top of production and reports to the production analytics project. The work is in keeping all three in sync — so the goal is to make each layer boringly declarative.

## The Dart layer: an enum and thin entry points

Start with a single source of truth for "which flavor am I?" — an enum plus a tiny accessor class. No logic, just the flavor and the things that are derived from it:

```dart
enum Flavor { local, staging, production }

class AppConfig {
  static Flavor? current;

  // Different display name per flavor, so three installs are distinguishable.
  static String get appTitle => 'MyApp ${current?.name}';

  // Each flavor loads its own bundled config file (see next section).
  static String get configFile => '${current?.name}.config.json';
}
```

Then give each flavor its own `main_*.dart`. These entry points stay deliberately trivial — set the flavor, then defer *everything* to one shared bootstrap so startup logic never forks per environment:

```dart
// lib/main_staging.dart
import 'bootstrap.dart';
import 'app_config.dart';

Future<void> main() async {
  AppConfig.current = Flavor.staging;
  await bootstrap();
}
```

```dart
// lib/main_production.dart
Future<void> main() async {
  AppConfig.current = Flavor.production;
  await bootstrap();
}
```

The rule that keeps this maintainable: **an entry point is allowed to do exactly one thing — name its flavor.** The moment `main_staging.dart` and `main_production.dart` start to drift (an extra initializer here, a different plugin there), you've lost the guarantee that all flavors run the same code. Push all real setup into `bootstrap()`.

## Config as data, not code

The flavor picks *which* configuration, but the configuration itself shouldn't be a `switch` statement buried in Dart. Keep it as **data** — one file per flavor, bundled as an asset:

```json
// staging.config.json
{
  "apiBaseUrl": "https://staging.example.com/",
  "analyticsKey": "staging-a1b2c3",
  "enableDebugBanner": true,
  "featureNewOnboarding": true
}
```

Load it at boot and decode it into a **typed** model — not a raw `Map`. The type is what turns "someone added a key to staging but forgot production" from a null-pointer crash in the field into a build-time or first-launch error you catch immediately:

```dart
@JsonSerializable()
class Environment {
  Environment({
    required this.apiBaseUrl,
    required this.analyticsKey,
    required this.enableDebugBanner,
    required this.featureNewOnboarding,
  });

  final String apiBaseUrl;
  final String analyticsKey;
  final bool enableDebugBanner;
  final bool featureNewOnboarding;

  // Generated by build_runner — you never hand-write or mistype this.
  factory Environment.fromJson(Map<String, dynamic> json) =>
      _$EnvironmentFromJson(json);
}
```

```dart
// In bootstrap(), before you build the app:
final raw = await rootBundle.loadString(AppConfig.configFile);
final env = Environment.fromJson(jsonDecode(raw));
// `env.apiBaseUrl` is now a strongly-typed value the whole app reads from.
```

Two things make this scale. First, **code generation** (`json_serializable`) writes the `fromJson` for you, so adding a field is a one-line change with no boilerplate to keep in sync. Second, because every config file is decoded into the *same* model, a missing or mistyped key can't hide — the deserialization fails loudly instead of silently handing you a `null` three screens later.

## Android: product flavors

On Android, flavors live in `android/app/build.gradle.kts`. Each one sets the pieces that make builds coexist and identify themselves. The key move is `applicationIdSuffix`, which gives non-production builds a distinct package name so they install **side by side** with the store version:

```kotlin
android {
    flavorDimensions += "app"

    productFlavors {
        create("staging") {
            dimension = "app"
            applicationIdSuffix = ".staging"          // com.example.myapp.staging
            resValue("string", "app_name", "MyApp Staging")
        }
        create("production") {
            dimension = "app"
            resValue("string", "app_name", "MyApp")
        }
    }
}
```

`resValue` injects a per-flavor string resource (here the display name) without maintaining separate `strings.xml` files. If you need a native build-time constant your Kotlin code reads, a `buildConfigField(...)` entry does the same job on the native side. Keep this list aligned with the Dart flavors: same names, same count.

## iOS: schemes and xcconfig

iOS has no direct equivalent of product flavors, so you assemble the same outcome from two Xcode primitives:

- **Schemes** — one per flavor (`staging`, `production`). The scheme selects which build configuration runs and which entry point/target is used.
- **`.xcconfig` files** — plain-text build settings per flavor that drive the bundle identifier, display name, and signing.

It's more manual than Android — you're clicking through Xcode's target settings and duplicating build configurations rather than writing a tidy DSL block — but the result is identical: a `staging` scheme produces an app with its own bundle ID (so it installs alongside production) and its own display name. Once the schemes exist, day-to-day work is just picking the scheme, and CI selects it with `--flavor`.

## Running and building

With the three layers in place, you never edit a file to change environments — you pass a flag. The `--flavor` selects the native variant and `-t` (target) selects the Dart entry point; they must agree:

```bash
# Run staging locally
flutter run --flavor staging -t lib/main_staging.dart

# Build a production release
flutter build ipa --flavor production -t lib/main_production.dart
flutter build appbundle --flavor production -t lib/main_production.dart
```

The one risk here is a human pairing the wrong `--flavor` with the wrong `-t` (staging flavor, production target). Don't rely on discipline — encode each combination once, in a build script or CI job, and let people invoke *that*:

```bash
# scripts/run_staging.sh — the only blessed way to run staging
flutter run --flavor staging -t lib/main_staging.dart
```

In CI, the same pairing lives in the pipeline definition, so a release build physically cannot be produced with a mismatched target.

## Pitfalls worth pre-empting

A few failure modes show up on every large flavored app. Each has a cheap mitigation:

- **Config drift between flavors.** A key exists in `production.config.json` but not `staging.config.json`, and staging crashes on launch weeks later. *Mitigation:* the typed model above — plus a tiny test that decodes every config file, so a missing key fails in CI, not in the field.
- **Secrets in the repo.** Real API keys and tokens don't belong in committed config files. *Mitigation:* keep only non-sensitive values in versioned config; inject secrets at build time (CI environment variables, `--dart-define`, or an untracked config that's generated during the build).
- **Forgetting a layer.** The Dart side is perfect but the Android `applicationId` is identical across flavors, so staging overwrites production on the device. *Mitigation:* treat "add a flavor" as a checklist that touches Dart, Android, and iOS together — never just one.
- **Debug affordances leaking to production.** A debug banner or a "switch environment" menu that ships to the store. *Mitigation:* gate them on the flavor (`if (AppConfig.current != Flavor.production)`), not on `kDebugMode` alone.

## The payoff

Once flavors are wired properly, a whole category of incident simply stops happening. Three installs sit on my phone at once — local, staging, production — each with its own icon, none able to clobber the others. QA tests staging with total confidence it can't touch live data. Adding a fourth environment (a load-test backend, a demo build for a conference) is a diff: one config file, one entry point, one flavor block per platform.

That's the real value of build flavors. They cost a day to set up and they buy you a permanent guarantee that "which environment is this?" is answered by the build system, not by whoever last edited a constant. On a codebase shipping weekly to millions of users, that guarantee is worth far more than the day.

*For the bigger picture on how iStoria stays shippable at scale, see the [iStoria case study](/case-studies/istoria) and the companion article on [managing large Flutter codebases](/articles/managing-large-flutter-codebases).*

---

---
title: "Offline-First vs Local-First: What's the Difference?"
slug: offline-first-vs-local-first
date: 2026-07-07
readTime: 7 min read
tags: [Offline-first, Architecture, Flutter, PowerSync]
author: Abdelrahman Saed
description: "Offline-first and local-first get used interchangeably, but they answer different questions — one is about whether the app works with no network, the other about who owns the canonical copy of the data. Where the line falls, with examples from a 5M-user Flutter app."
url: https://bnsaed.com/articles/offline-first-vs-local-first
---

# Offline-First vs Local-First: What's the Difference?

*Subtitle: The two terms get used interchangeably, but they answer different questions. One is about whether the app works with no network; the other is about who owns the canonical copy of the data.*

"Offline-first" and "local-first" sound like synonyms, and plenty of posts treat them as one. They're not. **Offline-first** asks a question about *availability* — does the app keep working when the network drops? **Local-first** asks a question about *authority* — is the copy of the data on your device the real one, or just a cache of the server's? Every local-first app is offline-first, but the large majority of offline-first apps are not local-first. This piece draws the line, and shows where a real 5M-user Flutter app actually sits.

## The one-sentence difference

Offline-first is about **availability**: the app functions without a connection. Local-first is about **authority**: the copy of the data on the device is the source of truth, and the cloud is a sync peer — not the owner.

That sounds academic until you notice it changes your architecture, your conflict strategy, and what happens to a user's data the day your backend goes away.

## Offline-first: the app keeps working

Offline-first is a resilience principle that grew out of the mobile and progressive-web-app world. The idea is simple: treat the network as an *enhancement*, not a prerequisite. The app reads from a local cache so it never blocks on a round-trip, queues writes made offline and replays them on reconnect, and behaves as if the connection might vanish at any second — because on a phone, it might.

Crucially, in a classic offline-first app the **server is still the source of truth**. The local store is a cache: fast and useful, but disposable. If it's wiped, the app rebuilds it from the backend. Conflict resolution tends to be simple — last-write-wins, or "the server decides" — because there's an authority to defer to.

You know you're looking at offline-first when:

- The app opens and shows your data instantly, even in airplane mode.
- Actions you take offline don't fail — they're captured and synced later.
- But sign in on a fresh device with no network, and there's nothing there. The data lives on the server; the device only ever had a copy.

## Local-first: the device owns the data

**Local-first** is a stronger claim, and it has a specific origin: a 2019 essay from the research lab Ink & Switch, *"Local-first software: you own your data, in spite of the cloud."* It flips the authority relationship. The canonical copy of the data lives **on your device**. The cloud still exists — to sync between your devices, to back up, to enable collaboration — but it's a peer, not the owner. Delete the cloud account and you still have your data, in full, on your machine.

The essay lays out seven ideals a local-first app aims for: it's **fast** (no spinners waiting on a server), works across **multiple devices**, works **offline**, supports **real-time collaboration**, lasts for the **long term** (still opens in ten years), is **private and secure** by default, and leaves the user in **ultimate control** of their data.

The hard part of local-first is the flip side of its power. If the device is authoritative and two devices both edit while offline, there's no server to referee the merge. That's why local-first is tightly associated with **CRDTs** — conflict-free replicated data types — data structures that merge automatically and deterministically no matter what order the edits arrive in. CRDTs are what make "everyone's copy is canonical" actually work.

## Side by side

| | Offline-first | Local-first |
| --- | --- | --- |
| **Source of truth** | The server (local is a cache) | The device (cloud is a sync peer) |
| **Core motivation** | Resilience and UX | Data ownership, longevity, collaboration |
| **Conflict strategy** | Last-write-wins / server-authoritative | Automatic merge, often via CRDTs |
| **Lose the cloud and…** | The app degrades; data may be at risk | You still have all your data |
| **Where it came from** | Mobile / PWA engineering | Ink & Switch essay (2019) |
| **Typical products** | Most consumer apps | Notes, docs, design and collaboration tools |

## They're a spectrum, not opposites

The cleanest way to hold this: **local-first is a superset of offline-first.** A local-first app is, by definition, also offline-first — if the device owns the data, of course it works offline. But an offline-first app is usually *not* local-first, because the server is still where the truth lives.

Most real apps sit somewhere on the spectrum, and — this is the part people miss — **often at different points for different data.** A single app can be server-authoritative for one kind of data and device-authoritative for another.

## In practice: building each in Flutter

**To build offline-first**, you need three things: a local database as a cache (Drift or Hive), a read path that serves from that cache first, and a way to get writes out when you're back online. A repository that returns cached data immediately and refreshes in the background is the whole pattern:

```dart
Future<Either<Failure, List<Story>>> fetchStories() async {
  final cached = await _local.cachedStories(); // instant, offline-safe
  unawaited(_remote.refreshInBackground());     // sync, never blocks the UI
  return Right(cached);
}
```

Pair that with an offline write queue — persist the request locally, return an optimistic result, replay on reconnect — and you have a genuinely offline-first app while the server stays the source of truth.

**To move toward local-first**, you change *what's authoritative*. Instead of treating the local database as a disposable cache, you make it the primary store and use a sync engine that treats the device as a peer. Tools like **PowerSync** and ElectricSQL do exactly this: the SQLite database on the device is the thing your code reads and writes, and changes stream bidirectionally to the backend. For true multi-writer collaboration, you reach for CRDT libraries (Automerge, Yjs) so edits merge without a referee.

At [iStoria](/case-studies/offline-first-sync) the app is squarely **offline-first** for the catalog — the story content is server-authoritative, and the device holds a cache — while the learner's *own* progress leans local-first: your streaks, completed chapters, and stage progress live on the device in a local database, and PowerSync reconciles them in the background. That split is deliberate, and it's also the norm: pick the model per data type, not for the whole app.

## Which one do you actually need?

- **Reach for offline-first** if your users are sometimes offline and you simply don't want the app to break. That's almost every mobile app, and it's the right default.
- **Reach for local-first** when data *ownership* is part of the product promise — privacy, longevity, no lock-in — or when you need real-time collaboration on user-created content. Notes apps, document editors, and design tools are the classic cases.
- **Don't pay the CRDT tax you don't need.** If your data has a natural authority — a backend that already arbitrates — full local-first with automatic merge is complexity you can skip.

## The takeaway

Offline-first is a **UX guarantee**: the app works without a network. Local-first is a **data-ownership stance**: the device holds the real copy, and the cloud serves it. They overlap, but they're not the same decision — and the most useful move is to stop choosing one for the whole app and start choosing per data type.

If you want the production version of how this plays out — the read path, the offline write queue, and where the authority line actually falls — I wrote it up in the [offline-first case study](/case-studies/offline-first-sync), and I gave a [talk on building unstoppable offline-first apps](/speaking).

---

# Scaling iStoria: Offline-First Architecture for 5M+ Learners — Case Study

Offline-first Flutter architecture behind story-based English learning for 5M+ learners

## Context

iStoria teaches English through short, narrated stories — listening, reading, and speaking practice wrapped in a streak-driven learning loop. Today it serves **5M+ learners across iOS and Android**.

I joined as the founding mobile engineer in 2022 and have owned the app the whole way from founding-stage build to a platform serving millions: I designed the architecture, built the offline-first data layer, ran the release process — and since June 2025 I lead the four-engineer iOS/Android squad that ships it.

This study is the honest account of that arc: what the product needed, what I personally owned, the decisions that got us here, the trade-offs they cost, and the numbers that came out the other end.

## The Challenge

The job was never a single feature. It was an architecture that could hold a demanding set of requirements *at once* — offline access, real-time audio, a fast release cadence, content protection, and a codebase a small team could keep moving in — and keep getting *faster* as it grew.

Get the platform right and every feature after it ships cheaply; get it wrong and the whole thing calcifies. The constraints below shaped every decision that followed.

## Constraints

Before any architecture, the non-negotiables the mobile app had to satisfy simultaneously:

- **Offline-first, not offline-tolerant.** Many learners study on unreliable or metered connections. Lessons, progress, and audio had to be available offline and reconcile cleanly the moment a device reconnected — never a spinner on a learner's own progress.
- **A 99.9% crash-free bar, held while shipping weekly.** A paying, education-focused audience won't forgive instability, and the roadmap demanded frequent releases. Stability and velocity had to hold *together*, not trade against each other.
- **Real-time audio at the center.** Story playback, text-to-speech, and "Read-with" speech recognition *are* the product — the data and rendering layers had to keep an audio loop responsive, not treat it as a side feature.
- **A small squad against a large surface.** Four engineers owning 50+ modules and 140+ routes meant the architecture itself had to keep everyone fast — consistency enforced by structure, not willpower.
- **Protect subscription revenue without punishing honest learners.** Piracy and account abuse threaten the catalog that funds the product; the defense had to stay invisible to legitimate users.

## My Ownership

What *I* personally owned across this arc — separate from what the product achieved. The distinction matters at this scale: outcomes below are the squad's; the rails they run on are mine.

- **Architecture direction.** I designed — and still own — the Clean Architecture, feature-modular layout: 50+ feature modules behind 140+ routes, and the `DataSource → Repository → Cubit` convention every feature follows.
- **The offline-first data layer.** I designed the PowerSync + Drift infrastructure: selective per-key sync, conflict resolution, and the schema-migration strategy serving millions of accounts. The deep dive is [Offline-First Flutter at 5M Learners](/case-studies/offline-first-sync/).
- **Release governance and CI/CD.** I own the pipeline end to end — trunk-based development, feature flags, 14 CI/CD workflows — and cut the release cycle from four weeks to one. The mechanics are in [From Four-Week Releases to Weekly](/case-studies/release-engineering/).
- **The performance program.** I drove the sustained work that took ~35% off cold start, ~60% off rendering, and ~20% off memory, tracked release-over-release through Sentry.
- **Team leadership.** Since June 2025 I lead the 4-engineer iOS/Android squad — architecture direction, the hiring bar, release governance, and quarterly roadmaps for the 5M+ user base.
- **Security.** I architected the cross-platform anti-piracy system (jailbreak / root / tamper detection plus backend behavioral analysis) and hardened the app end to end: PII masked in logs and Sentry, cleartext traffic disabled, TLS certificate validation enforced.

## Architecture

### The platform

I designed iStoria on **Clean Architecture with a feature-modular Flutter codebase** — today **50+ feature modules** behind **140+ routes** — so a small team works in isolation and the app stays navigable as it grows. Four bets carry the platform:

- **Offline-first by default.** A **PowerSync + Drift** data layer keeps a local SQLite mirror in sync with the backend, with selective per-key sync and conflict resolution, so learners never wait on the network for their own progress.
- **Reactive presentation with BLoC/Cubit.** State is modeled explicitly per feature through a `DataSource → Repository → Cubit` flow, with `Either<Failure, T>` error handling so failures are values the UI renders rather than exceptions that crash it.
- **A pipeline built for weekly shipping.** Trunk-based development, feature flags, and end-to-end CI/CD took the release cycle from **four weeks to one** — across **800+ merged PRs** — without lowering the crash-free bar.
- **Security as a first-class feature.** A cross-platform anti-piracy system plus hardened logging and transport protect both the catalog and learner data.

On top of that platform the squad ships the features learners actually touch — daily **streaks**, a social **Leaderboard**, friend **referrals**, the **iStro** AI chat companion, AI **"Read-with"** speech practice, home-screen **widgets**, **dark mode**, and a steady stream of **subscription and paywall experiments**.

### Layering

Every feature follows the same path, which keeps the codebase predictable at 50+ modules:

```dart
// DataSource → Repository → Cubit, with failures as values (dartz Either).
class StoriesRepository {
  StoriesRepository(this._remote, this._local);

  final StoriesRemoteDataSource _remote;
  final StoriesLocalDataSource _local;

  Future<Either<Failure, List<Story>>> fetchStories() async {
    try {
      final stories = await _local.cachedStories(); // offline-first read
      unawaited(_remote.refreshInBackground());      // sync, never blocks UI
      return Right(stories);
    } on CacheException catch (e) {
      return Left(CacheFailure(e.message));
    }
  }
}
```

The presentation layer consumes repositories through Cubits, so widgets stay declarative and every async path resolves to a `Left` (failure) or `Right` (data) the UI can render.

### Offline-first data

The data layer pairs **PowerSync** (streaming sync against the backend) with **Drift** (typed local SQLite). Reads are served locally first; sync runs in the background with selective per-key replication and conflict resolution. Because PowerSync exposes its tables as **views**, schema changes ship as versioned migrations that drop and recreate views rather than tables.

### Audio & speech

The learning loop is audio-heavy: `just_audio` for story playback, `flutter_tts` for text-to-speech, and speech recognition powering the AI **"Read-with"** experience, with `media_kit` handling video. Keeping this stack responsive drove much of the performance work below.

### Monetization, analytics & experimentation

Subscriptions run through **RevenueCat**, behind a paywall that is constantly A/B-tested — family plans, returning-user redesigns, and trial and pricing variants — gated by **GrowthBook** feature flags so experiments ship dark and ramp safely. Product and stability signals fan out to Firebase, Adjust, Sentry, and Clarity. The app builds in multiple flavors (development / staging / production), so one pipeline ships the same codebase to internal and store channels.

## Trade-offs

None of the bets above were free. The ones worth documenting:

- **Selective per-key sync over whole-database sync.** Syncing only the keys a learner actually needs keeps the local database small and saves bandwidth and battery — at the price of a more complex sync contract: per-key replication rules and PowerSync view migrations the team has to understand. Whole-database sync would have been simpler to build and costlier forever.
- **Modularity over build simplicity.** 50+ feature modules add tooling and boundary-discipline overhead — CI quality gates, review conventions, module scaffolding. The payoff is four engineers moving in parallel without stepping on each other, and a codebase where an unfamiliar module is readable in seconds. "Consistency beats cleverness" is the operating rule that makes the trade worth it.
- **Trunk-based + flags over long-lived branches.** Continuous integration is what made weekly releases safe, but it moves the risk into flag hygiene: every dark-shipped feature must be deliberately ramped or killed. A misconfigured flag replaces the merge conflict — rarer, but higher-stakes.
- **Weekly cadence as a stability investment.** The automation that made weekly shipping possible — branch protection, auto-rebasing bots, generated QA reports — took real engineering time that could have gone to features. It repaid itself: 350+ releases at a 99.9% crash-free rate, with velocity and stability holding together instead of trading off.

## Results

### Product outcome

- **5M+ learners** on iOS and Android, at a **99.9% crash-free** rate.
- **800+ merged PRs**, **350+ production releases**, and the release cycle cut **from four weeks to one**.
- **40,000+ abusive accounts auto-blocked** by the cross-platform anti-piracy system (jailbreak / root / tamper detection plus backend behavioral analysis), protecting subscription revenue.
- A sustained performance program: **~35% faster cold start**, **~60% rendering improvement**, and **~20% lower memory** — kept large stories and long sessions stable on low-end devices.
- A run of growth and AI features shipped end-to-end — daily **streaks**, a social **Leaderboard** with sharing, friend **referrals**, the **iStro** AI chat companion, AI **"Read-with"** speech practice, home-screen **widgets**, **dark mode**, quizzes, and an ongoing program of **subscription and paywall experiments**.

### My contribution

I designed the architecture and own it still. I built the offline-first data layer, the release pipeline, and the anti-piracy system; I drove the performance program behind the three numbers above; and I lead the four-engineer squad that ships all of it. The outcomes ran on rails I laid and maintain.

> **Impact measurement:** production crash telemetry (Sentry, release-over-release) and CI/CD delivery records (GitHub Actions — release counts, cycle time, merged PRs).

## Engineering Lessons

What held up under load — and what I'd weigh differently next time:

- **Offline-first is an architecture decision, not a feature.** Committing to local-first reads up front meant every feature inherited offline support for free. Retrofitting it later would have touched every data path in the app.
- **Consistency beats cleverness at 50+ modules.** One predictable `DataSource → Repository → Cubit` shape, applied everywhere, makes an unfamiliar module readable in seconds — worth more than any locally-optimal design only its author understands.
- **Failures as values paid for themselves.** Modeling every repository as `Either<Failure, T>` turned a whole class of "forgot to handle the error path" bugs into compile-time forks — a real part of holding 99.9% crash-free.
- **Release cadence is a force multiplier.** Cutting the cycle from four weeks to one didn't just ship features faster; it changed how the team plans — smaller, safer changes and much faster feedback.
- **What I'd do earlier:** invest in the module boundaries and CI quality gates from day one. Entropy compounds, and the cost of adding guardrails rises with every module you add before them.

---

# Offline-First Flutter at 5M Learners — Case Study

A local-first Flutter data layer — Drift, PowerSync, and a custom offline write queue — so learners never wait on the network.

## The Problem

iStoria's learners study on the move — commutes, spotty Wi-Fi, metered data, planes. For a learning app, the worst possible moment to stall is when someone opens it to continue a lesson and the screen spins waiting on a network round-trip to fetch *their own progress*.

So "works offline" was never a feature to bolt on at the end. For 5M+ learners across iOS and Android, the app had to treat the network as something that *reconciles* state in the background — not something the UI blocks on — and do that without losing a single completed chapter when a device drops offline mid-session and comes back an hour later.

## Constraints

The hard requirements the data layer had to satisfy at once:

- **Offline-first, not offline-tolerant.** Reads of a learner's own data — chapters done, stages, streaks, level progress — must resolve *instantly* from the device, never from the network.
- **Writes must survive going offline.** Finishing a chapter on the subway can't be lost; the write has to persist locally and replay when connectivity returns.
- **Reconcile without data loss.** When an account syncs from two states, the merge has to be deterministic — no silently overwritten progress.
- **Never leak credentials to disk.** A queued offline request persisted to storage must not carry an auth token in plaintext.
- **Hold at millions.** Whatever the design, it had to stay stable at 5M+ accounts without turning the local database into a liability.

## The Solution

The app is **local-first**: the source of truth the UI reads is a database *on the device*, and the network's job is to keep that database in sync in the background.

Three pieces carry it:

- **Drift** (typed, code-generated SQLite) holds the structured learner state — chapters done, chapters in progress, stages, levels, notification state — as real tables the UI reads directly.
- **PowerSync** streams changes between that local database and the backend, so the device converges on the server's state without the app ever awaiting a fetch to render.
- A custom **offline write queue** (a Dio interceptor backed by a local Hive box) captures writes made while offline and replays them automatically on reconnect.

Repositories return `Either<Failure, T>` — failures are values the UI renders, not exceptions that crash it — over a Clean-Architecture data flow (data source → repository → reactive controller), so every offline and online path resolves to something the UI can draw.

## Architecture

### The read path

Every read is served locally first. A repository returns cached data immediately and kicks off a background refresh it does not await, so the UI never blocks on the network — the path the diagram below traces from the widgets down to the backend.

### The offline write queue

Writes are where offline gets hard. Any write that must survive being offline opts in explicitly, and a Dio interceptor takes over when the network is gone: it persists the request to a local Hive box, returns an *optimistic* success so the UI keeps moving, and replays the queue automatically the moment connectivity is restored.

```dart
// A write that must survive being made offline opts in explicitly.
await client.post(url, body: data, queueOffline: true);
// Offline → persisted to a local queue, optimistic success returned,
// then replayed automatically on reconnect.
```

Two details that matter at scale: the queue **strips sensitive headers** (the auth token, API keys) before anything touches disk, so credentials are never persisted in plaintext; and it only queues requests that explicitly opted in, so a failed *read* never silently becomes a phantom *write*.

### Reconciliation

Because reads resolve against the local database and PowerSync reconciles in the background, state converges on reconnect rather than fighting the UI — the "rebuild state on reconnect" idea I dug into in my [offline-first talk](/speaking).

## Performance

The payoff is felt, not benchmarked:

- **No spinner on your own progress.** Opening the app mid-commute shows your chapters, streaks, and stages instantly, because they come from the device.
- **Writes are never lost.** Finish a lesson offline and it's captured locally and synced later — the learner never hits a failure they have to retry.
- **Bandwidth and battery saved.** The app isn't re-fetching state it already has; the network reconciles, it doesn't render.
- **Stable at 5M+.** The local-first design held a 99.9% crash-free rate across millions of accounts on real, unreliable networks.

## Results

- **5M+ learners** on iOS and Android, each fully usable offline, at a **99.9% crash-free** rate.
- Every feature built afterward **inherited offline support for free**, because the read/write plumbing lives in the shared data layer rather than in each screen.
- A single, consistent `Either<Failure, T>` data flow across the app — offline and error states handled by design instead of re-invented per screen.

## Engineering Lessons

- **Offline-first is an architecture decision, not a feature.** Committing to local-first reads up front meant every screen inherited it. Retrofitting caching screen-by-screen later would have touched every data path in the app.
- **Make the offline write path explicit.** Opting writes in with a flag — rather than queuing everything — kept the behaviour predictable and stopped failed reads from becoming phantom writes.
- **Never persist credentials to disk.** Stripping the auth token before a queued request is stored is a small, load-bearing detail; an offline queue is an attack surface if you're careless with it.
- **Local-first changes how the UI is written.** Once reads are instant and can't really "fail" the way a network call does, the UI collapses from a minefield of loading and error states into a simple render.

---

# From Four-Week Releases to Weekly — Case Study

Trunk-based development, a self-driving CI/CD pipeline, and feature flags — cutting the release cycle 75% without lowering the crash-free bar.

## The Problem

When I joined iStoria the mobile release cycle was **four weeks** long. A growing catalog and an ambitious roadmap needed features in front of learners far faster than that — but the audience is paying and education-focused, so speed could not come at the cost of stability. The 99.9% crash-free bar was non-negotiable.

The usual trap is to treat that as a trade-off: ship fast *or* stay stable. The job was to make them hold *together* — take the cycle from four weeks to one while a small team kept merging into a busy trunk, and keep the crash-free rate exactly where it was.

## Constraints

- **Stability can't drop.** Every acceleration had to preserve the 99.9% crash-free rate — no "move fast and break the paywall."
- **Small team, busy trunk.** A handful of engineers merging constantly means PRs drift out of date, translation files collide, and merge conflicts pile up faster than people can babysit them.
- **Two stores, three environments.** iOS and Android, plus local / staging / production builds — all from one codebase, with no hand-rolled build steps.
- **Manual chores don't scale.** Enforcing branch hygiene, rebasing PRs, chasing stale branches, and compiling release and QA reports by hand is exactly the work that gets skipped under deadline pressure and bites you at the worst time.

## The Solution

Three moves turned a four-week cycle into a weekly one.

- **Trunk-based development.** Short-lived branches merge into a protected `master` continuously, so integration happens in small, safe increments instead of a big, scary merge every month.
- **Feature flags (GrowthBook) that decouple *deploy* from *release*.** Unfinished work ships dark behind a flag and ramps when it's ready — so a slow feature never blocks the train, and anything misbehaving is killed instantly without an app-store round-trip.
- **Three build flavors** (local / staging / production) from one codebase, so the same pipeline ships to internal testers and to the stores without divergent build logic.

The multiplier on top of all three is automation: I turned the repository itself into something that maintains its own hygiene.

## Architecture

### A self-driving pipeline

The path from a pull request to the store is a series of gates and builds, and a suite of **14 GitHub Actions workflows** keeps that path green so people don't have to.

- **Guardrails** — `master` is branch-protected (it rejects direct pushes, including mine), sensitive files are protected from casual edits, and every PR runs Dart static analysis as a merge gate.
- **PR hygiene, automated** — bots keep open PRs rebased on `master`, sweep for merge conflicts and flag the PRs that need attention, auto-resolve the translation-file conflicts a multi-language app generates constantly, and clean up stale branches on a schedule.
- **Delivery** — CI builds the iOS and Android release artifacts, uploads to the store track, and generates a release and QA report; a workflow even keeps team throughput stats current.

I wrote about the guardrail half of this at length in the **[Self-Driving Repo](/articles/github-actions-branch-protection-guardrails)** series — the point is that at scale, the repository's own hygiene *is* part of the release architecture, not a chore you do around it.

## Performance

- **Release cycle cut 75%** — from four weeks to one.
- **350+ production releases** shipped and **900+ PRs** reviewed over the period.
- **99.9% crash-free** held the entire time — velocity went up, stability did not come down.
- Feature flags meant risky changes shipped **dark and ramped safely**, so a bad experiment was a flag flip away from off, not a hotfix release away.

## Results

- A small squad shipping **weekly** into a 50+ module app at a **99.9% crash-free** rate.
- An entire category of manual release work — rebasing, conflict-chasing, branch cleanup, QA reporting — taken **off people's plates** and done more reliably by CI than by hand.
- Deploy decoupled from release: the team ships continuously and *chooses* when learners see something, instead of the store review being the release event.

## Engineering Lessons

- **Automate the repository itself.** The highest-leverage CI work wasn't building the app — it was the small, sharp jobs that keep the trunk mergeable so a busy team never drowns in hygiene.
- **Feature flags are what make trunk-based safe.** Being able to merge unfinished work dark, ramp it, and kill it instantly is what lets you integrate continuously without shipping half-done features.
- **Guardrails beat discipline.** A machine that rejects a push to `master` enforces the rule every time; a wiki page asking people not to does not.
- **Cadence compounds.** Cutting four weeks to one didn't just ship features sooner — it shrank every change, tightened the feedback loop, and changed how the team plans.

---

# flutter_cached_pdfview: Cached PDF Viewer for Flutter — Case Study

An open-source cached PDF viewer for Flutter, adopted across thousands of apps

## The Problem

Displaying remote PDFs in a mobile app sounds trivial until you ship it. The common approaches each fall short:

- **Re-downloading on every open.** Naively loading a PDF from a URL fetches the whole file again each time — slow for the user and wasteful of bandwidth and data plans.
- **No offline story.** Once the network drops, documents a user already opened are simply gone.
- **Platform fragmentation.** iOS and Android render PDFs through entirely different native components, so a consistent, performant viewer means bridging both.
- **Boilerplate everywhere.** Loading states, error handling, and cache management get re-implemented in every app that needs a PDF.

I kept solving the same problem across projects, so I built it once, properly, and open-sourced it.

## Constraints

The design had four hard requirements:

- **Zero-config correctness.** The cached, offline-capable path had to be the *default* in a few lines — not an opt-in callers assemble from parts.
- **Two native renderers, one API.** iOS and Android render PDFs through entirely different components; the public surface had to hide that split completely.
- **Small footprint.** A dependency thousands of apps embed can't ship a heavyweight PDF engine — it had to lean on what the platforms already provide.
- **Maintainable for years.** As a package others depend on, it had to survive Flutter breaking changes — null-safety, `super.key`, evolving cache-manager APIs — without breaking downstream apps.

## The Solution

**`flutter_cached_pdfview`** renders remote PDFs natively while caching each downloaded file on-device — so a document opens instantly and works offline on every load after the first.

It wraps the native **`flutter_pdfview`** renderer with **`flutter_cache_manager`**, and exposes one small, declarative API over three sources — **URL, asset, and file path** — with progress placeholders, error widgets, and password-protected and gesture-zoom support built in:

```dart
// First open downloads + caches; every later open is served from disk.
const PDF().cachedFromUrl(
  'https://example.com/doc.pdf',
  placeholder: (progress) => Center(
    child: CircularProgressIndicator(value: progress / 100),
  ),
  errorWidget: (error) => const Center(child: Text('Failed to load PDF')),
);
```

The goal was to make the *right* behavior — cached, offline-capable, native rendering — the **default**, in a few lines, on both platforms.

## Architecture

The package is a thin, deliberate composition rather than a reinvention:

- **Native rendering, not a Dart PDF engine.** It delegates pixel-level rendering to the platform components through `flutter_pdfview`, so documents look and scroll the way users expect on each OS, and the package stays small.
- **Caching via `flutter_cache_manager`.** Remote files are downloaded once and stored in a managed on-device cache; later reads resolve straight to a local file, which is what makes repeat opens instant and offline-safe.
- **A source abstraction.** `cachedFromUrl`, `fromAsset`, and `fromPath` unify remote, bundled, and local documents behind one widget, so callers swap sources without touching their UI.
- **Lifecycle hooks as first-class API.** `placeholder` (download progress) and `errorWidget` are part of the public surface, so loading and failure states are handled by design rather than bolted on.

It supports **Android API 20+** and **iOS 11+**, ships under the **MIT** license, and has stayed maintained since **2020**.

## Performance

The entire point of the package is performance and resilience under real-world conditions:

- **Instant repeat opens.** The first load downloads and caches the file; every subsequent open is served from disk, eliminating the network round-trip and the re-download.
- **Bandwidth saved.** Documents are fetched once, not once per view — meaningful for users on metered or slow connections.
- **Offline by consequence.** Because reads resolve to the on-device cache, previously opened PDFs keep working with no connection at all.
- **Small footprint.** Leaning on the native renderers keeps the package lightweight and avoids shipping a heavyweight PDF engine inside every app that depends on it.

## Results

The package became one of the go-to PDF solutions in the Flutter ecosystem:

- **134+ GitHub stars**, **501+ pub.dev likes**, and **57,000+ downloads**.
- **100+ forks** and contributions from the community.
- Maintained continuously since **2020**, kept current with modern Flutter (null-safety, `super.key`, current cache-manager APIs).
- Adopted in production document readers, e-book apps, and EdTech products — including the PDF experiences I shipped at iStoria.

A small library with an outsized footprint: a focused tool that solved a recurring problem well enough that thousands of other apps now rely on it. The companion guide below walks through using it end-to-end.

## Engineering Lessons

Three things this package reinforced:

- **Compose, don't reinvent.** Wrapping `flutter_pdfview` and `flutter_cache_manager` beat writing a Dart PDF engine — smaller, faster, and it inherited native rendering quality for free.
- **Make the right thing the default.** Adoption came from *removing* decisions: cached, offline-capable, native rendering with no configuration. Good defaults are a feature.
- **Maintenance is the product.** Six years of staying current with Flutter is the reason apps still depend on it. Publishing a package is a commitment, not a drop.

---

# What is BLoC in Flutter?

BLoC (Business Logic Component) is a reactive state management pattern for Flutter that separates business logic from presentation using streams. Events go in, states come out, and the UI never touches business logic directly — it reacts to state changes the way it reacts to any data source.

## How it works

BLoC converts a stream of **events** (user actions, lifecycle signals, data fetches) into a stream of **states** (the data the UI renders). The bloc itself is a pure function — the same events in the same order always produce the same states — which makes it trivially testable.

The data flow is strictly one-directional:

```
Widget → Event → BLoC → State → Widget
```

A widget dispatches an event when the user interacts with it. The bloc receives the event, runs the business logic (calling repositories, transforming data, enforcing rules), and emits a new state. The widget rebuilds with the new state via `StreamBuilder` or `BlocBuilder`.

Here is a minimal counter bloc:

```dart
// Events
abstract class CounterEvent {}
class IncrementPressed extends CounterEvent {}
class DecrementPressed extends CounterEvent {}

// States
abstract class CounterState {}
class CounterInitial extends CounterState {
  final int count;
  CounterInitial(this.count);
}

// Bloc
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(CounterInitial(0)) {
    on<IncrementPressed>((event, emit) {
      emit(CounterInitial((state as CounterInitial).count + 1));
    });
    on<DecrementPressed>((event, emit) {
      emit(CounterInitial((state as CounterInitial).count - 1));
    });
  }
}
```

At iStoria (5M+ users, 50+ modules), every feature follows the same pattern: events define every user intent, states define every UI configuration, and the bloc mediates. The result is that a new engineer can read any feature's event/state classes and understand its full behaviour without touching the UI layer.

## When to use it

**Choose BLoC when:**

- Your app has complex state flows with multiple interacting data sources (API, local database, sync engine).
- You need strict testability — every state transition is a testable unit, and no business logic lives in widgets.
- A team of engineers works on the same codebase — BLoC's enforced structure prevents the "every screen does it differently" problem.
- You want traceability — `bloc_concurrency` and `hybrid_bloc` logging let you replay any state transition.

**Skip BLoC when:**

- The app is small (a few screens, no complex state) — the boilerplate isn't worth it; use Cubit or Riverpod instead.
- Your team has no state management experience — BLoC's learning curve is steeper than Cubit or Riverpod, and misusing it (e.g. putting business logic in event transformers) creates more problems than it solves.

## FAQ

### What is the difference between BLoC and Cubit?

Cubit is a simplified version of BLoC that replaces event classes with direct method calls. Instead of dispatching an `IncrementPressed` event, you call `increment()`. Cubit is better for simple state; BLoC is better when you need the event trail or complex event-to-state transformations.

### Is BLoC still recommended in 2026?

Yes. BLoC remains the most battle-tested state management pattern for large Flutter apps. It is the default at companies like iStoria (5M+ users) and is actively maintained with the flutter_bloc package. For new apps, Cubit (BLoC without events) is often a better starting point, migrating to full BLoC when event granularity is needed.

### Does BLoC work with offline-first apps?

Yes — BLoC pairs naturally with offline-first architectures. The bloc receives events from the UI, calls repository methods (which read from the local database first), and emits state. Whether the data came from the local Drift database or a PowerSync sync round-trip is transparent to the bloc.

---

# What is Cubit in Flutter?

Cubit is a streamlined state management class in Flutter from the flutter_bloc library. It simplifies the BLoC pattern by replacing event classes with simple methods, making it easier to learn while preserving the same reactive state model and testability.

## How it works

A Cubit exposes methods instead of events. You call a method, it runs some logic, and it emits a new state. The UI listens to the Cubit and rebuilds when the state changes.

```
Widget → Method call → Cubit → State → Widget
```

Compare the same counter, this time as a Cubit instead of a BLoC:

```dart
class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);
}
```

No event classes. No event-to-state mapping. Just methods that emit state. The UI uses the same `BlocBuilder<CubitCubit, int>` it would use with a full BLoC.

At iStoria, we use Cubit for features where the state logic is simple (settings, theme, simple CRUD screens) and full BLoC for features with complex event interactions (the learning flow, subscription paywall, leaderboard). The two coexist cleanly because Cubit extends BlocBase — the same base class — so the testing and tooling story is identical.

## When to use it

**Choose Cubit when:**

- The state logic is straightforward — a few methods, each doing one thing.
- You want BLoC's testability without the event-class boilerplate.
- You're prototyping and don't yet know which events you need — start with Cubit, migrate to BLoC if event tracing becomes necessary.

**Choose full BLoC instead when:**

- You need an audit trail of user intents (events) for debugging or analytics.
- Multiple events should trigger shared logic via event transformers.
- The same event can produce different states depending on the current state and async results.

## FAQ

### Should I start with Cubit or BLoC?

Start with Cubit. It covers 80% of state management needs with far less boilerplate. Migrate specific features to full BLoC when you need event tracing, event transformers, or complex concurrency control. At iStoria, roughly half our Cubits are Cubits, not full BLoCs.

### Can I mix Cubit and BLoC in the same app?

Yes — they share the same BlocBase interface, so BlocProvider, BlocBuilder, and BlocListener work identically with both. Most production apps mix them: Cubit for simple screens, BLoC for complex flows.

---

# What is Riverpod in Flutter?

Riverpod is a reactive state management framework for Flutter that resolves the limitations of the Provider package. It provides compile-time safety, eliminates BuildContext dependency for dependency injection, and handles caching and disposal automatically through providers.

## How it works

Riverpod revolves around **providers** — declarative descriptions of a piece of state. A provider can be as simple as a value or as complex as an async stream with caching and invalidation.

```dart
// A simple provider
final counterProvider = StateProvider<int>((ref) => 0);

// An async provider with caching
final userProfileProvider = FutureProvider.family<User, String>((ref, userId) async {
  final repo = ref.watch(userRepositoryProvider);
  return repo.fetchUser(userId);
});
```

The UI consumes providers with `ref.watch` (rebuilds when the state changes) or `ref.read` (reads once without rebuilding):

```dart
class UserProfileWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(userProfileProvider('123'));
    return user.when(
      data: (u) => Text(u.name),
      loading: () => CircularProgressIndicator(),
      error: (e, s) => Text('Error: $e'),
    );
  }
}
```

Riverpod's key differentiators:

- **No BuildContext needed** — providers are resolved through a `Ref`, not `context`. This means you can access providers in tests, in background isolates, and in non-widget code.
- **Compile-time safety** — the compiler catches circular dependencies, missing providers, and type mismatches.
- **Auto-dispose** — providers marked with `autoDispose` clean up their resources when no widget is watching them, preventing memory leaks.
- **Family** — parameterized providers let you create one per input (e.g. one `userProfileProvider` per user ID).

## When to use it

**Choose Riverpod when:**

- You want compile-time safety — the compiler catches provider wiring errors before runtime.
- You need to access state outside the widget tree (services, background tasks, tests).
- Your app benefits from automatic caching and disposal of state.
- You like the Provider model but hit its limitations (context-dependent lookups, runtime exceptions for missing providers).

**Choose BLoC/Cubit instead when:**

- You need an explicit event/state audit trail.
- Your team is already fluent in the BLoC pattern.
- You prefer strict event-driven architecture over reactive providers.

## FAQ

### Is Riverpod better than BLoC?

Neither is objectively better. Riverpod excels at compile-time safety, caching, and provider composition. BLoC excels at strict event-driven architecture with an audit trail. At iStoria (5M+ users), we use BLoC/Cubit because the event/state pattern scales predictably across 50+ modules with a team of engineers. Many teams successfully use Riverpod at similar scale.

### Does Riverpod need code generation?

No — you can use Riverpod without code generation using the classic provider syntax. However, the riverpod_generator package provides @riverpod-annotated providers that are more concise and support better analyzer tooling. For apps with more than 20 providers, the generated approach is recommended.

---

# What is GetX in Flutter?

GetX is a multi-purpose Flutter framework that combines state management, dependency injection, navigation, internationalization, and utility functions in a single package. It is popular for its simplicity and rapid development speed, but controversial in the Flutter community for its anti-pattern practices and tight coupling.

## How it works

GetX provides three main pillars: state management, dependency injection, and route management — all in one package.

**State management** uses reactive ".obs" variables and `GetBuilder`/`Obx` widgets:

```dart
class Controller extends GetxController {
  final count = 0.obs;

  void increment() => count.value++;
}

// In the widget:
Obx(() => Text('${controller.count.value}'))
```

**Dependency injection** uses `Get.put()` and `Get.find()`:

```dart
Get.put(MyController());
final controller = Get.find<MyController>();
```

**Navigation** uses `Get.to()`, `Get.back()` without BuildContext:

```dart
Get.to(OtherPage());
Get.back();
```

The appeal is obvious: one dependency, no BuildContext needed, minimal boilerplate. The cost is that GetX replaces Flutter's standard APIs with its own versions — navigation, theming, internationalization — creating a framework-within-a-framework.

## When to use it

**Choose GetX when:**

- You are building a prototype or MVP where speed-to-market matters more than architecture.
- Your team is new to Flutter and wants the simplest possible setup.
- You need navigation and DI without configuring go_router and get_it separately.

**Avoid GetX when:**

- You are building a production app that a team will maintain for years.
- You need to integrate with standard Flutter APIs (GetX's navigation replaces Flutter's, making testing and deep linking harder).
- You care about community code quality — GetX is banned by many companies for its controversial practices, anti-patterns, and the maintainer's history of breaking changes.

At iStoria, we deliberately chose BLoC over GetX because GetX's global state and context-free APIs make large codebases harder to reason about, not easier.

## FAQ

### Why is GetX controversial?

GetX uses global singletons and replaces standard Flutter APIs (navigation, theming, localization) with its own. This creates tight coupling and hidden dependencies that are harder to test and maintain at scale. The Flutter team has not recommended it, and many production teams ban it.

### Is GetX suitable for production apps?

It depends. GetX works for small-to-medium apps where speed of development matters. For large production apps with long lifespans and multiple engineers, BLoC, Riverpod, or Cubit are better choices because they enforce cleaner separation of concerns and integrate with standard Flutter APIs.

---

# What is Clean Architecture in Flutter?

Clean Architecture in Flutter organizes an app into concentric layers — presentation (UI + state), domain (business logic), and data (APIs, databases, caches) — with dependencies always pointing inward. The domain layer knows nothing about Flutter, databases, or network clients; it defines pure business rules that the outer layers implement.

## How it works

The dependency rule is the core: **dependencies point inward only**. The presentation layer depends on the domain layer; the data layer depends on the domain layer; the domain layer depends on nothing.

At iStoria (5M+ users, 50+ modules), every feature follows the same three-layer structure:

```
lib/features/story/
  domain/
    entities/story.dart          — pure Dart class, no Flutter imports
    repositories/story_repo.dart — abstract interface
  data/
    datasources/story_remote.dart — API client
    datasources/story_local.dart  — Drift database
    repositories/story_repo_impl.dart — concrete implementation
  presentation/
    bloc/story_bloc.dart         — state management
    pages/story_page.dart        — widgets
    widgets/story_card.dart
```

The repository contract lives in domain; its implementation lives in data:

```dart
// domain/repositories/story_repo.dart
abstract class StoryRepository {
  Future<Either<Failure, List<Story>>> fetchStories();
}

// data/repositories/story_repo_impl.dart
class StoryRepositoryImpl implements StoryRepository {
  StoryRepositoryImpl(this._remote, this._local);
  final StoryRemoteDataSource _remote;
  final StoryLocalDataSource _local;

  @override
  Future<Either<Failure, List<Story>>> fetchStories() async {
    try {
      final stories = await _local.cachedStories();   // offline-first read
      unawaited(_remote.refreshInBackground());        // sync, never blocks UI
      return Right(stories);
    } on CacheException catch (e) {
      return Left(CacheFailure(e.message));
    }
  }
}
```

The presentation layer never sees `StoryRemoteDataSource` or `StoryLocalDataSource` — it only knows the abstract `StoryRepository`. That means you can swap the backend, add caching, or switch from REST to GraphQL without touching the BLoC or the UI.

## When to use it

**Use Clean Architecture when:**

- Your app will live for years and be maintained by a team — the layering pays for itself in onboarding and refactoring safety.
- You have multiple data sources (API, local DB, cache, sync engine) that need to be swappable.
- You want business logic to be unit-testable without Flutter bindings.

**Skip it when:**

- The app is small (a few screens, one data source) — the layering overhead isn't worth it.
- Your team is new to Flutter — the abstraction layers add cognitive load that slows initial development.

## FAQ

### Is Clean Architecture overkill for Flutter?

For small apps, yes. For production apps with a team and a multi-year lifespan, no — the layering pays for itself. At iStoria (5M+ users, 50+ modules), Clean Architecture is why four engineers can out-ship larger teams. The key is to apply it at the feature level, not globally — a simple settings screen doesn't need a full three-layer structure.

### How does Clean Architecture work with offline-first?

Cleanly: the domain layer defines the repository contract; the data layer implements it with offline-first reads (local database first, background sync). The presentation layer is unaware whether data came from the network or the cache — it just calls the repository and gets an Either<Failure, T>.

---

# What is Offline-First in Flutter?

Offline-first is a mobile architecture pattern where the local database is the primary source of truth for the UI. Reads resolve instantly from the device, writes are persisted locally and replayed on reconnect, and the network's role is background reconciliation — not blocking the user interface.

## How it works

In an offline-first app, every read goes to the local database first. The UI never waits on a network round-trip for data it already has. A background sync engine reconciles the local database with the server.

```
User opens app → reads from local DB (instant) → background sync updates DB
User writes → persisted locally (instant) → queued and replayed on reconnect
```

The data flow at iStoria (5M+ users) uses **PowerSync** for streaming sync and **Drift** for the typed local SQLite database:

```dart
class StoriesRepository {
  StoriesRepository(this._remote, this._local);
  final StoriesRemoteDataSource _remote;
  final StoriesLocalDataSource _local;  // reads from Drift

  Future<Either<Failure, List<Story>>> fetchStories() async {
    try {
      final stories = await _local.cachedStories();    // instant local read
      unawaited(_remote.refreshInBackground());         // sync, never blocks UI
      return Right(stories);
    } on CacheException catch (e) {
      return Left(CacheFailure(e.message));
    }
  }
}
```

The key insight: the repository returns local data immediately and kicks off a background refresh it does NOT await. The UI gets instant data; PowerSync converges the local DB with the server asynchronously; the UI picks up changes reactively through Drift's stream.

Offline writes use a **queue** — a Dio interceptor backed by a local Hive box captures writes made while offline, returns an optimistic success so the UI keeps moving, and replays them automatically on reconnect:

## When to use it

**Use offline-first when:**

- Users are on unreliable or metered connections (mobile, travel, rural areas).
- The app's worst moment would be a spinner waiting for the user's own data.
- You need writes to survive going offline (completing a lesson on the subway).

**Don't use offline-first when:**

- The app is inherently real-time (a live dashboard, a trading app) where stale data is worse than no data.
- Your data set is too large to cache locally and too sparse to selectively sync.
- The sync/conflict-resolution logic would be more complex than the app itself.

## FAQ

### What is the difference between offline-first and local-first?

Offline-first means the app works without a network — reads are cached, writes are queued. Local-first goes further: the local database IS the source of truth, and the server's role is to sync, not to serve. Every local-first app is offline-first, but not every offline-first app is local-first. See the offline-first vs local-first comparison for details.

### What database should I use for offline-first Flutter?

Drift (typed SQLite with code generation) is the most popular choice for structured data. For key-value storage, Hive or Isar. For sync, PowerSync (which syncs Drift tables with a Postgres backend) or Supabase Realtime. At iStoria, we use PowerSync + Drift.

---

# What is Local-First Architecture?

Local-first architecture is a software design philosophy where the local device holds the authoritative copy of the user's data. All reads and writes go to the local database; the server exists to synchronize and back up, not to serve as the primary data source. This gives the app instant responsiveness, full offline capability, and user data ownership.

## How it works

Local-first goes beyond offline-first by redefining the source of truth:

- **Offline-first**: the server is the source of truth; the cache makes the app work without network.
- **Local-first**: the device is the source of truth; the server syncs and backs up.

The practical difference is in the read path. In an offline-first app, the cache is a performance optimization layered on top of network calls — the app tries the cache, falls back to the network, and updates the cache. In a local-first app, the app reads ONLY from the local database; the network's job is to keep that database in sync in the background.

```
Local-first read path:
  Widget → Repository → Local DB (Drift) → instant data
  Background → PowerSync → reconciles local DB with server

Offline-first read path:
  Widget → Repository → try cache → if miss, fetch from network → update cache
```

At iStoria, we are fully local-first: every read of user progress, chapters, streaks, and levels comes from the Drift database on the device. PowerSync streams changes between that database and the Postgres backend. The UI never awaits a network call to render user data.

## When to use it

**Choose local-first when:**

- You want the app to feel instant — every read resolves from the device, zero network latency.
- You need full offline capability with deterministic conflict resolution.
- You want every feature to inherit offline support automatically (because the read/write plumbing lives in the data layer, not per-screen).

**Choose traditional online-first when:**

- The data is too large to store locally (enterprise-scale datasets).
- Real-time collaboration requires server-side authority (Google Docs-style co-editing).
- The app is a thin client over a server that holds the canonical data.

## FAQ

### What is the difference between local-first and offline-first?

Local-first makes the device's database the source of truth — the server syncs but doesn't serve. Offline-first means the app works without network — the server is still the source of truth, but a cache lets the app function offline. Every local-first app is offline-first; not every offline-first app is local-first.

---

# What is Dependency Injection in Flutter?

Dependency injection (DI) in Flutter is a design pattern where objects receive their dependencies from an external source rather than creating them internally. This decouples construction from usage, making code modular, testable, and maintainable — especially in large codebases with many services.

## How it works

Without DI, a class creates its own dependencies:

```dart
// Tight coupling — hard to test, hard to swap
class UserService {
  final _api = ApiClient();  // created internally
  final _db = Database();     // created internally
}
```

With DI, the class declares what it needs, and an external source provides it:

```dart
// Loose coupling — easy to test, easy to swap
class UserService {
  UserService(this._api, this._db);
  final ApiClient _api;
  final Database _db;
}
```

In Flutter, the most common DI approaches are:

1. **get_it** — a service locator. Register types at startup, resolve them anywhere:
```dart
GetIt.instance.registerSingleton(ApiClient());
GetIt.instance.registerFactory<UserService>(() => UserService(
  GetIt.instance<ApiClient>(),
  GetIt.instance<Database>(),
));
final userService = GetIt.instance<UserService>();
```

2. **injectable** — a code-generation layer on top of get_it that auto-wires dependencies via annotations.

3. **Provider** — uses the widget tree for DI via `InheritedWidget`.

At iStoria, we use **get_it + injectable** because it works outside the widget tree (critical for background tasks, isolates, and tests) and because injectable's generated wiring prevents manual registration errors at 50+ modules.

## When to use it

**Use DI when:**

- Your app has more than a few services (API clients, databases, analytics, feature flags).
- You need to swap implementations in tests (mock the API, use an in-memory database).
- Multiple features depend on the same shared services.

**Skip DI when:**

- The app is small enough that manual construction is simpler than a DI container.
- You don't write tests (DI's primary value is testability through swappable dependencies).

## FAQ

### Should I use get_it or Provider for dependency injection?

Use get_it (with injectable) for services that need to be accessible outside the widget tree — API clients, databases, analytics. Use Provider for things that are tied to the widget tree lifecycle — theme, locale, feature flags scoped to a screen. Many apps use both.

---

# What is Trunk-Based Development?

Trunk-based development is a source control strategy where all developers commit frequently to a single shared branch (the trunk, usually master or main). Feature branches are short-lived (hours to a day or two) and merge back immediately, rather than living for weeks as in GitFlow.

## How it works

In trunk-based development, the `master` branch is always deployable. Engineers create short-lived branches, make small changes, and merge quickly:

```
master:  ──●──●──●──●──●──●──●──●──●──●──
            \ /    \ /    \ /
feature:    ●●      ●●      ●●
```

Key practices:

1. **Short-lived branches** — a branch lives for hours or a day, not weeks. If a feature takes longer, it ships dark behind a feature flag.
2. **Small commits** — merge one logical change at a time, not a month of work.
3. **Branch protection** — `master` rejects direct pushes; every merge goes through CI gates.
4. **Feature flags** — incomplete work merges dark behind a flag and ramps when ready.

At iStoria, trunk-based development is what let us cut the release cycle from four weeks to one. Engineers merge multiple times per day into a protected `master`, and a suite of 14 GitHub Actions workflows keeps the trunk green:
- `master` rejects direct pushes (even from the lead).
- Every PR runs Dart analysis as a merge gate.
- Bots auto-rebase open PRs after each merge, so conflicts never sit unnoticed.

## When to use it

**Use trunk-based development when:**

- You want to ship frequently (weekly or faster).
- You have CI/CD gates that catch issues quickly.
- Your team values fast feedback over long-running "complete" branches.

**Use GitFlow instead when:**

- You ship infrequently (monthly or slower) and need release branches.
- You have multiple supported versions in production.
- Your team isn't ready for feature flags.

## FAQ

### What is the difference between trunk-based development and GitFlow?

Trunk-based development merges everything to one branch frequently with short-lived feature branches. GitFlow uses long-lived feature branches and dedicated release branches. Trunk-based enables faster releases (days/weeks); GitFlow suits slower, multi-version release cycles (months).

### How do feature flags work with trunk-based development?

Feature flags let you merge unfinished code dark — the code is in master but gated behind a flag that defaults to off. When the feature is ready, you enable the flag remotely (via GrowthBook, Firebase Remote Config, etc.) without a new release. This decouples deploy from release.

---

# What are Feature Flags in Flutter?

Feature flags (also called feature toggles) are a software development technique that lets you enable or disable functionality at runtime without deploying new code. In Flutter, they decouple deploy from release — you ship code dark behind a flag, then remotely enable it when ready.

## How it works

A feature flag is a remote-controlled boolean (or string, or JSON) that your app checks at runtime:

```dart
if (featureFlags.isEnabled('new_checkout_flow')) {
  return NewCheckoutPage();
} else {
  return OldCheckoutPage();
}
```

The flag values are fetched from a remote service (GrowthBook, Firebase Remote Config, LaunchDarkly) and cached locally so the app works offline. Changing a flag on the server changes the app's behaviour for all users — or for a targeted segment — without an app store release.

At iStoria, we use **GrowthBook** for feature flags and A/B testing. Feature flags enable:

1. **Dark launches** — ship code to production behind a flag that defaults to off. Ramp when ready.
2. **Percentage rollouts** — enable for 5% of users, watch crash rates, then ramp to 100%.
3. **Instant kill switch** — a misbehaving feature is one flag flip from off, no hotfix release needed.
4. **A/B testing** — run two variants and measure which drives better conversion.

Combined with trunk-based development, feature flags let you merge continuously to master and *choose* when learners see something — instead of the app store review being the release event.

## When to use it

**Use feature flags when:**

- You practice trunk-based development and need to merge incomplete work dark.
- You want to ramp features gradually (5% → 50% → 100%) to catch issues early.
- You need an instant kill switch for production issues.
- You run A/B tests on UI flows, pricing, or onboarding.

**Skip them when:**

- The feature is small enough to ship fully complete in one PR.
- Your team isn't ready for the operational overhead of managing flag lifecycle (flags must eventually be removed).

## FAQ

### What is the difference between feature flags and A/B testing?

Feature flags control which code runs (on/off or variant). A/B testing is one use of feature flags — it assigns users to variants and measures outcomes. Every A/B test uses feature flags; not every feature flag is an A/B test. See our feature flags vs A/B testing comparison.

### Which feature flag service should I use for Flutter?

GrowthBook (open-source, self-hostable) and Firebase Remote Config (free, managed by Google) are the most popular. LaunchDarkly is the enterprise option. At iStoria, GrowthBook gives us both flags and A/B testing in one SDK.

---

# What is PowerSync in Flutter?

PowerSync is a sync engine that synchronizes a local SQLite database (via Drift or sqflite) with a Postgres backend in real time. It enables local-first Flutter apps where the UI reads and writes against the local database while PowerSync handles background synchronization, conflict resolution, and schema migrations.

## How it works

PowerSync sits between the Postgres backend and the local SQLite database on the device:

```
Postgres → PowerSync Service → Sync Engine → Local SQLite (Drift) → UI
```

The key concepts:

1. **Sync rules** — server-side JSON config that defines which rows sync to which clients (selective per-user replication).
2. **Views** — PowerSync exposes its tables as views, so schema changes ship as versioned migrations that drop and recreate views rather than altering tables.
3. **Conflict resolution** — last-write-wins by default, with custom resolution hooks for complex cases.
4. **Local writes** — writes go to the local database first, then PowerSync queues and uploads them to Postgres.

At iStoria (5M+ users), PowerSync + Drift is the foundation of our offline-first architecture:

```dart
// PowerSync schema — mirrors the Postgres tables as local views
class Schema extends SchemaObject {
  @override
  List<Table> get tables => [
    Table('stories', [
      Column.text('id'),
      Column.text('title'),
      Column.integer('position'),
      Column.boolean('is_completed'),
    ]),
  ];
}
```

The UI reads from these tables via Drift's reactive streams — when PowerSync updates a row in the background, Drift emits a new stream event and the UI rebuilds automatically.

## When to use it

**Use PowerSync when:**

- You have a Postgres backend and want offline-first sync with conflict resolution.
- You need selective per-user sync (not every user sees every row).
- You want real-time updates without building a custom WebSocket/sync layer.

**Don't use PowerSync when:**

- Your backend is not Postgres.
- You need full-text search or complex server-side queries (PowerSync syncs data, not queries).
- Your data set per user is too large for a local SQLite database.

## FAQ

### Is PowerSync free?

PowerSync offers a free tier (up to 5,000 monthly active users) and paid plans for larger scale. It is open-source and self-hostable via the PowerSync Service, which runs alongside your Postgres database.

### How does PowerSync handle conflicts?

By default, PowerSync uses last-write-wins based on a timestamp column. For complex cases, you can define custom conflict resolution hooks on the server side that run during the sync process. At iStoria, the default works for 95% of our data; the other 5% uses server-side hooks.

---

# What is Drift in Flutter?

Drift is a reactive, type-safe SQLite ORM for Flutter and Dart. It uses code generation to create typed table definitions and query APIs, with built-in support for reactive streams that automatically rebuild the UI when database rows change.

## How it works

Drift generates Dart classes from your schema definitions:

```dart
// Define tables
class Stories extends Table {
  TextColumn get id => text()();
  TextColumn get title => text()();
  IntColumn get position => integer()();
  BoolColumn get isCompleted => boolean().withDefault(const Constant(false));

  @override
  Set<Column> get primaryKey => {id};
}

// Define the database
@DriftDatabase(tables: [Stories])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  // Reactive query — returns a Stream that rebuilds when data changes
  Stream<List<Story>> watchStories() {
    return (select(stories)..orderBy([(t) => OrderingTerm(expression: t.position)])).watch();
  }
}
```

The `watch()` method is the killer feature: it returns a `Stream<List<Story>>` that emits a new list whenever any story row changes. The UI rebuilds automatically — no manual invalidation, no event bus, no setState.

At iStoria, Drift is the local data layer that PowerSync syncs against. Every read of user progress, chapters, streaks, and levels comes from Drift. When PowerSync updates a row in the background, Drift's stream emits a new value and the UI rebuilds.

## When to use it

**Use Drift when:**

- You need structured local storage with type-safe queries.
- You want reactive queries — the UI auto-updates when data changes.
- You're building an offline-first app and need a typed SQLite layer.

**Choose Hive or Isar instead when:**

- You need key-value storage, not relational queries.
- You want NoSQL flexibility without a schema.
- You want to avoid code generation.

## FAQ

### Is Drift better than sqflite?

Drift builds on sqflite and adds type safety, reactive streams, code generation, and migration helpers. For any app with more than one table, Drift is the better choice — raw sqflite requires hand-written SQL strings and manual cache invalidation. See our Drift vs sqflite comparison.

### Does Drift work with PowerSync?

Yes — PowerSync exposes its synced tables as Drift-compatible views. You define your Drift schema to match the PowerSync sync rules, and PowerSync populates the tables. Drift's reactive streams then auto-update the UI as PowerSync syncs changes.

---

# What are Build Flavors in Flutter?

Build flavors in Flutter allow you to create multiple variants of your app from a single codebase, each with its own bundle identifier, app name, icons, and configuration. Flavors let you run development, staging, and production environments side by side on the same device without conflicts.

## How it works

Flavors work by defining per-variant build configurations in both the native iOS (Xcode schemes) and Android (Gradle product flavors) layers, then selecting the right configuration from Dart.

**Android** (in `android/app/build.gradle`):

```gradle
flavorDimensions "default"
productFlavors {
  dev {
    applicationIdSuffix ".dev"
    versionNameSuffix "-dev"
  }
  staging {
    applicationIdSuffix ".staging"
  }
  production {}
}
```

**iOS** (Xcode schemes per flavor, with matching build configurations).

**Dart entry points** — one per flavor:

```dart
// lib/main_dev.dart
void main() {
  runApp(MyApp(config: DevConfig()));
}

// lib/main_production.dart
void main() {
  runApp(MyApp(config: ProductionConfig()));
}
```

Run with `flutter run --flavor dev -t lib/main_dev.dart`.

At iStoria, three flavors (dev, staging, production) let engineers test on real devices with the dev variant while QA tests the staging variant against the staging backend — all without uninstalling the production app.

## When to use it

**Use build flavors when:**

- You have multiple environments (dev, staging, production) with different backends or configuration.
- You need to run multiple variants on the same device simultaneously.
- You want different app names, icons, or bundle IDs per environment.

**Skip flavors when:**

- You only have one environment — a single build configuration is simpler.
- Your team is small and environment switching is done via environment variables or dart-define.

## FAQ

### What is the difference between build flavors and dart-define?

Build flavors change the native build configuration (bundle ID, app name, icons). dart-define passes compile-time values to Dart code without changing the native build. Use flavors when you need different bundle IDs or native configuration; use dart-define when you only need to swap Dart-level values like API URLs.

### How many flavors should I have?

Three is standard: development (for local coding), staging (for QA against a staging backend), and production (the app store build). Some teams add a fourth (beta) for TestFlight distribution, but that usually overlaps with staging.

---

# What is a Good Crash-Free Rate?

Crash-free rate is the percentage of app sessions that end without a crash. It is the single most important mobile quality metric — it directly measures user experience. A rate of 99.9% means fewer than 1 in 1,000 sessions crash. Industry benchmark: 99.5%+ is acceptable; 99.9%+ is excellent.

## How it works

Crash monitoring tools (Sentry, Firebase Crashlytics, Bugsnag) instrument the app to capture unhandled exceptions and native crashes:

```
Crash-free rate = (total sessions - crash sessions) / total sessions × 100
```

A "session" starts when the app launches and ends when the user closes it or it crashes. A single user can have many sessions per day.

At iStoria (5M+ users, 350+ releases), we hold **99.9% crash-free** across every release. How:

1. **Sentry** captures every crash in real time — new regressions are triaged within hours, not store reviews.
2. **Failures as values** — every repository returns `Either<Failure, T>`, so error paths are explicit forks the UI renders, not unhandled exceptions that crash the app.
3. **Feature flags** — risky changes ship dark behind a flag. If the crash rate ticks up on a flagged feature, the flag is killed instantly without a hotfix.
4. **Release governance** — no merge to master without CI gates (static analysis, tests, build success). The branch protection rule rejects even the lead engineer's direct pushes.

## When to use it

Every production app should track crash-free rate. There is no "when not to" — it is a baseline quality metric.

**Target benchmarks:**

- **99.0%+** — minimum acceptable for any production app.
- **99.5%+** — good; most apps should aim for this.
- **99.9%+** — excellent; this is what 5M+ user apps like iStoria hold.
- **99.95%+** — exceptional; achievable with aggressive monitoring and rapid response.

## FAQ

### What is a good crash-free rate?

99.5%+ is good, 99.9%+ is excellent. At iStoria (5M+ users), we hold 99.9% — fewer than 1 in 1,000 sessions crash. Anything below 99% is a red flag that needs immediate attention.

### Should I use Sentry or Firebase Crashlytics?

Both are excellent. Sentry has superior Dart stack traces and is better for Flutter. Firebase Crashlytics is better integrated with the Firebase ecosystem. At iStoria, we use Sentry. See our Sentry vs Firebase Crashlytics comparison.

---

# What is Modular Architecture in Flutter?

Modular architecture in Flutter organizes the codebase into self-contained feature modules, each responsible for one domain of functionality. Every module owns its UI (pages, widgets), state management (BLoC/Cubit), domain logic (entities, repository contracts), and data layer (APIs, databases). Modules communicate through well-defined interfaces, never reaching into each other's internals.

## How it works

The codebase is split by feature, not by layer. Each feature folder is a vertical slice:

```
lib/
  features/
    auth/           — login, signup, password reset
    story/          — story browsing, reading, completion
    streak/         — daily streak tracking
    leaderboard/    — rankings, social sharing
    subscription/   — paywall, RevenueCat, trials
  core/             — shared utilities, theme, networking
  shared/           — cross-feature widgets, mixins
```

Each feature module follows the same internal structure:

```dart
features/story/
  domain/           — entities, repository contracts
  data/             — datasources, repository implementations
  presentation/     — bloc, pages, widgets
  story.dart        — public API (barrel file, exports only what other modules need)
```

At iStoria, 50+ feature modules and 140+ routes are organized this way. A new engineer opens any feature folder and knows exactly where everything lives — the structure is the same in every module.

The critical discipline: **modules export only through their barrel file** (`story.dart`). Other modules import from `features/story/story.dart`, never from `features/story/presentation/bloc/story_bloc.dart`. This prevents coupling — a module's internal refactor never breaks another module.

## When to use it

**Use modular architecture when:**

- Your app has 10+ distinct features.
- A team of engineers works on the same codebase — modules give each engineer a clear ownership boundary.
- You want to enforce consistency — every module follows the same structure, so onboarding is fast.

**Skip it when:**

- The app is small (under 10 screens) — a simple layered structure is simpler.
- You're the only developer — the discipline of module boundaries is less valuable solo.

## FAQ

### What is the difference between modular architecture and Clean Architecture?

Clean Architecture defines the layering within a feature (domain → data → presentation). Modular architecture defines the feature boundaries themselves. They are complementary: each module in a modular architecture follows Clean Architecture internally. At iStoria, every one of our 50+ modules is internally Clean-Architected.

---

# What is Reactive Programming in Flutter?

Reactive programming is a programming paradigm oriented around data streams and the propagation of change. In Flutter, it means the UI automatically updates when the underlying data changes — through streams, observables, or reactive state — without manual invalidation or setState calls.

## How it works

In imperative programming, you call `setState()` to tell the UI to rebuild. In reactive programming, the UI subscribes to a stream of data and rebuilds automatically whenever a new value arrives:

```dart
// Imperative — manual rebuild
String _count = '0';
void _update() {
  setState(() { _count = '${repository.getCount()}'; });
}

// Reactive — automatic rebuild
StreamBuilder<int>(
  stream: repository.watchCount(),  // emits new values automatically
  builder: (context, snapshot) {
    return Text('${snapshot.data ?? 0}');
  },
)
```

Flutter's reactive primitives:

1. **Stream** — a pipe of asynchronous events. Dart's core async primitive.
2. **StreamController** — the write end of a stream; you add events and listeners receive them.
3. **StreamBuilder** — a widget that rebuilds when the stream emits.
4. **RxDart** — extends Dart streams with operators (debounce, throttle, combineLatest, switchMap).

BLoC/Cubit is built on this model: events go in (stream), states come out (stream), the UI listens via BlocBuilder.

At iStoria, the entire data layer is reactive: Drift's `watch()` queries emit when rows change, PowerSync updates those rows in the background, and the UI rebuilds through the stream — no manual invalidation anywhere.

## When to use it

**Use reactive programming when:**

- Data changes over time and the UI should reflect those changes automatically.
- You're building a real-time or sync-heavy app (chat, live data, offline-first).
- You want to eliminate manual setState management and reduce UI bugs.

**Skip it when:**

- The UI is static — a settings page with no dynamic data doesn't need streams.
- The team is new to async programming — streams have a learning curve and debugging stream chains is harder than imperative code.

## FAQ

### Do I need RxDart for reactive programming in Flutter?

No — Dart has built-in streams and StreamBuilder. RxDart adds operators (debounce, throttle, combineLatest, switchMap) that make complex stream compositions easier. For simple reactive flows, plain Dart streams suffice. For complex pipelines (e.g. a search-as-you-type with debounce + cancellation), RxDart is worth it.

---

# What is the Repository Pattern in Flutter?

The repository pattern is a structural design pattern that abstracts data access behind a uniform interface. Instead of the UI or business logic calling APIs, databases, or caches directly, they call a repository — which decides where data comes from, handles caching, and manages offline behaviour.

## How it works

A repository defines a contract (abstract class) in the domain layer and provides concrete implementations in the data layer:

```dart
// The contract — what the app needs
abstract class StoryRepository {
  Future<Either<Failure, List<Story>>> fetchStories();
  Stream<List<Story>> watchStories();
}

// The implementation — how the app gets it
class StoryRepositoryImpl implements StoryRepository {
  StoryRepositoryImpl(this._remote, this._local);
  final StoryRemoteDataSource _remote;
  final StoryLocalDataSource _local;

  @override
  Future<Either<Failure, List<Story>>> fetchStories() async {
    try {
      final stories = await _local.cachedStories();    // offline-first read
      unawaited(_remote.refreshInBackground());         // sync, never blocks UI
      return Right(stories);
    } on CacheException catch (e) {
      return Left(CacheFailure(e.message));
    }
  }
}
```

The BLoC or Cubit never sees `StoryRemoteDataSource` or `StoryLocalDataSource`. It calls `repository.fetchStories()` and gets data. The repository is where offline-first logic, caching strategy, and error mapping live.

At iStoria, every feature has at least one repository. The repository is the only thing the presentation layer knows about when it needs data. Whether that data comes from the API, the local Drift database, or PowerSync's sync layer is the repository's business — nobody else's.

## When to use it

**Use the repository pattern when:**

- You have more than one data source (API + cache, or local DB + remote).
- You want to swap implementations (mock for testing, different backend).
- You're building an offline-first app — the repository is where the offline logic lives.

**Skip it when:**

- The app has a single data source and no caching needs — a direct API call is simpler.
- You're prototyping and the data layer is a single function.

## FAQ

### What is the difference between a repository and a data source?

A data source is one source of data (an API client, a local database, a cache). A repository orchestrates multiple data sources behind one interface. The repository decides: read from cache first, fall back to API, update cache. Data sources don't know about each other; the repository does.

---

# What is Either<Failure, T> in Flutter?

Either<Failure, T> is a functional error-handling pattern from the dartz package where a function returns either a Left (containing a Failure) or a Right (containing the success value T). Instead of throwing exceptions on errors, you return them as values — making every error path an explicit fork the caller must handle.

## How it works

`Either<L, R>` is a union type — it holds either a Left value or a Right value, never both. By convention, Left holds the error (Failure) and Right holds the success (T):

```dart
// A repository method returning Either<Failure, T>
Future<Either<Failure, List<Story>>> fetchStories() async {
  try {
    final stories = await _local.cachedStories();
    return Right(stories);  // success
  } on CacheException catch (e) {
    return Left(CacheFailure(e.message));  // failure as a value
  }
}

// The caller must handle both cases
final result = await repository.fetchStories();
result.fold(
  (failure) => showError(failure.message),
  (stories) => showStories(stories),
);
```

The critical difference from exceptions: **the compiler doesn't force you to handle an exception**, but the type system forces you to handle an Either. You can't access the success value without acknowledging that a failure might exist.

At iStoria (99.9% crash-free across 350+ releases), this pattern is a core reason the app is stable. Every repository returns `Either<Failure, T>`. The UI renders failure states the same way it renders success states — there is no unhandled exception path that can crash the app.

A typical failure hierarchy:

```dart
abstract class Failure {
  final String message;
  Failure(this.message);
}

class NetworkFailure extends Failure {
  NetworkFailure(super.message);
}

class CacheFailure extends Failure {
  CacheFailure(super.message);
}

class ServerFailure extends Failure {
  ServerFailure(super.message);
}
```

## When to use it

**Use Either<Failure, T> when:**

- You want to eliminate "forgot to handle the error path" bugs.
- You're using Clean Architecture — repositories return Either, not throw.
- You want error states to be first-class UI states (loading, error, success — all rendered).

**Skip it when:**

- The app is small and try/catch is sufficient.
- Your team finds the pattern adds cognitive load without proportional value.
- You use a framework (like Riverpod) that has its own error handling via AsyncValue.

## FAQ

### Should I use dartz or fpdart for Either?

fpdart is the modern choice — it is actively maintained, has better Dart 3 integration (sealed classes, pattern matching), and offers additional types like TaskEither and Option. dartz works but hasn't been updated in years. At iStoria, we started with dartz; new features use fpdart.

### Does Either<Failure, T> replace try/catch?

It replaces throwing exceptions across layer boundaries. Inside a data source (network client, database), you still use try/catch — you catch the exception and wrap it in a Left(Failure). The Either is the contract between layers; try/catch is the implementation inside a layer.

---

# What is Sentry for Flutter?

Sentry is an error tracking and performance monitoring platform that captures unhandled exceptions, native crashes, and performance traces in real time. The Sentry Flutter SDK instruments Dart code, native iOS (Objective-C/Swift), and native Android (Kotlin/Java) layers, giving full-stack crash visibility in production.

## How it works

The Sentry Flutter SDK wraps the app's run zone to capture unhandled exceptions:

```dart
await SentryFlutter.init(
  (options) {
    options.dsn = 'https://your-dsn@sentry.io/project';
    options.tracesSampleRate = 1.0;  // performance tracing
    options.profilesSampleRate = 1.0; // profiling
  },
  appRunner: () => runApp(MyApp()),
);
```

Key capabilities:

1. **Crash capture** — Dart exceptions, Flutter framework errors, native iOS crashes (mach exceptions), and native Android crashes (NDK signals).
2. **Stack traces** — full Dart stack traces with source maps, even in obfuscated/release builds.
3. **Performance tracing** — traces slow operations (network calls, screen renders, database queries) with distributed tracing.
4. **Session replay** — reproduces the exact user interaction that led to a crash.
5. **Release tracking** — every crash is tagged with the release version, so you know which release introduced it.

At iStoria (5M+ users, 99.9% crash-free), Sentry is the first thing checked every morning. New crashes from the latest release are triaged within hours — before they become store reviews. PII is masked in Sentry events, and cleartext traffic is disabled so crash reports never contain sensitive data.

## When to use it

**Use Sentry when:**

- You need real-time crash monitoring in production.
- You want performance tracing alongside crash reports.
- Your app runs on both iOS and Android and you need unified crash visibility.

**Choose Firebase Crashlytics instead when:**

- You're already deep in the Firebase ecosystem and want to avoid another dependency.
- You need a free tier with no quota concerns.

## FAQ

### Is Sentry better than Firebase Crashlytics?

For Flutter specifically, Sentry has better Dart stack traces and richer performance tracing. Firebase Crashlytics is free, deeply integrated with Firebase, and sufficient for basic crash monitoring. At iStoria, we use Sentry. See our Sentry vs Firebase Crashlytics comparison.

### How much does Sentry cost?

Sentry has a free Developer tier (5,000 errors/month). Team and Business plans scale with event volume. For a 5M+ user app, expect to pay for the Business tier. Firebase Crashlytics is completely free, which is its main advantage.

---

# What is RxDart in Flutter?

RxDart is a Dart library that extends the standard Stream API with functional reactive operators inspired by ReactiveX. It adds operators like debounce, throttle, switchMap, combineLatest, and BehaviorSubject on top of Dart's built-in streams, making complex asynchronous data flows composable and declarative.

## How it works

RxDart does NOT replace Dart streams — it enhances them. You wrap a Dart stream with RxDart operators to get composition power:

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

// Search-as-you-type: debounce, cancel previous, combine
final searchResults = searchTextSubject
    .debounceTime(Duration(milliseconds: 300))     // wait for typing to stop
    .distinctUniqueChanged()                        // skip duplicate queries
    .switchMap((query) =>                           // cancel previous search
        repository.search(query).asStream())
    .startWith([]);                                 // initial empty state
```

Key operators:

1. **debounceTime** — wait for a burst of events to settle, then emit the last one. Perfect for search-as-you-type.
2. **throttleTime** — emit at most one event per time window. Perfect for button-tap debouncing.
3. **switchMap** — transform each event into a new stream, canceling the previous one. Critical for search cancellation.
4. **combineLatest** — combine multiple streams, emitting whenever any one changes. Perfect for combining user data from different sources.
5. **BehaviorSubject** — a StreamController that replays the last value to new listeners. The BLoC library uses this internally for state streams.

At iStoria, RxDart powers the search flow, the leaderboard refresh logic, and the sync status indicator. The reactive data layer (Drift streams + PowerSync sync) is composed with RxDart operators before reaching the BLoC.

## When to use it

**Use RxDart when:**

- You have complex stream compositions (debounce + cancel + combine).
- You need BehaviorSubject for state that should replay to new listeners.
- Your BLoC/Cubit has complex event transformers.

**Skip it when:**

- Your streams are simple — a single data source with no composition.
- You're new to reactive programming — start with plain Dart streams and graduate to RxDart when you hit its use cases.

## FAQ

### Is RxDart still needed in 2026?

For complex stream compositions, yes. Dart's built-in streams don't have debounce, switchMap, or combineLatest. If you're building search-as-you-type, multi-stream composition, or complex BLoC event transformers, RxDart is the standard tool. For simple single-stream use cases, plain Dart streams suffice.

### What is the difference between RxDart and Dart streams?

Dart streams are the built-in async primitive. RxDart is a library that adds operators ON TOP of Dart streams. RxDart doesn't replace streams — it extends them with ReactiveX-style composition operators.

---

# BLoC vs Riverpod: Which Flutter State Management Should You Choose?

BLoC vs Riverpod for Flutter: a production-scale comparison of boilerplate, testing, debugging, and team adoption from a 5M-user codebase.

## Quick answer

Use **BLoC** when you lead a team shipping a large, long-lived app where every state transition must be auditable and testable. Use **Riverpod** when you want less ceremony for a smaller app or a feature-team that moves fast and prefers compile-time safety over explicit event classes.

I run BLoC/Cubit across a 5M-user, 50+ module Flutter codebase at iStoria and I would pick it again for that scale. The enforced separation between events, states, and business logic pays for itself the first time a bug report says "the cart showed the wrong total" and you can replay the exact event stream in a unit test. Riverpod is excellent and I reach for it on greenfield side-projects — but for a squad of engineers who did not all write the original code, BLoC's explicitness is a load-bearing wall.

| Feature | BLoC | Riverpod |
| --- | --- | --- |
| Paradigm | Event-driven (events → states) | Reactive providers / notifiers |
| Boilerplate | High (events, states, bloc) | Low to moderate |
| Learning curve | Steep | Moderate |
| Testability | Excellent (blocTest replays events) | Good (assert on state) |
| Compile-time safety | Good | Excellent (no context lookups) |
| Team scalability | Very strong (enforced structure) | Good (needs convention) |
| DevTools / observability | BlocObserver logs transitions | Provider list + DevTools |
| Persistence | hydrated_bloc (built-in) | Manual or shared_preferences |
| Community content volume | Larger (older) | Growing fast |
| Best for | Large teams, auditable flows | Lean teams, fast iteration |


## Detailed comparison

## The core philosophical split

BLoC is **event-driven**: the UI dispatches events, a bloc maps events to states through a pure transformer, and the UI rebuilds on state changes. Every state transition has a named, typed event you can grep for. Riverpod is **reactive/declarative**: you declare providers that compute or hold state, and the UI watches them. Mutations happen by calling methods on a notifier; there is no separate event type.

This sounds academic until you inherit a codebase. With BLoC, `SearchQueryChanged` is a real class with a constructor and a field. With Riverpod, the same mutation is a method call on a notifier — just as valid, but harder to audit from a crash log alone.

## Boilerplate and learning curve

BLoC asks for more files: an event class, a state class, and the bloc itself. For a simple counter that feels absurd. For a checkout flow with six events, four states, and validation rules, the structure is exactly what keeps junior engineers from creating spaghetti. Riverpod's `Notifier` subclass with a few methods is leaner; a new hire can be productive in a day.

The honest trade-off: BLoC's boilerplate is a **constraint that scales**, Riverpod's brevity is a **velocity that needs discipline**.

## Testing

This is where BLoC wins decisively for teams. `blocTest` lets you pump a sequence of events and assert the exact emitted states:

```dart
blocTest<CheckoutBloc, CheckoutState>(
  'emits [loading, success] on valid cart',
  build: () => CheckoutBloc(cartRepo: mockCart),
  act: (bloc) => bloc.add(const SubmitCheckoutPressed()),
  expect: () => [CheckoutLoading(), CheckoutSuccess(orderId: '123')],
);
```

You cannot get that one-liner with Riverpod — you can test providers, but you assert on the resulting state, not on a replayed event timeline. For regression-heavy teams, BLoC's testability is its strongest argument.

## Scalability and team adoption

At 50+ modules with four engineers, BLoC's conventions (one bloc per feature, states as sealed unions, events named after user intent) make code **navigable**. A new engineer opens `CheckoutBloc` and sees every possible thing a user can do. Riverpod achieves the same with discipline, but nothing in the library forces it — I have seen Riverpod codebases where state mutations are scattered across widget callbacks.

## Tooling and debugging

BLoC has the **bloc_concurrency** and **hydrated_bloc** packages, plus the BlocObserver hook that logs every transition. Riverpod counters with compile-time safety (no `Provider.of` context lookups, no `late` initialization foot-guns) and excellent DevTools integration. Both are well-tooled; BLoC's runtime observability is slightly richer because events are first-class objects.

## Ecosystem maturity

Both are mature, well-documented, and widely used. BLoC predates Riverpod and has more community content; Riverpod (the successor to Provider) is the default recommendation in much of the newer Flutter community. Neither is going anywhere.

## Code comparison

### Counter — BLoC

```dart
// counter_event.dart
sealed class CounterEvent {}
class CounterIncrementPressed extends CounterEvent {}

// counter_state.dart
sealed class CounterState { final int value; const CounterState(this.value); }
class CounterInitial extends CounterState { const CounterInitial() : super(0); }

// counter_bloc.dart
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(const CounterInitial()) {
    on<CounterIncrementPressed>((event, emit) =>
        emit(CounterState(state.value + 1)));
  }
}

// usage
BlocBuilder<CounterBloc, CounterState>(
  builder: (_, state) => Text('${state.value}'),
);
```

### Counter — Riverpod

```dart
final counterProvider = NotifierProvider<CounterNotifier, int>(() {
  return CounterNotifier();
});

class CounterNotifier extends Notifier<int> {
  @override
  int build() => 0;
  void increment() => state++;
}

// usage
Consumer(builder: (_, ref, __) {
  final count = ref.watch(counterProvider);
  return TextButton(
    onPressed: ref.read(counterProvider.notifier).increment,
    child: Text('$count'),
  );
});
```

The BLoC version is ~30 lines across three files for a counter — that is the tax. For a feature with ten events and validation, that same structure is what keeps the codebase readable at 50 modules.

## Which should you choose?

**Choose BLoC when:** you lead a team of 3+ engineers on a long-lived app, you need every state transition auditable in tests and crash logs, you have complex multi-step flows (checkout, onboarding, sync), or you are regulated/enterprise where traceability matters. The boilerplate is the feature.

**Choose Riverpod when:** you are a solo dev or a small team that values compile-time safety and fast iteration, your app is mostly CRUD or view-state, or you are prototyping and the event-class ceremony would slow you down. Pair it with a lint convention and it scales further than people give it credit.

## FAQ

### Is BLoC deprecated or being replaced by Riverpod?

No. BLoC (felangel/bloc) is actively maintained and widely used in production at scale. Riverpod is more popular in newer greenfield projects, but BLoC remains the stronger choice for large teams that need explicit, testable state transitions.

### Which has better performance?

For the vast majority of apps, performance is not the deciding factor — both rebuild only the widgets that watch changed state. BLoC streams have slightly more overhead per event, but it is negligible compared to widget rebuild cost. Optimize widget granularity before swapping state management.

### Can I use both in the same app?

Yes, and many teams do during a migration. Use BLoC for complex feature flows and Riverpod for simple view-scoped state. The risk is cognitive overhead for new engineers, so set a convention and document it.

---

# BLoC vs Cubit: Which One Should You Actually Use?

BLoC vs Cubit: when the event-class ceremony is worth it and when Cubit's simpler API is the right call — from a team that runs both at 5M-user scale.

## Quick answer

**Cubit is a BLoC without event classes.** Same package, same `BlocBuilder`, same testing infra — you just call methods (`emit`) instead of dispatching events. Use Cubit for state that changes through simple, internal logic (theme, onboarding step, a toggle). Use full BLoC for flows where **user intent and external triggers** must be traceable (checkout, auth, sync).

In our 50+ module codebase we use both: Cubit for lightweight feature state, BLoC for anything with a multi-step lifecycle or external inputs. The line is not aesthetic — it is whether you would ever need to ask "what events led to this state?" in a bug report.

| Feature | BLoC | Cubit |
| --- | --- | --- |
| Event classes | Required | None — methods instead |
| API surface | on<Event> handlers | Methods calling emit |
| Boilerplate | Higher (events + states + bloc) | Lower (states + cubit) |
| Audit trail | Events logged via BlocObserver | State emissions only |
| Testing | blocTest with event sequences | blocTest with method calls |
| Event transformers | Yes (bloc_concurrency) | No (not event-based) |
| Same package? | Yes (bloc) | Yes (bloc) |
| Migration effort | — | Trivial (Cubit ↔ BLoC) |
| Best for | Multi-step, external-input flows | Simple, internal-logic state |


## Detailed comparison

## What Cubit actually is

Cubit is part of the `bloc` package. It extends the same base, emits the same state objects, and works with `BlocBuilder`, `BlocListener`, and `BlocProvider`. The only difference: instead of registering `on<Event>` handlers, you expose public methods that call `emit` directly. There are no event classes.

```dart
class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
  void increment() => emit(state + 1);
}
```

That is the entire API. If you have ever felt BLoC's event files were overkill for a settings screen, Cubit exists for you.

## When the event class earns its keep

The event class is not ceremony for its own sake — it is an **audit trail**. Consider a checkout flow: `SubmitCheckoutPressed`, `CouponApplied`, `PaymentMethodSelected`, `RetryAfterFailure`. When a user reports "the order went through twice," you look at the BlocObserver log and see the exact event sequence with timestamps. With Cubit, the same mutations are method calls; the log shows state emissions but not the semantic trigger.

For a sync engine talking to PowerSync, events like `SyncStarted`, `ConflictDetected`, `ConflictResolvedManually` are the vocabulary of the system. Collapsing those into method calls would lose meaning.

## Testing differences

Both are testable with the same `bloc_test` package:

```dart
// Cubit — act by calling methods
cubitTest<CounterCubit>(
  'increments',
  build: () => CounterCubit(),
  act: (cubit) => cubit.increment(),
  expect: () => [1],
);
```

You lose the ability to replay an **event stream** in a test, because there are no events. For most Cubit use cases that is fine — you are testing state outputs, not event choreography.

## A pragmatic team rule

The convention we landed on after two years at scale:

- **Cubit** for state that is a pure function of user input with no meaningful "why did this happen?" question (toggles, form field values, pagination offset, local UI state).
- **BLoC** for anything with external inputs (network, database, sync engine), multi-step flows, or where business rules mean the same state can be reached by different paths and the path matters.

This keeps the 80% of feature state lean (Cubit) and reserves the event-class overhead for the 20% where it pays.

## Code comparison

### Fetch-and-cache — BLoC

```dart
sealed class ArticleEvent {}
class ArticleFetchRequested extends ArticleEvent {
  final String id; const ArticleFetchRequested(this.id);
}

sealed class ArticleState {}
class ArticleInitial extends ArticleState {}
class ArticleLoading extends ArticleState {}
class ArticleLoaded extends ArticleState { final Article a; const ArticleLoaded(this.a); }

class ArticleBloc extends Bloc<ArticleEvent, ArticleState> {
  ArticleBloc(this.repo) : super(ArticleInitial()) {
    on<ArticleFetchRequested>(_onFetch);
  }
  final ArticleRepo repo;
  Future<void> _onFetch(ArticleFetchRequested e, Emitter<ArticleState> emit) async {
    emit(ArticleLoading());
    final a = await repo.fetch(e.id);
    emit(ArticleLoaded(a));
  }
}
```

### Fetch-and-cache — Cubit

```dart
sealed class ArticleState {}
class ArticleInitial extends ArticleState {}
class ArticleLoading extends ArticleState {}
class ArticleLoaded extends ArticleState { final Article a; const ArticleLoaded(this.a); }

class ArticleCubit extends Cubit<ArticleState> {
  ArticleCubit(this.repo) : super(ArticleInitial());
  final ArticleRepo repo;
  Future<void> fetch(String id) async {
    emit(ArticleLoading());
    final a = await repo.fetch(id);
    emit(ArticleLoaded(a));
  }
}
```

The Cubit is ~40% less code. For a single fetch that is the right call. If that flow later grows retries, conflict handling, and a manual refresh event, graduate it to a BLoC — the refactor is mechanical because the state classes stay identical.

## Which should you choose?

**Choose BLoC when:** the flow has multiple triggers (user action + push notification + sync), you need `bloc_concurrency` for debouncing/dedup, or debugging requires an event timeline. The event class is documentation that the codebase cannot drift from.

**Choose Cubit when:** the state changes through simple methods, there is one obvious trigger, and the "why did this happen?" question is trivially answered by reading the method. Start with Cubit; promote to BLoC when complexity arrives — they share the same package so the migration is painless.

## FAQ

### Is Cubit just a lighter BLoC?

Yes — literally. Cubit is a base class in the bloc package. It omits event classes and lets you call emit from methods. Everything else (providers, builders, testing) is identical, so you can migrate between them with minimal effort.

### Can I switch from Cubit to BLoC later without a rewrite?

Yes. The state classes and UI layer (BlocBuilder/BlocProvider) stay exactly the same. You add event classes and convert methods to `on<Event>` handlers. The refactor is mechanical and low-risk.

### Does Cubit support event transformers like restartable/droppable?

No — event transformers operate on the event stream, and Cubit has no events. If you need debouncing or dedup on triggers, either implement it inside the method (e.g. a Timer) or graduate to a full BLoC with `bloc_concurrency`.

---

# Riverpod vs Provider: Why the Flutter Community Moved On

Riverpod vs Provider: compile-time safety, no BuildContext dependency, and testability — why Riverpod replaced Provider in modern Flutter codebases.

## Quick answer

**Use Riverpod. Provider is effectively legacy.** Riverpod was written by the same author (Rémi Rousselet) specifically to fix Provider's design flaws — `Provider.of` runtime crashes, `BuildContext` coupling, and test friction. There is no scenario where a new Flutter project in 2026 should start with Provider.

If you inherit a Provider codebase, migrate incrementally — they coexist — but every new feature should be Riverpod. I would not greenlight a new project using Provider today, full stop.

| Feature | Riverpod | Provider |
| --- | --- | --- |
| Resolution | Compile-time | Runtime (throws if missing) |
| BuildContext dependency | No | Yes (required for lookup) |
| Author | Rémi Rousselet | Rémi Rousselet (older) |
| Status | Active, recommended | Maintenance / legacy |
| Testing | ProviderContainer, no widget tree | Needs widget tree + MultiProvider |
| Code generation | Optional (riverpod_generator) | None |
| AutoDispose / family | First-class | Limited |
| Learning curve | Moderate | Low (but footguns) |
| Best for | New projects | Legacy maintenance only |


## Detailed comparison

## The history

Provider was the first widely-adopted DI/state solution for Flutter, and the Flutter team even featured it in samples. But its reliance on `BuildContext` for lookups created real problems: runtime `ProviderNotFoundException`, untestable widgets without a tree of ancestors, and implicit dependencies. Rémi Rousselet wrote Riverpod to solve exactly these issues — it is Provider's deliberate successor.

## Compile-time safety

Provider lookups happen at runtime:

```dart
final user = Provider.of<UserModel>(context); // throws if not found
```

Riverpod references are resolved at compile time through generated provider objects:

```dart
final user = ref.watch(userProvider); // resolved at compile time
```

If `userProvider` does not exist, the code does not compile. This alone eliminates a whole class of runtime crashes that plague Provider codebases.

## No BuildContext dependency

Provider is bound to the widget tree — you need a `BuildContext` to read it, which means business logic in non-widget code has to thread context through or use workarounds. Riverpod providers are independent objects; you can read them in a repository, a test, or a background isolate. This makes the architecture cleaner and testing trivial.

## Testing

Provider tests require building a `ProviderScope`/`MultiProvider` ancestor tree and pumping a widget. Riverpod tests create a `ProviderContainer` and read the provider directly — no widget tree needed:

```dart
final container = ProviderContainer();
expect(container.read(countProvider), 0);
```

For a team that cares about unit-testing business logic without a widget harness, this is a massive quality-of-life win.

## Migration story

Provider and Riverpod coexist fine — you can wrap the app in both `MultiProvider` and `ProviderScope` and migrate feature by feature. The risk of a big-bang rewrite is never worth it. Migrate the next feature you touch and let attrition do the rest.

## DevTools and debugging

Riverpod's DevTools integration shows a live provider graph — every provider, its current value, its dependencies, and which widgets are listening. When a provider rebuilds unexpectedly or you have a circular dependency, the graph view points you at the problem in seconds. Provider has no equivalent; you are back to `debugPrint` and `Provider.of` breakpoints. For a team that debugs state issues weekly, this is a meaningful day-to-day advantage that compounds as the provider graph grows.

## Code comparison

### Dependency injection — Provider

```dart
// must provide in the tree
MultiProvider(
  providers: [
    Provider<AuthRepo>(create: (_) => AuthRepo()),
    ChangeNotifierProvider<UserModel>(create: (_) => UserModel()),
  ],
  child: MyApp(),
);

// reading — runtime lookup, throws if missing
final repo = context.read<AuthRepo>();
```

### Dependency injection — Riverpod

```dart
// declare providers anywhere
final authRepoProvider = Provider((ref) => AuthRepo());
final userModelProvider = NotifierProvider<UserNotifier, User>(UserNotifier.new);

// reading — compile-time resolved
Consumer(builder: (_, ref, __) {
  final repo = ref.read(authRepoProvider);
});

// testable without a widget tree
void main() {
  final container = ProviderContainer();
  expect(container.read(authRepoProvider), isA<AuthRepo>());
}
```

The Riverpod version is testable in a pure Dart test, has no runtime lookup risk, and the provider graph is explicit. There is no contest for new work.

## Which should you choose?

**Choose Riverpod when:** you are starting a new project or feature, you want compile-time safety and testability, or you are building an architecture where business logic lives outside widgets. This is the default for all new Flutter work.

**Choose Provider when:** you are maintaining an existing Provider codebase and a full rewrite is not justified. Wrap new features in Riverpod and migrate opportunistically. Do not start new projects with Provider.

## FAQ

### Is Provider officially deprecated?

Not marked deprecated on pub.dev, but it is in maintenance mode and the Flutter team and its own author recommend Riverpod for new work. Treat it as legacy for new project decisions.

### Is the Riverpod learning curve steep?

Moderate. The concepts (providers, ref, autoDispose, family) take a day to internalize. Code generation with riverpod_generator reduces boilerplate further. It is far less steep than BLoC.

### Can Provider and Riverpod coexist during migration?

Yes. Wrap the app in both MultiProvider and ProviderScope. Migrate one feature at a time. The two do not conflict because Riverpod does not use the widget tree for lookups.

### Should I learn Riverpod with or without code generation?

Start without it — learn the provider types, `ref`, `autoDispose`, and `family` by writing them longhand so the mental model sticks. Once that clicks, adopt `riverpod_generator` (`@riverpod` annotations) for new providers; it reduces boilerplate, gives you safer naming, and generates the boilerplate you would hand-write anyway. The generated API is identical to the manual one, so the knowledge transfers both ways.

### What about InheritedWidget — is that not enough?

InheritedWidget is the Flutter primitive both Provider and Riverpod build on, but it has real pain points: no compile-time safety, no auto-dispose, no family parameters, and lookups are O(1) but require a `BuildContext`. Provider wraps InheritedWidget to make it bearable; Riverpod abstracts it away entirely. For anything beyond passing a theme down the tree, reach for Riverpod instead of raw InheritedWidget.

---

# BLoC vs GetX: The State Management Argument Settled

BLoC vs GetX: architecture, testability, dependencies, and long-term maintainability — why production teams pick BLoC over GetX's all-in-one toolkit.

## Quick answer

**Use BLoC for any team or long-lived app. Use GetX only for rapid prototypes where you accept the technical debt.** GetX bundles state management, routing, DI, localization, and networking into one package with a `.obs` / `.obsX` reactive API that is genuinely fast to build with. The cost is tight coupling to a framework with non-standard patterns, a dependency surface that spans your entire app, and an architecture that does not scale to a multi-engineer codebase.

I would not allow GetX in a production codebase with more than one engineer. For a weekend hackathon, it is fine. The speed you gain in week one, you pay back tenfold in year two.

| Feature | BLoC | GetX |
| --- | --- | --- |
| Scope | State management only | All-in-one framework |
| Coupling | Low (composable) | High (entire app depends on it) |
| Reactive API | Events → states | .obs observables |
| Boilerplate | High | Very low |
| Testability | Excellent (blocTest) | Limited (global runtime) |
| DI | Bring your own | Get.put / Get.find (global) |
| Routing | Bring your own (go_router) | Built-in (Get.to) |
| Architecture fit | Clean Architecture friendly | Service-locator pattern |
| Team scalability | Strong | Poor |
| Best for | Production / team apps | Prototypes / solo hacks |


## Detailed comparison

## Scope and coupling

GetX is not a state management library — it is an **application framework**. It provides routing (`Get.to`), dependency injection (`Get.put`), localization, theming, networking, and state (`.obs`, `GetBuilder`). That breadth is the appeal: one dependency, no wiring. It is also the problem: every layer of your app depends on GetX, and swapping any one piece out means rewriting how you navigate, inject, and translate.

BLoC does one thing — state management — and composes with your own routing (go_router), DI (get_it/riverpod/inherited), and networking (dio/http) choices. That separation is what lets a 50-module codebase evolve without a straitjacket.

## Reactive model

GetX's `.obs` observables are simple:

```dart
final count = 0.obs;
count.value++;
Obx(() => Text('${count.value}'));
```

That is undeniably fast to write. The trade-off: state is a bag of mutable observables with no enforced structure, no event traceability, and no compile-time guarantees about what triggers a rebuild. At scale, this devolves into the classic "spaghetti of observables" problem.

BLoC forces every mutation through a typed event, which is more code but produces an auditable, testable state machine.

## Testability

BLoC is designed for testing: `blocTest` replays events and asserts states, pure and deterministic. GetX controllers are testable but rely on the GetX runtime (`Get.testMode`), and the global `Get` singleton (routing, DI) makes isolated unit tests harder. For a team practicing TDD or regression-heavy testing, BLoC is the clear winner.

## Architecture and long-term cost

GetX encourages putting logic in `GetxController` classes and calling them from anywhere via `Get.find<T>()` — a service-locator pattern with global mutable state. This is fast initially and fragile long-term: hidden dependencies, test isolation problems, and coupling that resists refactoring. BLoC's explicit `BlocProvider` dependency graph and unidirectional data flow map cleanly onto Clean Architecture layers, which is why production teams standardize on it.

## Code comparison

### Counter — GetX

```dart
class CounterController extends GetxController {
  final count = 0.obs;
  void increment() => count.value++;
}

// register once
Get.put(CounterController());

// usage — global lookup
final c = Get.find<CounterController>();
Obx(() => Text('${c.count.value}'));
```

### Counter — BLoC

```dart
sealed class CounterEvent {}
class Increment extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((_, emit) => emit(state + 1));
  }
}

// usage — explicit dependency
BlocProvider(
  create: (_) => CounterBloc(),
  child: BlocBuilder<CounterBloc, int>(
    builder: (_, count) => Text('$count'),
  ),
);
```

GetX is ~40% less code and reads simpler. But notice `Get.find<CounterController>()` — that is a global lookup with no compile-time guarantee the controller is registered. In a 50-module app, that is exactly the kind of hidden dependency that makes onboarding and testing painful. BLoC's `BlocProvider` makes the dependency explicit and scoped.

## Which should you choose?

**Choose BLoC when:** more than one engineer will touch the code, the app will live longer than a few months, you need testable and auditable state, or you want to compose best-in-class libraries for routing/DI/networking instead of an all-in-one. This is the production choice.

**Choose GetX when:** you are solo, prototyping fast, the app is throwaway or short-lived, and you value immediate velocity over long-term maintainability. Understand you are buying speed with technical debt — budget a rewrite if the app survives.

## FAQ

### Why do people hate on GetX so much?

Because it tightly couples an entire app to one package with non-standard, global-state patterns that do not scale to teams. It is not that the API is bad — it is fast — but the architectural cost shows up months later in testability and refactor friction. For solo prototypes it is genuinely productive; for team production code it is a liability.

### Is GetX faster than BLoC at runtime?

Marginally, for trivial benchmarks, because `.obs` rebuilds are lightweight. But state management overhead is almost never the bottleneck in a real app — widget rebuild cost and I/O dominate. Do not choose based on micro-benchmarks.

### Can I migrate from GetX to BLoC?

Yes, but it is a real refactor because GetX touches routing and DI too, not just state. Plan it feature-by-feature and introduce go_router + get_it alongside the BLoC migration. Do not attempt a big-bang rewrite.

### Is GetX safe to use for just routing or DI without the state management?

Technically yes — you can use `Get.to` for navigation without adopting `.obs` — but I would still avoid it. Once GetX is in your dependency graph, the global `Get` singleton is everywhere, and new engineers will reach for `Get.find` and `.obs` because they are the path of least resistance. If you only need routing, use `go_router`; if you only need DI, use `get_it` or Riverpod. Compose single-purpose libraries instead of importing a framework that wants to own every layer.

---

# Riverpod vs BLoC for Performance: A Production Benchmark

Riverpod vs BLoC performance: rebuild granularity, stream overhead, memory, and large-list scrolling — measured from a 5M-user Flutter app.

## Quick answer

**For 99% of apps, performance is not the deciding factor between Riverpod and BLoC — both rebuild only what watches changed state.** The measurable differences are at the margins: BLoC's stream-based event pipeline has slightly more overhead per emission, while Riverpod's fine-grained `select` gives more surgical rebuild control. At 5M users with feed scrolling, infinite lists, and real-time sync, neither has been a performance bottleneck for us.

Choose on **architecture and team fit**, not micro-benchmarks. If you are hitting rebuild problems, the fix is widget granularity and `select`/`buildWhen`, not swapping your state library.

| Metric | Riverpod | BLoC |
| --- | --- | --- |
| Rebuild granularity | select() — slice-level | buildWhen — per-builder |
| Per-emission overhead | Very low | Low (stream pipeline) |
| High-frequency events (60Hz) | Handle natively | Use bloc_concurrency |
| Auto-cleanup | autoDispose modifier | Manual (BlocProvider lifecycle) |
| Large-list friendliness | Good with select | Good with buildWhen |
| Memory for transient state | Excellent (autoDispose) | Good (manual scope) |
| DevTools rebuild tracking | Provider list | BlocObserver transitions |
| Winner for raw micro-perf | Marginal | Marginal |
| Real bottleneck? | Widget rebuild cost | Widget rebuild cost |


## Detailed comparison

## Rebuild granularity

Both libraries rebuild only widgets that observe changed state — but the controls differ.

Riverpod's `select` lets a widget watch a **slice** of a provider's state and rebuild only when that slice changes:

```dart
final userName = ref.watch(userProvider.select((u) => u.name));
```

BLoC's `buildWhen` on `BlocBuilder` does the same:

```dart
BlocBuilder<UserBloc, UserState>(
  buildWhen: (prev, curr) => prev.name != curr.name,
  builder: (_, s) => Text(s.name),
);
```

Functionally equivalent. Riverpod's `select` is slightly more ergonomic and composable across multiple providers; BLoC's `buildWhen` is per-builder. Neither has a meaningful perf edge here.

## Stream overhead (BLoC)

BLoC emits states through an `EventSink` → transformer → `Stream<State>` pipeline. Each event flows through `on<Event>`, an emitter, and the stream before listeners rebuild. For high-frequency events (scrolling, drag updates, sensor streams at 60Hz) that pipeline adds micro-overhead per event.

In practice, I have only seen this matter for **sensor/joystick input at 60+ Hz**, where collapsing events with `bloc_concurrency` (`droppable` or `restartable`) or moving to a raw `ValueNotifier` for that one stream is the fix. For normal UI, it is invisible.

## Provider overhead (Riverpod)

Riverpod providers are lightweight objects with a dependency graph. Reading a provider is cheap; the cost is in **how many widgets rebuild**, not in the provider mechanism. `autoDispose` providers clean up when no longer watched, keeping memory bounded — useful for per-item providers in long lists.

## Large-list scrolling

This is where people wrongly blame state management. A janky infinite list is almost always a **widget rebuild cost** problem, not a BLoC/Riverpod problem. The fixes are identical regardless of library: `ListView.builder` (not `Column`), `const` constructors, `RepaintBoundary` around heavy items, `select`/`buildWhen` to avoid rebuilding the whole row when one field changes, and keys on items.

At iStoria we render feed lists of 50+ module cards with BLoC and the jank disappeared once we added `buildWhen` and `RepaintBoundary` — not after considering a library swap.

## Memory footprint

BLoC blocs stay alive as long as their `BlocProvider` is in the tree; you manage lifecycle manually. Riverpod's `autoDispose` modifier frees providers automatically when no widget watches them, which is friendlier for transient state. For long-lived app state, both are comparable in memory.

## Cold start and initialization

There is a subtle difference at app launch. BLoC initializes eagerly when `BlocProvider(create:)` runs in the widget tree, so you control exactly when each bloc spins up by where you place the provider. Riverpod providers are lazy by default — they are created on first `ref.watch`/`ref.read` — which means less wasted initialization for features the user has not visited yet. For a 50-module app where not every screen is reached in a session, lazy initialization is a real cold-start win. You can make Riverpod eager with `keepAlive`, and you can make BLoC lazy with `lazy: true` on `BlocProvider`, so neither is locked in — but the defaults favor Riverpod for startup cost.

## Code comparison

### Rebuild a single field — Riverpod

```dart
class UserNotifier extends Notifier<User> {
  @override
  User build() => User(name: '', avatar: '', bio: '');
  void updateBio(String b) => state = state.copyWith(bio: b);
}

// only rebuilds when `name` changes — avatar/bio updates skip this widget
final name = ref.watch(userProvider.select((u) => u.name));
```

### Rebuild a single field — BLoC

```dart
BlocBuilder<UserBloc, UserState>(
  buildWhen: (p, c) => p.name != c.name,
  builder: (_, s) => Text(s.name),
);
```

Both achieve the same surgical rebuild. The performance lesson: **neither library is your bottleneck**. Profile widget rebuilds with the Flutter Performance overlay and DevTools before touching your state architecture.

## Which should you choose?

**Choose Riverpod for perf when:** you have many transient providers and want automatic cleanup (`autoDispose`), or you prefer `select` ergonomics for fine-grained rebuilds. Also the default if you simply prefer Riverpod's API.

**Choose BLoC for perf when:** you need event transformers (`bloc_concurrency`) to debounce/dedupe high-frequency events, or your team already standardized on BLoC. Performance alone almost never justifies a swap either direction.

## FAQ

### Is BLoC slower because of streams?

Marginally per emission, but not measurably for normal UI. The stream pipeline adds microseconds. It only matters at high-frequency event rates (60Hz+ input), where bloc_concurrency transformers collapse events anyway.

### Which uses less memory?

Roughly comparable for long-lived state. Riverpod's autoDispose gives better memory behavior for transient/per-item providers because they free automatically. BLoC requires manual lifecycle management via BlocProvider scoping.

### My list scrolls janky — should I switch state libraries?

No. Switch to ListView.builder, add const constructors, wrap heavy items in RepaintBoundary, and use select/buildWhen to avoid rebuilding whole rows. Jank in lists is a widget-rebuild problem, not a state-management problem.

### Does Riverpod's autoDispose have a performance cost?

autoDispose providers tear down their state when no listener watches them, and rebuilding them on re-subscription has a small cost proportional to what the provider does. For trivial providers (a computed value, a filter flag) that cost is negligible. For expensive providers (a stream subscription, a heavy computation) it can matter — in that case keep the provider alive longer (a `keepAlive` link or a parent that stays subscribed) rather than disabling autoDispose globally. The default is good; tune the exceptions.

### How do I profile which widgets rebuild?

Use the Flutter DevTools Performance overlay and the 'Track widget rebuilds' option in the inspector. Both Riverpod and BLoC show up as rebuild sources there. If a widget rebuilds more than expected, narrow the watch with `select` or `buildWhen` before considering structural changes. The profiler tells you *what* rebuilds; your job is to make it rebuild *less*.

---

# Drift vs Hive: Local Database for Flutter Offline-First Apps

Drift vs Hive: relational SQL with reactive queries vs key-value NoSQL — which to pick for offline-first Flutter, from a PowerSync+Drift production codebase.

## Quick answer

**Use Drift for structured, relational data with complex queries. Use Hive for simple key-value or document storage.** Drift gives you SQLite with type-safe Dart DSL, reactive `watch()` queries, and migrations — the right tool when your data has relationships, joins, and a schema that evolves. Hive is a fast NoSQL box (now backed by `isar` in CE) for blobs, cached responses, and settings.

At iStoria we run **Drift as the local source of truth** under PowerSync for a 5M-user offline-first app. Hive is not in the stack — once you need queries, joins, and reactive UI updates, a relational database wins decisively.

| Feature | Drift | Hive |
| --- | --- | --- |
| Model | Relational (SQLite ORM) | Key-value NoSQL |
| Query language | Type-safe DSL + raw SQL | None (filter in Dart) |
| Joins / aggregations | First-class | Not supported |
| Reactive queries | watch() per query | box.listenable() (whole box) |
| Migrations | Versioned, explicit steps | Schemaless (manual) |
| Type safety | Generated row classes | Manual adapters |
| Best data shape | Relational, structured | Flat, key-value, blobs |
| Sync integration | PowerSync, Supabase | Manual |
| Learning curve | Moderate (SQL + DSL) | Low |
| Best for | Offline-first structured apps | Cache, settings, simple docs |


## Detailed comparison

## Data model and query power

Drift is a **relational ORM over SQLite**. You define tables as Dart classes, write queries in a type-safe DSL (or raw SQL), and get compile-time-checked results. Joins, aggregations, filtering, indexing — all first-class.

Hive is a **key-value NoSQL store**. You write Dart objects to boxes keyed by a string or int. There is no query language; you load a box and filter in Dart. That is fast for "get by key" and hopeless for "give me all users created last week who have a pending order."

```dart
// Drift — a real query
final recent = await (select(users)
  ..join([
    innerJoin(orders, orders.userId.equalsExp(users.id)),
  ])
  ..where(users.createdAt.isBiggerThanValue(weekAgo))
  ..orderBy([OrderingTerm.desc(users.createdAt)]))
  .map((row) => row.readTable(users)).get();
```

There is no Hive equivalent — you would load the whole users box and filter in memory.

## Reactivity

Drift's killer feature is **reactive queries** via `watch()`:

```dart
Stream<List<User>> watchUsers() => select(users).watch();
```

The stream emits a new result whenever the underlying rows change. Wire that to a BLoC/Riverpod provider and the UI updates automatically when data changes — no manual invalidation. For offline-first apps, this is how you keep every screen in sync as PowerSync writes flow in.

Hive has `box.listenable()` for `ValueListenableBuilder`, but it notifies on the whole box, not a query result. Fine for a settings screen, inadequate for a relational UI.

## Type safety and migrations

Drift generates typed row classes and a typed database companion. Schema changes go through migration steps (`onUpgrade`) with versioned schemas. This is real database engineering — non-trivial, but exactly what a production app needs. Hive boxes are schemaless; adding a field means handling null defaults yourself, and there is no migration story beyond versioned box names.

## Performance characteristics

Hive is extremely fast for point reads/writes (it memory-maps files). Drift/SQLite is fast for queries that can use indexes and is the only option for complex joins. For bulk inserts, Drift's batch APIs are efficient; for single-object writes, Hive wins on raw speed. In a real app the bottleneck is network sync, not local storage speed.

## Code comparison

### Insert + reactive query — Drift

```dart
// table
class Users extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text()();
}

// reactive stream
Stream<List<User>> watchUsers() => select(users).watch();

// insert
await into(users).insert(UsersCompanion.insert(name: 'Aisha'));
// UI watching watchUsers() rebuilds automatically
```

### Insert + listen — Hive

```dart
// open box
final box = await Hive.openBox<User>('users');
// write
await box.put('u1', User(name: 'Aisha'));
// listen — fires on ANY box change, you refilter in Dart
box.listenable().addListener(() {
  final all = box.values.where((u) => u.name.isNotEmpty);
});
```

Drift's `watch()` emits the precise query result on relevant writes. Hive's listenable fires on every box mutation and forces you to refilter. For a relational UI, Drift is categorically better.

## Which should you choose?

**Choose Drift when:** your data has relationships (users → orders → items), you need joins/filtering/aggregation, you want reactive UI updates from local writes, your schema evolves and needs migrations, or you integrate with PowerSync/Supabase for sync. This is the offline-first production choice.

**Choose Hive when:** you need a fast key-value cache (HTTP responses, image metadata), app settings, a shopping cart blob, or any flat store with no relational queries. Simple, fast, and the right tool for that job — but do not force it to be a database.

## FAQ

### Can I use Hive as my main database?

You can, but you will regret it once you need queries. Hive is a key-value store; anything beyond point lookups means loading boxes into memory and filtering in Dart. For a real app with relational data, use Drift or Isar.

### Is Drift hard to learn?

Moderate. You need basic SQL and the Drift DSL. The reactive watch() and generated classes pay off fast. Migrations take care but are well-documented. For a production app it is a necessary investment.

### Does Hive work with PowerSync?

No. PowerSync syncs SQLite tables. If you want local-first sync, the local DB is Drift (or raw sqflite) by necessity. Hive cannot participate in that pipeline.

### What about Isar — is that a better middle ground?

Isar is an excellent NoSQL database that supports indexing and querying (unlike Hive) and is very fast. It sits between Hive and Drift: more powerful than key-value, but still not relational SQL. If your data is document-shaped and you need indexed lookups without joins, Isar is viable. But once you need joins, reactive queries, or PowerSync sync, Drift over SQLite is still the right answer. For our offline-first production stack, the relational model and sync integration make Drift non-negotiable.

---

# Drift vs sqflite: Should You Write Raw SQL in Flutter?

Drift vs sqflite: type safety, reactive queries, migrations, and boilerplate — why production Flutter teams layer Drift over raw sqflite.

## Quick answer

**Use Drift. It sits on top of sqflite (or native SQLite) and adds type safety, reactive `watch()` queries, generated row classes, and managed migrations — for free.** Raw sqflite means hand-writing SQL strings, manually mapping rows to objects, and no reactivity. There is almost no scenario where a production Flutter app should use raw sqflite directly.

The only reason to touch sqflite directly is if you are evaluating a raw query in a reproducible script. For an app, Drift is the abstraction layer sqflite always needed.

| Feature | Drift | sqflite |
| --- | --- | --- |
| Layer | ORM over sqflite/sqlite3 | Raw SQLite binding |
| Query style | Type-safe DSL + raw SQL fallback | Raw SQL strings |
| Compile-time checks | Yes (generated) | No (runtime errors) |
| Row mapping | Generated classes | Manual Map<String,dynamic> |
| Reactive queries | watch() Stream | None (poll/manual) |
| Migrations | Versioned, typed strategy | Hand-written onUpgrade |
| Boilerplate | Low (generated) | High (manual mapping) |
| Raw SQL escape hatch | Yes (customSelect) | Native |
| Best for | Any production app | Scripts, legacy, tiny apps |


## Detailed comparison

## What sqflite gives you

The `sqflite` package is a thin binding over the native SQLite plugin. You open a database, execute raw SQL strings, and read back `List<Map<String,dynamic>>`:

```dart
final db = await openDatabase('app.db');
await db.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)');
final rows = await db.rawQuery('SELECT * FROM users WHERE name = ?', ['Aisha']);
```

Functional, but every query is a stringly-typed contract. Rename a column and the compiler will not warn you — you discover it at runtime.

## What Drift adds

Drift is a layer **over** sqflite (or the newer `sqlite3` native backend). You define tables as Dart classes, and code generation produces typed row classes, companions, and a database class:

```dart
class Users extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text()();
}

final user = await (select(users)..where((u) => u.name.equals('Aisha'))).getSingle();
```

Rename `name` and the build fails. That is the difference.

## Reactive queries

This is the decisive feature. `select(users).watch()` returns a `Stream` that re-emits whenever the table changes. sqflite has no equivalent — you poll, or build your own invalidation layer. For reactive Flutter UI that updates when local data changes, Drift removes an entire class of plumbing.

## Migrations

Drift migrations are versioned and explicit:

```dart
MigrationStrategy get migration => MigrationStrategy(
  onCreate: (m) => m.createAll(),
  onUpgrade: (m, from, to) async {
    if (from < 2) await m.addColumn(users, users.email);
  },
);
```

sqflite forces you to hand-write `onUpgrade` with raw `ALTER TABLE` strings and version constants. Doable, error-prone, and where most production data loss happens.

## When raw sqflite still makes sense

Rarely. If you are building a throwaway script, a tiny app with one table and no queries, or you have an existing SQL-heavy codebase you cannot refactor — maybe. Even then, wrapping new tables in Drift incrementally is usually worth it.

## Testing and DI

Drift makes your data layer testable in ways raw sqflite cannot match. You can run the entire database in memory (`NativeDatabase.memory()`) for tests — no file cleanup, no state leakage between test cases — and inject it through a repository interface. Every query becomes a pure, deterministic function of the data you seed. With raw sqflite, you are managing temp database files and teardown logic in every test file. For a team that unit-tests repositories and use cases against a real SQL engine, this in-memory mode is a quiet but significant advantage.

## Code comparison

### Reactive user query — sqflite

```dart
final db = await openDatabase('app.db');

Future<List<Map<String,dynamic>>> getUsers() {
  return db.rawQuery('SELECT * FROM users');
}
// no reactivity — UI must manually refetch after writes
```

### Reactive user query — Drift

```dart
class Users extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text()();
}

// reactive — UI rebuilds automatically on change
Stream<List<User>> watchUsers() => select(users).watch();
```

The sqflite version gives you a `Future` of raw maps. The Drift version gives you a typed `Stream` that updates the UI for free. That is the entire argument.

## Which should you choose?

**Choose Drift when:** you are building a real app with a database. The type safety, reactive queries, and managed migrations save hours of bugs. This is the default; reach for it unless you have a specific reason not to.

**Choose sqflite when:** you are writing a one-off script, maintaining a legacy codebase that already uses it, or have a single trivial table where code generation feels disproportionate. Even then, consider Drift for any new table.

## FAQ

### Is Drift slower than sqflite?

Negligibly. Drift adds a thin mapping layer over the same SQLite engine. For hot-path queries you can always drop to raw SQL via customSelect. The productivity and safety gains vastly outweigh the microseconds of overhead.

### Does Drift require code generation?

Yes — it uses build_runner to generate row classes and the database. This is a one-time `dart run build_runner build` in your workflow (we run it in CI). The generated code is what gives you compile-time safety.

### Can I use raw SQL in Drift when I need to?

Yes. Drift exposes customSelect/customStatement for arbitrary SQL, and you can map results to generated classes. You get the escape hatch without losing the typed API for the 95% case.

### Can I migrate an existing sqflite app to Drift incrementally?

Yes, but it is a real migration, not a drop-in. You define your existing tables as Drift table classes, set the schema version to match your current database, and write a no-op migration strategy (`onUpgrade` that does nothing if the version matches) so Drift opens the existing `.db` file without recreating it. Then you replace raw queries one repository at a time. The risk is schema drift between your Drift table definitions and what is actually on disk — version your migration carefully and test against a real production database copy before shipping.

### Does Drift work well with PowerSync?

Yes — this is our exact production stack. PowerSync syncs into a local SQLite database, and Drift wraps that same database with typed queries and reactive `watch()` streams. You define Drift tables that mirror the PowerSync sync schema, and the UI reads through Drift while PowerSync writes flow in underneath. The one caveat: PowerSync writes bypass Drift's streaming updates, so use Drift's `watch()` on the underlying tables to get reactive UI updates when sync data lands.

---

# PowerSync vs Supabase: Offline-First Sync for Flutter

PowerSync vs Supabase: local-first SQLite sync vs a full Postgres backend — how they fit together in a 5M-user offline-first Flutter app.

## Quick answer

**They are not competitors — they are layers. Supabase is your Postgres backend (auth, database, storage, realtime). PowerSync is a sync engine that mirrors subsets of that Postgres database into on-device SQLite so your Flutter app works fully offline and reconciles conflicts when connectivity returns.**

At iStoria we use **both together**: Supabase as the source of truth in the cloud, PowerSync to keep each device's local Drift/SQLite database in sync. If you need true offline-first with conflict resolution, this is the stack. Supabase alone gives you realtime but not robust offline writes.

| Feature | PowerSync | Supabase |
| --- | --- | --- |
| Role | Sync engine (Postgres ↔ SQLite) | Backend (Postgres, Auth, Storage) |
| Offline writes | First-class, queued + synced | Manual (you build the queue) |
| Conflict resolution | Built-in policies | DIY |
| Sync rules / partitioning | Yes (YAML DSL) | No (you filter in queries) |
| Realtime | Via local SQLite watch() | Websocket Realtime (online) |
| Source of truth | Postgres (Supabase or own) | Postgres |
| Conflict cost | Included | Your engineering time |
| Best with | Drift/sqflite on device | Own client (postgrest, realtime) |
| Together? | Yes — recommended | Yes — recommended |


## Detailed comparison

## What each one is

**Supabase** is an open-source Firebase alternative: managed Postgres, Auth, Storage, Edge Functions, and Realtime (websockets). It is your server-side backend. Your canonical data lives in Postgres.

**PowerSync** is a sync service that sits between a Postgres database (Supabase or your own) and on-device SQLite. It defines **sync rules** (which rows each user/device should receive), watches the Postgres WAL, and pushes changes to devices; device writes are queued locally and pushed back up with conflict resolution.

They solve different problems and compose cleanly.

## The offline-first gap Supabase alone does not fill

Supabase Realtime delivers changes to connected clients over websockets. That is **online realtime**, not offline-first. If the device loses connectivity, queued writes are your responsibility, and there is no built-in conflict resolution for concurrent edits. You can build it (a writes queue, logical clocks, merge logic) but you are engineering the sync layer yourself.

PowerSync exists precisely to provide that layer: deterministic local-first sync with a conflict-resolution policy and a sync-rules DSL for partitioning data per user.

## The Flutter integration

PowerSync ships a Flutter client (`powersync`) that syncs into a local SQLite database — which Drift can wrap. So your architecture becomes:

```text
Supabase Postgres  ←→  PowerSync sync service  ←→  on-device SQLite (Drift)  ←→  BLoC/UI
```

Reads and writes go to local SQLite (instant, offline-capable). PowerSync reconciles in the background. The UI never blocks on network.

## When you need PowerSync

You need PowerSync (or equivalent) when:
- The app must function fully offline (create, edit, read) and reconcile on reconnect.
- Each user should sync only their partition of a large dataset (sync rules).
- You want deterministic conflict resolution without building it yourself.

You do **not** need PowerSync when the app is online-only or can tolerate simple last-write-wins with your own queue.

## Code comparison

### PowerSync + Supabase + Drift — the offline-first stack

```dart
// 1. Supabase backend
final supabase = Supabase.instance.client;

// 2. PowerSync client syncing into local SQLite
final powerSync = PowerSyncDatabase(
  schema: AppSchema(),
  path: 'app.db',
);
await powerSync.initialize();

// 3. connect sync with auth from Supabase
await supabase.auth.signInWithPassword(email, password);
powerSync.connect(async () => await fetchSupabaseToken());

// 4. read/write local — offline-capable, instant
final rows = await powerSync.execute('SELECT * FROM tasks WHERE owner = ?', [userId]);
await powerSync.execute('INSERT INTO tasks (id, title) VALUES (?, ?)', [id, title]);
// PowerSync pushes the insert upstream when online
```

### Supabase alone — online realtime, no offline write queue

```dart
final supabase = Supabase.instance.client;

// online realtime — breaks when offline
final sub = supabase.from('tasks').stream(primaryKeyKey: ['id']).listen((rows) {
  // update UI
});

// write — fails or is lost if offline unless you queue it yourself
await supabase.from('tasks').insert({'title': title});
```

The PowerSync version gives you offline writes, sync rules, and conflict resolution out of the box. The Supabase-only version leaves offline behavior entirely to you.

## Which should you choose?

**Choose PowerSync when:** the app must work offline-first with writes that reconcile on reconnect, you need per-user data partitioning via sync rules, or you want conflict resolution without building it. Pair it with Supabase (or any Postgres) as the backend.

**Choose Supabase (alone) when:** the app is primarily online, realtime updates are sufficient, offline behavior is limited to read-caching, or you are willing to build your own write queue and conflict logic. Most apps start here and add PowerSync when offline writes become a real requirement.

## FAQ

### Do I need Supabase if I use PowerSync?

You need a Postgres database as the source of truth. Supabase is the most common choice because PowerSync has first-class Supabase integration, but you can use any Postgres. Supabase also gives you Auth, Storage, and Edge Functions that pair naturally.

### Can I use Supabase Realtime instead of PowerSync for offline?

No — Supabase Realtime is an online websocket stream. It does not queue offline writes or resolve conflicts. If you need offline-first writes, you need a sync layer like PowerSync (or you build one yourself).

### How does PowerSync handle conflicts?

PowerSync applies a deterministic conflict resolution policy on the server side when concurrent writes reconcile. You define sync rules in YAML to control which rows each client receives. For app-level merge semantics (e.g. field-level merge), you handle that in your write logic or Postgres triggers.

### What is the latency for a local write to reach the server with PowerSync?

On a healthy connection, a local write typically syncs upstream within seconds — PowerSync queues the change and pushes it on the next sync cycle. The key point is that the UI never waits: the write is committed locally and is immediately visible to the user. If the device is offline, the change sits in the local upload queue and reconciles automatically when connectivity returns. You design the UX around 'write is instant, sync is eventual' — which is the entire premise of local-first.

---

# Flutter vs React Native: What a 5M-User App Team Chose

Flutter vs React Native: rendering, performance, talent, ecosystem, and long-term maintenance — from a senior mobile engineer leading a 5M-user Flutter app.

## Quick answer

**For a new cross-platform app in 2026, I would choose Flutter.** It compiles to native ARM code with its own rendering engine (Skia/Impeller), delivers consistent 60-120fps UI across platforms without bridging native widgets, and has a single language (Dart) with a strong type system and tooling. React Native (with the New Architecture / Fabric / TurboModules) has closed the gap, but the JavaScript bridge tax, the JSI complexity, and the fragmentation across native modules still make Flutter the more predictable bet for a team that wants one codebase to just work.

I lead a 5M-user, 50+ module Flutter app and the consistency and velocity are why we would not switch. If your team is already deeply invested in React/TypeScript, RN is viable — but greenfield, Flutter wins.

| Feature | Flutter | React Native |
| --- | --- | --- |
| Language | Dart (sound null-safe) | JS / TypeScript |
| Rendering | Own engine (Impeller/Skia) | Native widgets |
| UI consistency | Pixel-identical cross-platform | Per-platform native look |
| Performance | Excellent (compiled ARM) | Good (New Arch + Hermes) |
| JS bridge | None | JSI (faster than old bridge) |
| Hot reload | Best-in-class | Fast (Fast Refresh) |
| Talent pool | Growing | Large (JS/TS devs) |
| Native modules | Platform channels / FFI | Native modules (2 languages) |
| Ecosystem maturity | Strong (pub.dev) | Larger (npm), more churn |
| Best for | UI-heavy, consistent cross-platform | Teams invested in React/TS |


## Detailed comparison

## Rendering model

Flutter paints every pixel itself using its own engine (Skia, now Impeller for iOS/Android). There are no platform views in the default path — the UI is identical pixel-for-pixel across iOS and Android. This means no "this component looks different on Android" issues and full control over layout.

React Native renders to **actual native widgets** (UIView / androidx views). That gives a more "native feel" per platform but introduces inconsistency: a component behaves differently on iOS vs Android, and updates to the OS can change rendering. The New Architecture (Fabric) improves this but does not eliminate the fundamental difference.

## Performance

Flutter's compiled-to-ARM Dart and own compositor give predictable, smooth performance — especially scrolling and animations — without JS-native marshalling. Impeller on iOS has eliminated most jank reports.

React Native with the New Architecture and Hermes is much faster than the old bridge model, but the JS thread still exists and communication with native modules still has overhead. For UI-heavy apps with lots of lists/animations, Flutter has a measurable edge; for apps that are mostly screens of native components, the difference shrinks.

## Language and DX

Dart is a strongly-typed, null-safe, JIT-and-AOT language purpose-built for UI. Sound null safety, excellent tooling (Dart analyzer, DevTools), and a coherent single language across the app. The hot reload is best-in-class.

JavaScript/TypeScript has a larger talent pool and richer ecosystem, but RN development means dealing with native build tooling (Xcode/Gradle), bridging native modules in two languages, and a more fragmented package ecosystem (many abandoned native modules). TypeScript's type safety is strong, but the JS runtime and native interop add complexity.

## Ecosystem and talent

React Native has a larger community and more third-party packages (npm), and finding JS/TS developers is easier. Flutter's pub.dev ecosystem is mature for UI and mobile-specific needs and growing fast, and Dart is easy to ramp up for engineers coming from Java/Kotlin/Swift.

For a team that already knows React, RN is the faster onboarding path. For a team starting fresh, Dart+Flutter is a cleaner, more cohesive stack.

## Maintenance and long-term cost

Flutter's single-rendering-engine model means fewer platform-specific bugs. RN's reliance on native modules means you inherit maintenance of those modules — when a package stops supporting a new RN version, you fork or rewrite. Over a multi-year app lifecycle, Flutter's lower surface area for platform-specific breakage is a real cost advantage.

## Code comparison

### A stateful counter widget — Flutter

```dart
class CounterPage extends StatefulWidget {
  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(child: Text('$_count', style: TextStyle(fontSize: 48))),
      floatingActionButton: FloatingActionButton(
        onPressed: () => setState(() => _count++),
        child: Icon(Icons.add),
      ),
    );
  }
}
```

### A stateful counter component — React Native

```tsx
import { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';

export function CounterPage() {
  const [count, setCount] = useState(0);
  return (
    <View style={styles.center}>
      <Text style={styles.count}>{count}</Text>
      <TouchableOpacity onPress={() => setCount(c => c + 1)}>
        <Text>+1</Text>
      </TouchableOpacity>
    </View>
  );
}
```

Both are concise. Flutter's widget tree is more explicit and typed; RN's JSX is familiar to React devs. The difference shows at scale: Flutter's single-language, single-engine model has fewer moving parts to break across platforms.

## Which should you choose?

**Choose Flutter when:** you want one codebase with pixel-consistent UI, predictable performance without a JS bridge, a single typed language, lower long-term platform-specific maintenance, or you are greenfield. This is my production choice and the bet I would make again.

**Choose React Native when:** your team is already strong in React/TypeScript, you need to share code with an existing React web app (React Native Web), you require deep integration with platform-native UI feel, or hiring JS developers is a priority. Viable, especially with the New Architecture.

## FAQ

### Is Flutter going to be killed by Google?

No credible sign of that. Flutter is actively developed, has a large installed base (Google's own apps, BMW, Alibaba, etc.), and Google's investment in Dart and Impeller is ongoing. This concern is recycled FUD, not a real risk.

### Does React Native feel more native than Flutter?

RN uses actual native widgets so it inherits platform look-and-feel by default. Flutter renders its own UI, which is identical across platforms and can be styled to match each platform's guidelines (Material/Cupertino). 'Feel' is subjective; Flutter's consistency and performance are objective.

### Which is faster to ship?

Depends on your team. If you know React, RN onboards faster. Greenfield with no prior investment, Flutter's hot reload, single language, and consistent rendering typically ship faster because you debug fewer platform-specific issues.

---

# Flutter vs Kotlin Multiplatform: 2026 Cross-Platform Choice

Flutter vs Kotlin Multiplatform: shared UI vs shared logic, native interop, and when KMP's business-logic-only model beats Flutter's full UI sharing.

## Quick answer

**Choose Flutter if you want to share the entire UI across platforms. Choose Kotlin Multiplatform (KMP) if you want to share business logic, data, and networking while keeping fully native UIs per platform.** KMP (especially with Compose Multiplatform sharing UI to iOS/Android/desktop) is closing the gap, but the mature, battle-tested choice for "one UI, all platforms" remains Flutter.

For a consumer app where cross-platform UI consistency and velocity matter most, I would still pick Flutter — as we did at iStoria. For a team with strong native iOS and Android expertise who want to deduplicate networking, persistence, and domain logic but keep hand-tuned native UIs, KMP is excellent.

| Feature | Flutter | Kotlin Multiplatform |
| --- | --- | --- |
| Shares | UI + logic (everything) | Logic (UI native or Compose MP) |
| Language | Dart | Kotlin |
| UI on iOS | Flutter engine | SwiftUI (or Compose iOS) |
| UI on Android | Flutter engine | Jetpack Compose |
| Native interop | Platform channels / FFI | Direct (Kotlin ↔ Swift/ObjC) |
| App size impact | ~4-5MB engine | Smaller (no runtime engine) |
| Incremental adoption | All-or-nothing per screen | Module-by-module |
| UI maturity on iOS | Excellent | Compose MP maturing |
| Best for | Cross-platform UI sharing | Shared logic, native UI |


## Detailed comparison

## What each shares

Flutter shares **everything**: UI, state, logic, navigation — one Dart codebase renders to all platforms via Flutter's engine. You get one UI.

KMP shares **logic**: viewmodels, repositories, networking, database (SQLDelight), domain models — written once in Kotlin, compiled to JVM and native iOS frameworks. The **UI is written separately** per platform (Compose on Android, SwiftUI on iOS), unless you use Compose Multiplatform which brings Compose to iOS (still maturing).

This is the fundamental architectural difference and it drives every other trade-off.

## UI consistency vs native feel

Flutter: identical UI across platforms by design. One design system, one set of widgets, one set of bugs. Great for design-driven consumer apps.

KMP: native UI per platform. iOS users get SwiftUI, Android users get Compose. Each platform feels 100% native, but you maintain two UI codebases. Compose Multiplatform reduces this but is newer and less mature than Flutter on iOS.

## Performance and footprint

Flutter ships its own engine (~4-5MB app size increase) and renders everything itself. Performance is excellent and consistent.

KMP compiles Kotlin to native iOS binaries (via Kotlin/Native). There is no runtime engine — logic runs natively. The UI uses platform-native rendering, so there is no rendering overhead. App size impact is smaller than Flutter's. For pure logic sharing, KMP has a performance and footprint edge; for UI sharing, Flutter is more mature.

## Ecosystem and interop

KMP's superpower is **seamless native interop**: Kotlin code calls Swift/Objective-C and Java/Kotlin APIs directly, and vice versa. This makes it ideal for **incremental adoption** in an existing native app — you can share one module (say, networking) and keep the rest native. Flutter's platform channels can call native code but the interop is more ceremonial and less tight.

## Talent and team structure

Flutter needs Dart engineers (or mobile engineers willing to learn Dart — the ramp is short). KMP needs Kotlin engineers comfortable with both Android (Compose) and iOS (SwiftUI), plus Kotlin/Native interop. A team of senior native iOS + Android engineers who already write Kotlin is the ideal KMP adopter; a team that wants maximum sharing with minimum platform-specific expertise should pick Flutter.

## Code comparison

### Shared networking — Kotlin Multiplatform (common module)

```kotlin
// commonMain — shared across iOS and Android
class UserRepository(private val api: HttpClient) {
    suspend fun fetchUser(id: String): User {
        return api.get("/users/$id").body()
    }
}

// androidMain — Compose UI
@Composable
fun UserScreen(vm: UserViewModel) {
    val user by vm.user.collectAsState()
    Text(user.name)
}

// iosMain — SwiftUI consumes the shared framework
// let repo = UserRepository(api: httpClient)
// let user = try await repo.fetchUser(id: "123")
```

### Shared networking + UI — Flutter

```dart
// one codebase — logic AND UI shared
class UserRepository {
  final dio = Dio();
  Future<User> fetchUser(String id) async {
    final r = await dio.get('/users/$id');
    return User.fromJson(r.data);
  }
}

// same language, same UI on iOS and Android
Consumer(builder: (_, ref, __) {
  final user = ref.watch(userProvider);
  return Text(user.name);
});
```

Flutter shares the UI too; KMP shares the repository but you write `UserScreen` twice (Compose + SwiftUI) unless you adopt Compose Multiplatform.

## Which should you choose?

**Choose Flutter when:** cross-platform UI consistency and velocity are the priority, you want one codebase for UI + logic, your team prefers a single language, or you are building a consumer app where design consistency matters. The default for most new cross-platform apps.

**Choose Kotlin Multiplatform when:** you have strong native iOS and Android teams who want to keep fully native UIs, you want to deduplicate business logic/data/networking only, you are incrementally introducing sharing into an existing native app, or native interop and footprint are critical. Excellent for engineering-led teams.

## FAQ

### Is Compose Multiplatform ready to replace Flutter?

Not yet for production iOS in the way Flutter is. Compose Multiplatform on iOS has improved rapidly but is less mature than Flutter's iOS story. For sharing logic now and UI later, KMP is a safe bet; for UI sharing today, Flutter is more proven.

### Can I adopt KMP incrementally?

Yes — this is one of KMP's biggest strengths. You can share a single module (e.g. networking or a data layer) in an existing native iOS+Android app and keep everything else native. Flutter is more all-or-nothing per screen.

### Which has better performance?

KMP compiles to native code with no runtime engine, so shared logic has minimal overhead and the UI uses platform-native rendering. Flutter adds its own engine (~4-5MB) but renders consistently. For pure logic, KMP; for UI consistency and smoothness, Flutter is very competitive.

---

# Clean Architecture vs MVVM in Flutter: Which Scales?

Clean Architecture vs MVVM for Flutter: layering, testability, boilerplate, and which scales at 50+ modules — from a production squad lead.

## Quick answer

**Use Clean Architecture (layered: presentation → domain → data) for long-lived apps with 3+ engineers and complex business rules. Use MVVM (ViewModel + View, thinner layers) for apps where the UI-state mapping is the main complexity and you want less ceremony.** They are not mutually exclusive — MVVM is often the presentation layer *inside* a Clean Architecture app.

In our 50+ module Flutter codebase we run a layered Clean Architecture with BLoC/Cubit as the presentation-layer ViewModel equivalent. The layering earns its keep when a feature's business rules outgrow its UI, and when you want domain + data layers fully unit-testable without Flutter bindings.

| Feature | Clean Architecture | MVVM |
| --- | --- | --- |
| Layers | 3 (presentation/domain/data) | 2 (View/ViewModel) |
| Domain purity | Pure Dart, no Flutter | ViewModel may touch Flutter |
| Use cases | Explicit interactors | Logic in ViewModel |
| Repository contracts | In domain, impl in data | Often direct in ViewModel |
| Testability | Domain fully isolated | Good, less isolated |
| Boilerplate | High | Low-moderate |
| Learning curve | Steep | Moderate |
| Best for | Complex, long-lived apps | UI-state-centric apps |
| Flutter fit | BLoC as presentation layer | BLoC/Provider as ViewModel |


## Detailed comparison

## The layering difference

**Clean Architecture** (Uncle Bob's, adapted) separates three layers with strict dependency rules:
- **Presentation** — widgets + BLoC/Cubit/ViewModel. Depends on domain.
- **Domain** — entities, use cases (interactors), repository interfaces. Pure Dart, no Flutter. Depends on nothing.
- **Data** — repository implementations, data sources (API, DB), DTOs. Depends on domain.

Dependencies point inward: presentation → domain ← data. The domain layer knows nothing about Flutter, databases, or HTTP.

**MVVM** is lighter: a **ViewModel** holds UI state and exposes commands; the **View** (widget) binds to it. There is no prescribed data/domain split — the ViewModel often calls repositories or services directly. It is presentation-pattern-first.

## Where they overlap

Most well-structured Flutter apps are **both**: Clean Architecture for the layering, with MVVM (a ViewModel/BLoC) as the presentation component. The real choice is how many layers you formalize, not MVVM-vs-Clean as opposites.

## Testability

Clean Architecture's killer feature: the **domain layer is pure Dart**. Use cases and repository contracts are unit-tested in milliseconds with no `WidgetTester`, no Flutter binding, no mocking the widget tree. For regression-heavy teams, this is gold.

MVVM ViewModels are testable too, but they typically sit closer to data sources and may pull in Flutter types (`BuildContext`-free, but still). The isolation is less strict.

## Boilerplate and velocity

Clean Architecture asks for more files: entity, repository contract, repository impl, use case, DTO, mapper, bloc, states, events. For a CRUD screen this is overhead. For a feature with real business rules (pricing, permissions, sync conflict resolution), that structure is what keeps the codebase navigable at 50 modules.

MVVM is leaner: a ViewModel + View + a repository call. Faster to start, easier to reason about for small features, but can drift into fat ViewModels if business logic accumulates.

## A pragmatic hybrid

The convention that works for us: formal Clean Architecture for **feature modules with non-trivial business rules** (checkout, sync, auth, content licensing); lighter MVVM-with-repository for **simple CRUD features** (settings, profile editing). Not every screen needs three layers — match the ceremony to the complexity.

## Code comparison

### Clean Architecture — use case + repository contract

```dart
// domain — pure Dart, no Flutter
class CheckoutOrder {
  final OrderRepo repo;
  CheckoutOrder(this.repo);
  Future<Either<Failure, OrderId>> call(Cart cart) => repo.submit(cart);
}

abstract class OrderRepo {
  Future<Either<Failure, OrderId>> submit(Cart cart);
}

// data — implementation
class OrderRepoImpl implements OrderRepo {
  final Dio dio;
  OrderRepoImpl(this.dio);
  Future<Either<Failure, OrderId>> submit(Cart cart) async {
    // map to DTO, call API, map back
  }
}

// presentation — BLoC calls the use case
class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
  CheckoutBloc(this.checkout) : super(...) {
    on<SubmitPressed>((e, emit) async {
      final result = await checkout(e.cart);
      result.fold((f) => emit(Error(f)), (id) => emit(Success(id)));
    });
  }
  final CheckoutOrder checkout;
}
```

### MVVM — ViewModel calls repository directly

```dart
class CheckoutViewModel extends Cubit<CheckoutState> {
  CheckoutViewModel(this.repo) : super(...);
  final OrderRepo repo;
  Future<void> submit(Cart cart) async {
    final result = await repo.submit(cart);
    result.fold((f) => emit(Error(f)), (id) => emit(Success(id)));
  }
}
```

The Clean Architecture version is more files but the domain layer is pure Dart and unit-testable in isolation. The MVVM version is leaner but the business logic lives one layer up.

## Which should you choose?

**Choose Clean Architecture when:** the app has complex, long-lived business rules, you need the domain layer fully unit-testable without Flutter, you have 3+ engineers and 10+ feature modules, or the same domain logic must serve multiple presentation surfaces (app, web, CLI). The layering pays off at scale.

**Choose MVVM when:** the app's complexity is mostly UI-state mapping, features are CRUD-shaped, the team is small, or velocity matters more than long-term layering. You can always promote a fat ViewModel into use cases when business logic accumulates.

## FAQ

### Can I use MVVM inside Clean Architecture?

Yes — most well-structured Flutter apps do exactly this. Clean Architecture defines the layers; MVVM (a BLoC/Cubit acting as ViewModel) is the presentation-layer component. They are not competing patterns.

### Is Clean Architecture overkill for small apps?

Often yes. For a 5-screen CRUD app, three formal layers is ceremony. Start lighter (MVVM + repository) and introduce use cases and domain entities only where business logic justifies them.

### Does Clean Architecture hurt performance?

No. The layers are compile-time abstractions with no runtime cost. Indirection through interfaces is trivially cheap. The cost is in developer ceremony and file count, not performance.

---

# Offline-First vs Local-First: The Sync Architecture Split

Offline-first vs local-first: cloud-as-source-of-truth vs device-as-source-of-truth — the architectural decision that shapes your entire data layer.

## Quick answer

**Offline-first** means the app is designed to tolerate disconnection — it caches, queues writes, and degrades gracefully — but the **cloud is still the source of truth**. **Local-first** means the **on-device database is the source of truth** and sync is a background reconciliation, not a primary read/write path. Local-first is the stronger guarantee and the harder architecture.

At iStoria we run a **local-first** model: PowerSync keeps each device's local Drift/SQLite database authoritative, and the UI never blocks on network. The distinction is not academic — it determines whether the app is usable on a flaky train, and whether concurrent edits survive.

| Feature | Offline-First | Local-First |
| --- | --- | --- |
| Source of truth | Cloud / server | On-device database |
| Reads | Network → cache fallback | Local SQLite (instant) |
| Writes | To server (queued if offline) | To local (synced later) |
| Offline writes | Queued, replayed | First-class, immediate |
| Conflict resolution | Usually LWW or manual | Built into sync layer |
| Network dependency for UX | Moderate | Minimal |
| Architecture complexity | Lower to start | Higher upfront |
| Sync engine | Often hand-rolled | PowerSync / CRDT / ElectricSQL |
| Best for | Read-heavy, server-authoritative | Collaborative, write-heavy, field apps |


## Detailed comparison

## The source-of-truth question

This is the core difference and it propagates everywhere.

**Offline-first**: the server database is authoritative. The app reads from network, caches locally for speed/offline reads, and writes go to the server when online (or are queued and replayed). On conflict, the server usually wins (last-write-wins or app-specific merge).

**Local-first**: the local database is authoritative. Every read and write hits local SQLite instantly. A sync engine (PowerSync, ElectricSQL, CRDT libraries) reconciles changes in the background. The app is fully functional with zero connectivity, and conflict resolution is part of the sync layer, not bolted on.

## Implications for the data layer

Offline-first typically means: a repository that tries network first, falls back to cache for reads, and a write queue (often hand-rolled) for offline mutations. State management must handle "optimistic vs confirmed" states.

Local-first means: the repository reads/writes local SQLite unconditionally. A separate sync subsystem watches for connectivity and runs reconciliation. The repository does not know or care about the network — that is the sync engine's job. This is a cleaner separation and is exactly what PowerSync provides.

## Conflict resolution

Offline-first apps usually punt: last-write-wins on the server, or a manual merge screen for known conflict cases. This breaks under real concurrent edits.

Local-first architectures bake conflict resolution into the sync layer. PowerSync applies server-side deterministic resolution; CRDT-based libraries (Yjs, Automerge) do field-level merges. The architecture forces you to think about merges upfront, which is the only honest way to handle distributed writes.

## Complexity and cost

Local-first is more architectural investment: sync rules, conflict policy, schema that supports syncing (timestamps, soft deletes, client IDs). Offline-first is easier to start with but accumulates merge debt as the app grows.

## Which to choose

Choose local-first when the app must be **fully usable offline with writes** (field apps, note-taking, collaborative tools, content creation). Choose offline-first when offline is a **read-only degradation** (news apps, dashboards) or when writes are rare and server-authoritative.

## Code comparison

### Offline-first repository — network-first, cache fallback

```dart
class ArticleRepo {
  final Dio dio;
  final Cache cache;

  Future<Article> fetch(String id) async {
    try {
      final r = await dio.get('/articles/$id');
      final a = Article.fromJson(r.data);
      await cache.put(id, a); // cache for offline reads
      return a;
    } on DioException {
      return cache.get(id); // degrade to cache
    }
  }

  // write — queued if offline (you build the queue)
  Future<void> bookmark(String id) async {
    await writeQueue.enqueue(() => dio.post('/bookmarks', data: {'id': id}));
  }
}
```

### Local-first repository — local SQLite is authoritative, PowerSync syncs

```dart
class ArticleRepo {
  final PowerSyncDatabase db;

  Future<Article> fetch(String id) async {
    // read local — always instant, always works offline
    final rows = await db.execute('SELECT * FROM articles WHERE id = ?', [id]);
    return Article.fromRow(rows.first);
  }

  // write local — immediate, PowerSync reconciles upstream
  Future<void> bookmark(String id) async {
    await db.execute('INSERT INTO bookmarks (id) VALUES (?)', [id]);
  }
}
// sync runs in the background; the repo never touches the network
```

The local-first version is simpler in the repository because the sync engine owns the network. The offline-first version forces the repo to handle network-vs-cache branching and a write queue.

## Which should you choose?

**Choose offline-first when:** offline is primarily a read-caching concern, writes are server-authoritative and infrequent, the app degrades acceptably without connectivity, or you want a simpler initial architecture. News, dashboards, and admin tools fit here.

**Choose local-first when:** the app must be fully functional offline including writes, you have concurrent edits that need real conflict resolution, or the UX cannot tolerate network latency on the read/write path. Note-taking, field/data-collection, and collaborative apps demand this. It is more upfront investment but the only honest architecture for write-heavy offline apps.

## FAQ

### Is local-first just offline-first with better marketing?

No. The distinction is the source of truth. Offline-first keeps the server authoritative and tolerates disconnection; local-first makes the device authoritative and syncs as a background concern. The data-layer code is structurally different, and local-first forces real conflict resolution rather than last-write-wins.

### Do I need PowerSync for local-first?

Not necessarily — you can use ElectricSQL, a CRDT library, or your own sync layer. PowerSync is a strong choice for Flutter + Postgres because it handles sync rules and conflict resolution and integrates with Drift/SQLite. The point is you need *a* sync engine; local-first without one is just an offline cache.

### Is local-first harder to build?

Yes, upfront. You must design schema for sync (timestamps, client IDs, soft deletes), define sync rules/partitioning, and pick a conflict policy. The payoff is that the read/write path becomes simpler and the app works unconditionally offline. For write-heavy offline apps, it is the right trade.

---

# Fastlane vs Codemagic: Flutter Release Automation

Fastlane vs Codemagic for Flutter: local release automation vs managed CI — what a GitHub-Actions-native team picked and why.

## Quick answer

**Use Fastlane when you want release automation that runs anywhere (your Mac, GitHub Actions, any CI) and stays under your version control as Ruby files. Use Codemagic when you want a managed CI that handles iOS signing, Flutter builds, and store publishing without you managing machines or signing identities.**

At iStoria we standardized on **GitHub Actions + Fastlane** because we already live in GitHub, need fine-grained control over the pipeline, and want the release logic in the repo as code. Codemagic is excellent for teams that want to avoid iOS-signing hell and Mac infrastructure entirely — but you pay in vendor lock-in and per-build cost.

| Feature | Fastlane | Codemagic |
| --- | --- | --- |
| Type | Automation toolchain (Ruby) | Managed CI/CD service |
| Runs where | Any Mac / CI runner | Codemagic cloud (managed Mac) |
| iOS signing | match (you manage certs) | Fully managed |
| Config | Fastfile (Ruby, in repo) | codemagic.yaml / UI |
| Portability | High — any CI | Locked to Codemagic |
| Cost | Free + your infra | Per-build-minute pricing |
| Flutter integration | Via Flutter CLI in lanes | First-class |
| Store publishing | deliver / supply lanes | Built-in steps |
| Customization | Full (arbitrary Ruby) | Platform-constrained |


## Detailed comparison

## What each is

**Fastlane** is a Ruby-based automation toolchain (lanes) for mobile release tasks: building, signing, screenshot generation, metadata management, and App Store / Play Store upload. You define `Fastfile` lanes in your repo and run them anywhere Ruby runs — local Mac, GitHub Actions macos-latest, any CI.

**Codemagic** is a managed CI/CD service purpose-built for mobile (and Flutter in particular). You configure builds in a `codemagic.yaml` or via UI, and Codemagic provides the Mac hardware, manages iOS code signing, runs Flutter builds, and publishes to stores.

## Portability vs managed convenience

Fastlane is **portable**: the same `Fastfile` runs on your laptop and in GitHub Actions. The release logic lives in your repo and is reviewable in PRs. You bring the infrastructure (a Mac, CI runners).

Codemagic is **managed**: they provide Mac hardware, handle provisioning profiles and signing certificates, and integrate store publishing. You do not manage machines or signing identities — but you are locked to their platform and pricing model.

## iOS signing — the real pain

iOS code signing is the single biggest reason teams pick Codemagic. Managing signing identities, provisioning profiles, and App Store Connect API keys across CI is genuinely painful. Codemagic abstracts this. Fastlane's `match` (with a private git repo or cloud storage for certificates) solves it too, but you set it up and maintain it.

If you have already solved signing with Fastlane match, there is little reason to move to Codemagic. If you have not and want to avoid the rabbit hole, Codemagic is attractive.

## Cost model

Fastlane is free; you pay for the infrastructure it runs on (your Mac, or GitHub Actions macOS minutes — which are expensive, ~10x Linux minutes). Codemagic charges per build minute (Mac plans are pricier). For a team already on GitHub Actions, Fastlane reuses existing CI budget; Codemagic is a separate line item.

## Control and customization

Fastlane lanes are arbitrary Ruby — full programmatic control, integrate any tool, run any shell command. Codemagic yaml is powerful but constrained to what the platform exposes. For elaborate release governance (the kind we enforce — branch protection, flavor-aware builds, staged rollouts), Fastlane-as-code in the repo wins.

## Code comparison

### Fastlane lane — Flutter build + upload (runs in GitHub Actions)

```ruby
# Fastfile
default_platform(:ios)

lane :beta do
  sh("flutter build ipa --flavor production --release")
  upload_to_testflight(
    ipa: "build/ios/ipa/production.ipa",
    skip_waiting_for_build_processing: true,
  )
end

# GitHub Actions step
# - run: bundle exec fastlane beta
```

### Codemagic — codemagic.yaml (managed)

```yaml
workflows:
  flutter-ios:
    name: Flutter iOS Beta
    environment:
      flutter: stable
      ios_signing:
        distribution_type: app_store
        bundle_identifier: com.istoria.app
    scripts:
      - flutter build ipa --flavor production --release
    artifacts:
      - build/ios/ipa/*.ipa
    publishing:
      app_store_connect:
        api_key: $APP_STORE_KEY
        submit_to_testflight: true
```

Fastlane's lane is portable Ruby that runs anywhere; Codemagic's yaml is platform-specific but handles signing and hardware for you.

## Which should you choose?

**Choose Fastlane when:** you want release logic as reviewable code in your repo, you already have CI infrastructure (GitHub Actions, Mac runners), you need elaborate custom release governance, or you want to avoid vendor lock-in. Pair it with match for signing.

**Choose Codemagic when:** you want to offload iOS signing and Mac infrastructure entirely, you value a managed Flutter-first experience, your team is small and does not want to maintain CI machinery, or you are willing to pay per-build for convenience.

## FAQ

### Can I use Fastlane inside Codemagic?

Yes — Codemagic can run Fastlane lanes in its build scripts, so you can combine Codemagic's managed signing/hardware with your existing Fastfile. Common when teams migrate incrementally.

### Is GitHub Actions replacing both?

GitHub Actions with macos runners + Fastlane is what we run, and it can fully replace Codemagic for Flutter. You still manage iOS signing (via match). The trade-off is macOS minute cost and signing setup vs Codemagic's managed convenience.

### Which is cheaper for a small team?

If you already pay for GitHub Actions, Fastlane reuses that budget (watch macOS minute costs). Codemagic has a free tier but scales to paid Mac plans. For sporadic releases, Codemagic's free tier may win; for high build volume, owned CI + Fastlane is usually cheaper.

### How do I handle Flutter flavors with each tool?

Both handle flavors, but the setup differs. Fastlane lanes receive parameters — `lane :beta do |options|` — so you parameterize the flavor, scheme, and output path per lane invocation and call the right one from CI. Codemagic declares flavors in its environment config and yaml scripts, which is more declarative but less flexible if your release matrix is complex. In our multi-flavor production setup (dev, staging, production), Fastlane's parameterized lanes give us one `Fastfile` that handles every variant, which is cleaner than maintaining parallel Codemagic workflow definitions.

---

# GitHub Actions vs Codemagic for Flutter CI/CD

GitHub Actions vs Codemagic for Flutter: cost, iOS signing, control, and why a 5M-user app team standardized on GitHub Actions + Fastlane.

## Quick answer

**Use GitHub Actions if your code and review process already live in GitHub — the CI belongs next to the PRs. Use Codemagic if you want managed Mac infrastructure and iOS signing without the setup.** For a GitHub-native team, GitHub Actions (with macos runners + Fastlane) gives tighter PR integration, branch protection, and release governance than a separate CI vendor.

We run GitHub Actions end-to-end at iStoria — including iOS builds on macos runners with Fastlane. The only reason to look at Codemagic is if iOS signing pain or Mac runner cost becomes unbearable.

| Feature | GitHub Actions | Codemagic |
| --- | --- | --- |
| Hosting | GitHub (repo-native) | External managed service |
| Config | .github/workflows/*.yml | codemagic.yaml / UI |
| PR integration | Native (checks, branch protection) | Via commit status |
| Ecosystem | Massive (marketplace) | Mobile-focused |
| Beyond mobile CI | Full general-purpose CI | Mobile-CI-focused |
| iOS signing | DIY (match + secrets) | Fully managed |
| Mac runner cost | High (macOS minutes) | Per-build pricing |
| Self-hosted runners | Yes | No |
| Best for | GitHub-native teams, broad automation | Signing-averse mobile teams |


## Detailed comparison

## Integration with your workflow

GitHub Actions runs **in the repo**: workflows are `.github/workflows/*.yml`, reviewed in PRs, triggered by pushes/PRs/issues/releases. Status checks feed branch protection directly. This tight loop is the main reason GitHub-native teams stay there.

Codemagic is an **external service**: you connect a repo, configure builds in its UI or a `codemagic.yaml`, and results report back via commit status. It works, but the configuration lives partly outside GitHub and the PR integration is less native.

## Ecosystem and extensibility

GitHub Actions has the **largest CI ecosystem on earth** — the marketplace has thousands of actions for caching, linting, deployments, Slack notifications, you name it. You compose them in yaml. Codemagic has purpose-built steps for mobile but a smaller ecosystem for everything else.

For a team that automates more than just builds — PR hygiene bots, stale branch cleanup, Jira sync, AI review summaries (all of which we run) — GitHub Actions is the only realistic foundation. Codemagic is mobile-CI-shaped.

## Cost

GitHub Actions: free minutes for public repos; for private repos, macOS minutes are ~10x Linux minutes and are the expensive part of Flutter iOS builds. Self-hosted mac runners cut this.

Codemagic: per-build pricing, with Mac plans being pricier. Predictable but a separate budget line. For high build volume, owned GitHub Actions runners can be cheaper; for low volume, Codemagic's free/cheap tier may win.

## iOS signing

This is Codemagic's strongest card — fully managed signing. GitHub Actions requires you to manage certs (via Fastlane match, App Store Connect API keys, encrypted secrets). It is solved but requires setup and maintenance. If your team has no one who wants to own signing, Codemagic removes that burden.

## Caching and build speed

Both platforms support caching, but the mechanics differ. GitHub Actions uses `actions/cache` to persist `~/.pub-cache`, Gradle caches, and Pods directories between runs — you wire this yourself in the workflow yaml. Codemagic caches Flutter, Gradle, and CocoaPods automatically with less configuration. In practice, once you set up caching in GitHub Actions (a one-time ~20 lines of yaml), build times are comparable. The bigger speed lever is splitting your pipeline: run `flutter test` on Linux runners (cheap, fast) and reserve macOS runners for the iOS archive step only. Codemagic forces Mac pricing for the whole pipeline, which makes it less cost-efficient for test-heavy workflows.

## Code comparison

### Flutter iOS beta — GitHub Actions + Fastlane

```yaml
# .github/workflows/beta.yml
name: Beta
on:
  push:
    branches: [main]
jobs:
  ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with: { flutter-version: '3.x' }
      - run: flutter pub get
      - run: flutter test
      - run: bundle exec fastlane beta
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          APP_STORE_KEY: ${{ secrets.APP_STORE_KEY }}
```

### Flutter iOS beta — Codemagic (managed signing)

```yaml
workflows:
  beta:
    name: iOS Beta
    environment:
      flutter: stable
      ios_signing:
        distribution_type: app_store
        bundle_identifier: com.example.app
    scripts:
      - flutter test
      - flutter build ipa --release
    publishing:
      app_store_connect:
        api_key: $APP_STORE_KEY
        submit_to_testflight: true
```

The GitHub Actions version requires you to wire signing secrets; Codemagic manages signing in its environment. Both get you a TestFlight build — the choice is about where you want to own the complexity.

## Which should you choose?

**Choose GitHub Actions when:** your code is in GitHub, you want CI config reviewed in PRs with native branch protection, you automate more than mobile builds (bots, hygiene, deployments), or you want self-hosted runners to cut macOS cost. This is our production choice.

**Choose Codemagic when:** iOS signing is a burden you want fully offloaded, you want managed Mac hardware, your CI needs are mobile-only, or you have no existing GitHub Actions investment. A strong choice for signing-averse or mobile-only teams.

## FAQ

### Are GitHub Actions macOS minutes too expensive for Flutter?

They are pricier than Linux (~10x), but for most teams the total is manageable, especially with caching and self-hosted mac runners. If build volume is very high, self-hosted runners or Codemagic's pricing may be cheaper — do the math for your cadence.

### Can Codemagic trigger from GitHub PRs?

Yes — Codemagic integrates with GitHub and can build on PRs, reporting status checks back. The integration is solid; it is just less native than GitHub Actions' in-repo workflows for branch-protection governance.

### Which is better for a solo Flutter dev?

If you are already on GitHub, GitHub Actions + Fastlane is the path of least resistance and keeps everything in the repo. If you dread iOS signing, Codemagic's managed signing saves you a weekend of pain.

### Can I run unit and widget tests on both platforms?

Yes — `flutter test` runs identically on GitHub Actions Linux runners and Codemagic. This is where GitHub Actions has a real cost advantage: unit and widget tests do not need macOS, so they run on cheap Linux minutes. Only iOS build/sign/archive steps require macos runners. A smart pipeline splits the job: run tests on Linux, then fan out to a macOS runner only for the iOS build. Codemagic charges Mac pricing for the entire pipeline, which makes test-heavy workflows more expensive per run.

---

# Feature Flags vs A/B Testing: Different Tools, Different Jobs

Feature flags vs A/B testing: release governance vs experimentation — what each is for and why conflating them causes bad decisions.

## Quick answer

**Feature flags control who can see a feature (release governance). A/B testing measures which variant performs better (experimentation).** They overlap — an A/B test is often implemented *using* feature flags — but they are different tools with different jobs. Conflating them leads to shipping flags without metrics, or running tests without rollout safety.

Use **feature flags** for trunk-based development, dark launches, staged rollouts, and kill switches. Use **A/B testing** when you have a metric you want to move and a hypothesis about how. In practice: flag every non-trivial feature, and A/B test only where you have the volume and a clear success metric.

| Feature | Feature Flags | A/B Testing |
| --- | --- | --- |
| Purpose | Release control / governance | Experimentation / learning |
| Question answered | Who sees this? | Which variant wins? |
| Variants | On/off, %, audience | 2+ randomized cohorts |
| Needs metrics | No (often watches crashes) | Yes (primary success metric) |
| Statistical rigor | None needed | Significance testing |
| Typical duration | Days to weeks (rollout) | Until significance reached |
| Cleanup | Remove flag at 100% | Ship winner, delete loser |
| Overlap | Used to implement A/B tests | Implemented via flags |
| Best for | Trunk-based, dark launches, kill switches | Conversion, retention optimization |


## Detailed comparison

## What each solves

**Feature flags** decouple **deployment from release**. You merge code to main (deployed) but keep a feature off for users until it is ready (released). Variants: on/off, percentage rollout, audience targeting, kill switch. The goal is **safety and control**.

**A/B testing** compares two or more variants against a **success metric** (conversion, retention, click-through). Users are randomized into cohorts, you collect telemetry, and a statistical test decides whether the difference is significant. The goal is **learning**.

An A/B test is often *implemented* with a feature flag (flag returns variant A or B), which is why people conflate them. But a flag without measurement is just a rollout tool, and a measurement without controlled rollout is just analytics.

## When to flag without testing

Most flags are not experiments. You flag a risky refactor to roll out to 1% then 10% then 100%, watching crash rates — that is release governance, not an experiment. You flag a feature dark-launched behind a toggle so you can turn it off without a hotfix — that is a kill switch. Trunk-based development depends on this: incomplete features merge to main behind a flag.

## When to A/B test

A/B testing is worth it when: you have enough users to reach statistical significance in a reasonable time, you have a clear primary metric, and the change is consequential enough to justify the rigor. Small UI tweaks on a 5M-user app? Worth testing. Niche B2B feature with 200 users? Probably not — just ship it and watch qualitatively.

## The trap: flags as permanent config

The most common failure mode is flags that never get cleaned up. A flag added for a rollout stays in the codebase for years, branching logic accumulating. Pair every flag with a removal ticket once it reaches 100%. The same applies to concluded A/B tests — ship the winner and delete the loser variant.

## Tooling

Feature flags: GrowthBook, LaunchDarkly, Firebase Remote Config, PostHog, or a homegrown config service. A/B testing: the same tools often do both (GrowthBook, PostHog, Firebase A/B testing). At the app layer, a flag-service abstraction lets you swap providers.

## Code comparison

### Feature flag — staged rollout (no measurement)

```dart
final flag = await flagService.eval('new_checkout', user: currentUser);
// flagService controls % rollout + audience targeting
if (flag.enabled) {
  return NewCheckoutFlow();
} else {
  return LegacyCheckoutFlow();
}
```

### A/B test — randomized variant + metric tracking

```dart
final variant = await experiment.assign(
  'checkout_redesign_v2',
  user: currentUser,
  // variants: 'control' | 'treatment'
);

// track the primary metric on conversion
analytics.capture('checkout_completed', {
  'experiment': 'checkout_redesign_v2',
  'variant': variant,
  'value_usd': order.total,
});

return variant == 'treatment' ? NewCheckoutFlow() : LegacyCheckoutFlow();
```

The flag version gates access. The experiment version randomizes and instruments. Same plumbing, different intent — and the experiment only means something if you have the volume and a defined metric.

## Which should you choose?

**Choose feature flags when:** you practice trunk-based development, you want to decouple deploy from release, you need staged rollouts or kill switches, or you are dark-launching a risky change. This is a release-engineering tool — default to flagging non-trivial features.

**Choose A/B testing when:** you have a clear success metric, enough users to reach significance, a hypothesis about user behavior, and the change is consequential. Not every flag needs a test — test where the decision is genuinely uncertain and measurable.

## FAQ

### Do I need a fancy tool for feature flags?

No. A simple remote config (Firebase Remote Config, a JSON file on a CDN, PostHog) works for most apps. You need a flag service when you want fine-grained audience targeting, instant kill switches, or experimentation layered on top. Start simple; adopt a platform when pain demands it.

### When is A/B testing a waste of time?

When you lack the user volume to reach significance, when you have no clear primary metric, or when the change is obviously better (accessibility fixes, crash fixes). Testing for the sake of testing burns engineering time and produces noise.

### Should every flag be cleaned up?

Yes. Every rollout flag should have a removal ticket once it reaches 100%. Accumulated flags create branching debt that makes the codebase harder to reason about. Concluded experiments should ship the winner and delete the loser.

### Can I run an A/B test without a dedicated experimentation platform?

Yes for simple cases — assign a variant based on a stable user ID hash (so the same user always sees the same variant), log the assignment and the outcome metric to your analytics (PostHog, Amplitude, Firebase Analytics), and run a chi-square or t-test yourself once you have enough samples. This is fine for one-off tests. You need a real platform (GrowthBook, PostHog Experiments, LaunchDarkly) when you want automatic significance calculation, mutual exclusion between concurrent experiments, and audience targeting without hand-rolling the bucketing logic.

---

# Patrol vs Maestro: Flutter Integration Testing

Patrol vs Maestro for Flutter integration tests: Dart-native vs YAML-based, custom finder vs accessibility, and which fits a Flutter-first team.

## Quick answer

**Use Patrol if your team writes Dart and wants integration tests that live next to unit/widget tests with full access to Flutter internals and native plugins. Use Maestro if you want simple, human-readable YAML flows that non-engineers can author and that run across platforms with minimal setup.**

For a Flutter-Dart team like ours, **Patrol** is the better fit: tests are Dart, finders are Dart, you can mock platform channels, and CI integration is a `flutter test` invocation. Maestro is fantastic for smoke-test flows and cross-platform acceptance tests where simplicity beats expressiveness.

| Feature | Patrol | Maestro |
| --- | --- | --- |
| Language | Dart | YAML DSL |
| Authoring | Engineer-friendly | Readable by non-engineers |
| Finders | Flutter widget finders (Keys) | Accessibility IDs / visible text |
| Native UI access | Yes (permissions, webview) | Yes (cross-platform) |
| Platform channels mocking | Yes | No |
| Runs via | flutter test | Maestro CLI |
| Debugging | Dart debugger / IDE | Flow logs / screenshots |
| Best for | Flutter-Dart teams, complex flows | Smoke tests, cross-platform acceptance |


## Detailed comparison

## Authoring model

**Patrol** tests are written in **Dart** using `integration_test` + patrol's custom test framework. You get Dart finders, `pumpAndSettle`, and the full Flutter testing API. Native interactions (permissions, webview, notifications) are handled via patrol's native automation.

**Maestro** tests are written in **YAML flows** — a declarative DSL: `launchApp`, `tapOn`, `assertVisible`. No Dart. Designed to be readable by product managers and QA, not just engineers.

## Finders and robustness

Patrol uses Flutter's widget finders (Keys, text, semantics) — precise, typed, refactor-friendly if you use Keys. Maestro uses accessibility identifiers and visible text — robust to widget tree changes but coarser, and dependent on labels being present.

## Native and plugin coverage

Patrol's standout feature: it can interact with **native UI outside Flutter** (permission dialogs, webviews, notifications) via its native automation layer. Standard `integration_test` cannot. For apps with heavy native plugin interaction (camera, push notifications), Patrol is the only pure-Dart option that reaches outside Flutter.

Maestro also handles native UI across platforms — that is part of its cross-platform promise — but you author in YAML, not Dart, so complex logic is harder.

## CI and speed

Patrol runs as `flutter test integration_test/` — integrates with any CI that runs Flutter, including our GitHub Actions setup. Maestro runs via its CLI and can target Android, iOS, and web. Both are CI-friendly; Patrol fits a Flutter-native pipeline more naturally.

## Maintenance cost

Maestro flows are shorter and more readable but break when labels change and are harder to debug for complex flows. Patrol tests are longer Dart code but benefit from IDE support, type checking, and refactoring tools.

## Selectors and test stability

The selector strategy is the single biggest factor in how maintainable your integration tests are. Patrol lets you use `ValueKey` finders — the same keys your widgets already use — so a rename is caught by the compiler or at worst by a clear 'element not found' error. Maestro relies on accessibility identifiers and visible text strings; a marketing copy change can break a flow silently in CI. If you go with Maestro, establish a convention of stable accessibility IDs on every interactive widget and treat them like a public API — never change the ID without updating the flows. Either way, avoid brittle selectors like nth-child or screen position, which break on any layout shift.

## Code comparison

### Patrol — Dart integration test

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

void main() {
  patrolTest('login flow', ($) async {
    await $.pumpWidgetAndSettle(MyApp());
    await $(#emailField).enterText('user@example.com');
    await $(#passwordField).enterText('password');
    await $(#loginButton).tap();
    expect($(#homeScreen), findsOneWidget);
    // patrol can also grant native permission dialogs:
    // await $.native.grantPermissionWhenInUse();
  });
}
```

### Maestro — YAML flow

```yaml
# login_flow.yaml
appId: com.example.app
---
- launchApp
- tapOn:
    id: "email_field"
- inputText: "user@example.com"
- tapOn:
    id: "password_field"
- inputText: "password"
- tapOn: "Login"
- assertVisible: "Home"
```

Patrol is typed and debugger-friendly; Maestro is declarative and readable. The right choice depends on who authors tests and how much native interaction you need.

## Which should you choose?

**Choose Patrol when:** your team writes Dart, tests live next to the app code, you need to interact with native UI (permissions, webviews) or mock platform channels, or you want IDE/debugger support. The natural choice for a Flutter-first engineering team.

**Choose Maestro when:** you want simple smoke/acceptance flows readable by QA or product, you need cross-platform coverage (Android + iOS + web) from one YAML, or you want to start with E2E tests in an hour. Great for the 80% case of "does the critical path still work."

## FAQ

### Can I use both Patrol and Maestro?

Yes. A common setup is Patrol for engineer-authored regression tests (complex flows, native interaction) and Maestro for quick smoke tests on every release. They serve different authoring audiences.

### Which is faster to set up?

Maestro — install the CLI, write a YAML, run. Patrol requires Dart test files and the integration_test setup, which is more initial work but pays off as tests grow complex.

### Does Patrol handle iOS permissions dialogs?

Yes — that is one of its headline features. Patrol's native automation can tap system permission dialogs, which standard integration_test cannot. This is a decisive advantage for apps that test permission-gated flows.

### How flaky are Patrol and Maestro tests in CI?

Both have flake risk, but from different sources. Patrol tests are more stable against UI label changes because you use typed Keys, not visible text — but they can flake on animations not settling (`pumpAndSettle` timing out) or on native dialog race conditions. Maestro flows flake when accessibility IDs or text labels change, and they are harder to debug because you get flow logs and screenshots, not a Dart stack trace. For either tool, the flake-reduction playbook is the same: use stable selectors (Keys for Patrol, stable accessibility IDs for Maestro), add explicit waits before assertions, and run tests on fixed device OS images in CI rather than shared ephemeral runners that may have different OS versions.

### Which has better parallelism for large test suites?

Patrol supports running tests in parallel across multiple simulators/emulators via its custom test runner, which can dramatically cut wall-clock CI time for large suites. Maestro has `maestro test` with shard support but parallelism is more manual — you split flows across jobs and aggregate results yourself. For a team with 100+ integration tests where CI duration matters, Patrol's built-in parallelism on familiar Flutter test infrastructure is an advantage.

---

# Dio vs http: Flutter Networking Package Showdown

Dio vs http for Flutter: interceptors, cancellation, FormData, retries, and when the standard http package is enough — from a production networking layer.

## Quick answer

**Use Dio for any app with non-trivial networking: interceptors, token refresh, file uploads, request cancellation, timeout policies, or retry logic. Use the `http` package for one-off scripts or tiny apps with a couple of GETs.**

`http` is the official, minimal Dart HTTP client — fine and boring for simple cases. Dio is a richer client built for real apps: interceptors (auth, logging, retry), `CancelToken`, `FormData` for multipart, and consistent error handling via `DioException`. In a 50+ module production app, Dio's interceptor stack is load-bearing infrastructure.

| Feature | Dio | http |
| --- | --- | --- |
| Interceptors | First-class | None (DIY wrapper) |
| Cancellation | CancelToken | None |
| Timeouts | Configurable per-request | Global only |
| Multipart / FormData | Ergonomic + progress | MultipartRequest (basic) |
| Retry logic | Interceptor or built-in | DIY |
| Auth / token refresh | Interceptor pattern | Manual per call |
| Error model | DioException with type enum | ClientException (basic) |
| Streaming response | Yes | Yes (via HttpClient) |
| Best for | Real apps with networking complexity | Scripts, tiny apps |


## Detailed comparison

## Surface area

`http` is intentionally minimal: `get`, `post`, `put`, `delete`, `head`, `patch`. You get a `Response` with a body string/bytes. No interceptors, no cancellation tokens, no built-in retry.

Dio wraps a similar core with a much richer API: interceptors (request/response/error), `CancelToken`, `FormData` (multipart uploads), configurable timeouts, response type control (JSON, bytes, stream), and a typed `DioException` with a clear `type` enum.

## Interceptors — the decisive feature

Interceptors are why production apps pick Dio. An auth interceptor attaches the bearer token and refreshes it on 401; a logging interceptor records every request for debugging; a retry interceptor retries transient failures. Composing these in one place beats scattering `headers['Authorization'] = ...` across every call site.

```dart
dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) {
    options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  },
  onError: (e, handler) async {
    if (e.response?.statusCode == 401) {
      await refreshToken();
      return handler.resolve(await dio.fetch(e.requestOptions)));
    }
    handler.next(e);
  },
));
```

With `http`, you reimplement this as a wrapper function and call it everywhere, or wrap every call manually.

## Cancellation

Dio's `CancelToken` lets you cancel in-flight requests (e.g. when a user navigates away from a search screen). `http` has no cancellation primitive — you rely on the underlying `HttpClient` or ignore the result.

For typeahead/search UIs where stale responses must be discarded, cancellation is essential. We use it across every search-driven screen.

## File uploads

Dio's `FormData` handles multipart uploads cleanly, including progress callbacks. `http` supports `MultipartRequest` but with less ergonomic progress reporting. For apps that upload images/documents (most consumer apps), Dio is noticeably smoother.

## When http is fine

If your app makes five GETs to a public API with no auth, no uploads, and no cancellation needs, `http` is simpler and has zero extra dependencies. Do not reach for Dio for a prototype weather widget.

## Logging and observability

In production, you need to see what your app is sending and receiving — for debugging user-reported issues and for performance monitoring. Dio's `LogInterceptor` can log full request/response bodies, headers, and timing, and you can gate verbosity by environment (verbose in dev, redacted in prod). Sentry's Dio integration automatically captures failed HTTP requests as breadcrumbs, so a crash report shows the exact API call that preceded it. With `http`, you build your own logging wrapper and there is no first-party Sentry breadcrumb integration — you manually capture the request context. For a 5M-user app where triaging a bug means correlating a crash with the API response that triggered it, Dio's observability story is a real operational advantage.

## Code comparison

### Authenticated GET with token refresh — Dio

```dart
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
dio.interceptors.add(AuthInterceptor(refreshToken));

// every request gets auth, retries on 401, cancels on navigate-away
final r = await dio.get('/me', cancelToken: cancelToken);
```

### Authenticated GET — http

```dart
// you must attach headers and handle refresh at every call site
final r = await http.get(
  Uri.parse('https://api.example.com/me'),
  headers: {'Authorization': 'Bearer $token'},
);
if (r.statusCode == 401) {
  await refreshToken();
  // ... retry manually
}
// no cancellation, no interceptor reuse
```

Dio centralizes cross-cutting concerns; http pushes them to every call site. For an app with auth, uploads, and cancellation needs, Dio is the clear production choice.

## Which should you choose?

**Choose Dio when:** the app has auth/token refresh, file uploads, request cancellation, retry policies, or any cross-cutting networking concern. Interceptors make the networking layer maintainable. The default for real apps.

**Choose http when:** you are writing a script, a tiny app with a few unauthenticated GETs, or you want zero extra dependencies. Simpler is better when the complexity is not there.

## FAQ

### Is Dio heavier than http?

Marginally in package size, negligible at runtime. The interceptor model has trivial overhead per request. For any app where networking is a first-class concern, the productivity gain vastly outweighs the footprint.

### Can I use interceptors with http?

Not natively. You wrap calls in a helper function that does the auth/logging/retry. It works but scatters logic and is harder to compose than Dio's interceptor chain. At a certain complexity, migrating to Dio is cleaner.

### Does Dio work with Retrofit codegen?

Yes — `retrofit` (the Dart package) generates type-safe API clients on top of Dio. That gives you typed API interfaces with all of Dio's interceptor/cancellation benefits. A great combo for a large API surface.

### How do I test a repository that uses Dio?

Use Dio's built-in `MockAdapter` or inject a mock `Dio` instance into your repository. Because Dio is a concrete class with a clean interface, you mock it at the `Dio` level — set up `when(dio.get('/path'))` responses and assert on the calls. With `http`, you typically mock the `http.Client` interface, which works but means your test doubles model a lower-level abstraction. The practical difference: Dio's interceptor-aware mock lets you test the full request pipeline (including auth header injection) in a unit test, while mocking `http.Client` tests only the final request/response and leaves interceptor-equivalent logic untested.

---

# Freezed vs json_serializable: Dart Model Codegen

Freezed vs json_serializable: sealed unions, copyWith, equality, immutability — when Freezed's union modeling wins over plain JSON mapping.

## Quick answer

**Use Freezed when you need immutable data classes with `copyWith`, value equality, sealed unions (success/loading/error states), and JSON. Use json_serializable when you only need JSON (de)serialization on plain mutable Dart classes.** Freezed does more; json_serializable does one thing.

They compose — Freezed uses json_serializable under the hood for the JSON part. In practice, reach for Freezed for your **state and domain models** (which benefit from immutability, unions, and copyWith) and json_serializable for **DTOs** where a plain class with JSON mapping is enough.

| Feature | Freezed | json_serializable |
| --- | --- | --- |
| JSON (de)serialization | Yes (via json_serializable) | Yes |
| Immutability | Enforced (final fields) | Optional |
| copyWith | Generated (null-aware) | None |
| Value equality (==) | Generated | None (reference) |
| Sealed unions | First-class (.when/.map) | No |
| toString | Generated | None |
| Scope | Data/state models | JSON mapping only |
| Generated code size | Larger | Smaller |
| Best for | State, domain, union models | Simple DTOs |


## Detailed comparison

## Scope

**json_serializable** generates `fromJson`/`toJson` methods for a Dart class annotated with `@JsonSerializable()`. It handles field renaming, nullable vs required, enums, and nested objects. The class itself is an ordinary Dart class — you can make it mutable.

**Freezed** generates a full immutable data class: private constructor, `copyWith` with nullable-override semantics, `==`/`hashCode` (value equality), `toString`, and — its killer feature — **sealed unions** for modeling state:

```dart
@freezed
class AppState with _$AppState {
  const factory AppState.loading() = _Loading;
  const factory AppState.success(Data data) = _Success;
  const factory AppState.error(String message) = _Error;
}
// exhaustive switch via .when/.map
```

## copyWith and equality

Freezed's `copyWith` handles nested objects and lets you set a field to null (a notorious pain with hand-written copyWith). It generates value equality so two `User(name: 'A')` instances are equal — essential for BLoC/Riverpod rebuild checks. Plain Dart classes have reference equality by default.

For state classes where you compare previous vs current state to decide rebuilds (BLoC `buildWhen`, Riverpod `select`), value equality is not optional — and Freezed gives it for free.

## Sealed unions for state

Modeling `Loading | Success<T> | Error` as a sealed union with exhaustive `switch`/`.when` is the idiomatic Dart way to represent async state. Freezed makes this trivial; with plain classes you hand-roll an abstract base and subclasses, which is error-prone.

## JSON

Freezed integrates json_serializable for JSON support — add `fromJson`/`toJson` and you get both immutability and serialization. So Freezed is a superset for the JSON use case, with the trade-off of more code generation and a slightly steeper learning curve.

## When plain json_serializable wins

For a simple API response DTO with five fields, no state modeling, and no need for immutability — json_serializable alone is lighter. You avoid Freezed's generated file size and the union API. Match the tool to the model's role.

## Build time and project structure

Freezed generates more code per model than json_serializable — roughly 3-5x the generated file size — because it produces copyWith, equality, hashCode, toString, and union machinery. In a project with 200+ Freezed models, `build_runner` can take 60-90 seconds on a cold build. The mitigation: use `build_runner watch` during development (incremental rebuilds are fast) and run full builds only in CI. Structure your project so domain/state models (Freezed) and DTOs (json_serializable) live in separate barrels — this makes the codegen boundary clear and lets you run targeted `build_runner` on just the layer that changed.

## Code comparison

### State model — Freezed (union + copyWith + equality)

```dart
@freezed
class UserState with _$UserState {
  const factory UserState.loading() = _Loading;
  const factory UserState.success({required User user}) = _Success;
  const factory UserState.error(String message) = _Error;
}

// exhaustive handling
state.when(
  loading: () => Spinner(),
  success: (user) => Profile(user: user),
  error: (msg) => ErrorView(msg),
);
```

### DTO — json_serializable (plain JSON mapping)

```dart
@JsonSerializable()
class UserDto {
  final int id;
  final String name;
  UserDto({required this.id, required this.name});
  factory UserDto.fromJson(Map<String, dynamic> json) => _$UserDtoFromJson(json);
  Map<String, dynamic> toJson() => _$UserDtoToJson(this);
}
```

Use Freezed for state/domain models that need unions, immutability, and equality. Use json_serializable for plain API DTOs. They coexist in the same codebase — different tools for different model roles.

## Which should you choose?

**Choose Freezed when:** you model state (loading/success/error), need immutable data classes with copyWith and value equality, want exhaustive union handling, or your domain entities benefit from immutability. The default for BLoC/Riverpod state classes.

**Choose json_serializable when:** you only need JSON mapping on a simple DTO, immutability and unions are overkill, or you want minimal generated code. Pair it with Freezed in the same project for different model roles.

## FAQ

### Does Freezed replace json_serializable?

No — Freezed uses json_serializable under the hood for JSON. Freezed adds immutability, copyWith, equality, and unions on top. You can use both in the same project: Freezed for state/domain, json_serializable for plain DTOs.

### Is Freezed's generated code slow to build?

It adds to build_runner time but is manageable. For large codebases, use `build_runner watch` during development and run full builds in CI. The generated-code size is a trade-off for the runtime safety and ergonomics.

### Do I need value equality for BLoC states?

Yes, if you rely on buildWhen/select comparing previous vs current state. Hand-writing == for every state class is error-prone; Freezed generates correct equality for free. This is a strong reason to use Freezed for state models.

### Can I use Freezed with Dart 3 sealed classes instead of the @freezed union?

Yes, and it is the modern approach. Dart 3 introduced native `sealed` classes with exhaustive `switch`, which covers the union-use-case that Freezed pioneered. You can use `@freezed` with sealed class syntax to get native exhaustive pattern matching plus Freezed's generated `copyWith`, equality, and JSON. If you only need the sealed union without copyWith or equality, a hand-written Dart 3 sealed class is enough and generates nothing. We use Freezed on top of Dart 3 sealed classes for state models that also need copyWith and value equality — the combination is the best of both.

---

# RevenueCat vs Stripe: Flutter In-App Subscriptions

RevenueCat vs Stripe for Flutter: App Store/Play subscriptions vs custom payments — what a 5M-user app uses and why you likely need both.

## Quick answer

**They are not competitors — they handle different payment surfaces. RevenueCat manages App Store and Play Store in-app subscriptions (the only way to sell digital goods inside a mobile app). Stripe handles web/external payments and is how mobile apps sell subscriptions *outside* the app (to avoid the 30% store cut).**

At iStoria we use **RevenueCat** for in-app iOS/Android subscriptions because it abstracts StoreKit/Play Billing, cross-platform entitlements, and receipt validation. We use **Stripe** for web checkout. Many apps need both — RevenueCat inside the app, Stripe on the web — with entitlements unified.

| Feature | RevenueCat | Stripe |
| --- | --- | --- |
| Surface | In-app (App Store / Play Store) | Web / external checkout |
| Handles StoreKit/Play Billing | Yes (abstracted) | No |
| Receipt validation | Server-side, managed | N/A (Stripe validates its own) |
| Cross-platform entitlements | First-class | Via your backend |
| Store cut (15-30%) | Yes (mandatory) | No (web checkout) |
| Webhooks | Entitlement events | Payment events |
| Analytics | Subscription MRR/churn | General payments |
| A/B testing paywalls | Built-in | DIY |
| Use together? | Yes — unified entitlements | Yes — unified entitlements |


## Detailed comparison

## The payment surface split

Apple and Google **require** digital goods sold inside an iOS/Android app to go through their stores (StoreKit / Play Billing), taking 15-30%. You cannot use Stripe for an in-app digital subscription. Stripe is for payments that happen **outside** the app — a web checkout, a customer support flow — where you can avoid the store cut.

So the question is not RevenueCat *or* Stripe; it is which surface you are monetizing on.

## What RevenueCat does

RevenueCat is a **subscription management layer** over StoreKit and Play Billing. It handles:
- Purchasing flows (presenting paywalls, completing transactions)
- Receipt validation server-side (so you trust entitlements)
- Cross-platform entitlement syncing (user subscribes on iOS, gets access on Android and web)
- Webhooks to your backend for entitlement changes
- Analytics (MRR, churn, conversion) in one dashboard
- A/B testing paywalls

Writing this yourself against raw StoreKit + Play Billing + a validation server is months of work and ongoing maintenance as Apple/Google change rules. RevenueCat is the standard for a reason.

## What Stripe does

Stripe is a general-purpose **payment processor**: cards, Apple Pay/Google Pay (on web), bank transfers, subscriptions, invoicing. Use it when the transaction happens outside the app stores — typically a web checkout that creates or extends an entitlement.

## Unifying entitlements

The production pattern: RevenueCat handles in-app subscriptions and publishes entitlement webhooks to your backend; Stripe handles web payments and also writes entitlements to the same backend. Your app checks a single entitlement source (your backend or RevenueCat's dashboard) regardless of where the purchase happened. RevenueCat can even ingest Stripe purchases to unify the view.

## Cost model

RevenueCat is free up to $10K monthly tracked revenue, then a percentage. Stripe charges per-transaction (2.9% + 30¢ typical). For a subscription app, RevenueCat's fee is the cost of not rebuilding StoreKit/Play Billing infrastructure; for web payments, Stripe's fee is the cost of card processing.

## Code comparison

### In-app subscription — RevenueCat

```dart
await Purchases.configure(PurchasesConfiguration('public_sdk_key'));

// present offering and purchase
final offering = await Purchases.getOfferings();
final customerInfo = await Purchases.purchasePackage(
  offering.current!.availablePackages.first,
);
if (customerInfo.entitlements.active['pro'] != null) {
  // user has pro entitlement — cross-platform
}
```

### Web checkout — Stripe

```dart
// your backend creates a Stripe Checkout Session
final session = await api.createCheckoutSession(userId, planId);
// redirect user to session.url on web; Stripe handles payment
// on success, your backend writes the entitlement and (optionally)
// notifies RevenueCat to unify the view
await api.syncEntitlementToRevenueCat(userId);
```

RevenueCat owns the in-app purchase + entitlement; Stripe owns the web payment; your backend unifies entitlements so the app checks one source regardless of where the purchase happened.

## Which should you choose?

**Choose RevenueCat when:** you sell digital subscriptions inside the iOS/Android app (you must use the stores), you need cross-platform entitlement syncing, or you want paywall A/B testing and subscription analytics without building StoreKit/Play Billing infrastructure. The default for in-app subscriptions.

**Choose Stripe when:** the payment happens on the web or outside the app (to avoid the store cut), you sell physical goods or services, or you need custom billing/invoicing. Often paired with RevenueCat so entitlements unify.

## FAQ

### Can I use Stripe inside a Flutter app to sell subscriptions?

Not for digital goods — Apple and Google require those to go through their stores. Stripe is for payments that happen outside the app (web checkout) or for physical goods/services. Use RevenueCat inside the app, Stripe on the web.

### Do I need both RevenueCat and Stripe?

If you sell on both mobile (in-app) and web, yes — RevenueCat for the in-app subscriptions, Stripe for web checkout, with your backend unifying entitlements. If you only sell in-app, RevenueCat alone is enough.

### Is RevenueCat worth the fee?

For any serious subscription app, yes. The alternative is building receipt validation, cross-platform entitlement sync, paywall A/B testing, and analytics yourself against StoreKit and Play Billing — months of work plus ongoing maintenance as store rules change. RevenueCat's fee buys that for free up to $10K MTR.

### How do I handle entitlements when a user subscribes on web via Stripe but uses the mobile app?

The pattern is: Stripe webhook hits your backend on successful payment → your backend writes the entitlement to its own database and calls RevenueCat's REST API to grant the entitlement there too. The mobile app then reads entitlements from RevenueCat (or your backend) and sees 'pro' regardless of whether the purchase happened in-app or on the web. The reverse works identically — RevenueCat webhooks hit your backend, which can sync to Stripe if needed. The unification point is your backend; never let the app talk to Stripe directly for entitlement checks.

---

# Sentry vs Firebase Crashlytics: Flutter Crash Monitoring

Sentry vs Firebase Crashlytics for Flutter: grouping, release tracking, performance, source maps, and why a 5M-user app standardized on Sentry.

## Quick answer

**Use Sentry if you want best-in-class error grouping, release health tracking, performance monitoring, and rich context (breadcrumbs, tags, user scope) in a single dashboard. Use Firebase Crashlytics if you are already deep in Firebase and want crash reporting with zero additional vendor overhead.**

At iStoria we standardized on **Sentry** because crash-free rate alone is not enough — I need to know *which release* introduced a regression, see the breadcrumb trail that led to a crash, and correlate errors with releases and source maps. Crashlytics is fine for basic crash counts; Sentry is a quality platform.

| Feature | Sentry | Firebase Crashlytics |
| --- | --- | --- |
| Grouping / fingerprinting | Excellent (custom rules) | Basic |
| Release health tracking | First-class (per release) | Crash-free rate, less correlation |
| Performance tracing | Bundled | Separate (Firebase Performance) |
| Breadcrumbs | Automatic, rich | Custom logs/keys |
| Source maps / symbolication | Yes (uploaded per release) | Yes (via dSYM/upload symbols) |
| Dashboard | Unified errors + perf | Firebase console (crash-focused) |
| Setup cost | Separate platform | Zero if already on Firebase |
| Flutter SDK | First-class | First-class |
| Best for | Quality-obsessed, regression tracking | Firebase-native, basic crash counts |


## Detailed comparison

## Grouping and noise

Sentry's **fingerprinting and grouping** is meaningfully better. It groups similar stack traces intelligently, lets you custom-fingerprint (e.g. group by error code), and deduplicates noise. Crashlytics groups more crudely, which leads to either one issue ballooning with unrelated traces or many duplicate issues.

For a 5M-user app where one crash can generate thousands of events, grouping quality is the difference between a triageable inbox and an ignored one.

## Release health and regression detection

Sentry's **Release Health** tracks crash-free sessions, adoption, and issue counts **per release** — so you can see "crash-free rate dropped from 99.9% to 99.6% in v3.4" and link it to the commits in that release. Crashlytics shows crash-free users/sessions but with less release-correlation tooling.

This is the feature that made us pick Sentry. When a regression ships, I want to know the release and the commit range within minutes, not after digging through Crashlytics filters.

## Performance monitoring

Sentry bundles **performance tracing** (Dart spans, HTTP spans, navigation) alongside errors, so you see slow transactions correlated with errors in the same view. Crashlytics is crash-only; you pair it with Firebase Performance for tracing, which is a separate product and dashboard.

Having errors and traces in one platform is a real workflow advantage for incident triage.

## Context and breadcrumbs

Sentry's **breadcrumbs** (navigation events, HTTP calls, UI taps leading up to a crash) and rich scoping (tags, user context, extra data) give you the reproduction path without a user report. Crashlytics has custom keys and logs but the breadcrumb model is less automatic.

## Firebase integration

If you are already all-in on Firebase (Auth, Firestore, Messaging), Crashlytics is zero-marginal-vendor and integrates with the Firebase console. Sentry is a separate platform. For a Firebase-native team that only needs crash counts, Crashlytics is the path of least resistance.

## Cost

Both have free tiers. Sentry's paid plans scale with events volume; Crashlytics is effectively free (part of Firebase) but you pay for Firebase Performance if you need tracing. For a quality-obsessed app, Sentry's cost is justified by faster regression detection.

## Code comparison

### Sentry — release-scoped capture with breadcrumbs

```dart
await Sentry.init((options) {
  options.dsn = 'https://...@sentry.io/...';
  options.tracesSampleRate = 1.0;
  options.release = 'istoria@3.4.0+120'; // enables release health
});

// scoped context — breadcrumbs captured automatically
await Sentry.configureScope((scope) {
  scope.setUser(SentryUser(id: userId));
  scope.setTag('plan', 'pro');
});

await Sentry.captureException(exception, stackTrace: stack);
```

### Firebase Crashlytics — basic crash capture

```dart
await Firebase.initializeApp();
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;

// custom keys for context
await FirebaseCrashlytics.instance.setUserIdentifier(userId);
await FirebaseCrashlytics.instance.setCustomKey('plan', 'pro');
await FirebaseCrashlytics.instance.recordError(exception, stack, fatal: false);
```

Both capture crashes. Sentry adds release-scoped regression tracking, performance traces, and richer breadcrumbs — the workflow advantages that matter at scale.

## Which should you choose?

**Choose Sentry when:** you need release health tracking and regression detection per release, want errors and performance traces in one platform, value intelligent grouping and breadcrumbs for triage, or run a quality-obsessed app where fast regression detection saves users. Our production choice.

**Choose Firebase Crashlytics when:** you are already deep in Firebase and want crash reporting with no additional vendor, your needs are crash counts and stack traces, or budget demands the effectively-free option. Pair with Firebase Performance if you need tracing.

## FAQ

### Is Crashlytics free?

Effectively yes — it is part of Firebase and has no separate usage-based pricing for crash reporting. If you add Firebase Performance for tracing, that has its own limits. Sentry has a free tier and paid plans scaling with events.

### Can I run Sentry and Crashlytics together?

Yes, during a migration or for redundancy. Both can capture the same Flutter errors. The overhead is double-reporting and two dashboards to triage, so most teams pick one once they have evaluated.

### How do I get Dart stack traces symbolicated in Sentry?

Sentry's Flutter SDK handles Dart stack symbolication automatically for Dart-originated errors. For native (iOS/Android) frames, upload debug symbols (dSYMs/ProGuard mappings) per release — Sentry's upload tooling integrates with CI. Without symbols, native frames show as unresolved.

### How much overhead does Sentry add to app startup and runtime?

The Sentry Flutter SDK initializes in a few milliseconds on startup — negligible relative to Flutter engine init and first-frame render. At runtime, the SDK captures errors asynchronously; the `tracesSampleRate` controls how many transactions are traced (set it to 1.0 in dev, 0.1-0.2 in production to sample). Breadcrumbs and scope enrichment are trivially cheap. The one thing to watch: if you add heavy custom spans around hot paths (e.g. wrapping every database query), that can add up. Profile with the Flutter Performance overlay before and after integration if you are concerned, but for most apps the overhead is invisible to users and well worth the observability.

---

# Best Flutter State Management Packages

Choosing a state management package is the single most consequential architecture decision in a Flutter app. Get it right and every feature after it ships cheaply; get it wrong and the whole codebase calcifies.

The packages below are evaluated on four criteria: **production readiness** (can it hold at 5M+ users?), **API ergonomics** (does it get out of the way?), **testing story** (can you unit-test state without a widget tree?), and **ecosystem maturity** (tooling, community, documentation).

At iStoria (5M+ users, 50+ modules), we use **flutter_bloc + Cubit** for every feature. The consistency — every module follows the same event/state pattern — is why four engineers out-ship larger teams.

## The packages

### flutter_bloc (9.5/10)

Reactive state management with events, states, and strict separation of business logic from UI.

**Best for:** Production apps with complex state flows, multiple data sources, and teams that need consistency across modules.

*Production note:* We use flutter_bloc across all 50+ modules at iStoria. The event/state pattern scales predictably — a new engineer reads any feature's event classes and understands its full behaviour. Combined with Either<Failure, T> error handling, it is the backbone of our 99.9% crash-free rate.

### riverpod (9/10)

Reactive caching and state management framework with compile-time safety and no BuildContext dependency.

**Best for:** Apps that need compile-time safety, reactive caching, and access to state outside the widget tree.

*Production note:* I have not used Riverpod at iStoria (we committed to BLoC early), but I have used it in side projects and it excels at compile-time safety and provider composition. The auto-dispose feature is genuinely better than BLoC's manual lifecycle management.

### provider (7/10)

Lightweight InheritedWidget wrapper for dependency injection and simple state management.

**Best for:** Small apps, simple DI needs, or teams that want the simplest possible setup.

*Production note:* Provider is what we used at iStoria before migrating to BLoC. It works well until the app has 10+ screens with complex state — then the lack of structure creates inconsistency.

### get (5/10)

All-in-one framework: state management, DI, navigation, and utilities in one package.

**Best for:** Prototypes and MVPs. Not recommended for production apps with long lifespans.

*Production note:* We deliberately chose BLoC over GetX at iStoria because GetX's global state makes large codebases harder to reason about, not easier. I would not use it for any app expected to live more than a year.

## FAQ

### Which Flutter state management package should I choose in 2026?

For production apps: flutter_bloc (or Cubit for simpler features). For compile-time safety: Riverpod. For small apps: Provider. Avoid GetX for production. The best choice depends on your team's experience and the app's complexity — see our BLoC vs Riverpod comparison.

### Is flutter_bloc still the best choice for large Flutter apps?

Yes for teams that need strict event-driven architecture and consistency across 50+ modules. Riverpod is a strong alternative for teams that prioritize compile-time safety. Both scale to millions of users.

---

# Best Flutter PDF Packages

PDF handling in Flutter splits into three needs: **viewing** (rendering PDFs from URLs, assets, or files), **generating** (creating PDFs from data), and **editing** (filling forms, annotating). Each has different packages.

This directory is evaluated by Abdelrahman Saed — the author of flutter_cached_pdfview, one of the most popular PDF viewing packages for Flutter (134+ stars, 57K+ downloads). The evaluations are based on real production use at iStoria and across client projects.

## The packages

### flutter_cached_pdfview (9/10)

Native PDF viewer with on-device caching, offline support, and URL/asset/file sources — zero-config defaults.

**Best for:** Any app that needs to display remote PDFs with caching and offline support.

*Production note:* I built this package at iStoria after solving the same PDF caching problem across multiple projects. The design goal was to make the right behavior — cached, offline-capable, native rendering — the default in a few lines of code.

### syncfusion_flutter_pdfviewer (8.5/10)

Full-featured commercial PDF viewer with form filling, annotations, bookmarks, and search.

**Best for:** Apps that need advanced PDF features like annotation, form filling, or text search.

*Production note:* I evaluated Syncfusion before building flutter_cached_pdfview. It is excellent for document-heavy apps (e-signature, legal, healthcare), but overkill for simple viewing needs.

### pdf (8.5/10)

Generate PDFs from Flutter widgets — render widgets to PDF documents programmatically.

**Best for:** Generating invoices, reports, tickets, certificates, or any structured PDF document from data.

*Production note:* We use the pdf package at iStoria to generate completion certificates for learners. The Dart-only rendering is reliable across platforms.

### flutter_pdfview (7/10)

Lightweight native PDF viewer for iOS and Android — the rendering engine behind flutter_cached_pdfview.

**Best for:** Apps that need basic PDF viewing with their own caching layer.

*Production note:* flutter_pdfview is the rendering layer inside flutter_cached_pdfview. If you need just the viewer without caching, it works well. For most apps, flutter_cached_pdfview wraps it with the right defaults.

## FAQ

### What is the best Flutter PDF viewer?

For most apps: flutter_cached_pdfview (caching and offline support by default). For advanced features (annotations, forms, search): syncfusion_flutter_pdfviewer. For generating PDFs: the pdf package.

---

# Best Flutter Offline & Local Storage Packages

Offline data is the backbone of a great mobile experience. The packages below handle three layers: **local databases** (structured storage), **key-value stores** (simple persistence), and **sync engines** (keeping local and server data in sync).

At iStoria (5M+ users), we use **Drift + PowerSync** for structured offline-first data and **Hive** for the offline write queue. Every read is local; the server syncs in the background.

## The packages

### drift (9.5/10)

Reactive, type-safe SQLite ORM with code generation and stream-based reactive queries.

**Best for:** Any app with structured local data that needs type-safe queries and reactive updates.

*Production note:* Drift is the local data layer at iStoria. Every read of user progress, chapters, and streaks comes from Drift. When PowerSync updates a row, Drift's stream emits a new value and the UI rebuilds — no manual invalidation.

### isar (8.5/10)

Super-fast NoSQL database for Flutter with type-safe queries and cross-platform support.

**Best for:** Apps that need fast local storage with flexible schemas and don't need relational queries.

*Production note:* I have not used Isar in production (we chose Drift for its relational model and PowerSync compatibility), but benchmarks show it is faster than Hive for large datasets.

### hive (8/10)

Lightning-fast key-value database written in pure Dart — no native dependencies.

**Best for:** Simple key-value storage, offline write queues, caching, and settings.

*Production note:* We use Hive at iStoria for the offline write queue — a Dio interceptor backed by a Hive box that captures writes made while offline and replays them on reconnect. Hive is fast enough that the queue adds zero perceptible latency.

### powersync (9/10)

Sync engine that keeps a local SQLite database in sync with a Postgres backend in real time.

**Best for:** Offline-first apps with a Postgres backend that need automatic sync and conflict resolution.

*Production note:* PowerSync is the sync engine at iStoria. It streams changes between the local Drift database and Postgres, with selective per-key sync for 5M+ accounts. The UI never awaits a fetch to render — PowerSync reconciles in the background.

## FAQ

### Should I use Drift or Hive for local storage?

Drift for structured, relational data (users, posts, orders). Hive for key-value storage (settings, caches, queues). At iStoria, we use both — Drift for structured data, Hive for the offline write queue.

### How do I sync local data with a server in Flutter?

Use PowerSync if your backend is Postgres — it handles sync, conflict resolution, and selective replication automatically. For non-Postgres backends, you will need to build a custom sync layer (Dio interceptors, background workers, conflict markers).

---

# Best Flutter Networking Packages

Networking is the most common integration point in any Flutter app. The packages below handle HTTP requests, API client construction, and request orchestration.

The evaluation criteria: **reliability** (interceptors, retries, error handling), **API ergonomics** (how clean is the request/response cycle?), and **ecosystem** (plugins, community, documentation).

## The packages

### dio (9.5/10)

Powerful HTTP client with interceptors, global configuration, form data, and request cancellation.

**Best for:** Any production app that needs interceptors, auth token management, or request orchestration.

*Production note:* Dio is our HTTP client at iStoria. The interceptor system powers the offline write queue (a custom Dio interceptor backed by Hive), auth token refresh, Firebase Performance monitoring (via dio_firebase_performance_plus, a package I authored), and retry logic.

### http (7.5/10)

Dart's official, lightweight HTTP client — simple, composable, no extra dependencies.

**Best for:** Simple apps with few API calls, or teams that want zero-complexity HTTP.

*Production note:* The http package works for simple apps but does not scale. Once you need interceptors (auth refresh, logging, caching, offline queuing), Dio is the better choice.

### retrofit (8/10)

Type-safe HTTP client generator using annotations — define API interfaces, get type-safe calls.

**Best for:** Apps with many REST endpoints that want type-safe API clients with minimal boilerplate.

*Production note:* We evaluated retrofit at iStoria but chose raw Dio with repository-pattern wrappers because our API needs (GraphQL + REST + offline queuing) are too custom for annotation-based generation.

## FAQ

### Should I use Dio or http in Flutter?

Use Dio for any production app — the interceptor system alone (auth refresh, logging, caching, retry) is worth it. Use http for prototypes or apps with fewer than 5 API endpoints. See our Dio vs http comparison.

---

# Best Flutter CI/CD & Automation Tools

CI/CD for Flutter is about turning a four-week release cycle into a weekly one without lowering the crash-free bar. The tools below handle building, testing, signing, and deploying Flutter apps to both stores.

At iStoria, 14 GitHub Actions workflows + Fastlane for store deployment cut our release cycle 75% (four weeks to one) while holding 99.9% crash-free across 350+ releases.

## The packages

### github_actions (9.5/10)

GitHub's built-in CI/CD platform — unlimited workflows for public repos, generous limits for private.

**Best for:** Any team that uses GitHub — it is the default choice and the one we use at iStoria.

*Production note:* All 14 CI/CD workflows at iStoria run on GitHub Actions. Beyond building and deploying, we use Actions for repository hygiene: auto-rebase PRs, detect merge conflicts, clean stale branches, auto-fill Jira metadata, and generate weekly engineering summaries. The repo is self-driving.

### codemagic (8.5/10)

Dedicated CI/CD platform built for mobile — first-class Flutter support with pre-configured build environments.

**Best for:** Teams that want a turnkey Flutter CI/CD setup without configuring iOS signing and build environments.

*Production note:* We used Codemagic at iStoria before migrating to GitHub Actions. It is excellent for getting started — zero configuration, Flutter-first. We switched because GitHub Actions gave us more flexibility for the self-driving repo workflows.

### fastlane (8.5/10)

Automation tool for iOS and Android deployment — screenshots, beta distribution, and App Store / Play Store releases.

**Best for:** Store deployment automation — uploading to App Store Connect and Google Play with metadata and screenshots.

*Production note:* We use Fastlane at iStoria for the final store upload step within GitHub Actions workflows. Fastlane handles screenshots, metadata, and the actual upload to both stores.

## FAQ

### Should I use GitHub Actions or Codemagic for Flutter CI/CD?

GitHub Actions if you want maximum flexibility and already use GitHub. Codemagic if you want a turnkey setup without configuring iOS signing. At iStoria, we switched from Codemagic to GitHub Actions for the flexibility. See our GitHub Actions vs Codemagic comparison.

---

# Best Flutter Testing Packages

Testing in Flutter has three levels: **unit tests** (pure logic), **widget tests** (single widget rendering), and **integration tests** (full app flows). The packages below handle each level.

The evaluation: **test speed** (how fast does the suite run?), **API ergonomics** (how readable are tests?), and **CI integration** (how easy is it to run in a pipeline?).

## The packages

### flutter_test (9/10)

Flutter's built-in testing framework — unit tests, widget tests, and golden tests.

**Best for:** Every Flutter app — this is the baseline testing framework.

*Production note:* We use flutter_test for all unit and widget tests at iStoria. With BLoC, every state transition is a testable unit — no widget tree needed for business logic tests.

### mocktail (8.5/10)

Dart mocking library with a clean, null-safe API — no code generation required.

**Best for:** Mocking repository contracts and external services in unit and widget tests.

*Production note:* We use mocktail at iStoria for testing repository implementations. The Clean Architecture repository contracts make mocking trivial — each test injects a mocktail-mocked data source.

### patrol (8.5/10)

Integration testing framework with native OS control — interact with iOS and Android permissions and native UI.

**Best for:** End-to-end integration tests that need to interact with native OS elements (permissions, notifications, deep links).

*Production note:* We use Patrol at iStoria for testing the critical user journey: install → onboarding → first lesson → subscription flow. The native permission handling is the key advantage over integration_test.

### maestro (8/10)

Mobile UI testing framework with YAML-based test definitions — write tests without code.

**Best for:** Teams that want QA-friendly end-to-end tests without writing Dart code.

*Production note:* We evaluated Maestro alongside Patrol at iStoria. Patrol won because it integrates with our Dart codebase — we can share test data, configuration, and helpers between widget and integration tests.

## FAQ

### What testing strategy should I use for Flutter?

Unit tests for business logic (BLoC state transitions, repository methods). Widget tests for individual widgets. Integration tests (Patrol) for the critical user journey only. See our Flutter Testing Strategy guide.

---

# Best Flutter Animation Packages

Animation in Flutter ranges from built-in ImplicitAnimaton widgets (AnimatedContainer, AnimatedOpacity) to complex physics-based animations. The packages below handle the cases where built-in widgets aren't enough.

## The packages

### lottie (9/10)

Render After Effects animations exported as JSON — lightweight, scalable, and designer-friendly.

**Best for:** Designer-created animations (onboarding, empty states, celebrations, loading indicators).

*Production note:* We use Lottie at iStoria for celebration animations when learners complete a level. Designers create the animations in After Effects; we render them with the Lottie package.

### rive (9/10)

Interactive vector animations with state machines — animations that respond to user input and state.

**Best for:** Interactive animations — onboarding mascots, game-like UI, state-driven character animations.

*Production note:* We evaluated Rive for the iStoria mascot animations. It excels at interactive, state-driven animations that go beyond simple loops. We ultimately chose Lottie for our use case (simpler designer workflow), but Rive is the better choice for interactive characters.

### flutter_animate (8.5/10)

Declarative animation API — chain effects with a fluent builder pattern.

**Best for:** UI entrance animations, list item stagger effects, and any declarative animation that doesn't need physics.

*Production note:* We use flutter_animate at iStoria for section entrance animations on the homepage and article pages. The declarative chain is far cleaner than manual AnimationController code.

## FAQ

### Should I use Lottie or Rive for Flutter animations?

Lottie for designer-created looping animations (celebrations, loading, empty states). Rive for interactive, state-driven animations (characters, onboarding flows, games). Both are excellent; the choice depends on whether your animation needs to respond to user input.

---

# Best Flutter Navigation Packages

Navigation in Flutter moved from imperative Navigator 1.0 (push/pop) to declarative Navigator 2.0 (pages/configuration). The packages below make Navigator 2.0 usable with clean APIs.

## The packages

### go_router (9/10)

Official Flutter team declarative routing package — URL-based, deep-linkable, redirect-aware.

**Best for:** Any app that needs declarative routing, deep linking, or web support.

*Production note:* At iStoria, we use Navigator 1.0 (push/pop) with named routes because our routing needs are straightforward (140+ routes, but no deep linking). For new apps, go_router is the default I recommend.

### auto_route (8.5/10)

Type-safe declarative routing with code generation — type-safe parameters and guards.

**Best for:** Large apps with many routes that need type safety and complex navigation graphs.

*Production note:* I evaluated auto_route for iStoria. The type-safe parameters are excellent, but we stayed with named routes because 140+ routes were already wired. For a new large app, auto_route would be my choice.

## FAQ

### Should I use go_router or Navigator 1.0?

go_router for any app that needs deep linking, web support, or declarative routing. Navigator 1.0 (push/pop with named routes) is fine for apps with simple, non-deep-linkable navigation. go_router is the Flutter team's recommendation for new apps.

---

# Best Flutter Dependency Injection Packages

Dependency injection in Flutter has two camps: **service locators** (get_it — resolve types by key) and **widget-tree DI** (Provider — resolve types through the widget tree context). The packages below serve both approaches.

## The packages

### get_it (9/10)

Service locator for Dart — register types at startup, resolve them anywhere without BuildContext.

**Best for:** Registering services, repositories, and API clients that need to be accessible outside the widget tree.

*Production note:* We use get_it at iStoria for all service registration. Combined with injectable, it auto-wires dependencies at 50+ modules without manual registration errors.

### injectable (8.5/10)

Code-generated dependency injection on top of get_it — annotate classes, get auto-wired registration.

**Best for:** Apps with 10+ services that want compile-time safety for dependency registration.

*Production note:* injectable + get_it is our DI stack at iStoria. Every service, repository, and data source is annotated; injectable generates the registration; get_it resolves at runtime. Zero manual wiring across 50+ modules.

### provider (7/10)

InheritedWidget wrapper for widget-tree-scoped dependency injection and simple state.

**Best for:** Small apps, or widget-scoped dependencies (theme, locale, feature flags).

*Production note:* Provider works for small apps but doesn't scale. At iStoria, services need to be accessible from background tasks and tests — that requires get_it, not Provider.

## FAQ

### Should I use get_it or Provider for dependency injection?

get_it (with injectable) for services that need to be accessible outside the widget tree. Provider for widget-scoped dependencies (theme, locale). Many apps use both — get_it for services, Provider for UI-scoped state.

---

# Best Flutter Monetization & IAP Packages

Monetization in Flutter splits into **subscriptions/IAP** (RevenueCat, in_app_purchase) and **ads** (Google Mobile Ads). For subscription apps, RevenueCat is the de facto standard — it handles the cross-platform receipt validation, paywall AB testing, and entitlement management that Apple and Google make painful.

## The packages

### purchases_flutter (9.5/10)

RevenueCat SDK — cross-platform subscription management with receipt validation, entitlements, and A/B testing.

**Best for:** Any subscription app. RevenueCat is the standard for Flutter IAP.

*Production note:* RevenueCat is our subscription engine at iStoria (5M+ users). We run constant paywall A/B tests — family plans, returning-user redesigns, trial and pricing variants — all managed from the RevenueCat dashboard. The cross-platform entitlement system means iOS and Android share the same subscription state.

### in_app_purchase (7/10)

Flutter team's official IAP plugin — direct App Store and Google Play integration without a third party.

**Best for:** Apps that want zero vendor dependency and have the engineering resources to handle validation and entitlements.

*Production note:* We evaluated in_app_purchase before choosing RevenueCat. The receipt validation, entitlement management, and paywall testing that RevenueCat provides for free would have taken months to build and maintain ourselves.

### google_mobile_ads (7.5/10)

Google AdMob SDK for Flutter — banner, interstitial, rewarded, and native ads.

**Best for:** Free apps that monetize through ads, or freemium apps with ad-supported tiers.

*Production note:* We use AdMob at iStoria as a secondary revenue stream for non-subscribed users. The integration is straightforward for banner and rewarded ads; native ads require more platform-specific work.

## FAQ

### Should I use RevenueCat or in_app_purchase?

RevenueCat for any subscription app — the receipt validation, entitlements, and paywall testing are worth the revenue share. in_app_purchase if you want zero vendor dependency and have the resources to build and maintain validation yourself. See our RevenueCat vs Stripe comparison for the broader monetization landscape.

---

# Best Flutter Analytics Packages

Analytics in Flutter typically involves multiple tools: product analytics (user behavior, funnels), crash reporting (Sentry, Crashlytics), and attribution (Adjust, AppsFlyer). The packages below cover the main categories.

## The packages

### posthog (9/10)

Open-source product analytics — event tracking, session replay, feature flags, and A/B testing in one platform.

**Best for:** Teams that want product analytics, feature flags, and session replay in one platform.

*Production note:* bnsaed.com uses PostHog for web analytics (loaded lazily via idle callback). For iStoria, we use Firebase + Adjust for the deeper attribution and mobile-specific integrations.

### firebase_analytics (8/10)

Google Analytics for Firebase — free, unlimited event tracking with deep Firebase and Google Ads integration.

**Best for:** Apps already in the Firebase ecosystem that want free, unlimited event tracking.

*Production note:* We use Firebase Analytics at iStoria for baseline event tracking. It integrates cleanly with Firebase Crashlytics, Remote Config, and A/B testing. For deeper funnel analysis, we supplement with PostHog.

### amplitude (8/10)

Product analytics with advanced funnel, cohort, and retention analysis — built for data teams.

**Best for:** Data-driven teams that need advanced analytics and experiment tracking.

*Production note:* We evaluated Amplitude for iStoria but chose PostHog + Firebase for the combination of open-source flexibility and Firebase ecosystem integration.

## FAQ

### Which analytics package should I use for Flutter?

Firebase Analytics for free baseline tracking (especially if you're already in the Firebase ecosystem). PostHog for open-source, all-in-one analytics with session replay and feature flags. Amplitude for advanced funnel and cohort analysis. Many apps use two: Firebase for baseline + a dedicated tool for deep analysis.

---

# Best Flutter Forms & Validation Packages

Forms in Flutter range from simple (a single TextField with a validator) to complex (multi-step forms with conditional fields, async validation, and cross-field dependencies). The packages below handle the complex cases.

## The packages

### react_hook_form (8.5/10)

High-performance form management with hooks — minimal rebuilds, schema validation, and controller-free inputs.

**Best for:** Forms with many fields where rebuild performance matters.

*Production note:* We use react_hook_form at bnsaed.com for the contact form. The minimal-rebuild model is a significant performance win for forms with 5+ fields.

### flutter_form_builder (8/10)

Framework for building forms with complex validation, conditional fields, and dynamic layouts.

**Best for:** Complex forms with many field types, conditional logic, and dynamic structure.

*Production note:* We evaluated flutter_form_builder for iStoria's registration flow but built custom forms with react_hook_form for better control over rebuild performance.

## FAQ

### Should I use Flutter's built-in Form or a package?

For simple forms (1-3 fields): use Flutter's built-in Form + FormField with validators. For complex forms (5+ fields, conditional logic, multi-step): use react_hook_form (performance) or flutter_form_builder (rich field types).

---

# Best Flutter Push Notification Packages

Push notifications in Flutter split into **remote push** (sent from a server via APNs/FCM) and **local notifications** (scheduled on-device). Most apps need both.

## The packages

### firebase_messaging (8.5/10)

Firebase Cloud Messaging client for Flutter — cross-platform push notifications via APNs and FCM.

**Best for:** Any app that needs remote push notifications. FCM is the de facto standard.

*Production note:* We use firebase_messaging at iStoria for daily streak reminders, new content notifications, and re-engagement campaigns. Topic-based segmentation lets us target learners by level, language, and activity.

### flutter_local_notifications (8.5/10)

Schedule and display local notifications — no server needed, works fully offline.

**Best for:** Scheduled reminders, daily streak notifications, calendar alerts, and any notification that doesn't need a server.

*Production note:* We use flutter_local_notifications at iStoria for daily streak reminders that fire at the learner's preferred study time. The scheduling is entirely on-device — no server round-trip needed.

### onesignal (7.5/10)

Full-featured push notification platform with segmentation, A/B testing, and analytics built-in.

**Best for:** Marketing teams that want a visual dashboard for push campaigns without engineering involvement.

*Production note:* We chose firebase_messaging over OneSignal at iStoria because we needed programmatic control over notification targeting (integration with our user segmentation in the backend), not a visual dashboard.

## FAQ

### Should I use Firebase Cloud Messaging or OneSignal?

FCM if you want free, unlimited push with programmatic control and Firebase integration. OneSignal if you want a visual dashboard for marketing campaigns, A/B testing, and analytics without engineering involvement. Many apps start with FCM and add OneSignal when the marketing team needs self-service.

---

# Best Flutter Internationalization Packages

Internationalization (i18n) in Flutter has improved significantly. The built-in flutter_localizations with ARB files is now good enough for most apps, but third-party packages offer simpler workflows for large translation sets.

## The packages

### easy_localization (8/10)

Simple internationalization with JSON translation files — change locale at runtime without rebuild.

**Best for:** Apps that want simple JSON-based translations without code generation.

*Production note:* We use easy_localization at iStoria for 10+ languages. The JSON workflow is far simpler than ARB files for our translation team, and runtime locale switching is essential for our bilingual (Arabic/English) audience.

### slang (8.5/10)

Type-safe i18n with code generation — compile-time checking for missing translations.

**Best for:** Apps that want compile-time safety for translations.

*Production note:* I have not used slang at iStoria (we chose easy_localization for its simpler JSON workflow), but the type-safe approach is superior for large translation sets.

### flutter_localizations (7.5/10)

Flutter's built-in i18n with ARB files and the gen-l10n tool — the official approach.

**Best for:** Apps that want the official Flutter approach with no extra dependencies.

*Production note:* The official approach works but is more ceremony than easy_localization for our translation workflow. For apps with 2-3 languages and a developer managing translations, the built-in is fine.

## FAQ

### How do I handle RTL in Flutter?

Flutter handles RTL automatically when you set the locale to an RTL language (Arabic, Hebrew). Use Directionality.of(context) to check the current direction, and use EdgeInsetsDirectional / MarginDirectional instead of EdgeInsets / Margin for direction-aware spacing. At iStoria, full RTL support was built into the design system from day one.

---

# Best Flutter Developer Tools & Code Generation

Developer tools and code generation packages are the productivity multipliers that make large Flutter codebases manageable. The packages below reduce boilerplate, enforce immutability, and automate the repetitive code that every app needs.

## The packages

### freezed (9/10)

Code generator for immutable data classes, unions, and deep copy — eliminates data class boilerplate.

**Best for:** Any app with data models that need immutability, copyWith, and JSON serialization.

*Production note:* We use freezed at iStoria for all data models — entities, DTOs, and BLoC states. The generated copyWith and == alone save thousands of lines of boilerplate. The sealed union pattern for state classes (Loading, Success, Error) is particularly clean.

### json_serializable (9/10)

Generate type-safe JSON serialization and deserialization code from Dart classes.

**Best for:** Any app that parses JSON from APIs — which is every app.

*Production note:* json_serializable + freezed is our data model stack at iStoria. Every API response model is annotated, generated, and type-safe end to end.

### clipboard_plus (7.5/10)

Flutter clipboard plugin with silent content-type detection on iOS — detects URLs, text, and images without the paste permission dialog.

**Best for:** Apps that need to detect clipboard content without triggering the iOS paste permission.

*Production note:* I built clipboard_plus at iStoria to detect when learners copy a shareable link or code snippet without triggering the iOS paste dialog. The silent detection is the key feature — the paste permission prompt is a UX killer.

### flutter_zoom_checker (7/10)

iOS/Android display-zoom (UI scaling) detection plugin — works around Flutter framework issue #95421.

**Best for:** Apps that need pixel-perfect layouts and break when the OS display zoom is enabled.

*Production note:* I built flutter_zoom_checker to fix layout issues at iStoria when learners enabled iOS display zoom. The Flutter framework doesn't expose display zoom state; this plugin fills that gap.

## FAQ

### What is the best code generation stack for Flutter?

freezed + json_serializable + build_runner. This covers immutable data classes (with copyWith, ==, hashCode), JSON serialization, and the build pipeline. Add injectable for DI, and drift for database — both also use build_runner.

---

# Flutter Clean Architecture: A Complete Guide

Clean Architecture in Flutter is not about layers for the sake of layers — it is about making change cheap and testing easy. On iStoria, a 5M+ user education app with 50+ modules, Clean Architecture is what lets a 4-engineer squad ship weekly without a tangled rewrite every quarter.

The core idea is the dependency rule: dependencies point inward. The domain layer knows nothing about Flutter, HTTP, or SQLite. The data layer implements the domain's contracts. The presentation layer talks to the domain through use cases. Each boundary is an interface, which means you can swap the database, the HTTP client, or the state management library without touching the business rules.

This guide is the pattern I use in production — not the textbook version that spawns six use cases per feature. It is trimmed to what actually pays off at scale: testable domain logic, swappable data sources, and a presentation layer that is dumb on purpose.

## What Clean Architecture Actually Solves

Before adopting Clean Architecture, our codebase had a common sickness: business logic lived inside widget trees and API callbacks. Changing the database schema broke the UI. Testing meant booting the whole app. Onboarding a new engineer took weeks because there was no single place to look for "the rules of the business."

Clean Architecture forces three separations:

1. **Domain** — the business rules. Entities, value objects, repository *contracts* (abstract interfaces), and use cases. Pure Dart. No Flutter, no I/O.
2. **Data** — the implementation of those contracts. Repositories that coordinate remote and local data sources, DTOs, mappers, and the actual HTTP/database clients.
3. **Presentation** — the UI and state management. Widgets, BLoC/Cubit, routing. It depends on use cases, never on repositories directly.

The dependency rule is non-negotiable: **inner layers never import from outer layers.** The domain layer has zero knowledge of `flutter`, `dio`, `drift`, or `bloc`. This is what makes the domain testable in isolation and portable across state management choices.

## Layers in Detail

### Domain Layer

The domain layer holds the contracts and the rules. A repository is defined here as an abstract class — the data layer will implement it. Use cases (or "interactors") orchestrate one business operation each.

Keep use cases thin. If a use case is just `repository.get()`, skip it and call the repository directly from the BLoC. We learned this the hard way: dozens of pass-through use cases added ceremony without value. A use case earns its existence when it coordinates multiple repositories or enforces a business rule the repository should not know about.

### Data Layer

Repositories here implement the domain's abstract contracts. They depend on remote and local data sources, map DTOs to domain entities, and decide cache strategy. The key principle: the domain defines `getLessons()` returns `List<Lesson>`. The data layer decides whether that comes from the API, Drift, or PowerSync's sync engine. The domain never knows.

Mappers live here, not in entities. A `LessonModel` (DTO) has a `toEntity()` method. The domain entity has no `fromJson`. This keeps the domain pure.

### Presentation Layer

The presentation layer consumes use cases and renders state. At iStoria we use BLoC and Cubit — Cubit for simple feature states, BLoC for features with complex event flows or analytics requirements. The BLoC depends on use cases (or repositories for simple features), never on Dio, Drift, or any data-layer concrete class.

Widgets subscribe to the BLoC's state stream and render pure functions of state. No business logic in widgets. No `if (response.statusCode == 200)` in a `build()` method.

## The Dependency Rule in Practice

Here is the litmus test: can you delete the entire data and presentation layer and still compile the domain? If yes, your boundaries are clean. If the domain imports `dart:io` or `flutter/foundation.dart`, the boundary is broken.

At iStoria, we enforce this with import linting. The domain package has a `analysis_options.yaml` that bans `package:flutter/*`, `package:dio/*`, `package:drift/*`. A CI check fails the build if someone sneaks a Flutter import into the domain. This is more valuable than any architecture diagram.

## When to Use It vs. Skip It

Clean Architecture has a cost: indirection. For a prototype or a throwaway feature, three layers per feature is overkill. We use a lighter pattern for experiments — a repository and a Cubit, no separate domain package. When the feature stabilizes and acquires business rules worth protecting, we promote it to the full layered structure.

The rule of thumb: **use Clean Architecture when the business logic is complex enough that you would be afraid to change it without tests.** If the feature is CRUD over an API with no business rules, a repository + Cubit is enough.

## Testing Strategy

The layered structure pays for itself in testing. The domain is tested with pure unit tests — no mocks of Flutter, HTTP, or databases. The data layer is tested with fakes for remote/local sources. The presentation layer is tested with mocked use cases and widget tests.

This is the single biggest payoff: you can test the business rules in milliseconds, without a device, without a network mock, without waiting for a build. On a 50+ module codebase, that speed is what keeps the team shipping.

## Recommended folder structure

```text
lib/
├── core/                    # shared utilities, theme, routing, DI container
│   ├── error/
│   │   ├── failures.dart
│   │   └── exceptions.dart
│   ├── usecases/
│   │   └── usecase.dart     # abstract UseCase<Type, Params>
│   └── utils/
├── features/
│   └── lesson/
│       ├── domain/
│       │   ├── entities/
│       │   │   └── lesson.dart
│       │   ├── repositories/
│       │   │   └── lesson_repository.dart   # abstract contract
│       │   └── usecases/
│       │       └── get_lessons.dart
│       ├── data/
│       │   ├── datasources/
│       │   │   ├── lesson_remote_datasource.dart
│       │   │   └── lesson_local_datasource.dart
│       │   ├── models/
│       │   │   └── lesson_model.dart        # DTO + toEntity/fromJson
│       │   └── repositories/
│       │       └── lesson_repository_impl.dart
│       └── presentation/
│           ├── bloc/
│           │   ├── lesson_bloc.dart
│           │   ├── lesson_event.dart
│           │   └── lesson_state.dart
│           ├── pages/
│           │   └── lesson_page.dart
│           └── widgets/
│               └── lesson_card.dart
```

## Code example

```dart
// domain/entities/lesson.dart — PURE DART, no Flutter imports
class Lesson {
  final String id;
  final String title;
  final int durationMinutes;
  final bool isCompleted;
  final String courseId;

  const Lesson({
    required this.id,
    required this.title,
    required this.durationMinutes,
    required this.isCompleted,
    required this.courseId,
  });

  Lesson copyWith({
    String? title,
    int? durationMinutes,
    bool? isCompleted,
  }) {
    return Lesson(
      id: id,
      title: title ?? this.title,
      durationMinutes: durationMinutes ?? this.durationMinutes,
      isCompleted: isCompleted ?? this.isCompleted,
      courseId: courseId,
    );
  }
}

// domain/repositories/lesson_repository.dart — CONTRACT only, no implementation
abstract class LessonRepository {
  Future<Either<Failure, List<Lesson>>> getLessons(String courseId);
  Future<Either<Failure, Lesson>> markComplete(String lessonId);
  Stream<List<Lesson>> watchLessons(String courseId);
}

// data/repositories/lesson_repository_impl.dart — THE IMPLEMENTATION
class LessonRepositoryImpl implements LessonRepository {
  final LessonRemoteDatasource remoteDatasource;
  final LessonLocalDatasource localDatasource;
  final NetworkInfo networkInfo;

  LessonRepositoryImpl({
    required this.remoteDatasource,
    required this.localDatasource,
    required this.networkInfo,
  });

  @override
  Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
    if (await networkInfo.isConnected) {
      try {
        final remoteLessons = await remoteDatasource.fetchLessons(courseId);
        await localDatasource.cacheLessons(courseId, remoteLessons);
        return Right(remoteLessons.map((m) => m.toEntity()).toList());
      } on ServerException {
        // Fall through to local — offline-first cache strategy
      }
    }
    try {
      final localLessons = await localDatasource.getCachedLessons(courseId);
      return Right(localLessons.map((m) => m.toEntity()).toList());
    } on CacheException {
      return Left(CacheFailure());
    }
  }

  @override
  Stream<List<Lesson>> watchLessons(String courseId) {
    return localDatasource
        .watchCachedLessons(courseId)
        .map((models) => models.map((m) => m.toEntity()).toList());
  }
}
```

## FAQ

### Is Clean Architecture overkill for a small Flutter app?

For a prototype or an app with little business logic, yes. Start with a repository + Cubit per feature. Promote to full layered Clean Architecture when the business rules become complex enough that you need to test them in isolation and protect them from UI or data-source changes.

### Should every repository method have its own use case?

No. Use cases exist to coordinate multiple repositories or enforce a business rule. If a use case is just forwarding a call to a single repository method, skip it and call the repository directly from the BLoC. Pass-through use cases add ceremony without value.

### How does Clean Architecture work with BLoC?

The BLoC lives in the presentation layer and depends on use cases or repositories (abstract contracts from the domain). The BLoC never imports concrete data-layer classes. This means you can test the BLoC with mocked use cases and swap the state management library without touching business rules.

---

# Flutter Feature-First Modular Architecture

Feature-first modular architecture is the difference between a Flutter codebase that scales and one that collapses under its own weight. At iStoria, we run 50+ modules across a 4-engineer squad. If everything lived in a flat `lib/` folder, nobody would be able to find anything, and every change would risk breaking an unrelated feature.

Feature-first means the code is organized by *feature* (lesson, course, auth, profile), not by *type* (all models together, all widgets together, all repositories together). Each feature owns its own widgets, state, data, and domain. Features communicate through contracts, not through direct imports of each other's internals.

The goal is blast-radius containment: a change inside the lesson feature should not require reading or modifying the course feature. This guide shows the pattern we use to achieve that at scale.

## Feature-First vs. Layer-First

Layer-first organization (`lib/models/`, `lib/widgets/`, `lib/repositories/`) works for apps under ~10 screens. Beyond that, it breaks down. To change a feature, you open six directories across the project. Merge conflicts multiply because every feature touches the same folders. Feature-first fixes this by grouping everything a feature needs under one directory.

Feature-first and Clean Architecture are complementary, not competing. Within each feature directory, you apply the domain/data/presentation split. The feature directory is the *horizontal* boundary; Clean Architecture is the *vertical* boundary within each feature.

## The Shared Core Problem

Every modular architecture hits the same wall: features need to share *something*. Common UI widgets, networking utilities, the DI container, the theme. If features import these directly from each other, you have coupling. The solution is a shared `core/` or `shared/` package that features depend on, but that depends on nothing.

At iStoria, we split this into:

- **core/** — framework-level utilities (network client, error handling, DI setup, routing, theme). No business logic.
- **shared/** — cross-feature UI components and shared domain concepts (the User entity used by auth, profile, and social features).

The rule: `core/` and `shared/` never import from `features/`. Dependencies flow inward. This is the same dependency rule as Clean Architecture, applied at the package level.

## Package Splitting Strategy

For a large app, consider splitting features into actual Dart packages (Melos-managed monorepo). Each feature becomes a `packages/feature_lesson/` with its own `pubspec.yaml`. This enforces boundaries at the compiler level — a feature literally cannot import another feature's internals because it is not a dependency.

We did not start this way. We started with a single app package and feature directories. When we hit ~30 features, we began extracting the most independent ones into packages. The lesson: **do not start with a monorepo.** Start with feature directories inside the app. Extract to packages only when the boundary is stable and the compilation time becomes a problem.

## Cross-Feature Communication

Features should not import each other directly. Instead:

1. **Shared contracts** — if feature A needs to trigger something in feature B, define an abstract interface in `shared/` that both depend on. Feature B implements it; feature A calls it through DI.
2. **Navigation via routing** — features do not push each other's widgets directly. They call a route name registered in the core router. This decouples features from each other's widget trees.
3. **Event-based** — for loose coupling, a simple event bus or stream can work, but use sparingly. Over-reliance on events makes the data flow impossible to trace.

## Managing 50+ Modules

At iStoria's scale (50+ modules, 5M+ users), feature-first modular architecture is survival. The key practices:

- Each feature has an owner. When a bug appears in the lesson flow, there is exactly one engineer who knows that feature cold.
- Features are independently testable. Each feature has its own test directory testing its BLoC, repository, and widgets in isolation.
- The `core/` package changes rarely and is reviewed by a lead. Most PRs touch a single feature directory.
- We use barrel files (`feature_lesson.dart`) that export only the public API of each feature — the pages and widgets other features are allowed to see. Everything else is private to the feature.

## Common Failure Modes

The most common failure is the "shared utils" dump. Engineers under deadline pressure throw a helper into `core/utils/` instead of asking whether it belongs in the feature. Over time, `core/` grows into a dependency that every feature transitively couples to. The fix is discipline: `core/` should contain only framework-level concerns. If it is business logic, it goes in a feature or in `shared/`.

## Recommended folder structure

```text
lib/
├── main.dart
├── core/                       # framework-level, no business logic
│   ├── di/
│   │   └── injection.dart      # get_it / injectable setup
│   ├── networking/
│   │   ├── dio_client.dart
│   │   └── interceptors/
│   ├── error/
│   │   ├── failures.dart
│   │   └── error_handler.dart
│   ├── routing/
│   │   └── app_router.dart     # centralized GoRouter / AutoRoute config
│   ├── theme/
│   └── constants/
├── shared/                     # cross-feature domain + UI
│   ├── domain/
│   │   └── entities/
│   │       └── user.dart       # shared User entity
│   └── widgets/
│       ├── loading_indicator.dart
│       └── empty_state.dart
└── features/
    ├── auth/
    │   ├── domain/
    │   │   ├── entities/
    │   │   ├── repositories/
    │   │   │   └── auth_repository.dart
    │   │   └── usecases/
    │   ├── data/
    │   │   ├── datasources/
    │   │   ├── models/
    │   │   └── repositories/
    │   │       └── auth_repository_impl.dart
    │   ├── presentation/
    │   │   ├── cubit/
    │   │   │   ├── auth_cubit.dart
    │   │   │   └── auth_state.dart
    │   │   ├── pages/
    │   │   └── widgets/
    │   └── auth.dart            # barrel file — public API only
    ├── lesson/
    │   ├── domain/
    │   ├── data/
    │   ├── presentation/
    │   └── lesson.dart
    └── course/
        ├── domain/
        ├── data/
        ├── presentation/
        └── course.dart
```

## Code example

```dart
// features/auth/auth.dart — BARREL FILE: the only thing other features import
export 'presentation/pages/login_page.dart';
export 'presentation/cubit/auth_cubit.dart';
export 'domain/entities/user.dart';

// NOTE: auth_repository_impl.dart, auth_datasource.dart, auth_models.dart
// are NOT exported. They are implementation details private to the auth feature.

// shared/domain/entities/user.dart — shared across features
class AppUser {
  final String id;
  final String name;
  final String email;
  final String? avatarUrl;

  const AppUser({
    required this.id,
    required this.name,
    required this.email,
    this.avatarUrl,
  });
}

// core/di/injection.dart — features register their dependencies here
// but only through their public barrel API
final getIt = GetIt.instance;

Future<void> configureDependencies() async {
  // Core
  getIt.registerLazySingleton<DioClient>(() => DioClient());

  // Auth feature — only the public types are visible
  getIt.registerFactory<AuthCubit>(
    () => AuthCubit(repository: getIt<AuthRepository>()),
  );
}

// Cross-feature navigation — features call route names, not each other's widgets
// core/routing/app_router.dart
final appRouter = GoRouter(
  routes: [
    GoRoute(
      path: '/login',
      name: 'login',           // other features use appRouter.go('login')
      builder: (context, state) => const LoginPage(),
    ),
    GoRoute(
      path: '/lesson/:id',
      name: 'lessonDetail',
      builder: (context, state) => LessonDetailPage(
        lessonId: state.pathParameters['id']!,
      ),
    ),
  ],
);
```

## FAQ

### When should I split features into separate Dart packages?

Start with feature directories inside a single app package. Extract into packages (via Melos or similar) only when you have 20+ features and the boundaries are stable enough that package-level dependency enforcement is worth the build-complexity cost. Compiler-enforced boundaries are valuable but premature splitting slows iteration.

### How do features communicate without importing each other?

Three ways: shared abstract contracts in core/ or shared/ that both features depend on (implemented by one, called by the other through DI), named routes for navigation (features call route names, not each other's widgets), and sparingly, an event bus for truly decoupled notifications.

### What goes in core/ vs. shared/?

core/ holds framework-level concerns: the network client, DI container, router, theme, error handling. shared/ holds cross-feature domain concepts (like the User entity) and reusable UI components. The rule: core/ has no business logic and never imports from features/. shared/ may hold shared business entities but also never imports from features/.

---

# How to Build Offline-First Flutter Apps

An offline-first app does not just "work without internet" — it treats the local database as the single source of truth and syncs to the server as a background concern. Users read and write against local data with zero latency, and the sync engine reconciles changes when connectivity returns.

At iStoria, we serve 5M+ users across regions with unreliable connectivity. An offline-first architecture is not a luxury; it is the reason the app feels instant. We use PowerSync on top of Drift to handle the sync engine, but the patterns in this guide apply regardless of your sync technology.

This is a how-to/template guide — the specific pattern for building offline-first data flows. For the full story of why we chose this architecture and what it took to scale it at iStoria, read the offline-first sync case study.

## The Mental Model: Local Is Truth

The fundamental shift in offline-first architecture is this: **the local database is the source of truth, not the server.** Every read comes from the local database (instant, no network round-trip). Every write goes to the local database first (also instant), then a sync engine propagates it to the server asynchronously.

This is the opposite of the typical online-first pattern where the app calls the API, waits for the response, and caches the result. In that pattern, the cache is a performance optimization layered on top of network calls. In offline-first, the network is a sync concern layered on top of local operations.

## The Three Components

1. **Local database** — the source of truth. At iStoria, this is Drift (formerly Moor), a reactive SQLite wrapper for Flutter. Every read is a stream from the local database. Every write is a local insert/update/delete.

2. **Sync engine** — propagates local changes to the server and pulls server changes down to the local database. We use PowerSync, which provides a managed sync layer that handles the wire protocol, conflict resolution, and reconnection. You can build your own with a change log + polling/SSE, but that is a significant engineering effort.

3. **Connectivity awareness** — the UI needs to reflect sync status (synced, syncing, pending changes, conflict). The app needs to handle the transitions gracefully — queue writes when offline, resume sync when online, and show the user what is happening.

## Reading: Streams, Not Futures

In an offline-first app, reads are reactive streams, not one-shot futures. When you write to the local database, the stream emits a new value and the UI updates automatically. When the sync engine pulls changes from the server, the stream emits again. The UI does not need to know whether the change was local or remote — it just reacts to the stream.

This is why Drift's `watch()` method and PowerSync's reactive queries are so powerful. The UI subscribes to a query result, and the database notifies it whenever the result set changes. No manual refresh, no pull-to-refresh, no invalidation logic.

## Writing: Optimistic Local First

Writes follow an optimistic pattern:

1. Write to the local database immediately (the UI reflects the change via the stream).
2. The sync engine detects the local change and queues it for upload.
3. If online, the change is pushed to the server. If offline, it sits in the queue.
4. When connectivity returns, the queue is flushed.

If the server rejects the change (validation error, conflict), the sync engine triggers a conflict resolution callback. Your app decides what to do: last-write-wins, merge, or prompt the user. At iStoria, we use last-write-wins for most data and field-level merging for collaborative content.

## Conflict Resolution

Conflicts happen when the same record is modified on two devices before syncing. The three common strategies:

- **Last-write-wins (LWW):** The change with the latest timestamp wins. Simple, good enough for most data. Requires clocks to be roughly synchronized.
- **Field-level merge:** Non-overlapping field changes are merged. Requires a sync engine that understands field-level changes, not just whole-record overwrites.
- **Custom resolution:** For high-value conflicts, prompt the user or apply domain-specific logic.

Most apps use LWW for 95% of data and custom resolution for the handful of cases where it matters. Do not over-engineer conflict resolution until you have a real conflict problem.

## Sync Status in the UI

The user should always know the state of their data. We show four states:

- **Synced** — all local changes have been confirmed by the server.
- **Syncing** — changes are being uploaded or downloaded.
- **Pending** — local changes are queued but not yet synced (offline or in queue).
- **Error/Conflict** — the last sync failed and needs attention.

This is not just a nice-to-have. On an app used in low-connectivity regions, users *will* wonder whether their progress was saved. A clear sync indicator builds trust.

## Testing Offline Behavior

Test the offline/online transitions explicitly. In integration tests, toggle connectivity and verify that writes are queued, reads still work, and sync resumes correctly. The biggest bugs in offline-first apps happen at the transition points — going offline mid-sync, coming back online with a large queue, or handling partial sync failures.

## Recommended folder structure

```text
lib/
├── core/
│   ├── database/
│   │   ├── app_database.dart          # Drift database definition
│   │   └── tables/
│   │       ├── lessons.dart
│   │       └── sync_queue.dart        # pending local changes
│   ├── sync/
│   │   ├── sync_engine.dart           # PowerSync / custom sync coordinator
│   │   ├── connectivity_manager.dart
│   │   └── conflict_resolver.dart
│   └── error/
└── features/
    └── lesson/
        ├── domain/
        │   ├── entities/lesson.dart
        │   └── repositories/lesson_repository.dart
        ├── data/
        │   ├── datasources/
        │   │   └── lesson_local_datasource.dart   # Drift queries
        │   └── repositories/
        │       └── lesson_repository_impl.dart     # reads stream, writes local
        └── presentation/
            ├── cubit/
            │   ├── lesson_cubit.dart
            │   └── lesson_state.dart               # includes syncStatus
            └── widgets/
                └── sync_status_badge.dart
```

## Code example

```dart
// domain/repositories/lesson_repository.dart — offline-first contract
abstract class LessonRepository {
  /// Reactive stream from the LOCAL database — instant, no network wait.
  /// Updates automatically when sync pulls new data from the server.
  Stream<List<Lesson>> watchLessons(String courseId);

  /// Optimistic write: saves to local DB first, sync engine handles upload.
  Future<Either<Failure, Lesson>> markComplete(String lessonId);

  /// Forces a sync cycle (used by "refresh" gestures and connectivity restore).
  Future<Either<Failure, void>> syncNow();
}

// data/repositories/lesson_repository_impl.dart
class LessonRepositoryImpl implements LessonRepository {
  final LessonLocalDatasource localDatasource;
  final SyncEngine syncEngine;

  LessonRepositoryImpl({
    required this.localDatasource,
    required this.syncEngine,
  });

  @override
  Stream<List<Lesson>> watchLessons(String courseId) {
    // This stream emits from the local Drift database.
    // When syncEngine pulls server changes and writes them locally,
    // Drift's watch() fires and the UI gets the new data automatically.
    return localDatasource
        .watchLessonsByCourse(courseId)
        .map((rows) => rows.map(_toEntity).toList());
  }

  @override
  Future<Either<Failure, Lesson>> markComplete(String lessonId) async {
    try {
      // 1. Write to local DB immediately (optimistic — UI reacts via stream)
      await localDatasource.updateLessonStatus(
        lessonId,
        isCompleted: true,
        pendingSync: true,   // marks the row for the sync queue
      );

      // 2. Sync engine picks up pendingSync rows and uploads them.
      // If offline, they sit in the queue until connectivity returns.
      unawaited(syncEngine.enqueueUpload(LessonCompletedEvent(lessonId)));

      // 3. Return success — the user sees their change instantly
      final updated = await localDatasource.getLessonById(lessonId);
      return Right(_toEntity(updated));
    } on CacheException {
      return Left(CacheFailure());
    }
  }

  @override
  Future<Either<Failure, void>> syncNow() async {
    try {
      await syncEngine.forceSync();
      return const Right(null);
    } on SyncException catch (e) {
      return Left(SyncFailure(message: e.message));
    }
  }

  Lesson _toEntity(LessonEntry row) => Lesson(
        id: row.id,
        title: row.title,
        durationMinutes: row.durationMinutes,
        isCompleted: row.isCompleted,
        courseId: row.courseId,
      );
}

// presentation/cubit/lesson_state.dart — sync status is part of UI state
abstract class LessonState {}
class LessonInitial extends LessonState {}
class LessonLoading extends LessonState {}
class LessonLoaded extends LessonState {
  final List<Lesson> lessons;
  final SyncStatus syncStatus;    // synced | syncing | pending | error

  LessonLoaded({required this.lessons, required this.syncStatus});
}
```

## FAQ

### What is the difference between offline-first and local-first?

Offline-first means the app works without connectivity but still treats the server as the primary source of truth — the local database is a fast cache with sync. Local-first means the local database IS the primary source of truth permanently; the server is a sync peer, not an authority. Offline-first is more common in client-server apps; local-first is used in collaborative apps. See our offline-first vs. local-first article for the full breakdown.

### Do I need PowerSync, or can I build my own sync engine?

You can build your own with a change log table + polling or SSE, but it is significant engineering effort — conflict resolution, reconnection logic, partial sync recovery, and schema migrations all become your problem. PowerSync handles these for you. For a team of 4 engineers serving 5M users, a managed sync layer was the right call. Build your own only if you have a dedicated platform team.

### How do I handle conflicts in an offline-first app?

Start with last-write-wins (LWW) for most data — it is simple and handles 95% of cases. For fields where concurrent edits are common (collaborative content), use field-level merging or prompt the user. Design your conflict resolution per data type, not globally. And test conflict scenarios explicitly in integration tests.

---

# Flutter Repository Pattern: A Practical Guide

The repository pattern is the single most impactful structural pattern in a Flutter app. It is the boundary between your business logic and your data sources — the place where you decide 'where does this data come from?' Without it, business logic leaks into API calls, database queries, and widget trees. With it, you can swap your backend, add caching, or go offline-first without touching a line of business code.

At iStoria, every feature has at least one repository. The repository is the only thing the presentation layer (BLoC/Cubit) knows about when it needs data. Whether that data comes from the API, the local Drift database, or PowerSync's sync layer is the repository's business — nobody else's.

This guide shows the pattern as we use it in production: abstract contracts in the domain, concrete implementations in the data layer, and the cache/offline strategies that make a 5M-user app feel instant.

## What the Repository Pattern Actually Does

A repository is an abstraction over data access. It presents a collection-like interface to the business logic: `getLessons()`, `saveLesson()`, `watchCourse()`. Behind that interface, the repository decides:

- Does this come from the remote API or the local cache?
- Do I need to refresh the cache from the network?
- What happens if the network fails — do I return stale local data?
- How do I map between the wire format (JSON DTO) and the domain entity?

The business logic never asks these questions. It calls `repository.getLessons()` and gets a `List<Lesson>` back. The repository handles everything else.

## The Abstract Contract

The repository is defined as an abstract class (interface) in the domain layer. This is the contract the rest of the app depends on. The implementation lives in the data layer.

Why abstract? Because it lets you:

1. **Mock it in tests** — your BLoC unit tests pass a mock repository, no network needed.
2. **Swap implementations** — switch from API-first to offline-first by changing the implementation, not the call sites.
3. **Vary by environment** — use a different repository implementation in development (fake data) vs. production (real API + cache).

## Data Source Coordination

The repository's main job in a real app is coordinating multiple data sources. At iStoria, a typical repository has:

- **Remote data source** — the API client (Dio) that fetches and posts to the server.
- **Local data source** — the Drift database that caches data locally for offline access.
- **Network info** — a connectivity checker that tells the repository whether the network is available.

The repository decides the strategy. The three common patterns:

### Cache-First (Network with Fallback)
Read from the network. If it fails, read from the local cache. This is the default for most data — you want fresh data but degrade gracefully offline.

### Cache-Then-Network (Stale-While-Revalidate)
Return the local cache immediately (instant UI), then fetch from the network in the background and update the cache. The UI gets two emissions: stale data fast, then fresh data.

### Local-Only (Offline-First)
Read exclusively from the local database. The sync engine handles populating it from the network. This is what we use for data synced via PowerSync.

## Mapping: DTOs vs. Entities

The repository maps between the wire format and the domain. The API returns a `LessonModel` (DTO with `fromJson`). The repository converts it to a `Lesson` (domain entity). The BLoC never sees the DTO.

This separation matters because the API format is driven by the backend and changes independently of your domain model. If the backend renames a field, you change the mapper, not every BLoC that uses the entity.

## Error Handling

Repositories return `Either<Failure, T>` (or a Result type), not raw values or exceptions. This forces the calling code to handle both success and failure explicitly. The Failure type is part of the domain — it represents business-meaningful errors (ServerFailure, CacheFailure, NotFoundFailure), not infrastructure exceptions.

See our error handling guide for the full `Either<Failure, T>` pattern. The key point here: the repository catches data-source exceptions and translates them into domain failures. The BLoC never catches a `DioError` or a `DriftException` — those are data-layer concerns.

## When You Have Multiple Sources of Truth

In an offline-first app with sync, the repository gets interesting. The local database is the source of truth, and the sync engine keeps it updated. The repository reads exclusively from local and writes to local. The sync engine handles the server.

But not all data is synced. For non-synced data (config, feature flags, one-off API calls), the repository uses the cache-first or cache-then-network strategy. Having a clear repository-per-data-strategy makes the codebase predictable: if you know which repository you are calling, you know the data strategy.

## Recommended folder structure

```text
lib/features/lesson/
├── domain/
│   ├── entities/
│   │   └── lesson.dart                   # pure domain entity
│   └── repositories/
│       └── lesson_repository.dart         # ABSTRACT contract
├── data/
│   ├── datasources/
│   │   ├── lesson_remote_datasource.dart  # Dio API calls
│   │   └── lesson_local_datasource.dart   # Drift database queries
│   ├── models/
│   │   └── lesson_model.dart              # DTO: fromJson / toEntity
│   └── repositories/
│       └── lesson_repository_impl.dart     # CONCRETE implementation
```

## Code example

```dart
// domain/repositories/lesson_repository.dart — THE CONTRACT
abstract class LessonRepository {
  Future<Either<Failure, List<Lesson>>> getLessons(String courseId);
  Stream<List<Lesson>> watchLessons(String courseId);
  Future<Either<Failure, Lesson>> markComplete(String lessonId);
}

// data/models/lesson_model.dart — DTO (data transfer object)
class LessonModel extends Lesson {
  final String remoteId;

  const LessonModel({
    required this.remoteId,
    required super.id,
    required super.title,
    required super.durationMinutes,
    required super.isCompleted,
    required super.courseId,
  });

  factory LessonModel.fromJson(Map<String, dynamic> json) {
    return LessonModel(
      remoteId: json['id'] as String,
      id: json['uuid'] as String,
      title: json['title'] as String,
      durationMinutes: (json['duration_sec'] as num).toInt() ~/ 60,
      isCompleted: json['completed'] as bool? ?? false,
      courseId: json['course_id'] as String,
    );
  }

  Lesson toEntity() => Lesson(
        id: id,
        title: title,
        durationMinutes: durationMinutes,
        isCompleted: isCompleted,
        courseId: courseId,
      );
}

// data/repositories/lesson_repository_impl.dart — THE IMPLEMENTATION
class LessonRepositoryImpl implements LessonRepository {
  final LessonRemoteDatasource remoteDatasource;
  final LessonLocalDatasource localDatasource;
  final NetworkInfo networkInfo;

  LessonRepositoryImpl({
    required this.remoteDatasource,
    required this.localDatasource,
    required this.networkInfo,
  });

  @override
  Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
    if (await networkInfo.isConnected) {
      try {
        final jsonList = await remoteDatasource.fetchLessons(courseId);
        final models = jsonList.map(LessonModel.fromJson).toList();

        // Cache locally for offline access
        await localDatasource.cacheLessons(courseId, models);

        return Right(models.map((m) => m.toEntity()).toList());
      } on ServerException catch (e) {
        // Network failed — try the cache before giving up
        return _getFromCache(courseId, fallbackFailure: ServerFailure(e.message));
      }
    }
    // Offline — serve from cache
    return _getFromCache(courseId, fallbackFailure: const OfflineFailure());
  }

  Future<Either<Failure, List<Lesson>>> _getFromCache(
    String courseId, {
    required Failure fallbackFailure,
  }) async {
    try {
      final cached = await localDatasource.getCachedLessons(courseId);
      if (cached.isEmpty) return Left(fallbackFailure);
      return Right(cached.map((m) => m.toEntity()).toList());
    } on CacheException {
      return Left(fallbackFailure);
    }
  }

  @override
  Stream<List<Lesson>> watchLessons(String courseId) {
    // Reactive stream from local DB — UI updates when cache or sync changes it
    return localDatasource
        .watchCachedLessons(courseId)
        .map((models) => models.map((m) => m.toEntity()).toList());
  }

  @override
  Future<Either<Failure, Lesson>> markComplete(String lessonId) async {
    try {
      // Optimistic: update local first
      await localDatasource.markComplete(lessonId);
      // Then sync to server
      await remoteDatasource.postCompletion(lessonId);
      final updated = await localDatasource.getLesson(lessonId);
      return Right(updated.toEntity());
    } on ServerException catch (e) {
      return Left(ServerFailure(e.message));
    }
  }
}
```

## FAQ

### Do I need a repository if I'm just calling a simple API?

Yes, even for a simple API. The repository gives you a seam for testing (mock it), caching (add it later without changing call sites), and error handling (map exceptions to domain failures). A repository that just forwards to an API call today can gain caching or offline support tomorrow without its callers changing.

### Should the repository return Future or Stream?

Use Future for one-shot reads and writes. Use Stream for data that should reactively update in the UI (watch queries that emit when the underlying data changes). A repository can have both: getLessons() returns Future for the initial fetch, watchLessons() returns Stream for reactive updates from the local database.

### How does the repository pattern work with offline-first sync?

In an offline-first architecture, the repository reads exclusively from the local database and writes to it. The sync engine (PowerSync or custom) handles propagating changes to the server. The repository's job simplifies: it always talks to the local database. The sync strategy is handled by the sync engine, not the repository.

---

# Flutter Dependency Injection: Provider, get_it, injectable

Dependency injection (DI) is the plumbing that makes Clean Architecture work. Without it, your BLoCs new up their own repositories, your repositories new up their own HTTP clients, and before long every class is wired to concrete implementations that cannot be mocked or swapped.

Flutter has three main DI approaches: Provider (widget-tree based), get_it (service locator), and injectable (code generation on top of get_it). Each has trade-offs. At iStoria, we use get_it + injectable across our 50+ module codebase — it handles the scale without widget-tree coupling or manual registration.

This guide compares all three, shows when to use each, and provides a production wiring pattern with annotated factories, singletons, and environment-based registration.

## Why Dependency Injection Matters

Without DI, classes construct their own dependencies:

```dart
// BAD — tightly coupled, untestable
class LessonCubit extends Cubit<LessonState> {
  LessonCubit() : super(LessonInitial()) {
    // Cubit creates its own repository, which creates its own Dio...
    _repository = LessonRepositoryImpl(
      remoteDatasource: LessonRemoteDatasource(Dio()),
      localDatasource: LessonLocalDatasource(database),
      networkInfo: NetworkInfoImpl(connectivity),
    );
  }
}
```

This is impossible to test (you cannot swap the repository with a mock) and impossible to reconfigure (changing the Dio instance means editing every class). DI fixes this by inverting the control: dependencies are created externally and passed in.

## The Three Approaches

### Provider (Widget-Tree DI)

Provider uses `InheritedWidget` to make objects available down the widget tree. You register dependencies at the top of the tree and consume them with `context.read()` / `context.watch()`.

**Pros:** Built into Flutter (via the `provider` package). Simple for small apps. Scoped to the widget tree (dependencies are disposed when the subtree is disposed).

**Cons:** Everything is tied to `BuildContext`. You cannot access a repository from a non-widget context (a background service, a use case, a scheduled callback). At scale, the provider tree becomes deeply nested and hard to trace. Not suitable for Clean Architecture where the domain layer must not depend on Flutter.

**Use when:** The app is small (under ~10 screens), all dependencies are UI-scoped, and you do not need DI outside the widget tree.

### get_it (Service Locator)

get_it is a simple service locator. You register dependencies in a global container and retrieve them by type. No `BuildContext` needed.

**Pros:** Works anywhere — widgets, BLoCs, use cases, background tasks. Simple API (`getIt.get()` / `getIt.call()`). No code generation required. Decoupled from the widget tree.

**Cons:** Registration is manual — for a large app, the `configureDependencies()` function becomes long and brittle. Dependencies are not disposed automatically (you manage lifecycles). The service-locator pattern is sometimes considered an anti-pattern because it hides dependencies (a class can call `getIt.get()` internally rather than declaring its dependencies in the constructor).

**Use when:** You need DI outside the widget tree, want simplicity, and the app is medium-sized (manual registration is manageable).

### injectable (get_it + Code Generation)

injectable generates the get_it registration code from annotations. You annotate your classes with `@injectable`, `@singleton`, `@LazySingleton`, and injectable creates the wiring for you.

**Pros:** All the benefits of get_it, minus the manual registration. Adding a new dependency is just annotating the class and running build_runner. Supports environments (dev, prod, test) for different registrations. Scales effortlessly to 50+ modules.

**Cons:** Requires code generation (build_runner). Adds a build step to the development workflow. The generated code is verbose (but you never read it).

**Use when:** The app is large (10+ features), you want automatic registration, and the team is comfortable with code generation. This is what we use at iStoria.

## The Anti-Pattern to Avoid

Regardless of which DI tool you use, avoid this:

```dart
// BAD — hidden dependency
class LessonCubit extends Cubit<LessonState> {
  LessonCubit() : super(LessonInitial()) {
    // Don't call getIt inside the class — declare the dependency in the constructor
    final repo = getIt<LessonRepository>();
  }
}

// GOOD — explicit dependency
@injectable
class LessonCubit extends Cubit<LessonState> {
  final LessonRepository repository;

  LessonCubit(this.repository) : super(LessonInitial());
}
```

The constructor-injected version declares its dependency explicitly. injectable reads the constructor parameter and automatically resolves `LessonRepository` from the container. The class is testable (pass a mock) and its dependencies are visible at a glance.

## Environment-Based Registration

injectable supports environments — you can register different implementations for dev, prod, and test:

```dart
@LazySingleton(as: AuthRepository, env: ['dev'])
class MockAuthRepository implements AuthRepository { ... }

@LazySingleton(as: AuthRepository, env: ['prod'])
class AuthRepositoryImpl implements AuthRepository { ... }
```

At iStoria, we use this for feature flags (a local implementation in dev, a remote-config implementation in prod) and for analytics (a no-op in dev, a PostHog implementation in prod).

## Async Registration

Some dependencies require async initialization (a database that needs to open, a sync engine that needs to connect). get_it supports `registerLazySingletonAsync` and the `isReady` mechanism. Structure your app initialization so all async dependencies are resolved before the first route renders. A splash screen or an initialization gate handles this cleanly.

## Recommended folder structure

```text
lib/
├── main.dart
├── core/
│   ├── di/
│   │   ├── injection.dart              # getIt instance + configureDependencies()
│   │   ├── injection.config.dart        # GENERATED by injectable
│   │   └── modules/
│   │       ├── network_module.dart      # @module providing Dio, interceptors
│   │       ├── database_module.dart     # @module providing Drift database
│   │       └── external_module.dart      # @module for third-party clients
│   └── ...
└── features/
    ├── auth/
    │   ├── data/repositories/auth_repository_impl.dart    # @LazySingleton
    │   └── presentation/cubit/auth_cubit.dart              # @injectable
    └── lesson/
        ├── data/repositories/lesson_repository_impl.dart   # @LazySingleton
        └── presentation/cubit/lesson_cubit.dart            # @injectable
```

## Code example

```dart
// pubspec.yaml
// dependencies:
//   get_it: ^8.0.0
//   injectable: ^2.6.0
// dev_dependencies:
//   injectable_generator: ^2.6.0
//   build_runner: ^2.4.0

// Run: dart run build_runner build --delete-conflicting-outputs

// core/di/injection.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injection.config.dart';

final getIt = GetIt.instance;

@InjectableInit(preferRelativeImports: true)
Future<void> configureDependencies(String env) async {
  await getIt.init(environment: env);
}

// core/di/modules/network_module.dart — @module provides third-party deps
@module
abstract class NetworkModule {
  @lazySingleton
  Dio dio() {
    return Dio(BaseOptions(
      baseUrl: 'https://api.istoria.app/v2',
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 15),
    ))..interceptors.addAll([
        AuthInterceptor(getIt<AuthStorage>()),
        LogInterceptor(requestBody: true, responseBody: true),
      ]);
  }

  @lazySingleton
  Connectivity connectivity() => Connectivity();
}

// features/lesson/data/repositories/lesson_repository_impl.dart
@LazySingleton(as: LessonRepository)
class LessonRepositoryImpl implements LessonRepository {
  final LessonRemoteDatasource remoteDatasource;
  final LessonLocalDatasource localDatasource;
  final NetworkInfo networkInfo;

  // injectable reads these constructor params and resolves them from getIt
  const LessonRepositoryImpl(
    this.remoteDatasource,
    this.localDatasource,
    this.networkInfo,
  );

  @override
  Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
    // ... implementation
  }
}

// features/lesson/presentation/cubit/lesson_cubit.dart
@injectable
class LessonCubit extends Cubit<LessonState> {
  final LessonRepository repository;

  LessonCubit(this.repository) : super(LessonInitial());

  Future<void> loadLessons(String courseId) async {
    emit(LessonLoading());
    final result = await repository.getLessons(courseId);
    result.fold(
      (failure) => emit(LessonError(failure.message)),
      (lessons) => emit(LessonLoaded(lessons)),
    );
  }
}

// main.dart — wire everything up
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await configureDependencies(Environment.prod);
  runApp(MyApp());
}

// In a widget or anywhere in the app:
// final cubit = getIt<LessonCubit>();    // resolved with all dependencies
```

## FAQ

### Provider or get_it — which should I use?

Use Provider if all your dependencies are UI-scoped and you never need DI outside the widget tree. Use get_it if you need to access dependencies from BLoCs, background services, or use cases without a BuildContext. For Clean Architecture specifically, get_it is the better choice because the domain layer must not depend on Flutter widgets.

### Is injectable worth the code generation overhead?

For apps with 10+ features, yes. Manual get_it registration becomes a 200-line file that breaks when you rename a class or add a parameter. Injectable eliminates that by generating the wiring from annotations. The build_runner step adds a few seconds to development but saves hours of debugging registration mismatches.

### Should BLoCs be singletons or factories?

BLoCs should be factory-scoped (@injectable, not @singleton) in most cases. A new instance per access means the state is fresh when the user navigates to the feature. Use @singleton only for app-wide BLoCs (like an AuthBloc or ThemeBloc) whose state must persist across navigation. A common mistake is singleton-scoping feature BLoCs, which causes stale state when revisiting a screen.

---

# Flutter BLoC Architecture: Structuring Events, States & Logic

BLoC (Business Logic Component) is the state management pattern we use across iStoria's 50+ modules. It separates events (what happened), states (what the UI should show), and the bloc itself (the pure logic that transforms events into states). The result is testable state management that works for both simple and complex feature flows.

The BLoC ecosystem has two flavors: full BLoC (event-driven, using `EventTransformer`s and `on<Event>` handlers) and Cubit (simpler, function-based with `emit()`). At iStoria we use both — Cubit for features with straightforward state transitions, BLoC for features with complex event flows, debouncing, or analytics requirements.

This guide covers structuring events, states, and blocs the way a production team does it — not the minimal tutorial version, but the patterns that survive in a 5M-user codebase.

## BLoC vs. Cubit: When to Use Which

**Cubit** is simpler. You call methods on it (`loadLessons()`, `markComplete()`) and those methods call `emit()` to update the state. Cubit is the right choice for features where the state transitions are driven by direct user actions — "user tapped this, emit that."

**BLoC** is event-driven. The UI dispatches events (`LessonLoadRequested`, `LessonCompleted`), and the BLoC transforms those events into states via registered handlers. BLoC is the right choice when you need:

- **Event debouncing or deduplication** — e.g., search-as-you-type with a 300ms debounce.
- **Event transformers** — e.g., `restartable()` to cancel the previous search when a new character arrives.
- **Complex event-to-state flows** — one event triggers multiple states (loading → success), or multiple events interact.
- **Auditable event history** — the event stream is loggable for debugging and analytics.

The rule at iStoria: **start with Cubit, upgrade to BLoC when you need event-level control.** Most features never need to upgrade.

## Structuring States

The most important principle: **states should be complete snapshots of the UI.** A widget should be able to render from the state alone, without combining it with other data.

The common pattern is a sealed class (or abstract class with subclasses):

```dart
abstract class LessonState {}
class LessonInitial extends LessonState {}
class LessonLoading extends LessonState {}
class LessonLoaded extends LessonState {
  final List<Lesson> lessons;
  final bool hasReachedMax;
  LessonLoaded({required this.lessons, this.hasReachedMax = false});
}
class LessonError extends LessonState {
  final String message;
  LessonError(this.message);
}
```

Avoid mutable state inside a state class. Every state is immutable. Transitions produce new state objects, not mutations of existing ones.

### The "Loading Inside Loaded" Pattern

A common mistake is representing every loading state as `LessonLoading` — which wipes the list when you pull-to-refresh. Instead, keep the loaded data and add a `isRefreshing` flag:

```dart
class LessonLoaded extends LessonState {
  final List<Lesson> lessons;
  final bool isRefreshing;   // pull-to-refresh shows a spinner without clearing the list
  final bool isPaginating;   // infinite scroll shows a bottom spinner
}
```

This is the difference between an app that flickers on every refresh and one that feels smooth.

## Structuring Events

Events should describe **what happened**, not **what to do**. Good event names are past-tense or noun phrases: `LessonLoadRequested`, `LessonCompleted`, `SearchQueryChanged`. Bad names: `LoadLessons`, `DoSomething`.

Events carry the data the BLoC needs to process them:

```dart
class SearchQueryChanged extends LessonEvent {
  final String query;
  SearchQueryChanged(this.query);
}
```

Do not put UI concerns in events (no `BuildContext`, no `TextEditingController`). Events are pure data.

## Event Transformers

Event transformers control how incoming events are processed relative to each other. The most useful ones from `package:bloc_concurrency`:

- **`restartable()`** — cancels the previous handler when a new event arrives. Use for search queries: typing a new character cancels the old search.
- **`droppable()`** — ignores new events while the handler is running. Use for submit buttons: prevent double-submission.
- **`sequential()`** — processes events one at a time in order. The default.
- **`concurrent()`** — processes all events concurrently.

```dart
on<SearchQueryChanged>(_onSearchChanged, transformer: restartable());
on<SubmitPressed>(_onSubmit, transformer: droppable());
```

These transformers prevent an entire class of race-condition bugs. At iStoria, every search input uses `restartable()` and every submit action uses `droppable()`.

## BLoC-to-BLoC Communication

BLoCs should not import each other directly. For cross-feature communication:

1. **Listener Bloc** — one BLoC subscribes to another's stream and reacts. Used when feature A needs to respond to state changes in feature B.
2. **Repository as mediator** — both BLoCs depend on a shared repository. When one writes, the other reads reactively. This is our preferred pattern at iStoria — it keeps BLoCs decoupled.
3. **Event-based** — one BLoC dispatches an event to another through a shared event bus. Use sparingly; it makes the flow hard to trace.

## Testing BLoCs

BLoC testing is one of the pattern's biggest wins. The `bloc_test` package lets you assert on state sequences:

```dart
blocTest<LessonCubit, LessonState>(
  'emits [Loading, Loaded] when loadLessons succeeds',
  build: () {
    when(() => mockRepository.getLessons(any()))
        .thenAnswer((_) async => const Right([testLesson]));
    return LessonCubit(mockRepository);
  },
  act: (cubit) => cubit.loadLessons('course-1'),
  expect: () => [isA<LessonLoading>(), isA<LessonLoaded>()],
);
```

This test runs in milliseconds, needs no device, and verifies the exact state sequence. This is why we invest in BLoC — the testability pays for the boilerplate.

## Recommended folder structure

```text
lib/features/lesson/presentation/
├── bloc/
│   ├── lesson_bloc.dart         # BLoC: event handlers, transformers
│   ├── lesson_event.dart         # Events: what happened (past tense)
│   └── lesson_state.dart         # States: complete UI snapshots
├── cubit/                        # Use Cubit instead when the flow is simple
│   ├── lesson_cubit.dart
│   └── lesson_state.dart
├── pages/
│   └── lesson_page.dart          # BlocProvider + UI
└── widgets/
    ├── lesson_list.dart
    └── lesson_error_view.dart
```

## Code example

```dart
// lesson_event.dart — EVENTS describe what happened
abstract class LessonEvent {}
class LessonLoadRequested extends LessonEvent {
  final String courseId;
  LessonLoadRequested(this.courseId);
}
class LessonRefreshRequested extends LessonEvent {
  final String courseId;
  LessonRefreshRequested(this.courseId);
}
class LessonCompleted extends LessonEvent {
  final String lessonId;
  LessonCompleted(this.lessonId);
}
class LessonSearchChanged extends LessonEvent {
  final String query;
  LessonSearchChanged(this.query);
}

// lesson_state.dart — STATES are complete, immutable UI snapshots
abstract class LessonState {}
class LessonInitial extends LessonState {}
class LessonLoading extends LessonState {}
class LessonLoaded extends LessonState {
  final List<Lesson> lessons;
  final bool isRefreshing;
  final bool isPaginating;
  final bool hasReachedMax;

  const LessonLoaded({
    required this.lessons,
    this.isRefreshing = false,
    this.isPaginating = false,
    this.hasReachedMax = false,
  });

  LessonLoaded copyWith({
    List<Lesson>? lessons,
    bool? isRefreshing,
    bool? isPaginating,
    bool? hasReachedMax,
  }) {
    return LessonLoaded(
      lessons: lessons ?? this.lessons,
      isRefreshing: isRefreshing ?? this.isRefreshing,
      isPaginating: isPaginating ?? this.isPaginating,
      hasReachedMax: hasReachedMax ?? this.hasReachedMax,
    );
  }
}
class LessonError extends LessonState {
  final String message;
  final List<Lesson>? lastKnownLessons; // keep old data visible on error
  LessonError(this.message, {this.lastKnownLessons});
}

// lesson_bloc.dart — THE BLOC: pure logic, event → state
class LessonBloc extends Bloc<LessonEvent, LessonState> {
  final LessonRepository repository;

  LessonBloc(this.repository) : super(LessonInitial()) {
    on<LessonLoadRequested>(_onLoadRequested);
    on<LessonRefreshRequested>(_onRefreshRequested);
    on<LessonCompleted>(_onCompleted);
    on<LessonSearchChanged>(_onSearchChanged, transformer: restartable());
  }

  Future<void> _onLoadRequested(
    LessonLoadRequested event,
    Emitter<LessonState> emit,
  ) async {
    emit(LessonLoading());
    final result = await repository.getLessons(event.courseId);
    result.fold(
      (failure) => emit(LessonError(failure.message)),
      (lessons) => emit(LessonLoaded(lessons: lessons)),
    );
  }

  Future<void> _onRefreshRequested(
    LessonRefreshRequested event,
    Emitter<LessonState> emit,
  ) async {
    // Keep the existing data visible — just show the refresh indicator
    final currentLessons = _currentLessons;
    emit(LessonLoaded(lessons: currentLessons, isRefreshing: true));

    final result = await repository.getLessons(event.courseId);
    result.fold(
      (failure) => emit(LessonError(failure.message, lastKnownLessons: currentLessons)),
      (lessons) => emit(LessonLoaded(lessons: lessons)),
    );
  }

  Future<void> _onCompleted(
    LessonCompleted event,
    Emitter<LessonState> emit,
  ) async {
    final result = await repository.markComplete(event.lessonId);
    result.fold(
      (failure) => emit(LessonError(failure.message, lastKnownLessons: _currentLessons)),
      (_) {
        // Optimistic: update the local list immediately
        if (state is LessonLoaded) {
          final updated = _currentLessons.map((l) {
            return l.id == event.lessonId ? l.copyWith(isCompleted: true) : l;
          }).toList();
          emit((state as LessonLoaded).copyWith(lessons: updated));
        }
      },
    );
  }

  Future<void> _onSearchChanged(
    LessonSearchChanged event,
    Emitter<LessonState> emit,
  ) async {
    if (event.query.isEmpty) return; // restartable() cancels stale searches
    final result = await repository.searchLessons(event.query);
    result.fold(
      (failure) => emit(LessonError(failure.message)),
      (lessons) => emit(LessonLoaded(lessons: lessons)),
    );
  }

  List<Lesson> get _currentLessons =>
      state is LessonLoaded ? (state as LessonLoaded).lessons : [];
}

// TEST — bloc_test verifies exact state sequences in milliseconds
void main() {
  late LessonRepository mockRepository;
  late LessonBloc bloc;

  setUp(() {
    mockRepository = MockLessonRepository();
    bloc = LessonBloc(mockRepository);
  });

  blocTest<LessonBloc, LessonState>(
    'emits [Loading, Loaded] on successful load',
    build: () {
      when(() => mockRepository.getLessons('c1'))
          .thenAnswer((_) async => Right([testLesson]));
      return bloc;
    },
    act: (b) => b.add(LessonLoadRequested('c1')),
    wait: const Duration(milliseconds: 100),
    expect: () => [
      isA<LessonLoading>(),
      isA<LessonLoaded>().having((s) => s.lessons.length, 'lessons', 1),
    ],
  );
}
```

## FAQ

### Should I use BLoC or Cubit for my Flutter app?

Start with Cubit for every feature. It is simpler (function calls + emit) and sufficient for most state transitions. Upgrade to full BLoC only when you need event transformers (debouncing, restartable search), complex event-to-state flows, or auditable event history. At iStoria, about 70% of features use Cubit and 30% use full BLoC.

### How do I prevent double-submission with BLoC?

Use the droppable() event transformer from package:bloc_concurrency on your submit event handler. It ignores new events while the handler is already running. This is a one-line fix that prevents an entire class of duplicate-submission bugs.

### How do I keep old data visible during an error?

Store the last known data in your error state. Instead of a bare LessonError, include lastKnownLessons so the UI can show the old list with an error banner. Alternatively, keep the Loaded state and add an error message field — both patterns work, but never let an error blank out the screen.

---

# Flutter Error Handling: Either<Failure, T> and Beyond

Error handling in Flutter has two layers: the domain layer (recoverable failures your code handles gracefully) and the platform layer (unexpected crashes you cannot recover from but must report). Mixing them up — catching everything in one big try/catch or throwing exceptions across layers — leads to bugs that are hard to trace and crashes that go unreported.

At iStoria, we maintain a 99.9% crash-free rate across 5M+ users. The pattern that makes this possible is `Either<Failure, T>` for recoverable errors and structured crash reporting (Sentry) for everything else. Each layer has a clear responsibility: repositories map exceptions to domain Failures, BLoCs fold over Either, and the platform layer catches the unexpected.

This guide covers the full pattern — from defining Failure types to wiring up async error zones and crash reporting — as we use it in production.

## Two Kinds of Errors

**Recoverable errors** are part of your business domain. The network went down, the cache was empty, the user entered invalid data. These are expected, the app handles them, and the user sees a friendly message. These should be `Either<Failure, T>`, not exceptions.

**Unrecoverable errors** are bugs. A null where there should not be one, an index out of bounds, a state that should never occur. The app cannot handle these gracefully. These should crash (or be caught by an error zone) and be reported to Sentry so you can fix them.

The mistake most teams make is treating all errors as the same kind. They catch every exception in a giant try/catch, suppressing bugs and hiding real crashes. Or they throw exceptions across layers, making the code path unpredictable and untestable.

## The Either<Failure, T> Pattern

`Either<L, R>` from the `fpdart` (or `dartz`) package represents a value that is one of two types. By convention, `Left` holds the failure and `Right` holds the success value.

```dart
// A repository method returns Either<Failure, T>
Future<Either<Failure, List<Lesson>>> getLessons(String courseId);

// The caller MUST handle both cases explicitly:
final result = await repository.getLessons(courseId);
result.fold(
  (failure) => emit(LessonError(failure.message)),
  (lessons) => emit(LessonLoaded(lessons)),
);
```

The power of this pattern is that **you cannot forget to handle the failure case.** With exceptions, a caller can forget the try/catch and the exception propagates unpredictably. With Either, the type system forces you to handle both branches.

## Defining Failures

Failures are part of the domain. They represent business-meaningful error states:

```dart
abstract class Failure {
  final String message;
  const Failure(this.message);
}

class ServerFailure extends Failure {
  final int? statusCode;
  const ServerFailure([super.message = 'Server error', this.statusCode]);
}

class CacheFailure extends Failure {
  const CacheFailure([super.message = 'Cache error']);
}

class OfflineFailure extends Failure {
  const OfflineFailure() : super('You are offline. Please check your connection.');
}

class ValidationFailure extends Failure {
  final Map<String, String> fieldErrors;
  const ValidationFailure(this.fieldErrors) : super('Validation failed');
}
```

The key principle: **Failures are domain concepts, not infrastructure exceptions.** `DioError`, `DriftException`, `SocketException` — these are data-layer concerns. The repository catches them and maps them to domain Failures. The BLoC never sees a `DioError`.

## Where Exceptions Still Belong

Not everything should be an Either. Some operations are genuinely exception-based:

1. **Programming errors** — null dereference, index out of bounds, assertion failures. These should crash and be reported, not be wrapped in Either.
2. **Third-party SDKs** — some packages throw exceptions. Wrap their calls in your repository and map exceptions to Failures.
3. **Truly unexpected states** — if a repository method receives a response that violates an invariant, throw. This is a bug, not a recoverable error.

## The Repository: Mapping Exceptions to Failures

The repository is the translation point. It catches infrastructure exceptions and converts them to domain Failures:

```dart
@override
Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
  if (!await networkInfo.isConnected) {
    return const Left(OfflineFailure());
  }
  try {
    final response = await remoteDatasource.fetchLessons(courseId);
    return Right(response.map((m) => m.toEntity()).toList());
  } on DioError catch (e) {
    return Left(ServerFailure(e.message ?? 'Unknown error', e.response?.statusCode));
  } on CacheException catch (e) {
    return Left(CacheFailure(e.message));
  } catch (e) {
    // Unexpected — report to Sentry, then return a generic failure
    await Sentry.captureException(e);
    return Left(UnexpectedFailure(e.toString()));
  }
}
```

The last `catch` block is important. It catches truly unexpected exceptions, reports them, and returns a failure. This prevents the app from crashing on unexpected exceptions while still surfacing them in Sentry.

## Async Error Zones and Crash Reporting

Flutter has error zones for isolating async failures. The two zones you need to know:

1. **`runZonedGuarded`** — wraps your entire app and catches uncaught async errors. This is where you configure Sentry to report crashes.
2. **`FlutterError.onError`** — catches Flutter framework errors (rendering, layout, widget build failures).

In `main()`:

```dart
void main() {
  runZonedGuarded(() {
    WidgetsFlutterBinding.ensureInitialized();
    FlutterError.onError = (details) {
      FlutterError.presentError(details);
      Sentry.captureException(details.exception, stackTrace: details.stack);
    };
    runApp(MyApp());
  }, (error, stack) {
    Sentry.captureException(error, stackTrace: stack);
  });
}
```

This ensures that *every* uncaught error — sync or async, framework or Dart — reaches Sentry. Combined with `Either<Failure, T>` for recoverable errors, this gives you a comprehensive error strategy: expected failures are handled in the UI, unexpected crashes are reported.

## Crash-Free Rate as a Metric

At iStoria, we track crash-free rate as a first-class metric. The target is 99.9% — meaning fewer than 0.1% of sessions crash. Achieving this requires:

- `Either<Failure, T>` on every repository method so recoverable errors never crash the app.
- Sentry on every uncaught exception so you know about crashes immediately.
- A triage workflow: every Sentry issue is reviewed within 24 hours and either fixed or annotated with context.
- Defensive programming in the presentation layer: never assume a list is non-empty, never assume a response field is non-null.

## Recommended folder structure

```text
lib/
├── core/
│   ├── error/
│   │   ├── failures.dart            # domain Failure hierarchy
│   │   └── exceptions.dart          # data-layer exception types
│   └── crash/
│       └── crash_reporter.dart       # Sentry wrapper, initialized in main()
├── features/
│   └── lesson/
│       ├── data/repositories/
│       │   └── lesson_repository_impl.dart   # catches exceptions → maps to Failures
│       └── presentation/
│           └── cubit/lesson_cubit.dart        # folds Either → emits states
└── main.dart                          # runZonedGuarded + FlutterError.onError
```

## Code example

```dart
// core/error/failures.dart — DOMAIN FAILURES
sealed class Failure {
  final String message;
  const Failure(this.message);
}

class ServerFailure extends Failure {
  final int? statusCode;
  const ServerFailure([super.message = 'Server error', this.statusCode]);
}

class CacheFailure extends Failure {
  const CacheFailure([super.message = 'Cache error']);
}

class OfflineFailure extends Failure {
  const OfflineFailure() : super('You appear to be offline.');
}

class ValidationFailure extends Failure {
  final Map<String, String> fieldErrors;
  const ValidationFailure(this.fieldErrors) : super('Validation failed');
}

class UnauthorizedFailure extends Failure {
  const UnauthorizedFailure() : super('Your session has expired.');
}

class UnexpectedFailure extends Failure {
  const UnexpectedFailure(String detail) : super('Something went wrong: $detail');
}

// core/error/exceptions.dart — DATA-LAYER EXCEPTIONS (never cross into domain)
class ServerException implements Exception {
  final String message;
  final int? statusCode;
  ServerException(this.message, [this.statusCode]);
}
class CacheException implements Exception {
  final String message;
  CacheException(this.message);
}

// data/repositories/lesson_repository_impl.dart — MAPS exceptions → Failures
class LessonRepositoryImpl implements LessonRepository {
  final LessonRemoteDatasource remote;
  final LessonLocalDatasource local;
  final NetworkInfo networkInfo;

  LessonRepositoryImpl({
    required this.remote,
    required this.local,
    required this.networkInfo,
  });

  @override
  Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
    if (!await networkInfo.isConnected) {
      // Expected: offline — try cache
      return _getFromCacheOrFailure(courseId, const OfflineFailure());
    }
    try {
      final remoteLessons = await remote.fetchLessons(courseId);
      await local.cacheLessons(courseId, remoteLessons);
      return Right(remoteLessons.map((m) => m.toEntity()).toList());
    } on ServerException catch (e) {
      // Server failed — try cache before giving up
      return _getFromCacheOrFailure(
        courseId,
        ServerFailure(e.message, e.statusCode),
      );
    } catch (e, stack) {
      // Truly unexpected — report it, return a safe failure
      await Sentry.captureException(e, stackTrace: stack);
      return Left(UnexpectedFailure(e.toString()));
    }
  }

  Future<Either<Failure, List<Lesson>>> _getFromCacheOrFailure(
    String courseId,
    Failure fallback,
  ) async {
    try {
      final cached = await local.getCachedLessons(courseId);
      if (cached.isEmpty) return Left(fallback);
      return Right(cached.map((m) => m.toEntity()).toList());
    } on CacheException {
      return Left(fallback);
    }
  }
}

// presentation/cubit/lesson_cubit.dart — FOLDS Either → emits states
class LessonCubit extends Cubit<LessonState> {
  final LessonRepository repository;
  LessonCubit(this.repository) : super(LessonInitial());

  Future<void> loadLessons(String courseId) async {
    emit(LessonLoading());
    final result = await repository.getLessons(courseId);
    emit(
      result.fold(
        (failure) => _mapFailureToState(failure),
        (lessons) => LessonLoaded(lessons: lessons),
      ),
    );
  }

  LessonState _mapFailureToState(Failure failure) {
    return switch (failure) {
      OfflineFailure() => LessonError(
          failure.message,
          action: ErrorAction.retry,
        ),
      UnauthorizedFailure() => LessonError(
          failure.message,
          action: ErrorAction.login,
        ),
      ServerFailure(:final statusCode) when statusCode == 404 =>
        LessonError('Lessons not found.', action: ErrorAction.none),
      ServerFailure() => LessonError(
          failure.message,
          action: ErrorAction.retry,
        ),
      _ => LessonError(failure.message),
    };
  }
}

// main.dart — ZONE GUARD + CRASH REPORTING
void main() {
  runZonedGuarded(() {
    WidgetsFlutterBinding.ensureInitialized();
    FlutterError.onError = (details) {
      FlutterError.presentError(details);
      Sentry.captureException(details.exception, stackTrace: details.stack);
    };
    runApp(MyApp());
  }, (error, stack) {
    // Catches uncaught async errors that slip past try/catch
    Sentry.captureException(error, stackTrace: stack);
  });
}
```

## FAQ

### Why Either<Failure, T> instead of try/catch?

Either forces the caller to handle the error case at the type level — the compiler will not let you forget. With try/catch, a caller can forget the catch block and the exception propagates unpredictably. Either makes error handling explicit and part of the function signature, which is especially valuable across module boundaries.

### Should I use fpdart or dartz for Either?

Both work. fpdart is more actively maintained and has better null-safety support. dartz is older and has some null-safety quirks. At iStoria we use fpdart. If you do not want a dependency, you can define your own Result<T> sealed class with Success and Failure variants — it is 20 lines of code and gives you the same pattern without a package.

### How do I report crashes that Either does not catch?

Wrap your app in runZonedGuarded in main() and set FlutterError.onError. These catch uncaught async errors and Flutter framework errors respectively. Route both to Sentry (or your crash reporter). This covers the 'unexpected' category of errors that should not happen but do — bugs that Either is not designed for.

---

# Flutter Testing Strategy: Unit, Widget & Integration Tests

Testing in Flutter follows a pyramid: lots of fast unit tests, a moderate number of widget tests, and a few slow integration tests. The mistake most teams make is inverting the pyramid — writing few unit tests and relying on slow integration tests that are brittle, flaky, and give feedback minutes after a change.

At iStoria, our 50+ module codebase has thousands of tests running in CI on every pull request. The strategy is simple: domain logic is unit-tested to the extreme, presentation is widget-tested with mocked dependencies, and only critical user journeys get integration tests. This keeps the suite fast (under 3 minutes) and trustworthy (no flaky tests).

This guide covers what to test at each layer, the patterns we use, and how to keep a large test suite maintainable.

## The Test Pyramid

The test pyramid has three levels, from most to fewest:

1. **Unit tests (70%)** — test individual classes in isolation. Domain entities, use cases, repositories (with mocked data sources), mappers, and BLoC/Cubit state transitions. These are pure Dart tests that run in milliseconds.

2. **Widget tests (20%)** — test individual widgets or small widget trees with mocked dependencies. You pump a widget, interact with it (tap, scroll, enter text), and assert on what it renders. These run in seconds.

3. **Integration tests (10%)** — test full user flows end-to-end on a simulated device. The app runs with real (or near-real) dependencies and the test drives it like a user. These are slow (minutes) and should be reserved for critical journeys (login, checkout, lesson completion).

The pyramid ratio is not arbitrary. Unit tests are fast, deterministic, and pinpoint exactly what broke. Integration tests are slow, can be flaky, and when they fail you do not know which layer broke. Invest in the base of the pyramid.

## Layer 1: Unit Testing the Domain

The domain layer is the easiest to test because it has no dependencies. Entities, value objects, use cases — all pure Dart, no mocking needed.

For use cases that depend on repository contracts, pass mock repositories (via `mocktail` or handwritten fakes). The test verifies that the use case calls the right methods, in the right order, with the right arguments, and maps the result correctly.

```dart
test('GetLessons returns Right(lessons) when repository succeeds', () async {
  when(() => mockRepo.getLessons('c1'))
      .thenAnswer((_) async => Right([testLesson]));
  final result = await usecase('c1');
  expect(result.isRight(), true);
});
```

These tests are the foundation. They run in under 1ms each and give you confidence that the business logic is correct regardless of the UI or data layer.

## Layer 2: Unit Testing Repositories

Repository tests verify the mapping from data-source exceptions to domain Failures. The repository is tested with mocked data sources — you simulate a server error, a cache miss, an offline state, and verify the repository returns the correct `Either<Failure, T>`.

This is where the `Either<Failure, T>` pattern pays off. Each test asserts on the exact Failure type returned, which documents the repository's error contract:

```dart
test('returns OfflineFailure when network is disconnected', () async {
  when(() => networkInfo.isConnected).thenAnswer((_) async => false);
  final result = await repository.getLessons('c1');
  expect(result.getLeft().toOption().toNullable(), isA<OfflineFailure>());
});
```

## Layer 3: BLoC/Cubit Testing

BLoC tests verify state sequences — given an event, the BLoC emits the expected states in order. The `bloc_test` package makes this declarative:

```dart
blocTest<LessonBloc, LessonState>(
  'emits [Loading, Loaded] on success',
  build: () {
    when(() => repo.getLessons(any()))
        .thenAnswer((_) async => Right([testLesson]));
    return LessonBloc(repo);
  },
  act: (b) => b.add(LessonLoadRequested('c1')),
  expect: () => [isA<LessonLoading>(), isA<LessonLoaded>()],
);
```

Every BLoC should have a test for every event handler, covering success, failure, and edge cases (empty list, pagination, concurrent events). These tests are fast and catch the majority of state-management bugs before they reach a device.

## Layer 4: Widget Testing

Widget tests verify that a widget renders correctly and responds to interaction. The widget is pumped with mocked BLoCs (via `BlocProvider` with a mock or a seeded BLoC):

```dart
testWidgets('renders lesson titles', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: BlocProvider.value(
        value: seededLessonBloc(LessonLoaded(lessons: [testLesson])),
        child: const LessonPage(),
      ),
    ),
  );
  expect(find.text('Introduction to Flutter'), findsOneWidget);
});
```

Widget tests should test behavior, not implementation: "when I tap this button, this widget appears" — not "when I tap this button, this method is called." Implementation-coupled tests break on refactors and provide false confidence.

## Layer 5: Golden Tests

Golden tests (also called snapshot tests) capture a rendered widget as an image and compare future renders against it. They are excellent for catching unintended UI changes — a padding change, a color regression, a layout shift.

```dart
testWidgets('lesson card matches golden', (tester) async {
  await tester.pumpWidget(wrapWithMaterial(LessonCard(lesson: testLesson)));
  await expectLater(find.byType(LessonCard), matchesGoldenFile('lesson_card.png'));
});
```

At iStoria, we use golden tests for reusable components (cards, buttons, list items) but not for full pages (too brittle). When the design changes intentionally, regenerate the goldens with `--update-goldens`.

## Layer 6: Integration Tests

Integration tests (`integration_test/` package) run the real app on a device or simulator and drive it like a user. They are reserved for critical user journeys:

- Login and authentication flow
- Completing a lesson (core user value)
- Offline-to-online sync transition
- Payment / subscription flow

Keep integration tests few and focused. Each one adds minutes to CI. Ten integration tests covering the critical paths are more valuable than fifty covering every edge case (those belong in unit/widget tests).

## CI Strategy

In CI, split the test runs:

1. **Unit + Widget tests** — run on every pull request. Should complete in under 3 minutes.
2. **Integration tests** — run on merge to main or nightly. These are slower and do not need to block every PR.
3. **Golden tests** — run on every PR but do not block (report differences for review). Block only on main.

Use `--coverage` and track coverage trends, but do not obsess over a coverage number. 80% coverage with meaningful tests is better than 100% coverage with trivial assertion-free tests. The goal is confidence, not a metric.

## Recommended folder structure

```text
test/                              # mirrors lib/ structure
├── core/
│   ├── error/
│   │   └── failures_test.dart
│   └── utils/
│       └── input_validator_test.dart
├── features/
│   └── lesson/
│       ├── domain/
│       │   ├── usecases/
│       │   │   └── get_lessons_test.dart
│       │   └── entities/
│       │       └── lesson_test.dart
│       ├── data/
│       │   ├── repositories/
│       │   │   └── lesson_repository_impl_test.dart
│       │   └── models/
│       │       └── lesson_model_test.dart        # mapper tests
│       └── presentation/
│           ├── bloc/
│           │   └── lesson_bloc_test.dart          # bloc_test
│           └── widgets/
│               ├── lesson_card_test.dart           # widget test
│               └── lesson_card_test.png            # golden image
└── helpers/
    ├── test_lesson.dart                           # shared fixture factory
    ├── mock_repository.dart                       # MockTail setup
    └── widget_test_helpers.dart                    # pumpWidget wrappers

integration_test/
├── auth_flow_test.dart
├── lesson_completion_test.dart
└── offline_sync_test.dart
```

## Code example

```dart
// test/helpers/mock_repository.dart — shared mock setup
import 'package:mocktail/mocktail.dart';
import 'package:flutter_test/flutter_test.dart';

class MockLessonRepository extends Mock implements LessonRepository {}
class MockNetworkInfo extends Mock implements NetworkInfo {}

MockLessonRepository setupMockLessonRepository() {
  final repo = MockLessonRepository();
  registerFallbackValue('test-course-id');
  return repo;
}

// test/features/lesson/data/repositories/lesson_repository_impl_test.dart
class MockRemote extends Mock implements LessonRemoteDatasource {}
class MockLocal extends Mock implements LessonLocalDatasource {}

void main() {
  late MockRemote remote;
  late MockLocal local;
  late MockNetworkInfo networkInfo;
  late LessonRepositoryImpl repository;

  setUp(() {
    remote = MockRemote();
    local = MockLocal();
    networkInfo = MockNetworkInfo();
    repository = LessonRepositoryImpl(
      remoteDatasource: remote,
      localDatasource: local,
      networkInfo: networkInfo,
    );
  });

  group('getLessons', () {
    test('returns Right(lessons) when remote succeeds', () async {
      when(() => networkInfo.isConnected).thenAnswer((_) async => true);
      when(() => remote.fetchLessons('c1'))
          .thenAnswer((_) async => [lessonModelFixture()]);

      final result = await repository.getLessons('c1');

      expect(result.isRight(), true);
      verify(() => local.cacheLessons('c1', any())).called(1);
    });

    test('returns OfflineFailure when disconnected and cache is empty',
        () async {
      when(() => networkInfo.isConnected).thenAnswer((_) async => false);
      when(() => local.getCachedLessons('c1'))
          .thenThrow(CacheException('empty'));

      final result = await repository.getLessons('c1');

      expect(result.getLeft().toOption().toNullable(), isA<OfflineFailure>());
    });

    test('falls back to cache when server throws ServerException', () async {
      when(() => networkInfo.isConnected).thenAnswer((_) async => true);
      when(() => remote.fetchLessons('c1'))
          .thenThrow(ServerException('500', 500));
      when(() => local.getCachedLessons('c1'))
          .thenAnswer((_) async => [lessonModelFixture()]);

      final result = await repository.getLessons('c1');

      expect(result.isRight(), true);
    });
  });
}

// test/features/lesson/presentation/bloc/lesson_bloc_test.dart
void main() {
  late MockLessonRepository repository;
  late LessonBloc bloc;

  setUp(() {
    repository = MockLessonRepository();
    bloc = LessonBloc(repository);
  });

  blocTest<LessonBloc, LessonState>(
    'emits [Loading, Loaded] on successful load',
    build: () {
      when(() => repository.getLessons(any()))
          .thenAnswer((_) async => Right([lessonFixture()]));
      return bloc;
    },
    act: (b) => b.add(LessonLoadRequested('c1')),
    wait: const Duration(milliseconds: 100),
    expect: () => [
      isA<LessonLoading>(),
      isA<LessonLoaded>()
          .having((s) => s.lessons.length, 'lesson count', 1),
    ],
  );

  blocTest<LessonBloc, LessonState>(
    'emits [Loading, Error] on failure',
    build: () {
      when(() => repository.getLessons(any()))
          .thenAnswer((_) async => const Left(ServerFailure('down')));
      return bloc;
    },
    act: (b) => b.add(LessonLoadRequested('c1')),
    wait: const Duration(milliseconds: 100),
    expect: () => [
      isA<LessonLoading>(),
      isA<LessonError>().having((s) => s.message, 'error', 'down'),
    ],
  );
}

// integration_test/lesson_completion_test.dart — full device test
void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('user can complete a lesson end-to-end', (tester) async {
    app.main(); // launches the real app
    await tester.pumpAndSettle();

    // Navigate to a course
    await tester.tap(find.text('Flutter Basics'));
    await tester.pumpAndSettle();

    // Tap a lesson
    await tester.tap(find.text('Introduction'));
    await tester.pumpAndSettle();

    // Mark complete
    await tester.tap(find.byKey(const Key('mark-complete-btn')));
    await tester.pumpAndSettle();

    // Verify the completion indicator appears
    expect(find.byIcon(Icons.check_circle), findsOneWidget);
  });
}
```

## FAQ

### How many integration tests should I write?

Few. Integration tests are slow and flaky. Reserve them for critical user journeys: login, the core value action (e.g., completing a lesson), payment, and the offline-to-online transition. Everything else should be covered by unit and widget tests. Ten focused integration tests are worth more than fifty that cover edge cases.

### Should I mock BLoCs in widget tests or use real ones?

Mock them. A real BLoC fires network calls and has async state transitions that make the test non-deterministic. Use BlocProvider.value with a seeded or mocked BLoC so the widget test is deterministic: given this state, does the widget render correctly? The BLoC itself is tested separately in bloc_test cases.

### How do I keep golden tests from being flaky?

Use golden tests for small, reusable components (cards, buttons) rather than full pages. Run them on the same platform and font rendering in CI as locally. When the design changes intentionally, regenerate with --update-goldens and review the diff in the PR. Do not block PRs on golden failures — report them for review and block only on main.

---

# Flutter Build & Release Pipeline: Flavors, CI/CD, Stores

A build and release pipeline is the difference between shipping confidently every week and dreading every release. At iStoria, we ship to 5M+ users on a weekly cadence with 99.9% crash-free sessions. That is not luck — it is a pipeline that builds, tests, signs, and deploys with zero manual steps.

This guide is a reference (not a tutorial) for the full pipeline: flavor configuration, CI/CD setup, store deployment, and the guardrails that prevent bad releases. For a step-by-step flavors tutorial, see our Flutter build flavors article. For the broader release-engineering story (trunk-based development, feature flags, staged rollout), see our release engineering case study.

The goal of this guide is to serve as the reference document you keep open while setting up or auditing your pipeline.

## Flavors: Three Environments

Every production Flutter app needs at least three flavors:

1. **Development** — local development against a dev backend. No real users. Hot reload. Used by engineers daily.
2. **Staging** — pre-production environment for QA, beta testing, and release candidates. Mirrors production as closely as possible (same backend, same feature flags, same analytics — just isolated data).
3. **Production** — the real app served to real users. Different bundle ID, different API keys, different signing credentials.

Each flavor has its own application ID/bundle ID, icon set, and environment configuration. The flavor system is the backbone of the pipeline — it lets you test the exact binary that will ship to users, just pointed at a different backend.

### Flavor Configuration

On Android, flavors are defined in `build.gradle` with `productFlavors`. On iOS, they are defined via Xcode schemes and build configurations. In Flutter, you select the flavor at build time:

```sh
flutter build apk --flavor staging --t lib/main_staging.dart
flutter build ipa --flavor staging --export-options-plist ios/staging/ExportOptions.plist
```

Each flavor uses a separate entry point (`main_dev.dart`, `main_staging.dart`, `main_prod.dart`) that injects the correct environment configuration before calling `runApp()`.

## CI/CD Pipeline

The CI/CD pipeline runs on every pull request and on every merge to main. The stages:

### Stage 1: Quality Gates (Every PR)
- `flutter analyze` — static analysis. Zero warnings tolerated.
- `dart format --set-exit-if-changed` — enforce formatting.
- Unit + widget tests with coverage report.
- Import linting — verify Clean Architecture boundaries (no Flutter imports in domain).

If any gate fails, the PR cannot merge. This is enforced by GitHub branch protection rules.

### Stage 2: Build (Every PR)
- Build APK and IPA for the staging flavor.
- Verify the build succeeds (catches platform-specific issues that analysis misses).
- Run integration tests on the built binary (in CI, on a simulator/emulator).

### Stage 3: Release (On Merge to Main or Tag)
- Bump version number (semantic versioning or build number).
- Build production APK and IPA with production signing credentials.
- Run the full test suite one final time.
- Upload to Google Play (internal testing track) and TestFlight.
- Tag the commit with the version number for traceability.

### Stage 4: Store Rollout (Manual Trigger)
- Promote from internal testing to production — staged rollout (1% → 10% → 50% → 100%).
- Monitor crash-free rate and rollback if it drops below threshold.

## Signing and Credentials

Signing is the most painful part of the pipeline, and it must be fully automated:

- **iOS:** Use App Store Connect API key + a signed certificate/profile stored as CI secrets. Fastlane `match` manages certificates in a private repo. Never commit certificates to the app repo.
- **Android:** Use a Google Play service account JSON key stored as a CI secret. Sign the app bundle with a keystore stored as an encrypted CI secret. Never commit the keystore.

At iStoria, signing is configured once and never touched. The CI pipeline picks up the credentials from GitHub Actions secrets and signs the binary automatically. No manual Xcode signing, no manual keystore passwords.

## Environment Configuration

Each flavor needs different configuration values: API URLs, API keys, feature flag endpoints, analytics tokens. We use `--dart-define` and `--flavor` to inject these at build time:

```sh
flutter build apk \
  --flavor prod \
  --dart-define=API_URL=https://api.istoria.app \
  --dart-define=ANALYTICS_TOKEN=prod-token
```

Inside the app, `String.fromEnvironment('API_URL')` reads the value. This keeps configuration out of the source code and makes each flavor's config explicit in the CI pipeline.

## Guardrails

The pipeline must have guardrails that prevent bad releases:

1. **Branch protection** — no direct pushes to main. Every change goes through a PR with passing CI.
2. **Required reviews** — at least one approval from a teammate. For release tags, require two.
3. **Crash-free threshold** — after a staged rollout to 1%, check the crash-free rate. If it drops below 99.5%, halt the rollout automatically.
4. **Feature flags** — new features ship behind flags, disabled by default. They are enabled server-side after the release is stable, allowing instant rollback without a new build.
5. **Database migration safety** — any schema migration must be backward-compatible (additive). Breaking migrations require a multi-release strategy (add new column → populate → remove old column across releases).

## The Weekly Cadence

At iStoria, we ship every week:

- **Monday–Thursday:** development on feature branches. PRs reviewed and merged to main behind feature flags.
- **Thursday:** tag the release. CI builds the production binary and uploads to internal testing tracks.
- **Friday morning:** QA verification on the release candidate. If good, staged rollout begins (1% → 10% → 50% → 100% over the day).
- **Friday afternoon:** monitor crash-free rate. If stable at 100% by end of day, the rollout completes.

This cadence requires trunk-based development (no long-lived branches) and feature flags (ship code without enabling features). The pipeline is what makes it repeatable — no manual steps, no manual signing, no manual store uploads.

## Recommended folder structure

```text
lib/
├── main.dart                    # production entry point (default)
├── main_dev.dart                 # development entry point
├── main_staging.dart             # staging entry point
├── main_prod.dart                # production entry point (explicit)
├── core/
│   └── config/
│       └── env_config.dart       # reads String.fromEnvironment values
└── ...

android/
├── app/
│   └── build.gradle              # productFlavors: dev, staging, prod
└── ...

ios/
├── Runner.xcodeproj              # schemes: dev, staging, prod
├── flutter/
│   ├── staging/ExportOptions.plist
│   └── prod/ExportOptions.plist
└── ...

.github/workflows/
├── pr-check.yml                  # analyze + test + build (staging) on every PR
├── release.yml                   # build prod + upload to stores on tag
└── nightly.yml                   # integration tests + dependency audit

fastlane/                         # optional, for store metadata management
├── Fastfile                      # lanes for upload to Play Store / TestFlight
└── match/                        # certificate management (private repo)
```

## Code example

```dart
// lib/main_dev.dart — development entry point
import 'package:flutter/material.dart';
import 'app.dart';
import 'core/config/env_config.dart';

Future<void> main() async {
  EnvConfig.initialize(
    apiUrl: const String.fromEnvironment(
      'API_URL',
      defaultValue: 'https://dev-api.istoria.app',
    ),
    environment: Environment.dev,
    analyticsEnabled: false,
  );
  runApp(const MyApp());
}

// lib/main_prod.dart — production entry point
Future<void> main() async {
  EnvConfig.initialize(
    apiUrl: const String.fromEnvironment(
      'API_URL',
      defaultValue: 'https://api.istoria.app',
    ),
    environment: Environment.prod,
    analyticsEnabled: true,
  );
  runApp(const MyApp());
}

// core/config/env_config.dart — central config
enum Environment { dev, staging, prod }

class EnvConfig {
  static late String apiUrl;
  static late Environment environment;
  static late bool analyticsEnabled;

  static void initialize({
    required String apiUrl,
    required Environment environment,
    required bool analyticsEnabled,
  }) {
    EnvConfig.apiUrl = apiUrl;
    EnvConfig.environment = environment;
    EnvConfig.analyticsEnabled = analyticsEnabled;
  }

  static bool get isProduction => environment == Environment.prod;
}

# android/app/build.gradle — flavor definitions
android {
    flavorDimensions += "default"
    productFlavors {
        dev {
            dimension "default"
            applicationIdSuffix ".dev"
            versionNameSuffix "-dev"
        }
        staging {
            dimension "default"
            applicationIdSuffix ".staging"
            versionNameSuffix "-staging"
        }
        prod {
            dimension "default"
        }
    }
}

# .github/workflows/release.yml — production release pipeline
name: Release
on:
  push:
    tags: ['v*']

jobs:
  build-and-deploy:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.x'
          channel: stable

      - name: Install dependencies
        run: flutter pub get

      - name: Run tests
        run: flutter test --coverage

      - name: Build Android (production)
        run: flutter build appbundle \
            --flavor prod \
            --dart-define=API_URL=https://api.istoria.app

      - name: Build iOS (production)
        run: flutter build ipa \
            --flavor prod \
            --export-options-plist ios/prod/ExportOptions.plist \
            --dart-define=API_URL=https://api.istoria.app

      - name: Upload to Google Play
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
          packageName: app.istoria
          releaseFiles: build/app/outputs/bundle/prodRelease/app-prod-release.aab
          track: internal
          status: completed

      - name: Upload to TestFlight
        uses: apple-actions/upload-testflight@v1
        with:
          app-store-connect-issuer-id: ${{ secrets.ASC_ISSUER_ID }}
          app-store-connect-key-id: ${{ secrets.ASC_KEY_ID }}
          app-store-connect-private-key: ${{ secrets.ASC_PRIVATE_KEY }}
          app-path: build/ios/ipa/istoria.ipa
```

## FAQ

### Do I really need three flavors? Can't I just use dev and prod?

You can start with two, but staging becomes essential as you scale. Without staging, you test new features against either a local dev backend (which does not match production) or the real production backend (risky). Staging is a pre-production environment that mirrors production as closely as possible — the same build, same backend, isolated data. It is where you catch integration issues before users do.

### How do I manage signing certificates across CI and team members?

Use Fastlane match for iOS (stores certificates in a private repo, team members and CI fetch them via a passphrase) and a Google Play service account JSON key for Android (stored as a CI secret). Never commit certificates to the app repo. Configure once, then the CI pipeline handles signing automatically on every build.

### What is staged rollout and why does it matter?

Staged rollout releases your app to a small percentage of users first (1%), then gradually increases (10% → 50% → 100%) over hours or days. If the crash-free rate drops or a critical bug surfaces at 1%, you halt the rollout before it reaches the majority of users. This is the single most important guardrail for a 5M-user app — it turns a bad release from a crisis into a minor incident.

---

# Flutter Performance Architecture: Profiling & Optimization

Flutter performance is about two things: not doing unnecessary work on the UI thread, and not blocking the UI thread with work that belongs elsewhere. Jank (dropped frames) happens when the build, layout, or paint phase of a frame takes longer than 16ms (for 60fps). At iStoria, we serve 5M+ users on devices ranging from flagships to low-end Android phones — keeping 60fps across that range requires disciplined architecture, not just clever tricks.

This guide covers the performance patterns we use: profiling methodology, build optimization, list virtualization, image handling, isolate offloading, and memory management. These are the patterns that separate a smooth app from a janky one at scale.

## Profile, Do Not Guess

The first rule of performance optimization: **never optimize without measuring.** Human intuition about performance is wrong more often than it is right. The Flutter DevTools Performance tab shows you exactly where frames are dropping and what code is responsible.

The profiling workflow:

1. Run the app in **profile mode** (`flutter run --profile`). Debug mode is not representative — it disables optimizations and adds overhead.
2. Open DevTools → Performance tab → record a session while interacting with the app.
3. Look at the frame timeline. Red frames are jank (took >16ms). Yellow frames are close to the limit.
4. Click a red frame to see the flame chart — the stack trace of what happened during that frame.
5. Identify the function that took the most time. Optimize it. Re-measure.

Optimizing without profiling leads to cargo-cult optimizations: premature caching, unnecessary complexity, and optimizations in places that were never the bottleneck.

## Build Optimization

The build phase (constructing the widget tree) is the most common source of jank. The strategies:

### Minimize Rebuilds with const Constructors

Mark widgets `const` wherever possible. A `const` widget is constructed once at compile time and never rebuilt. This is the single most impactful build optimization.

```dart
// GOOD — const widget, never rebuilds
const Padding(
  padding: EdgeInsets.all(16),
  child: Text('Hello'),
)

// BAD — new Padding and EdgeInsets instance on every parent rebuild
Padding(
  padding: EdgeInsets.all(16),
  child: Text('Hello'),
)
```

### Split Large Widgets into Smaller Ones

A giant `build()` method that returns a 500-line widget tree rebuilds entirely when any state changes. Split it into smaller widgets, each managing its own state. When a child widget's state changes, only that child rebuilds — not the entire tree.

### Use Builder/Selector for Granular Rebuilds

When using BLoC/Provider, use `BlocSelector` or `context.select` to rebuild only when the specific piece of state the widget cares about changes:

```dart
// Rebuilds only when lessons list changes, not when other LessonState fields change
BlocSelector<LessonBloc, LessonState, List<Lesson>>(
  selector: (state) => state is LessonLoaded ? state.lessons : [],
  builder: (context, lessons) => LessonList(lessons: lessons),
)
```

This prevents the widget from rebuilding on unrelated state changes. At iStoria, we use selectors on every list and detail widget — it eliminated the majority of our jank on scroll.

## List Virtualization

`ListView.builder` only builds the items visible on screen plus a small cache. `ListView()` (without builder) builds every item immediately. For any list with more than ~20 items, always use `ListView.builder` with `itemExtent` (if items have a fixed height) for maximum scroll performance.

For heterogeneous lists (mixed item types), use `SliverList` with `itemBuilder`. For infinite scroll with pagination, use `CustomScrollView` with slivers.

```dart
ListView.builder(
  itemCount: lessons.length,
  itemExtent: 72,  // fixed height → Flutter skips layout calculation
  itemBuilder: (context, index) => LessonTile(lesson: lessons[index]),
)
```

## Image Optimization

Images are the heaviest objects in a Flutter app. Unoptimized images cause memory spikes and jank on low-end devices.

1. **Use `cacheWidth` / `cacheHeight`** — decodes the image at the display size, not the source size. A 4000×3000 photo displayed at 200×150 should be decoded at 200×150, saving massive memory.

```dart
Image.network(
  lesson.thumbnailUrl,
  cacheWidth: 200,  // decodes at 200px width, not the full source
  cacheHeight: 150,
)
```

2. **Use WebP instead of PNG/JPEG** — WebP is ~30% smaller at the same quality. At iStoria, our build pipeline converts all article and lesson images to WebP automatically.

3. **Use `precacheImage`** for critical images — preloads the image before it is displayed, avoiding a pop-in on first render.

4. **Use `FadeInImage` with a placeholder** — shows a lightweight placeholder while the real image loads, avoiding layout shift.

## Isolate Offloading

Heavy computation (JSON parsing, image processing, cryptographic operations) blocks the UI thread and causes jank. Move it to an isolate via `compute()` or `Isolate.run()`:

```dart
final parsed = await compute(parseLessonJson, rawJsonString);
```

`compute()` spins up an isolate, runs the function, and returns the result. The UI thread stays free. Use this for any operation that processes more than a few kilobytes of data or takes more than a few milliseconds.

At iStoria, we offload all JSON deserialization of large API responses to isolates. On low-end devices, this was the difference between 40fps and 60fps on the lesson loading screen.

## Memory Management

Memory leaks in Flutter are usually caused by listeners and streams that are not disposed. Every `StreamSubscription`, `TextEditingController`, `ScrollController`, and `AnimationController` must be disposed in `dispose()`.

The common leak: a BLoC that holds a reference to a widget's `BuildContext` (via a listener or callback) after the widget is unmounted. The BLoC outlives the widget, and the widget's element stays in memory.

The fix: never pass `BuildContext` to a BLoC or repository. If a BLoC needs to trigger navigation, use a navigation service or a global router key, not the widget's context.

Use DevTools Memory tab to detect leaks. Take a snapshot, navigate through the app, take another snapshot, and compare. If objects accumulate, you have a leak.

## Avoiding Platform Channel Jank

Platform channel calls (MethodChannel) are asynchronous, but if the native side does heavy work on the main thread, it causes jank on the Flutter side. Move native-side heavy work to a background thread (dispatch queue on iOS, background thread on Android).

At iStoria, our PowerSync sync engine runs entirely on a background thread on the native side. We learned this the hard way — the initial implementation did SQLite operations on the main thread, causing jank on every sync cycle.

## Recommended folder structure

```text
lib/
├── core/
│   ├── performance/
│   │   ├── frame_monitor.dart        # tracks FPS, logs jank in profile mode
│   │   └── memory_tracker.dart       # detects retention spikes in DevTools
│   └── utils/
│       └── isolate_runner.dart       # compute() wrapper for typed offloading
├── features/
│   └── lesson/
│       └── presentation/
│           └── widgets/
│               ├── lesson_list.dart       # ListView.builder with itemExtent
│               ├── lesson_tile.dart        # const constructor, granular selector
│               └── cached_lesson_image.dart # cacheWidth/cacheHeight optimized
```

## Code example

```dart
// core/utils/isolate_runner.dart — typed compute() wrapper
import 'package:flutter/foundation.dart';

/// Offloads heavy computation to a background isolate.
/// Use for JSON parsing, data transformation, or any CPU-intensive work.
Future<T> runInIsolate<T, P>(
  T Function(P) work,
  P param,
) {
  return compute(work, param);
}

// Example: parse a large API response off the UI thread
List<LessonModel> parseLessons(String json) {
  final decoded = jsonDecode(json) as List;
  return decoded.map((e) => LessonModel.fromJson(e)).toList();
}

// In the repository:
@override
Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
  final rawJson = await remoteDatasource.fetchLessonsRaw(courseId);
  // Parse on a background isolate — UI thread stays smooth
  final models = await runInIsolate(parseLessons, rawJson);
  return Right(models.map((m) => m.toEntity()).toList());
}

// presentation/widgets/lesson_tile.dart — const + granular selector
class LessonTile extends StatelessWidget {
  final Lesson lesson;

  const LessonTile({super.key, required this.lesson});

  @override
  Widget build(BuildContext context) {
    return BlocSelector<LessonBloc, LessonState, bool>(
      // Rebuild this tile ONLY when this specific lesson's completion changes
      selector: (state) {
        if (state is! LessonLoaded) return false;
        return state.lessons
            .firstWhere((l) => l.id == lesson.id,
                orElse: () => lesson)
            .isCompleted;
      },
      builder: (context, isCompleted) {
        return ListTile(
          leading: CachedLessonImage(url: lesson.thumbnailUrl),
          title: Text(lesson.title),
          trailing: AnimatedSwitcher(
            duration: const Duration(milliseconds: 200),
            child: isCompleted
                ? const Icon(Icons.check_circle, key: ValueKey('done'))
                : const SizedBox.shrink(key: ValueKey('empty')),
          ),
        );
      },
    );
  }
}

// presentation/widgets/lesson_list.dart — virtualized list with itemExtent
class LessonList extends StatelessWidget {
  final List<Lesson> lessons;

  const LessonList({super.key, required this.lessons});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: lessons.length,
      itemExtent: 72,  // fixed height → skips per-item layout, maximum scroll FPS
      itemBuilder: (context, index) {
        return LessonTile(
          key: ValueKey(lessons[index].id),
          lesson: lessons[index],
        );
      },
    );
  }
}

// presentation/widgets/cached_lesson_image.dart — memory-optimized images
class CachedLessonImage extends StatelessWidget {
  final String url;

  const CachedLessonImage({super.key, required this.url});

  @override
  Widget build(BuildContext context) {
    // Determine display dimensions from the layout
    const displayWidth = 56.0;
    const displayHeight = 56.0;
    // Convert to physical pixels for cacheWidth/cacheHeight
    final dpr = MediaQuery.devicePixelRatioOf(context);
    final cacheW = (displayWidth * dpr).round();
    final cacheH = (displayHeight * dpr).round();

    return CachedNetworkImage(
      imageUrl: url,
      cacheWidth: cacheW,    // decode at display size, not full source resolution
      cacheHeight: cacheH,
      fadeInDuration: const Duration(milliseconds: 150),
      placeholder: (_, __) => Container(
        width: displayWidth,
        height: displayHeight,
        color: Theme.of(context).colorScheme.surfaceContainerHighest,
      ),
    );
  }
}
```

## FAQ

### How do I know which widgets are rebuilding unnecessarily?

Add a print or a debugPrint in the build() method of the widget you suspect. If it prints more often than expected, it is rebuilding unnecessarily. In profile mode, use the DevTools Performance tab to see rebuild counts per frame. Alternatively, use the RepaintBoundary widget to isolate paint regions and the Flutter inspector's 'Select Widget Mode' to inspect the widget tree during interaction.

### Should I use RepaintBoundary everywhere?

No. RepaintBoundary creates a separate layer, which helps when a complex subtree repaints independently (like a list item with an animation). But each RepaintBoundary adds memory and compositing overhead. Use it strategically on complex, independently-animating widgets — not on every widget. Profile before and after to confirm it helps.

### How do I test performance on low-end devices?

Use an actual low-end device (1-2GB RAM, budget CPU) — the Android emulator on a slow setting is not representative. Run in profile mode and interact with the app normally. The DevTools Performance tab shows frame times and dropped frames. Focus on the 90th-percentile frame time, not the average — the worst frames are what users perceive as jank.
