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
The Adelante Today screen on a phone. A single quote — "When you hold power over someone, hold it the way you would hold water." — is set large in italic under a Religious category chip, with its source, Adelante · Original Adelante, beneath it, then share, favourite and reshuffle buttons. A row below reads "Customize your widget — Transparent Glass · style, source, and timing".
One quote a day, and the source under every one of them. The row at the bottom is the whole app's second job: the screen you are reading is a preview of the widget.
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

  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.

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

Shared local store — device

  • App Group container group.com.adelanteapp.adelante (iOS)
  • SharedPreferences (Android)
  • Bundled asset library.json — 12,828 entries, 11.8 MB
  • 18 preference keys + content cache

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
  1. Build-time tooling — developer machineFlutter app — devicegenerates library.json into the app bundle
  2. Build-time tooling — developer machineiOS widget extensioncreates and repairs the extension target and its font resources
  3. Flutter app — deviceShared local store — devicewrites one serialised payload via home_widget
  4. Shared local store — deviceiOS widget extensionread-only — the extension never opens a socket
  5. Shared local store — deviceAndroid widgetread-only — the provider never opens a socket
  6. Flutter app — deviceFirebase — written, never deployedanonymous GET ?since=<version> — disabled unless an endpoint is compiled in
Three processes, one shared payload, and a backend the shipped build does not need.

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
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.

Test declarations
117
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.

Assertions
284
How it was counted notes

grep -rEo "expect\\(" test/*.dart | wc -l → 284.

Flutter widget tests
0
How it was counted notes

grep -rE "^\\s*testWidgets\\(" test/*.dart | wc -l → 0.

Backend tests
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.
The Adelante Today screen on a phone. A single quote — "When you hold power over someone, hold it the way you would hold water." — is set large in italic under a Religious category chip, with its source, Adelante · Original Adelante, beneath it, then share, favourite and reshuffle buttons. A row below reads "Customize your widget — Transparent Glass · style, source, and timing".
One quote a day, and the source under every one of them. The row at the bottom is the whole app's second job: the screen you are reading is a preview of the widget.
The Adelante Discover screen. A search field reads "Search a mood, author, or goal" beside a filter button, above a For you feed of quote cards. Each card carries its category, the quote in italic, and its full attribution — one reads "The Qur'an, Surah Aal-Imran 3:139, Pickthall translation".
Search is by mood, author or goal rather than by keyword. Every entry carries its full citation — surah, verse and translator where it has one — because a library of 12,828 quotations is only worth anything if each one can be traced.
The Adelante Widget studio. A live preview of the widget sits above controls to rename or duplicate it, a row of named presets — Soft Ivory, Deep Black, Midnight Focus — and a Content section of category toggles including Religious, Spiritual and Successful people.
The widget is configured against a live preview, not guessed at from a settings list. Presets set the surface; the Content toggles decide which of the twenty categories it is allowed to draw from.
The Adelante Favorites screen, listing saved quotations. Each keeps its category chip and its full source line, such as "The Qur'an, Surah Al-Baqarah 2:152, Pickthall translation".
Saved quotations keep their attribution. Nothing in the app ever shows a line without saying where it came from — the citation travels with the text into favourites and out to the widget.