Selected workAdelante

Adelante

Widget-first motivation for iOS and Android, read from the Home Screen.

Role
Sole developer — Flutter app, both native widget renderers, the content pipeline, and the backend design
Team
Solo
Timeline
June 2026 – Present
Status
Not released. The iOS app and its widget extension both build for release, with artifacts dated 2026-07-18; the last Android release APK is dated 2026-06-21, before the July overhaul, so Android has not been rebuilt since. There is no App Store or Google Play listing — every account, signing and submission item in docs/store/store-readiness.md is still unchecked. The Firebase backend is written, unit-tested from the client side, and has never run against a live project.
Category
Mobile — native widget surfaces
Platforms
iOS 16+ · Android
Built with
Flutter 3.44.1Dart 3.12.1Riverpod 3Swift / SwiftUIWidgetKitKotlinAndroid RemoteViewshome_widgetTypeScriptNode.js 20Firebase Cloud Functions (v2)Firestore

01/EXECUTIVE SUMMARY

What it is.

A motivation app only helps on the day nobody feels like opening a motivation app. Adelante puts the writing on the surfaces a phone already shows — Home Screen, Lock Screen and — because every view applies containerBackground — StandBy on iOS 17+ — instead of behind a launch. I built the Flutter application, both native widget renderers (WidgetKit and SwiftUI on iOS, RemoteViews on Android), the deterministic content pipeline behind a 12,828-entry library that ships inside the binary, and the Firebase content backend that the shipped build deliberately does not need. It matters because the hard part of a widget product is not the app: it is three separate processes that never talk to each other agreeing, offline, on what to show right now.

02/THE PROBLEM

What was broken.

The content has to arrive without being asked for, on a surface the user already looks at, on two platforms whose widget systems agree on almost nothing.

  • A widget is not a screen you own. WidgetKit decides when a widget wakes and how much refresh budget it gets, and the app may not be opened for days — so the widget has to advance on the user's chosen cadence without being told to.
  • It also has to be right when the app is opened. The Flutter app, the iOS extension and the Android provider are three separate processes with no channel between them, and all three have to name the same current quote.
  • The two native widget systems are not comparable. SwiftUI will draw whatever it is handed; RemoteViews cannot paint a gradient, cannot round a corner arbitrarily, and cannot set a typeface at runtime at all.
  • No accounts, no analytics, no off-device tracking was written as a product non-goal before implementation — which rules out the ordinary content-delivery shape where a server knows who asked for what.
  • Content at the volume a rotating widget needs cannot be hand-written. Generating roughly 12,700 candidate lines across 139 shards makes near-duplicates, filler phrasing, missing licenses and invented scripture citations a standing risk rather than a hypothetical one.
  • Faith is part of the content. A user who selects one tradition must never be shown another — the kind of promise a scoring function quietly breaks.

03/ROLE & OWNERSHIP

What I owned.

What I designed and engineered

  • The Flutter application — 8,963 lines across 41 Dart files: a Riverpod 3 provider graph, the content repository and search index, the recommendation engine and refresh policy, onboarding, the widget studio and settings.
  • The iOS widget extension end to end: the WidgetKit bundle and its seven families, the SwiftUI views, the shared payload decoder and rotation, App Group entitlements, and the Xcode target itself — created and kept reproducible by a Ruby script driving `xcodeproj`.
  • The Android widget: a RemoteViews AppWidgetProvider with five baked layout variants, a Canvas-rendered gradient background, and payload-driven padding, gravity, corner radius and metadata visibility.
  • The content pipeline: a 396-line deterministic gate (three dedup layers, quality filters, license and citation enforcement, stable ids) and the 12,828-entry library it produces.
  • The backend: five Cloud Functions in TypeScript, Firestore security rules and three composite indexes, a seeding script, and the anonymous delta-sync client that consumes them.
  • The test suite: 117 test declarations across 18 files, including three suites that read the Swift, Kotlin and XML sources off disk from Dart to pin cross-platform invariants.

What I did not own

  • The typefaces. Spectral, Cardo, PT Serif and Fira Sans are bundled under the SIL Open Font License, eight .ttf files across four families.
  • The scripture translations. The public-domain sources — King James Version, Pickthall, JPS 1917, Max Müller, Edwin Arnold — are cited, not written.
  • The prose, mostly. 12,732 of the 12,828 entries are generator output run through the gate; the other 96 are the curated seed — 72 public-domain lines and 24 written by hand. The curated entries are registered as authoritative so generated output can never displace them.

