Family cycle tracker built with Flutter: an account (Supabase Auth) syncs
cycle profiles across a household's devices and lets guardians share a
profile — co-parents, caregivers — each with their own role, invitation,
and attribution (see "Family sharing" below); sync and multi-guardian
collaboration are the product's priority and its differentiator, not an
add-on. One adult operator manages the profiles (some are minors), and the
account mirrors the local store into row-level-secured Postgres and syncs
it offline-first across the operator's devices and to accepted guardians;
nothing is uploaded until the operator signs in and, on a device that
already holds data, explicitly consents to the upload. Entries are stored
on the device encrypted at rest behind a biometric gate, and the app keeps
logging, viewing, and predicting when the network doesn't — offline is a
reliability property of the app, not its identity.
Three parties receive data off-device, all only for app functionality:
Supabase (the account email and the synced cycle rows), Sentry
(crash reports reduced to an allowlist — no user, no health content), and
Resend (transactional support email — an admin alert when you submit an
in-app feedback ticket, and the reply email if the admin responds; never the
ticket's message body or any attachment). Today the app tracks cycles and
flow, estimates the next period, and estimates a fertile window and ovulation
day from that same logged history (issue #143) — a calendar-method
back-calculation only, with the same confidence tiering as the period
estimate and no new user input; fertility-signal logging (BBT, cervical
mucus, ovulation tests, issue #144) that would refine it further does not
exist yet.
Targets iOS (iPhone first-class) and Android. The web client is a separate
React app in webapp/; the Flutter app itself no longer
builds for the web. The app holds sensitive health
data, including minors'; this repository is public, open source, and fully auditable,
and must never contain real personal or health data.
Privacy Policy: Read our full Privacy Policy.
Security Policy: Read our Security Policy to report vulnerabilities responsibly.
License: PolyForm Noncommercial 1.0.0 (Free for personal/noncommercial use; commercial resale prohibited).
Status: pre-release. TestFlight builds ship from main (latest upload 2026-09-30); store submission is gated (see Known limitations).
Flutter lives at C:\src\flutter on the lab desktop and is not on the
persistent PATH — prepend it in each shell that needs it:
$env:Path = "C:\src\flutter\bin;$env:Path"Then, from the repo root:
flutter pub get
flutter analyze # lints (flutter_lints)
flutter test # unit + widget tests
flutter run --dart-define-from-file=dart_defines.json # with Supabase/Sentry configured
flutter build apk --debug # Android debug APK
flutter build ios --release --no-codesign # unsigned; requires macOSWithout --dart-defines the build has no account section, no sync, and no
crash reporting — a development configuration for iterating without cloud
credentials, not a supported product mode: the shipped app is built around
an account, sync, and family sharing. See "Config & credentials".
dart run tool/quality_gate.dart # 90% coverage floor + per-method CRAP gate (also runs in CI)
dart run tool/mutation_gate.dart # mutation score for changed files (local only, no CI gate)
dart run tool/mutation_gate.dart --full # every non-excluded lib/ file; no time budgetquality_gate.dart runs flutter test --coverage (or, with one or more
--lcov <file> arguments, skips the run and merges those files — how CI
evaluates the gates over its three parallel test shards), filters coverage/lcov.info
through the reviewed exclusion list (tool/quality/exclusions.dart —
generated code plus platform adapters that can't run under flutter test,
e.g. PluginGoogleSignInClient), then checks total line coverage (floor
90%) and CRAP per method (comp² × (1 − cov/100)³ + comp, gate at 10),
printing both reports either way. flutter test alone is unaffected and
stays fast for iteration — the gates run only through this script (and the
CI step that calls it).
mutation_gate.dart wraps the mutation_test package: by default it
mutates only files changed against origin/main (including uncommitted
changes); when every changed file has a directly mirrored test file
(lib/a/b.dart → test/a/b_test.dart — true for most of lib/domain/),
it scopes the test command to just those files, which is what keeps a
typical run to roughly a minute or two. A change without a direct mirror
(most lib/ui//lib/data/ files, tested through broader per-feature
suites) falls back to the full test suite per mutant — slower, but never
silently mis-scoped. Reports the mutation score and surviving mutants per
file; nothing here gates CI, and the score isn't tracked automatically —
note it in the PR description if it's worth recording.
The remote schema, RLS policies, and the sync_push RPC live in
supabase/migrations/ and are proven by pgTAP tests in supabase/tests/.
The Supabase CLI is pinned to 2.116.0 and run through npx (not installed
globally); Docker must be running.
npx supabase@2.116.0 start -x realtime,storage-api,imgproxy,mailpit,studio,edge-runtime,logflare,vector,supavisor
npx supabase@2.116.0 db reset --local # re-apply migrations from scratch
npx supabase@2.116.0 test db --local # 355 pgTAP tests
npx supabase@2.116.0 stop --no-backupsupabase/config.toml configures the local stack only (its auth
settings — 6-character passwords, confirmations off, 1-hour JWT — are the CLI
defaults and do not reflect the cloud project's dashboard settings; see
docs/ops/supabase-go-live.md).
On Williams-Mini (or any macOS build machine with Xcode and Apple Team ID 5273C9R3V4):
# 1. Fetch dependencies
flutter pub get
# 2. Build unsigned archive
flutter build ipa --release --no-codesign
# Or via xcodebuild:
xcodebuild -workspace ios/Runner.xcworkspace -scheme Runner -configuration Release -archivePath build/ios/archive/Runner.xcarchive archive CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO
# 3. Export signed IPA using ExportOptions.plist
xcodebuild -exportArchive -archivePath build/ios/archive/Runner.xcarchive -exportOptionsPlist ios/ExportOptions.plist -exportPath build/ios/ipa
# 4. Deploy to connected physical device
xcrun devicectl device install app --device <device-id> build/ios/ipa/lunarlog.ipa
# or via flutter:
flutter install -d <device-id>The iOS target now carries ios/Runner/Runner.entitlements (Sign in with
Apple), so any signing profile — local or CI — must be generated with that
capability enabled on the com.wjdavis5.lunarlog App ID.
CI (.github/workflows/ci.yml) runs analyze +
test on ubuntu, the pgTAP database tests against a
local Supabase stack (db-tests job), a debug APK on ubuntu, and an unsigned
iOS build on macOS, for pushes and PRs to main. Every build passes the
Supabase, Sentry, and Google --dart-defines from repository secrets; on
forks they resolve to empty and the build must still succeed.
Database migrations are pushed to the cloud project by
.github/workflows/supabase-migrate.yml
when supabase/** changes on main, inside the production GitHub
environment (required reviewer). Automated iOS release to TestFlight and App
Store review (primary) is handled by
.github/workflows/ios-release.yml
(mirrored from taxiGame). Automated Android release to Google Play is handled
by .github/workflows/play-store-release.yml.
The iOS release declares no non-exempt encryption usage to App Store
Connect (the app compiles no third-party at-rest cipher; at-rest protection
is the OS's own), paired with an App Store availability restricted to the
US and Canada; see
docs/ops/ios-export-compliance.md for
the classification and the operator's recurring filing duties.
The account (Supabase Auth) is what makes sync and family sharing work:
signing in is how a device joins the household and how a profile gets
shared with other guardians. It stays technically optional — an
unconfigured build has no account section at all, and even in a
configured build nothing uploads until the operator signs in and, on a
device that already holds data, explicitly consents — but it is not an
afterthought; it is the product's core loop. Sign-in methods:
email/password, Google (iOS and Android, native picker), Apple (iOS), and
passwordless email — a sign-in link opened on the requesting device or the
code from the same email typed into the app. A signed-in operator can
see which methods the account has in the account section, add Google (or
Apple on iOS) to it after a fresh device-credential check, and remove
Google or Apple the same way once the account holds another method —
email/password is never removable, since it is the account's only
recovery path. Sign-ups are open in the dashboard: lunarlog is a public
store app, so anyone may download it and create an account (issues #800,
#821, #971). The app shows "New accounts for this app are set up by the
account owner" only when an operator closes sign-ups for a locked-down
deployment. Passkeys are deferred (Supabase passkeys are beta and
need an HTTPS relying-party domain the app does not yet have). The Google
button is hidden in any build without both GOOGLE_* defines. A signed-in
operator can delete the account outright (server rows, the account, an
Apple revocation when applicable, then the device reset) — a fresh
device-credential check runs first, the same as adding a sign-in method.
Exporting a JSON copy of profiles and entries does not require that check:
it sits behind the app's own lock, lives under Settings → Your data, and
needs no account at all (server data is included in the export only when
signed in); see PRIVACY.md for what each does. The same
section also offers "Export clinical summary (FHIR)" (issue #157): a
separate, IPS-shaped FHIR R4 document Bundle — self-reported cycle
Observations, a Composition, a Patient carrying only the display name, and
one Provenance record — built entirely on-device and delivered the same
share-sheet way; see docs/clinical/fhir-export.md.
The parent who created a
minor's profile can also transfer ownership of it to the minor's own
account (Issue #4): a "Transfer profile" action on the caregivers screen
lets the parent pick their own post-transfer role (co-manager or
read-only), then produces a single-use, 72-hour claim link the child opens
on their own device to sign in or create an account and confirm. Every
past entry stays exactly where it was, still labelled with who logged it;
the parent's continued access afterward is an ordinary, revocable
membership rather than custodial control. Plans:
docs/plans/2026-09-02-001-feat-supabase-auth-cloud-sync-plan.md,
docs/plans/2026-09-03-001-feat-social-logins-plan.md,
docs/plans/2026-09-05-001-feat-remove-linked-sign-in-method-plan.md,
docs/plans/2026-09-05-001-feat-account-deletion-and-json-export-plan.md,
and
docs/plans/2026-09-06-001-feat-child-ownership-transfer-plan.md.
A profile can be shared with other signed-in guardians via a single-use
invite link (lunarlog://invite?code=..., 1-168 hour TTL), with roles
primary_guardian, co_parent, caregiver, and viewer. A viewer sees
the calendar, history, and predictions but cannot open a writable day
sheet — tapping a day shows a read-only view naming the view-only reason,
distinct from an archived profile's read-only view. An outstanding
invitation can be cancelled from Manage Guardians before it is redeemed
(the same role ladder as removing a guardian: the primary guardian may
cancel any invitation; a co-parent may cancel one it created plus any
caregiver/viewer invitation) — cancelling makes the link permanently
unredeemable. When two guardians log symptoms for the same date while
offline or simultaneously, the app keeps both: their tags are merged
(a set union) onto the surviving entry rather than one caregiver's tags
being silently destroyed; flow and the free-text note remain
last-writer-wins, since neither has a merge that is both order-independent
and meaningful. Plan:
docs/plans/2026-09-06-001-feat-family-sharing-invitations-plan.md
(gap-closure pass against the shipped guardian/sharing infrastructure from
issue #8).
Remote backend: Supabase Cloud (dleexnnevuuddcgcpztq); crash reporting:
Sentry. Configuration reaches the app in exactly one way and the tooling in
another:
- App (client-side, build time):
lib/config.dart(AppConfig) readsSUPABASE_URL,SUPABASE_PUBLISHABLE_KEY,SENTRY_DSN,SENTRY_TRACES_SAMPLE_RATE,GOOGLE_IOS_CLIENT_ID, andGOOGLE_WEB_CLIENT_IDvia--dart-define. Empty means unconfigured —SENTRY_TRACES_SAMPLE_RATEstays empty in every workflow (opt-in only, after issue #19). For local runs copydart_defines.example.jsontodart_defines.json(gitignored; client-safe values only — the publishable key, DSN, and Google client ids are designed to ship inside the binary) and pass--dart-define-from-file=dart_defines.json. The iOS reversed Google client id is committed as a URL scheme inios/Runner/Info.plistonce the ids exist. - Tooling (server-side only):
.env(gitignored;.env.exampleis the template) feeds the Supabase CLI and direct database access — project ref, database password,DATABASE_URL. The app never reads it. - CI: GitHub repository secrets on
wjdavis5/lunarlogsupply the five client-safe defines to every workflow build; theproductionenvironment holds the migration credentials (SUPABASE_ACCESS_TOKEN,SUPABASE_DB_PASSWORD,SUPABASE_PROJECT_REF) behind a required reviewer.
Never commit credential values. The full list of secrets, the dashboard
settings the code cannot apply, and the go-live checklist live in
AGENTS.md and docs/ops/supabase-go-live.md.
Part of the home lab; the canonical inventory lives in the lab root's
CLAUDE.md (C:\git on the lab desktop).
- 2026-09-06, Flutter 3.47.2 stable on Windows (branch
issue-50):flutter analyzeclean (0 issues);flutter test903/903 passed;dart run tool/quality_gate.dartpassed (90% coverage floor + per-method CRAP gate). Not run in this environment: the pgTAP suite (needs Docker), the iOS build (needsWilliams-Mini), and the manual device checklist. - 2026-09-06, Flutter 3.47.2 stable on Windows (branch
issue-5, Issue #5 caregiver alerts and reminders):flutter analyzeclean (0 issues);flutter test955/955 passed;dart run tool/quality_gate.dartpassed (93.25% coverage, 0 CRAP-gate methods over threshold);flutter build apk --debugsucceeded with no--dart-defines (R17/R22);npx supabase@2.116.0 start+db reset --local+test db --localran 373/373 pgTAP tests;deno testinsupabase/functions/passed 41/41. Not run in this environment: the iOS build (macOS only) and every real-delivery device-checklist item (no Firebase project or physical device) — tracked indocs/ops/supabase-go-live.md. - 2026-09-03, Flutter 3.47.2 stable on Windows (branch
feat/supabase-auth-cloud-sync):flutter analyzeclean (0 issues);flutter test377/377 passed;flutter build web --release(no defines) andflutter build apk --debugboth succeeded (the Android Gradle build prints an upstream warning thatsentry_flutterapplies the Kotlin Gradle Plugin itself; harmless today, but a future Flutter may reject it);npx supabase@2.116.0 start+db reset --local+test db --localran 135/135 pgTAP tests. Dependencies: supabase_flutter 2.17.2, sign_in_with_apple 8.2.0, sentry_flutter 9.28.0, Supabase CLI 2.116.0. Not run in this environment: the iOS build onWilliams-Mini, the device checklist, the Sentry smoke test,supabase db push --dry-run, and the MCPget_advisorscheck — all tracked indocs/ops/supabase-go-live.md. - 2026-09-02, Flutter 3.47.2 stable on Williams-Mini (macOS arm64, Xcode 26.6):
flutter analyzeclean (0 issues);flutter test162/162 tests passed;flutter build ipa --release --no-codesignbuiltRunner.xcarchive(179.9MB);flutter build ios --release --no-codesignbuiltRunner.app(20.3MB). (Pre-dates the Sign in with Apple entitlement; re-verify.) - 2026-09-02, Flutter 3.47.2 stable on Windows:
flutter analyzeclean (0 issues);flutter test162/162 unit & widget tests passed across domain, data, gate, notifications, profiles, logging, and overview suites;ios/Runner.xcodeprojconfigured withPRODUCT_BUNDLE_IDENTIFIER = com.wjdavis5.lunarlog,DEVELOPMENT_TEAM = 5273C9R3V4,ExportOptions.plist, andPrivacyInfo.xcprivacy. - 2026-08-30, Flutter 3.47.2 stable on Windows:
flutter doctor -vall green (Android SDK 36.0.0, all licenses accepted, Chrome present);flutter analyzeclean;flutter test1/1 passed;flutter build apk --debugandflutter build web --releaseboth succeeded. - Re-verify after a Flutter SDK upgrade or any change to build config.
- Backup is account-based: a device signed in to an account keeps a copy of its data in that account and can restore it on another device. A device that never signed in has no backup — losing it loses the data. "Export my data" (Your data section) saves a JSON file of profiles and entries through the share sheet; it is still a manual, one-time export, not an automatic backup mechanism, but as of issue #140 that file is no longer a dead end — "Import from file" (same section) reads it back, previews what would change, and merges it into the local store, so exporting before a wipe or reinstall is a real (manual) restore path even for a device that never signed in.
- In-app account deletion and JSON export have shipped in code (issue
#17), but release itself still gates on the mechanically-enforced
check: no App Store submission and no Play
productiondispatch until theRELEASE_GATE_ACCOUNT_DELETIONrepository variable is set toshipped(.github/scripts/check-release-gate.shfails closed until then; a Playproductiondispatch also requires typingproductionintoconfirm_production). The variable readshippedon 2026-10-05 (set 2026-09-12);gh variable listshows its current value. Flipping the variable — once issue #17 has merged and the device checklist has passed — is a separate, deliberate release action, not automatic from merging the code. - "Sign out everywhere" revokes sessions, not tokens: other devices keep access until their JWT expires (up to an hour at Supabase's default, the chosen setting), which is why the app's copy says other devices may take up to an hour to notice.
- The former "no fertility features, by design; do not add them" commitment
was removed (#142, following the owner decision in #123). Fertile-window and
ovulation estimation (#143) has shipped: a calendar-method back-calculation
(next predicted period start minus an assumed 14-day luteal-phase length,
window ovulation −5…+1 days) computed on-device from already-logged cycle
dates, carrying the exact same confidence tier as the period prediction it
is derived from, shown on the calendar and in Insights with its own
not-contraception disclaimer, and available on every profile including
minors' with no restriction. Fertility-signal logging — BBT, cervical
mucus, ovulation tests (#144), which would refine the estimate beyond
calendar math — is still not built.
PRIVACY.mdrecords both the narrowed promise and the fertile-window disclosure in its change history. - App-switcher snapshots are suppressed by an opaque Flutter cover whenever
the app is not resumed, plus FLAG_SECURE on Android (a tiny platform
channel in
MainActivity.kt); iOS has no Flutter-level FLAG_SECURE equivalent, so the cover is the only mechanism there. - Backgrounding does not re-lock while the app's own system UI is on
screen — its credential prompt, the Google picker, the Apple sheet.
Neither platform distinguishes those from the operator genuinely
leaving (iOS reports a spurious
hiddenfor native modals, flutter/flutter#146734; Android's passcode fallback really does background the activity), and treating them as departures made the app impossible to unlock at all (issue #65). Content stays covered for the whole window; a departure the window absorbed is answered fail-closed the moment the system UI comes down, and a window left open locks after two minutes regardless of the inactivity toggle. The reminder scheduler's notification-permission prompt is covered by this too, on both platforms (issue #168): it is raised by the overview hint's "Turn on reminders" action, which runs inside the same window. The scheduler never asks at startup — iOS since issue #863, Android since issue #1425 — so starting reminders at launch opens no window and covers nothing. A push-configured build does still ask at launch, from push registration; that ask opens the window around its own request, and only when the system dialog can actually appear. - iOS: the database file now lives under
getApplicationSupportDirectory(), notDocuments/(issue #244 —Documents/is included in iCloud/device backup by default, and so isApplication Support: onlytmp/andLibrary/Caches/are excluded automatically. The directory move is hygiene, not the control), with a one-time crash-safe startup migration that relocates a pre-#244 install'sDocuments/lunarlog.db(and its-wal/-shm/-journalsiblings) the first time it runs (lib/startup/database_relocation.dart'srelocateLegacyDatabase— stages every sibling, verifies the staged copy against the legacy database, then atomically renames each into place and writes a completion sentinel last; any failure rolls back the partial target and falls back to opening the legacy file). The actual backup control isAppDelegate.swift'slunarlog/privacychannel (the iOS half of the same channel Android's FLAG_SECURE handler inMainActivity.ktuses): it marks the file (and its containing directory, so a-journalcreated later is covered too)NSURLIsExcludedFromBackupKeyandNSFileProtectionCompleteUntilFirstUserAuthentication(issue #906) — best effort, but a failure is reported (a type-only breadcrumb plusSentry.captureException, never a path) rather than swallowed silently. This has not yet been verified on a real device/Mac build (tracked as a blocker on issue #244;flutter analyze/flutter test/flutter build apkall pass, but the iOS-only platform-channel code cannot run underflutter testand this repo's Windows dev box cannot build iOS). There is no app-managed database key to pin or unpin now that at-rest protection is the OS's own (docs/ops/ios-export-compliance.md). The Supabase session and PKCE verifier are stored withfirst_unlock_this_deviceand never travel in a backup. - Android:
allowBackup="false"(device/cloud backup off entirely) is now joined byandroid:dataExtractionRules(API 31+) excluding the database file from both cloud backup and device-to-device transfer, andandroid:fullBackupContentfor the pre-31 schema — seeandroid/app/src/main/res/xml/data_extraction_rules.xmlandbackup_rules.xml(issue #244). - Realtime co-caregiver sync (issue #77) publishes only a dedicated
public.sync_signalstable (profile_id, updated_at — no health content) tosupabase_realtime, neverpublic.profiles/public.day_entriesthemselves: Realtime's WALRUS decodes with wal2json, which only honors a publication as a table-level membership list, so a narrow publication column list on the source tables would not have keptnote/tags/flowoff the websocket given this schema's table-wideselectgrant forauthenticated(has_column_privilege, the check Realtime actually applies, passes regardless of the publication's declared columns). This cannot be exercised end-to-end in CI: local and CI Supabase both start with-x realtime(no Realtime container), sosupabase/tests/realtime_publication_test.sqlproves catalog state (what is/isn't published,sync_signals's column list) and trigger behavior (writes populatesync_signals; a non-guardian cannot read another family's signal row; the publication guard corrects a simulated Studio-toggle drift instead of skipping it) from pgTAP. A manual, empirical check against a real Realtime container is still required before every merge that touchessupabase/migrations/20260905100000_realtime_publication.sqlor the coordinator's subscription shape: runsupabase/tests/manual/verify_realtime_delivery.mjs(see its header for usage) against local Supabase started without excludingrealtime(Docker required), or against the cloud project. It was run for this PR against a real local Realtime container and confirmed both halves: (a) the deliveredsync_signalspayload contains onlyprofile_id/updated_at, and (b) Realtime refusesday_entries/profilessubscriptions outright (an "Unable to subscribe to changes..."postgres_changessystem error) rather than silently filtering them, so no entry content reaches the websocket via those tables at all. The script asserts on this directly now (PR #92 review round 2): every channel must genuinely settle apostgres_changessystem message (not just create a channel object) or the script throws, and theprofileswrite it checks against happens after subscribing — the original version wrote it before, which made that half of the check structurally unable to fail. The script also creates exactly one throwaway auth user/profile/day-entries row and deletes all three (and thesync_signalsrow they generate) in afinally, so repeated runs against the cloud project do not accumulate test data or leave real-looking minor's-health-log content behind. This check is deliberately not wired into CI — investigated (arealtime-inclusive CI stack was prototyped) and found out of proportion for one migration's regression coverage; see the plan's KTD4 for why. - Caregiver alerts and reminders (issue #5) have no CI coverage of real push
delivery:
db-testscovers the SQL (eligibility, the outbox, quiet-hours math, the missed-entry scan) andedge-functionscoverspush-dispatch's claim-before-send logic and FCM message shape against fakes, but nothing in CI proves a real FCM/APNs delivery, a real quiet-hours release, or the cloud project'spg_cronschedule actually firing — those are manual device-checklist items (docs/ops/supabase-go-live.md). Push is entirely absent (AppConfig.hasPush) on a build without everyFCM_*dart-define, and richer lock-screen copy behind an opt-in was deliberately not built — every caregiver alert shows the same fixed generic text the app's own local reminders already use, by construction, not by a copy choice that could later be widened.
This project is licensed under the PolyForm Noncommercial License 1.0.0.
You are free to view, modify, and run this software for noncommercial personal and family use. Commercial use, reproduction, distribution, or resale is strictly prohibited without a separate commercial license from the author.
