Mornings are brutal enough. SYNC is an alarm app for two people. You both wake up. From the same alarm. Without cheating.
It is built for two and only two. Once you pair with someone — a six-digit code, no email confirmations, no accounts to forget — you can set alarms for them as easily as you set them for yourself. Their phone rings, your message shows up, and you both start moving.
There are no algorithms. There is no "engagement". There is no cloud-rec of your mornings. There is just an alarm.
- Sign in with Discord or GitHub — no new account.
- Pair with one person by sending them a six-digit code.
- Set alarms for you and them separately, with custom wake-up messages.
- Snooze with one tap. Stop requires a long press — because 6am is when accidents happen.
- Reactions on dismiss (❤️ 😴 🔥 ☕) show up in your shared feed.
- A
WAKE THEMbutton sends an in-app nudge via Supabase Realtime and an FCM HTTP v1 push so it reaches your partner whether their app is foregrounded, backgrounded, or fully killed. - Stats card: snooze streak, on-time rate, last wake-up — just for the two of you.
Strictly what you need to wake up.
| Layer | Picks |
|---|---|
| Frontend | Flutter 3.22+, Dart 3 |
| Backend | Supabase (Postgres + Realtime + Edge Functions) |
| Auth | Discord OAuth (primary) · GitHub OAuth |
| Local alarms | alarm (exact alarms, wake-lock) |
| Notifications | flutter_local_notifications |
| State | flutter_riverpod |
| Push | Firebase Cloud Messaging (HTTP v1, service-account auth) |
| UX scaling | flutter_screenutil + intl |
| CI | GitHub Actions → release APK per push |
UI is strictly monochrome. #000000 and #FFFFFF plus five greys. No accent colours, no shadows, no rounded corners above 4px. Wordmark is SYNC in monospace. It is supposed to look like a tool, not an app.
The nudge rides two rails so it works in every app-lifecycle state:
-
Supabase Realtime (
lib/providers/nudge_provider.dart).SupabaseService.insertNudge(targetId)writes a row into thenudgestable. Both apps have a Realtime subscription on that table. As long as either app is foregrounded, the recipient sees the Incoming Nudge screen within a heartbeat of the tap. This is the source of truth. -
Firebase Cloud Messaging HTTP v1 (
supabase/functions/nudge/index.ts). After the Realtime insert,SupabaseService.sendNudgePush(targetId)invokes thenudgeEdge Function. The function looks up the recipient'sprofiles.fcm_token, mints an OAuth2 access token from a service-account JSON, and POSTs tohttps://fcm.googleapis.com/v1/projects/<PROJECT_ID>/messages:send. The OS displays a high-priority notification even if the recipient's app is killed. Tapping the notification launches the app and the Realtime subscription picks up from there.
The Edge Function also inserts a duplicate row into nudges so the FCM path is idempotent with the Realtime path — there is exactly one nudge record per WAKE THEM tap.
- Create a project at supabase.com.
- Open the SQL Editor and run
supabase/init.sql. It creates the tables (profiles,pairings,alarms,alarm_logs,nudges), RLS policies, two pairing RPCs, the profile-on-signup trigger, and the Realtime publication.profilesincludes afcm_tokencolumn. - Authentication → Providers → enable Discord and GitHub, drop in your OAuth credentials.
- Authentication → URL Configuration → Site URL:
io.supabase.syncalarm://login-callback/. - Deploy the Edge Function:
supabase functions deploy nudge --project-ref <ref>
- Create a Firebase project at console.firebase.google.com. The Android package name must be
com.sync.alarm(matchesapplicationIdinandroid/app/build.gradle). - Project Settings → Your apps → Add Android app → enter
com.sync.alarm→ Downloadgoogle-services.json. Drop the file atandroid/app/google-services.json. Without it, theapply plugin: 'com.google.gms.google-services'line inandroid/app/build.gradleis skipped and the build still succeeds, but FCM will not function at runtime. - Project Settings → Cloud Messaging → ensure Cloud Messaging API (Legacy, HTTP) is disabled (it is not used in v1). The HTTP v1 API is the only path used.
- Project Settings → Service Accounts → click Generate new private key. A JSON file downloads.
- Read off
client_email,private_key,project_id, andtoken_urifrom the JSON and set them as Supabase Edge Function secrets:(One-line set with the full JSON contents, not the file path.) The Edge Function readssupabase secrets set \ FCM_PROJECT_ID=<project_id from the JSON> \ FCM_SERVICE_ACCOUNT_JSON="$(cat path/to/service-account.json)"
FCM_PROJECT_IDandFCM_SERVICE_ACCOUNT_JSONat invocation time and mints a fresh OAuth2 access token using the Web Crypto API — nogoogle-auth-libraryvendoring needed.
- Discord: Developer Portal → New App → OAuth2 → Redirect:
https://<ref>.supabase.co/auth/v1/callback. - GitHub: Settings → Developer settings → New OAuth App → same callback URL.
The app never contains a Supabase project URL or key in source control. Pass the values at build time instead:
flutter run \
--dart-define=SUPABASE_URL="$SUPABASE_URL" \
--dart-define=SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY"For GitHub releases, set SUPABASE_URL and SUPABASE_ANON_KEY as repository
Actions secrets. The workflow refuses to build an APK if either secret is
missing. The public/anon key will still be present in the compiled APK, as it
must be for a mobile client to connect; protect all data with Supabase RLS.
flutter pub get
flutter run # dev
flutter build apk --release \
--dart-define=SUPABASE_URL="$SUPABASE_URL" \
--dart-define=SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" # shipEvery push to main triggers .github/workflows/build-apk.yml. Set the
SUPABASE_URL and SUPABASE_ANON_KEY Actions secrets once in the repository
settings; the workflow injects them into the release build and fails if either
is absent. It then runs flutter analyze, builds the APK, and uploads it as
an artifact named release-apk. The build is signed with the debug keystore
as a stop-gap — rotate before publishing to the Play Store.
Tip for CI: if you want the GitHub Actions build to ship an APK with FCM wired in, add
google-services.jsonas a repository secret (GOOGLE_SERVICES_JSON_BASE64) and prepend a decode step at the top of the workflow:- name: Decode google-services.json run: echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 -d > android/app/google-services.json
Releases are tagged by hand (v1.0.0, v1.1.0, …). To publish one after CI passes:
gh release create v1.0.0 \
build/app/outputs/flutter-apk/app-release.apk \
--title "v1.0.0 — first wake" \
--notes-file - <<'EOF'
- First public build.
- Discord + GitHub auth.
- Pair with one person. One alarm. No fluff.
- FCM HTTP v1 + Realtime dual-rail nudge delivery.
EOFlib/
├── main.dart # entry, alarm boot, deep-link route
├── supabase_config.dart # env loader with baked-in defaults
├── models/ # alarm, profile, pairing, alarm_log, nudge
├── providers/ # Riverpod: auth, alarm, pairing, connectivity, nudge
├── services/ # Supabase, AlarmService, Battery, FcmService
├── screens/ # splash, login, pair, home, ring, form, reactions, incoming_nudge
├── widgets/ # monochrome_button, alarm_card, day_selector, …
└── theme/ # the only palette you may use
supabase/
├── init.sql # schema, RLS, RPCs, Realtime
└── functions/nudge/index.ts # FCM HTTP v1 nudge writer
android/ # AndroidX, deep-link, exact-alarm perms, FCM default channel
.github/workflows/
└── build-apk.yml # release APK per push to main
The app asks for SCHEDULE_EXACT_ALARM on first launch. If the user denied it, they're sent to system settings with a one-tap path. Without exact alarms, this app is useless — so we surface the permission clearly.
Doze mode: a one-time onboarding dialog explains how to disable battery optimization for SYNC.
iOS: schedules a critical local notification at alarm time as a fallback (Apple does not allow true background alarm ringing).
profiles stores: username, avatar URL, timezone, sleep status (awake/asleep), battery percent, FCM device token (so the partner can push to you). Nothing else.
alarm_logs stores: firing, snoozes, dismisses, and reactions — visible to both members of the pair only.
nudges stores: who nudged whom and when. Used in-app only; auto-pruned after read.
No analytics. No third-party trackers. No Crashlytics, no Google Analytics SDK. The only data we have is the data you typed in.
MIT. Build it, fork it, ship it.