04/CONSTRAINTS

What shaped the build.

No accounts, analytics, or off-device tracking

Written as a non-goal in the design spec before implementation, which meant no auth layer, no attribution SDK and no crash reporting could be added later for convenience. The claim is checkable from the manifest rather than asserted: the complete set of direct runtime dependencies is cupertino_icons, flutter_riverpod, shared_preferences, home_widget, uuid, share_plus, http and auto_size_text, and pubspec.lock contains no analytics, auth, attribution, crash-reporting or advertising package at any depth. Absence in the manifest is evidence about what the build contains, not an audit of runtime behaviour.

The widget must work with nothing but the local store

Neither native provider is allowed to reach the network. Both read only the shared local container — the App Group UserDefaults suite on iOS, SharedPreferences on Android — so everything a widget needs has to be serialised into the payload before it is written, including pre-tiered text and the rotation anchor.

RemoteViews is a description of a view, not a view

No gradients, no arbitrary corner radius, and uniform auto-size text that is silently disabled the moment any code calls setTextViewTextSize. The payload also crosses a process boundary with a hard size limit, so anything drawn on the app side has to be small enough to survive the trip.

Fonts do not cross the iOS extension boundary

A widget extension is its own bundle. Fonts registered by the host app are invisible to it, and `Font.custom` with a family name fails silently — it falls back to system with no error and no crash, so the failure only shows up as a widget that looks wrong.

Ship without a backend

The delivery decision was hybrid: the full library ships as a bundled asset and the Firebase sync is a growth channel, not a dependency. Blaze activation is optional and a build with no endpoint configured still has to run.

06/PRODUCT FLOW

How it works.

  1. Boot loads assets/content/library.json into QuoteRepository, which builds an inverted search index as it constructs.

  2. ContentFilter narrows the library by selected categories and religious preference — a hard predicate, applied before anything is scored.

  3. RecommendationEngine ranks what survives; QuoteRefreshPolicy decides which entry is current.

  4. WidgetPayload serialises a styled pool: each entry carries three length-bounded strings and a tier stamped from its full text, and the app's current quote is forced to index 0.

  5. home_widget writes the payload into the App Group container on iOS and SharedPreferences on Android.

  6. Each native renderer reads that store, filters the pool down to the tiers its family can hold, and computes the rotation index from the shared anchor — no message ever passes between the three processes.

Boot to widget — the path one quote takes from a bundled asset to a rendered Home Screen tile, without a network call.

07/SYSTEM ARCHITECTURE

How it fits together.

  • pipeline appgenerates library.json into the app bundle
  • pipeline ioscreates and repairs the extension target and its font resources
  • app sharedwrites one serialised payload via home_widget
  • shared iosread-only — the extension never opens a socket
  • shared androidread-only — the provider never opens a socket
  • app backendanonymous GET ?since=<version> — disabled unless an endpoint is compiled in
Three processes, one shared payload, and a backend the shipped build does not need.

08/ENGINEERING CHALLENGES

The hard parts.

Three renderers agreeing on which quote is now, with no way to talk to each other

Why it was hard

The Flutter app, the WidgetKit extension and the Android RemoteViews provider are three separate processes with no channel between them. WidgetKit decides when a widget wakes and rations the refresh budget, and the app may go unopened for days — so the widget has to advance on the user's cadence without being told to, and still be showing the same entry the app shows the moment it is opened.

Options considered

  • Push a fresh payload from the app on every rotation
  • Let each renderer keep its own cursor and rotate independently
  • Derive the index from shared constants so nobody has to be told anything

What I built

One pure function of (anchor timestamp, interval, pool length), written three times over: currentPoolIndex in lib/features/widgets/domain/widget_rotation.dart:6, AdelantePayload.quote(at:offset:maxTier:) in ios/AdelanteWidgets/AdelanteWidgetShared.swift:96, and currentPoolIndex in AdelanteWidgetProvider.kt:373. Every publish forces the app's current quote to pool index 0, so an in-app refresh snaps all three surfaces back into alignment rather than negotiating. iOS additionally precomputes up to twelve future timeline entries so WidgetKit can rotate on schedule inside its budget instead of waking for each change.

What it cost

The same arithmetic now lives in three languages, and nothing but tests keeps the copies honest. The Lock Screen's separate-surface offset is also a hardcoded 7 — the tests pin that lock differs from home, not that the value is 7.

How I know it works

test/widget_parity_test.dart (17 declarations) and test/widget_text_and_payload_test.dart (12) assert the shared contract, and the parity suite reads the native sources off disk so a change to the Swift or Kotlin copy fails a Dart test.

