Selected workAdelante
Adelante
Widget-first motivation for iOS and Android, read from the Home Screen.
I built a Flutter app and two native widget systems that agree on the current quote, even offline. A deterministic pipeline prepares the 12,828-entry content library.
See outcomes
- Role
- Sole developer — Flutter app, both native widget renderers, the content pipeline, and the backend design
- Team
- Solo
- Timeline
- June 2026 – Present
- Status
- In development · landing page live
- 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
The short version
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
Mine, and not mine
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
The boundaries it was built inside
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.
Evidence notes
docs/superpowers/specs/2026-07-18-adelante-overhaul-design.md:165 (Non-goals). pubspec.yaml:38-48 cross-checked against pubspec.lock `dependency: "direct main"`. Repo-wide grep for analytics|firebase_auth|firebase_core|admob|google_mobile_ads|facebook|mixpanel|amplitude|sentry|crashlytics|tracking|idfa|advertis across lib/, pubspec.yaml and pubspec.lock returns 2 hits, both non-SDK (a letter-spacing comment in lib/app/theme.dart:106 and the user-facing string in adelante_shell.dart:1528).
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.
Evidence notes
AdelanteWidgetShared.swift and AdelanteWidgetProvider.kt contain no networking calls; the invariant is documented at lib/features/content/data/content_sync_controller.dart:34-35. App Group group.com.adelanteapp.adelante declared in both Runner.entitlements and AdelanteWidgets.entitlements.
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.
Evidence notes
android/app/src/main/kotlin/com/adelanteapp/adelante/AdelanteWidgetProvider.kt (390 lines; wc -l, 2026-07-29), bitmap path at lines 230-270 with the 480 px longest-edge cap; the no-setTextViewTextSize rule is written as a comment at lines 129-130.
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.
Evidence notes
ios/AdelanteWidgets/Info.plist:24-34 re-declares all eight .ttf files in the extension's own UIAppFonts; the failure mode is written into the code comment at AdelanteWidgetViews.swift:148-157.
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.
Evidence notes
docs/superpowers/specs/2026-07-18-adelante-overhaul-design.md:17-20; lib/app/backend_config.dart:17 makes the endpoint a String.fromEnvironment with an empty default and isContentSyncEnabled false when empty.
05/PRODUCT FLOW
The path through it
-
Boot loads assets/content/library.json into QuoteRepository, which builds an inverted search index as it constructs.
-
ContentFilter narrows the library by selected categories and religious preference — a hard predicate, applied before anything is scored.
-
RecommendationEngine ranks what survives; QuoteRefreshPolicy decides which entry is current.
-
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.
-
home_widget writes the payload into the App Group container on iOS and SharedPreferences on Android.
-
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.
06/SYSTEM ARCHITECTURE
How it fits together.
Adelante runs as three independent processes on one device. The Flutter app holds the content repository, search index, recommendation engine and refresh policy, and writes a single serialised payload into a shared local store — an App Group container on iOS, SharedPreferences on Android — using the home_widget package. That shared store also holds the bundled 12,828-entry library.json asset, the cached content, and eighteen preference keys. Two native renderers read the shared store and never write to it and never open a socket: an iOS WidgetKit extension with seven widget families, SwiftUI views, a payload decoder, a copy of the rotation function and a precomputed twelve-entry timeline; and an Android AppWidgetProvider with five baked RemoteViews layout variants, a Canvas-rendered gradient bitmap and its own copy of the same rotation function. A separate build-time toolchain on the developer machine generates library.json through the deterministic gate and automates the Xcode widget target and its font resources. A Firebase project sits outside the device holding five Cloud Functions — getContent for delta sync, scheduledContentImport on a 24-hour schedule, approveContent and rejectContent as admin callables, and a one-time setAdminClaim bootstrap — over Firestore collections for content, content_versions, content_reviews, sources and meta. The app talks to that backend through one anonymous read-only GET carrying no identifiers, and only when an endpoint has been compiled into the build; it is empty by default, so the shipped app is offline-only.
On the device
Flutter app — device
- Riverpod 3 provider graph (lib/app/providers.dart)
- QuoteRepository + inverted SearchIndex
- ContentFilter → RecommendationEngine → QuoteRefreshPolicy
- WidgetPayload serialiser (tiering + text fitting)
- Widget studio, onboarding, settings
iOS widget extension
- WidgetKit bundle — 7 families
- SwiftUI views + containerBackground
- AdelantePayload decoder
- currentPoolIndex (Swift copy) + 12-entry timeline
Android widget
- AppWidgetProvider (RemoteViews)
- 5 baked layout variants — one per typeface
- Canvas-rendered gradient bitmap, 480 px cap
- currentPoolIndex (Kotlin copy)
On my infrastructure
Firebase — written, never deployed
- getContent (onRequest) — delta sync, 5-min CDN cache
- scheduledContentImport (onSchedule) — every 24 hours
- approveContent / rejectContent (onCall, admin)
- setAdminClaim (onCall) — one-time bootstrap
- Firestore: content, content_versions, content_reviews, sources, meta
Outside the system
Build-time tooling — developer machine
- tool/generate_library/gate.dart — dedup, quality, license
- ios/setup_widget_target.rb — Xcode extension target
- ios/add_widget_fonts.rb — idempotent font resources
- Build-time tooling — developer machine→Flutter app — devicegenerates library.json into the app bundle
- Build-time tooling — developer machine→iOS widget extensioncreates and repairs the extension target and its font resources
- Flutter app — device→Shared local store — devicewrites one serialised payload via home_widget
- Shared local store — device→iOS widget extensionread-only — the extension never opens a socket
- Shared local store — device→Android widgetread-only — the provider never opens a socket
- Flutter app — device→Firebase — written, never deployedanonymous GET ?since=<version> — disabled unless an endpoint is compiled in
07/ENGINEERING CHALLENGES
Where it got difficult
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.
Where to look notes
lib/features/widgets/domain/widget_rotation.dart:6; ios/AdelanteWidgets/AdelanteWidgetShared.swift:96; android/app/src/main/kotlin/com/adelanteapp/adelante/AdelanteWidgetProvider.kt:373; lib/core/home_widget/widget_payload.dart:31-32; ios/AdelanteWidgets/AdelanteWidgetProvider.swift:21-36. Commits 82c8e51 (2026-06-21), fd47846 and fb80270 (2026-07-18). Test counts from `grep -rE "^\s*(test|testWidgets)\(" test/*.dart`.
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.
Where to look notes
docs/superpowers/specs/2026-07-18-adelante-overhaul-design.md:41-43 (baseline defect); lib/features/widgets/domain/widget_text.dart:32; lib/core/home_widget/widget_payload.dart:107; ios/AdelanteWidgets/AdelanteWidgetViews.swift:38-44; AdelanteWidgetProvider.kt:74-79.
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.
Where to look notes
ios/AdelanteWidgets/Info.plist:24-34; ios/AdelanteWidgets/AdelanteWidgetViews.swift:148-157; ios/setup_widget_target.rb (86 lines); ios/add_widget_fonts.rb (29 lines); AdelanteWidgetProvider.kt:113-121; pubspec.yaml fonts block, 4 OFL families as 8 .ttf files; build artifacts dated 2026-07-18 15:09.
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.
Where to look notes
AdelanteWidgetProvider.kt:230-270 (bitmap path and 480 px cap), :129-130 (the no-setTextViewTextSize comment); test/android_widget_remote_views_test.dart (2 declarations); android/app/src/main/res/layout/adelante_widget.xml.
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.
Where to look notes
tool/generate_library/gate.dart (396 lines; dedup layers at :309-312, filter lists at :133-173, seedCurated at :191, id derivation at :376-395); functions/src/model.ts (217 lines) and functions/src/index.ts:132; assets/content/library.json verified with json.load — 11,837,788 bytes, 12,828 entries, 12,828 unique ids, 12,828 unique texts, 20 categories; faith counts islam 599, judaism 559, christianity 559, hinduism 558, buddhism 552, generalSpiritual 3, null 9,998; contentType original 12,732, reflection 19, affirmation 5, quote 45, verse 15, ayah 12 — the 12,732 is the generator figure; licenseStatus original is 12,756 because it folds in the 24 hand-written seed entries, which is why it is not the number published. tool/generate_library/report.txt (git-tracked) records the run: 139 raw batch files, 12,741 raw records read, 9 rejected (2 slop, 6 exactDuplicate, 1 nearDuplicate, and zero invalidLicense or missingCitation). All re-verified with json.load on 2026-07-29.
08/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
Instead of Fetching the library on first run and caching it
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.
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.
Where to look notes
docs/superpowers/specs/2026-07-18-adelante-overhaul-design.md:17-20 and :61; pubspec.yaml asset comment; assets/content/library.json is 11,837,788 bytes and git-tracked; ContentLibraryLoader.load parses it at boot.
Sync is off unless an endpoint is compiled in
Chose String.fromEnvironment('ADELANTE_CONTENT_ENDPOINT') with an empty default, which leaves isContentSyncEnabled false
Instead of A runtime feature flag, or an endpoint compiled in and toggled in settings
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.
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.
Where to look notes
lib/app/backend_config.dart:6-8 (the quoted comment) and :17; .firebaserc; absence of Firebase config files verified across the repository.
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
Instead of Keeping the content as compiled Dart, which is what the 1,498-line seed file used to be
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.
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.
Where to look notes
lib/features/content/data/content_library_loader.dart:9-12 (the quoted comment and the fallback); lib/features/content/data/seed_content.dart (1,498 lines); design spec lines 166-168 (Non-goals: no local DB migration, revisit past ~10k).
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
Instead of An account layer, which would have let favourites, presets and widget configurations follow a user between devices
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.
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.
Where to look notes
design spec line 165 (Non-goals); lib/features/content/data/remote_content_source.dart docstring :21-23; lib/app/local_store.dart (18 SharedPreferences keys, no upload path in the codebase); android/app/src/main/AndroidManifest.xml declares zero uses-permission entries; ios/Runner/Info.plist has no NSUserTrackingUsageDescription; firestore.rules requires request.auth.token.admin == true for all writes.
Firestore over Supabase for the content backend
Chose Firebase — Firestore, Cloud Functions v2 on Node 20, security rules and composite indexes
Instead of Supabase and Postgres
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.
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.
Where to look notes
docs/backend/content-system.md ('Backend Choice'); functions/package.json (Node 20, firebase-functions ^6.1.0, firebase-admin ^12.6.0); firestore.rules (41 lines, header comment; wc -l, 2026-07-29); functions/src/index.ts:47 (getContent); firestore.indexes.json (3 composite indexes).
09/DESIGN EVOLUTION
What changed, and when
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.
Evidence notes
docs/superpowers/specs/2026-07-18-adelante-overhaul-design.md:37-43.
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.
Evidence notes
Commit 82c8e51 (2026-06-21) 'remove independent pool rotation + anchor drift'; fd47846 and fb80270 (2026-07-18).
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.
Evidence notes
lib/features/content/data/search_index.dart (weights, _prefixScores at :116); lib/features/content/data/quote_repository.dart:75 (searchRanked docstring), :16-22 and :47-63 (cached unmodifiable view; favourite and markShown skip reindexing).
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.
Evidence notes
git show <sha>:assets/content/library.json piped through json.load — b22474d = 10,047 against a commit message claiming 10,051; 476346c = 10,056 and 3e0d06d = 12,828 are both exact; HEAD = 12,828.
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.
Evidence notes
Commits f4535e9 and 1c3343b (2026-06-21), c974605 (2026-07-18); ios/Runner/AppDelegate.swift:26-147 (adelante/native_tab_bar platform view factory, comment at :106-111); lib/features/app/presentation/native_tab_bar.dart.
10/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
How it was counted notes
find test -name '*_test.dart' -type f | wc -l → 18; find test -type f -name '*.dart' | xargs wc -l → 1898.
How it was counted notes
grep -rE "^\\s*(test|testWidgets)\\(" test/*.dart | wc -l → 117, across 20 groups. Two of the 117 sit inside generating loops (5 and 17 iterations), so the executed count is higher than the declared one. At commit 6bc4715 (2026-06-21) the same command returned 60 across 13 files.
How it was counted notes
grep -rEo "expect\\(" test/*.dart | wc -l → 284.
How it was counted notes
grep -rE "^\\s*testWidgets\\(" test/*.dart | wc -l → 0.
How it was counted notes
functions/ contains no test files, verified by listing the directory recursively. The five Cloud Functions are exercised only from the client side.
Representative cases
- A user who picks one tradition is never shown another — Faith filtering is a hard predicate applied before any scoring, in two places that both switch exhaustively over the ReligiousPreference enum. The tests generate one case per faith asserting both a minimum count and that no other tradition appears, plus a case that Islam-only never surfaces another faith and one that secular categories never leak scripture when no faith has been chosen.
- A Dart test that asserts on Swift — test/ios_widget_source_test.dart reads ios/AdelanteWidgets/AdelanteWidgetViews.swift and asserts the containerBackground modifier is applied outside the family switch. Moving it inside compiles perfectly and breaks the surfaces that require a container background — the exact class of bug a compiler cannot see.
- A Dart test that asserts on Android XML — test/android_widget_remote_views_test.dart reads the widget layout and asserts autoSizeTextType="uniform" is present and android:textSize= is absent from the quote TextView. Both halves guard the same one-line regression: any runtime text size silently disables uniform auto-sizing.
- Content floors asserted against the shipped asset — test/content_asset_floor_test.dart and test/content_library_test.dart load the real 12,828-entry library rather than a fixture and assert per-category and per-faith minimums, so a regenerated library that thins a category fails a test run instead of quietly shrinking a widget's pool.
11/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.
12/REFLECTION
In hindsight
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.