A local-first, server-synced iOS bill-splitting app — think Splitwise, built in SwiftUI for iOS 17+, with multi-currency expenses, multi-payer support, and Grovs-powered deferred deep-link invites.
Download on the App Store · tabkeep.uk · Licensed under the MIT License.
Invite flows are the connective tissue of any bill-splitting app — and TabKeep's are built on Grovs. Grovs handles:
- Universal Links — taps on
https://<your-grovs-domain>/<token>route the user directly to the invite-accept screen, even from cold app launches. - Deferred deep linking — if the recipient doesn't have TabKeep installed yet, Grovs preserves the invite token across the App Store install so the first launch lands on the group they were invited to. No lost invites.
- Branded short links — every invite URL uses the project's own short domain.
- Cross-platform ready — same SDK works for iOS, Android, and web.
The integration lives in Services/InviteService.swift (Grovs SDK wrapper) and the invite-flow state machine in Store/AppStore.swift. Without Grovs, every accept would require manual deep-link plumbing, AASA hosting, deferred-token storage at the OS level, and post-install token replay — all of which Grovs collapses into a single SDK call. If you fork this project, grab a free Grovs project at grovs.io and drop the SDK key into the config.
App Store screenshots live in screenshots/framed/ (iPhone) and screenshots/ipad/framed/ (iPad).
- Group expenses with equal, exact-amount, and percentage splits
- Multi-payer expenses and standalone member-to-member payments — both are first-class and balance-affecting
- Per-expense currency with snapshotted FX rate; group totals roll up in the group's currency
- Balance calculation + greedy debt simplification as pure, deterministic algorithms (Decimal-only math, banker's rounding)
- Activity feed and statistics dashboard — week / month / quarter / year / custom range, with category breakdowns
- Receipt photo attachments, uploaded to the backend and lazily downloaded for offline viewing
- Grovs-powered invite links with deferred deep linking (see above)
- Apple Sign-In and Google Sign-In, with anonymous device sessions before the user signs in (so the app works fully offline from launch)
- APNs push notifications for group activity
- Light / dark / system appearance, undo for any deletion, optimistic-write sync with conflict-banner resolution
| Layer | Choice |
|---|---|
| Language | Swift 5.10 |
| UI | SwiftUI + @Observable (no UIKit views) |
| Min iOS | 17.0 |
| Project | XcodeGen — project.yml is the source of truth, .xcodeproj is generated and git-ignored |
| State | Single @Observable AppStore as the source of truth |
| Persistence | Versioned JSON on disk (schema v19, explicit migrations) |
| Sync | State-based outbox (Sync/SyncState actor + SyncDrainer actor); local-first writes, parents-before-children on upsert, children-before-parents on delete |
| Auth | Apple Sign-In (native), Google Sign-In (GoogleSignIn-iOS), anonymous device bearer tokens |
| Push | APNs via UNUserNotificationCenter + custom upload to the backend |
| Deep links | Grovs — universal links + deferred deep linking |
| FX rates | Frankfurter (ECB-sourced, free public API) — snapshotted per expense and cached |
| Analytics | Firebase Analytics (firebase-ios-sdk) |
| Backend | Rails (separate repository, not included here) |
Dependency management is via Swift Package Manager, declared in project.yml. No CocoaPods, no Carthage.
- macOS with Xcode 16 or newer
- iOS 17 simulator or device
- XcodeGen —
brew install xcodegen - An Apple Developer team for signing
- A Rails backend the iOS app talks to (separate repository, not included here)
- A free Grovs project for invite links
- A Firebase project for Analytics (optional — set up dummy values if you don't want analytics)
- A Google Cloud OAuth iOS client for Google Sign-In
This repo ships with REPLACE_ME_* placeholders for every account-bound credential and identifier. You must replace them with your own values before the app will build, sign, or run end-to-end. Search for REPLACE_ME_ across the tree to find every spot:
grep -r REPLACE_ME_ . --exclude-dir=.git --exclude-dir=build| Placeholder | File(s) | What to put there |
|---|---|---|
REPLACE_ME_TEAM_ID |
project.yml |
Your Apple Developer Team ID (10-char, from developer.apple.com → Membership) |
com.example.tabkeep |
project.yml, TabKeep/GoogleService-Info.plist (BUNDLE_ID) |
Your app's bundle identifier — must match across both files and your Apple Developer App ID |
REPLACE_ME_API_HOST |
Config/Release.xcconfig |
Hostname of your deployed Rails backend (no scheme, no path) — e.g. api.example.com. Debug builds default to localhost:3000. |
REPLACE_ME_GROVS_SDK_KEY |
Config/Debug.xcconfig, Config/Release.xcconfig |
Your Grovs project SDK key (used for deep-linked invites). A Release build with this placeholder unset fails fast via a pre-build script guard. |
REPLACE_ME_GROVS_DOMAIN |
TabKeep/TabKeep.entitlements |
Your Grovs Universal Link host — e.g. myapp.sqd.link. Goes in the applinks: entry. |
REPLACE_ME_GROVS_URL_SCHEME |
project.yml, TabKeep/Info.plist |
Your Grovs custom URL scheme (Grovs assigns one per project). Listed in CFBundleURLTypes. |
REPLACE_ME_GOOGLE_OAUTH_CLIENT_ID |
Config/Shared.xcconfig |
The numeric prefix of your Google Cloud Console iOS OAuth client ID. The two lines build the full client ID + reversed client ID from this one value. |
REPLACE_ME_FIREBASE_API_KEY, REPLACE_ME_GCM_SENDER_ID, REPLACE_ME_FIREBASE_PROJECT_ID, REPLACE_ME_GOOGLE_APP_ID |
TabKeep/GoogleService-Info.plist |
Easiest: download a fresh GoogleService-Info.plist from the Firebase console for your iOS app and replace the whole file. |
- Apple Developer: create an App ID with your bundle identifier and enable Sign in with Apple, Push Notifications, and Associated Domains capabilities.
- Rails backend: deploy your own (the iOS app expects the REST shape defined by the DTOs in
TabKeep/Services/API/DTO/). - Firebase: create a project, register an iOS app with your bundle ID, download the new
GoogleService-Info.plistintoTabKeep/. - Google Cloud Console (in the same Firebase project): create an OAuth 2.0 iOS Client ID for Google Sign-In, paste it into
Config/Shared.xcconfig. - Grovs: create a project at grovs.io, copy the SDK key into the xcconfigs, copy the Universal Link domain into the entitlements, copy the URL scheme into
project.yml+Info.plist. Register your Apple Team ID + Bundle ID in the Grovs console so the Apple App Site Association file is served correctly. - APNs: upload your APNs Auth Key to whatever your backend uses to send pushes.
The Xcode project is generated from project.yml and is git-ignored. Generate it, then build:
xcodegen generate
xcodebuild -project TabKeep.xcodeproj -scheme TabKeep \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-configuration Debug buildOpen TabKeep.xcodeproj in Xcode to run on a simulator or device. There are no automated tests — verification is a clean xcodebuild build plus manual exercise in the simulator.
A launch argument --uitest-fresh-store wipes the local JSON store before AppStore loads — useful for testing flows from a clean slate.
In DEBUG builds, API_BASE_URL can be overridden at runtime via UserDefaults key api_base_url_override (see Services/API/APIBaseURL.swift).
See CLAUDE.md for the full architectural tour — layer boundaries, the state-based outbox sync model, multi-currency handling, auth state machine, invite flow, and the JSON persistence schema (currently v19).
Quick map:
App/—@mainentry point,AppDelegatefor APNs + Grovs activity forwardingAlgorithm/— pure value-in/value-out logic (balances, debt simplification, statistics)Models/—Codablevalue typesStore/AppStore.swift—@Observablesingle source of truth, persisted viaJSONPersistenceRepositories/— local / remote / hybridGroupsRepositorySync/— outbox state and drainerServices/— FX (Frankfurter), Auth (Apple/Google + Keychain), API client + DTOs, Push (APNs), Invite (Grovs)Views/— SwiftUI feature views and components
MIT.