Result

The independent-cursor version was shipped first and then removed: commit 82c8e51 (2026-06-21) is titled 'remove independent pool rotation + anchor drift', which deleted rotation outright to stop the drift. Rotation returned in fd47846 and fb80270 (2026-07-18) only once it was one deterministic index rather than three clocks.

Long quotes never landing on a surface that cannot hold them

Why it was hard

Before the overhaul the iOS medium, large and extra-large families all rendered the full text, so a long entry either overflowed its tile or shrank until it was unreadable — recorded as a baseline defect in the overhaul spec before any of it was touched. Fixing it where it showed up would have meant the same truncation rules written in Dart, Swift and Kotlin, each free to disagree about where a sentence ends.

Options considered

  • Shrink the text until it fits
  • Truncate inside each native renderer
  • Emit bounded strings from Dart and make long entries ineligible for small surfaces

What I built

Dart became the single source of truth twice over. lib/features/widgets/domain/widget_text.dart:32 emits three length-bounded strings per entry — small capped at 88 characters, medium clamped between 80 and 200 by the user's preset, large capped at 260 — and fit() prefers a sentence boundary in the back half of the budget, otherwise cuts on a word boundary and strips a trailing comma or dash before the ellipsis. Separately, widget_payload.dart:107 stamps a tier on the full text (0 at ≤90 characters, 1 at ≤170, 2 above), and each native surface filters the pool by tier before it picks: large and extra-large take tier 2, medium takes 1, and small plus every Lock Screen accessory take 0, while Android does the same by the widget's minimum width in dp. A long quote is not truncated onto a small widget — it is never eligible for one.

What it cost

The payload carries three strings and a tier per entry instead of one string, and a user whose library skews long sees a smaller effective pool on the small family than on the large one.

How I know it works

test/widget_text_and_payload_test.dart (12 declarations) pins the caps and the boundary behaviour; test/widget_parity_test.dart (17) asserts the tier gate is still present in both native sources.

Result

The truncation rules exist once, in the language that has the tests. Both native renderers only choose from a pool they were handed; neither one cuts a sentence.

User-chosen typefaces on two widget systems that both refuse them

Why it was hard

Two unrelated platform walls. On iOS the widget extension is its own bundle, so fonts registered by the host app are invisible to it, and Font.custom with a family name fails silently — system fallback, no error, no crash, just a widget that looks wrong. On Android, RemoteViews cannot set a typeface at runtime at all.

Options considered

  • Ship one system typeface on both platforms and drop the choice
  • iOS: add the fonts to the extension target and resolve by exact PostScript name
  • Android: bake one layout variant per typeface and select it from the payload

What I built

On iOS the four families were added to the extension target's own resource phase and declared in the extension's own UIAppFonts, then resolved by exact PostScript name — Spectral-Italic, Cardo-Italic, PTSerif-Italic, FiraSans-Regular — with the silent-fallback failure mode written into the comment beside them. Both Xcode mutations are automated in Ruby against xcodeproj so the project can be regenerated and rebuilt: setup_widget_target.rb creates the extension target, its entitlements and embed phase and reorders 'Embed Foundation Extensions' ahead of 'Thin Binary'; add_widget_fonts.rb is an idempotent font-resource adder. On Android there are five layout variants — adelante_widget.xml plus one each for Cardo, Fira Sans, PT Serif and Spectral — selected by a token from the payload at update time.

What it cost

A fifth typeface on Android is a fifth XML file, not a parameter. And the iOS path depends on PostScript names no compiler checks: rename a font file and the widget silently falls back to system, which is exactly the failure the comment exists to warn about.

How I know it works

Both targets build for release — build/ios/Release-iphoneos holds Runner.app and AdelanteWidgets.appex, both dated 2026-07-18 — and the eight .ttf files are declared in two places that have to agree: pubspec.yaml's fonts block and the extension's Info.plist.

Result

Four typefaces are selectable on both platforms, and the two Xcode mutations that are easy to lose in a regenerated project are scripts rather than remembered clicks.

Making RemoteViews look like the SwiftUI widget

Why it was hard

RemoteViews describes a view to another process rather than drawing one. It cannot paint a gradient, cannot round a corner to an arbitrary radius, and its uniform auto-size text is silently disabled the moment any code calls setTextViewTextSize — a one-line regression with no error attached. The payload also crosses a process boundary with a hard size limit.

Options considered

  • Accept a flat, solid Android widget that does not match iOS
  • Set text size programmatically per family and give up auto-sizing
  • Render the background to a bitmap sized to the widget's real aspect ratio

What I built

The background is drawn to a Bitmap on a Canvas sized to the widget's actual aspect ratio, so the corner radius still reads as circular after the ImageView's fitXY scaling, and capped at 480 px on the longest edge to stay inside the RemoteViews transaction limit. Text size is never set programmatically — the reason is written as a comment at the call site — and padding, per-element gravity, corner radius and the visibility of every metadata element are all driven from the same payload the iOS side reads.

What it cost

The bitmap is regenerated on every update and costs memory in proportion to the widget's on-screen size, and the 480 px cap trades sharpness on the largest widgets for staying under the transaction limit.

How I know it works

test/android_widget_remote_views_test.dart reads android/app/src/main/res/layout/adelante_widget.xml off disk and asserts that autoSizeTextType="uniform" is present and android:textSize= is absent from the quote TextView.

Result

Both widgets render the same gradient, radius and metadata layout from one payload. The guard is a source-text assertion, which catches the stray line being reintroduced but would not catch a subtler rendering regression — a limit worth naming rather than counting as coverage.

A deterministic gate over machine-generated content

Why it was hard

Reaching a floor of 500 entries per category meant producing roughly 12,700 candidates across 139 generated shards. Judgement can live in the generator, but enforcement cannot: near-duplicates, filler phrasing, missing licenses and invented scripture citations have to be rejected the same way every time, and provably, or the library quietly fills with paraphrases of itself.

Options considered

  • Read and approve every candidate by hand
  • Score candidates and accept everything above a threshold
  • Enforce with a pure, deterministic function and keep judgement upstream in the generators

What I built

tool/generate_library/gate.dart, 396 lines, with three independent dedup layers: exact normalised text, an order- and punctuation-insensitive token-set fingerprint, and a stopword-stripped sorted content key that catches 'Discipline is the bridge between goals and results' against 'Discipline is a bridge from goals to results'. Quality filters are a 12–320 character band, a sentence-shape regex requiring an uppercase or digit start and a terminal end, 15 exact bare clichés and 19 substrings that read as machine filler. License enforcement requires a non-empty status that is never 'unknown', and rejects anything typed as scripture without a citation. Ids are FNV-1a derived and stable, with collision suffixing, and there are nine typed rejection reasons. The 96 curated entries are registered first and prime the dedup sets, so generated output cannot re-introduce a curated line. The same shape exists server-side in functions/src/model.ts — a strict allowlist validator, a fingerprint documented as matching the Dart pipeline, a 0–1 quality heuristic, and a trusted-host allowlist where only a trusted feed scoring above 0.6 may auto-publish.

What it cost

A deterministic gate rejects safely and reads badly: it cannot tell a dull line from a good one, so it removes only the failure modes it can name. Enforcing tone with substring lists also means every newly noticed filler phrase is another entry added after the fact.

How I know it works

The shipped asset holds 12,828 entries with 12,828 unique ids and 12,828 unique texts — zero exact duplicates, verified by loading library.json and counting. test/content_foundation_test.dart (21 declarations), test/content_library_test.dart (9) and test/content_asset_floor_test.dart (7) hold the floors and the shape against the real asset rather than a fixture.

Result

The library grew from 96 curated entries to 12,828 across 20 categories, with five faith traditions each above 550 entries, and no duplicate text reached the asset. 12,732 of those entries are original text produced through the pipeline and 72 are public domain: 45 attributed quotes (Emerson, Seneca, Epictetus, Thoreau, Keller) and 27 scripture verses, 36 of which carry an explicit citation — the gate exists because the first number is that large, not in spite of it. The recorded run rejected 9 of 12,741 candidates — 2 filler, 6 exact duplicates, 1 near-duplicate. The value is that the rule runs identically every time over the whole library, not that it caught a lot.

09/TECHNICAL DECISIONS

Why this way.

Ship the whole library; treat the backend as growth

Chose An 11.8 MB library.json bundled into the app, with Firebase delta sync as an optional channel

The delivery decision was written as hybrid before it was built: ship the full generated library as an offline asset and keep getContent delta-sync as the growth and correction channel, with Blaze activation optional and not required to ship. A widget product whose first frame depends on a network call has a bad first frame.

Instead of Fetching the library on first run and caching it

Trade-off The asset is bundled uncompressed and parsed on the main isolate at boot. The design spec called for a gzipped library.json.gz produced with dart:io GZipCodec; the shipped asset is plain JSON, and the cold-start cost of parsing it has not been measured.

Sync is off unless an endpoint is compiled in

Chose String.fromEnvironment('ADELANTE_CONTENT_ENDPOINT') with an empty default, which leaves isContentSyncEnabled false

Stated in the source: an empty endpoint means sync is disabled and the app stays purely local, which is the safe default so a build with no backend configured still ships and runs. It also makes the privacy posture structural rather than a promise — a build with no endpoint cannot phone anywhere.

Instead of A runtime feature flag, or an endpoint compiled in and toggled in settings

Trade-off The backend is fully written and exercised by client-side tests but has never run against a live project: .firebaserc still contains the placeholder adelante-REPLACE-WITH-YOUR-PROJECT-ID, and there is no google-services.json, GoogleService-Info.plist or firebase_options.dart anywhere in the repo. Infrastructure that has never run is infrastructure that has not been proven.

A JSON asset instead of a compiled Dart list

Chose assets/content/library.json loaded at boot, with the compiled seed retained as a fallback

Written at the loader: this is what lets the library grow to thousands of entries without ballooning the compiled binary or the parse time of a giant source file. Any load or parse failure falls back to the compiled seedMotivationQuotes, so the app is never contentless.

Instead of Keeping the content as compiled Dart, which is what the 1,498-line seed file used to be

Trade-off Two representations of the content now have to stay compatible, and the fallback path is the one least likely to be exercised. The related 'no local database' decision was written with a note to revisit past roughly 10,000 entries; the library is 12,828 and it was not revisited.

No accounts, analytics, or ads — as a constraint, not a feature line

Chose Zero auth, analytics, attribution, crash-reporting or advertising dependencies, and a sync request that carries nothing

It was written as a product non-goal before implementation, so it shaped the architecture rather than being retrofitted. The only sync call is a single GET with an accept header and an optional ?since= version, carrying no body, identifiers or preferences, and its docstring says so; the client re-checks the approved, reviewed and active flags anyway as defence in depth. The only authentication anywhere in the system is a server-side admin custom claim that gates the operator, not users.

Instead of An account layer, which would have let favourites, presets and widget configurations follow a user between devices

Trade-off Nothing syncs — all 18 preference keys live on one device, so a reinstall loses favourites, presets and widget configurations. There is also no crash reporting and no analytics, which means a field failure is completely invisible.

Firestore over Supabase for the content backend

Chose Firebase — Firestore, Cloud Functions v2 on Node 20, security rules and composite indexes

Recorded in docs/backend/content-system.md under 'Backend Choice': mobile SDKs, offline persistence, security rules, scheduled functions, and a console-based admin path that meant the curation workflow did not need a custom API and review UI built first.

Instead of Supabase and Postgres

Trade-off The shipped client never uses the Firestore SDK at all — it calls one HTTPS function, which uses the Admin SDK and bypasses rules entirely, as the rules file's own header states. Most of what won the comparison is unused by the path the app actually takes.

10/DESIGN EVOLUTION

How it changed.

The baseline was written down before it was fixed

The overhaul spec opens with a 'Baseline facts (from codebase audit)' section naming the defects in the author's own code: every iOS family rendered full text regardless of size, and search was a substring scan over a recomputed 13-field blob with no index, ranking, debounce or diacritic folding — and it was caged inside the user's selected categories, so nothing outside them was discoverable.

Rotation was deleted before it was rebuilt

Independent per-renderer rotation shipped first and drifted. Commit 82c8e51 removed it outright rather than patching it, and the shared deterministic index arrived a month later as its replacement.

Search became an index

lib/features/content/data/search_index.dart maps token to quote id to accumulated field weight — text 3.0, author 2.0, category 1.5, tag 1.0, mood and tone 0.75 — with AND semantics across tokens and the last, still-being-typed token prefix-matched so live typing works without a trie. Incremental add and remove keep it consistent with sync upserts, and searchRanked is deliberately not preference-filtered, with the reason written in its docstring: search is how you find something outside your own categories.

The library grew in two jumps

96 curated seed entries, then 10,047 at commit b22474d, then 12,828 after the faith fill at 3e0d06d, which is where it stands. One commit message in that stretch (b22474d, "10,051 quotes") is four entries off the file at that SHA, which is why the citable number is the one read back out of the shipped asset.

The tab bar was reverted twice before it was native

A Flutter approximation of the iOS 26 Liquid Glass tab bar was built and reverted twice on 2026-06-21 — the commit message says a native UIVisualEffect renders flat inside Flutter. It was finally solved by registering a real UITabBar as a Flutter platform view, using configureWithDefaultBackground() specifically to keep the system glass while tinting selected and unselected items to the app palette. It is iOS-only; Android keeps the Flutter bar.

11/TESTING & RELIABILITY

How it is proven.

18 test files, 1,898 lines, 117 test declarations and 284 assertions — and no Flutter widget tests at all. The suite is aimed at data, contracts and cross-platform invariants rather than UI, and three of its suites do something unusual: they read the Swift, Kotlin and XML sources off disk from Dart and assert on their text, which pins invariants a Dart test cannot otherwise reach. Those are source-text guards, not behavioural tests, and they are worth naming as such.

Test files
18
Test declarations
117
Assertions
284
Flutter widget tests
0
Backend tests
0

Representative cases

  • [object Object]
  • [object Object]
  • [object Object]
  • [object Object]

12/RESULTS

What shipped.

iOS builds current; the Android artifact is stale
build/ios/Release-iphoneos holds Runner.app and AdelanteWidgets.appex with dSYMs, both dated 2026-07-18. build/app/outputs/flutter-apk holds a release APK, but it is dated 2026-06-21 — before the nineteen commits of 2026-07-18 that rewrote the payload, tiering, rotation and the Android provider — so nothing on disk demonstrates the current Android code compiles. The hand-built WidgetKit extension target compiles and embeds, which is the part of a Flutter widget project most likely not to.
Seven widget families from one set of views
The bundle declares systemSmall, systemMedium, systemLarge and systemExtraLarge plus accessoryInline, accessoryCircular and accessoryRectangular, and applies containerBackground(for: .widget) with a pre-iOS-17 fallback — which is what makes the same views legal in StandBy, since iOS 17 and later reuse those families there. There is no StandBy-specific code path: the accurate claim is StandBy-safe, not a separately built StandBy widget.
12,828 entries, no duplicate text
The shipped library holds 12,828 entries with 12,828 unique ids and 12,828 unique texts across 20 categories, and five traditions clearing the 500-per-category floor the pipeline was built to reach: islam 599, judaism 559, christianity 559, hinduism 558, buddhism 552.
Eight runtime dependencies, none of them tracking
The complete set of direct runtime dependencies is cupertino_icons, flutter_riverpod, shared_preferences, home_widget, uuid, share_plus, http and auto_size_text. There is no analytics, auth, attribution, crash-reporting or advertising package at any depth in the lockfile, the Android release manifest declares zero permissions, and Info.plist carries no tracking usage description. That is evidence from the manifest about what the build contains — it is not a runtime audit, and it is stated that way on purpose.

13/REFLECTION

What I learned.

What worked

  • Auditing the codebase and writing the defects down before touching them. The overhaul spec's 'Baseline facts' section is what made the fixes checkable a month later rather than remembered.
  • Treating deliberate replication as a contract instead of a synchronisation problem. One pure rotation function copied into three languages, with tests reading the copies off disk, beat any scheme where the three processes had to tell each other anything.
  • Keeping judgement in the generators and enforcement in a pure gate. A 396-line deterministic function can be read, tested and re-run over the whole library; a review pass cannot.

What I underestimated

  • The asset. 11.8 MB of uncompressed JSON is parsed on the main isolate at boot. The spec called for gzip, gzip was never applied, and the cold-start cost still has not been measured — which means the decision has not actually been evaluated.
  • The threshold I set myself. 'No local database' was written with a note to revisit past roughly 10,000 entries; the library is 12,828 and the note went unread.
  • What a source-text assertion buys. Reading Swift and XML from a Dart test catches deletion and reintroduction and says nothing about how either surface renders — useful, but not the coverage it can be mistaken for.

What I would do next

  • Compress the library asset and move the parse off the main isolate — after measuring cold start, so the change has a number attached to it.
  • Per-widget-instance configuration is specified and not built. The widget still uses StaticConfiguration with no AppIntentConfiguration, and Android declares no configure activity; separate surfaces are handled by a fixed pool offset instead.
  • Run the backend against a real Firebase project. Five Cloud Functions, security rules, three composite indexes and a seeding script have been written and never deployed.
  • Work the store-readiness checklist: 14 of its 24 items are open, and every item needing a developer account or a signing identity — Apple Developer membership, App Group capability, PrivacyInfo.xcprivacy, distribution signing, a release keystore, and both store listings — is untouched.