See every subscription you pay for in one calm, glanceable place.
What renews next · what you spend per month and year · what you can cancel.
A native iOS 17+ app that helps you see every subscription you pay for in one calm, glanceable place — what renews next, how much you spend per month and per year, and what you can cancel.
Built with SwiftUI + SwiftData (CloudKit sync) + WidgetKit + UserNotifications + Vision OCR. Your subscription data lives on your device and optionally syncs across your Apple devices via your own iCloud account — no account, no paywall, and no third-party backend for your data.
The UI is light-mode-first with full dark mode — premium, clean, and Apple-like, with soft cards, gentle gradients, rounded numerics, SF Symbols, and tasteful haptics.
- Screenshots
- Highlights
- Feature list (mapped to files)
- Architecture
- Targets & file membership
- Bundle IDs, App Group & iCloud container
- Generating the Xcode project
- Required manual Xcode steps
- Configuration (optional backends)
- CloudKit + SwiftData notes
- Currency conversion & the live-rate seam
- Import / export formats
- Widgets
- Notifications
- Privacy
- Project layout
- Contributing
- License
- Track everything — name, price, currency, billing cycle (weekly / monthly / yearly / custom), category, and a custom icon per subscription.
- Know what renews next — a renewal timeline, status chips ("Today", "Tomorrow", "Overdue", "N days"), and per-subscription countdowns.
- See the real cost — normalized monthly and yearly totals, with optional conversion to your default currency.
- Never miss a renewal — local reminders scheduled before each renewal (same day / 1 / 3 / 7 days ahead).
- Glance from the Home & Lock Screen — small/medium/large home-screen widgets and accessory lock-screen widgets.
- Import without typing — pull subscriptions from a Notion CSV, a Google Sheets CSV (file or public link), or even a screenshot (Vision OCR).
- Export your data — CSV or JSON, shareable to Files / AirDrop.
- Private by design — no account, no payment, no third-party backend. Optional iCloud sync uses your Apple ID.
| Feature | File |
|---|---|
@main app entry, ModelContainer with CloudKit (in-memory fallback) |
App/SubscriptionTrackerApp.swift |
Onboarding gate, notification auth on first appear, NavigationStack home |
App/RootView.swift |
Shared app-group settings (AppSettings, SettingsKeys, AppGroup) |
App/AppGroup.swift (shared) |
| Rebuild the widget snapshot + reload timelines after any change | App/WidgetRefresher.swift |
| Feature | File |
|---|---|
@Model Subscription, BillingCycle, IconType, SubscriptionCategory, computed costs/status |
Models/Subscription.swift |
Codable transfer object for export/import (SubscriptionDTO) |
Models/SubscriptionDTO.swift |
| Feature | File |
|---|---|
Currency formatting + USD-pivot conversion, ExchangeRateProvider seam |
Services/CurrencyService.swift |
Renewal math (advance, nextRenewal, upcoming) + spend summaries |
Services/RenewalCalculator.swift |
| Local reminder scheduling / cancelling | Services/NotificationService.swift |
| Robust CSV parse + export | Services/CSVService.swift |
CSV → ImportDraft with fuzzy column detection |
Services/ImportParser.swift |
Screenshot OCR → ImportDraft (Vision) |
Services/OCRService.swift |
| Favicon / logo lookup + curated SF Symbols | Services/IconService.swift |
| CSV / JSON export files | Services/ExportService.swift |
| Widget snapshot read/write (shared, no SwiftData) | Services/WidgetDataStore.swift (shared) |
| Feature | File |
|---|---|
Colors, corner radii, Color(hex:), softCard, ScreenBackground, PrimaryButtonStyle |
Theme/Theme.swift (shared) |
| Rounded icon badge (symbol / remote image / monogram) | Views/Components/IconBadge.swift |
| Status chips with tones | Views/Components/StatusChip.swift |
| Stat cards | Views/Components/StatCard.swift |
Section headers, form card containers, Haptics |
Views/Components/SharedComponents.swift |
| Feature | File |
|---|---|
| Home list, summary, stat cards, timeline, swipe/delete, pull-to-refresh, currency menu | Views/Home/HomeView.swift |
| Hero spend summary card | Views/Home/SummaryHeaderView.swift |
| Single subscription row | Views/Home/SubscriptionRowView.swift |
| Horizontal renewal timeline | Views/Home/RenewalTimelineView.swift |
| Empty state with Add / Import | Views/Home/EmptyStateView.swift |
| Add / edit form (premium, validated) | Views/AddEdit/SubscriptionFormView.swift |
| Subscription detail with countdown, archive, delete | Views/Detail/SubscriptionDetailView.swift |
| Background color + symbol + online icon picker | Views/IconPicker/IconPickerView.swift |
| Import hub (Notion / Sheets / Screenshot) | Views/Import/ImportView.swift |
| Review-before-save import preview | Views/Import/ImportPreviewView.swift |
| Screenshot OCR import | Views/Import/ScreenshotImportView.swift |
CSV / JSON export with ShareLink |
Views/Export/ExportView.swift |
| Settings hub (currency, iCloud, reminders, import/export, about, privacy) | Views/Settings/SettingsView.swift |
| Default currency + conversion toggle | Views/Settings/CurrencySettingsView.swift |
| Notification authorization + default reminder timing | Views/Settings/ReminderSettingsView.swift |
| 3-page paged onboarding | Views/Onboarding/OnboardingView.swift |
| Feature | File |
|---|---|
| Timeline provider / entry | Widgets/WidgetProvider.swift |
| Home-screen small / medium / large widget | Widgets/SubscriptionWidgets.swift |
| Lock-screen rectangular / inline / circular widgets | Widgets/LockScreenWidgets.swift |
@main widget bundle |
Widgets/SubscriptionWidgetBundle.swift |
┌─────────────────────────────────────────┐
│ SwiftData store │
│ @Model Subscription ──► CloudKit │
│ (private DB, automatic sync) │
└───────────────┬─────────────────────────┘
│ @Query / modelContext
┌──────────────────────────────┼──────────────────────────────┐
│ App target │
│ │
│ Views (SwiftUI) ──► Services ──► Models │
│ │ │ │
│ │ AppSettings (@EnvironmentObject, app-group backed) │
│ │ │ │
│ └──► WidgetRefresher ─┴──► WidgetDataStore (App Group) ───┐│
└───────────────────────────────────────────────────────────── ││
││
App Group: group.com.codedharmony.tabi ││
(UserDefaults suite shared by both targets) ││
▼▼
┌───────────────────────────────────────────────────────────────┐
│ Widget extension target │
│ WidgetProvider reads WidgetDataStore.load() → renders │
│ (NO SwiftData, NO Subscription model — snapshot only) │
└───────────────────────────────────────────────────────────────┘
Key boundaries
- State —
AppSettingsis anObservableObjectinjected via@EnvironmentObject, backed by@AppStorage(store: AppGroup.defaults)so the widget and app see the same preferences. SwiftData uses@Query/@Model. - The widget never touches SwiftData. The app computes a plain
WidgetSnapshot(totals, counts, and up to 5 upcomingWidgetItems) and persists it to the shared App Group viaWidgetDataStore. The extension only reads that snapshot. This keeps the shared files (AppGroup.swift,WidgetDataStore.swift,Theme.swift) importing only Foundation / SwiftUI / WidgetKit. - Side effects are explicit. After any add / edit / delete / import, the app calls
WidgetRefresher.refresh(...)(rebuilds the snapshot +WidgetCenter.shared.reloadAllTimelines()) andNotificationService(reschedule / cancel reminders). - Calculation is pure.
RenewalCalculator,SummaryCalculator, andCurrencyServiceare deterministic and side-effect-free, which keeps the views thin.
Two targets share three files. The shared files compile into both the app and the widget extension and therefore may import Foundation / SwiftUI / WidgetKit only — never SwiftData, and they never reference the Subscription @Model type.
App target — SubscriptionTracker
App/,Models/,Services/,Theme/,Views/,Resources/Assets.xcassets
Widget extension target — SubscriptionWidgets
Widgets/- plus the shared files, compiled into both targets:
App/AppGroup.swiftServices/WidgetDataStore.swiftTheme/Theme.swift
When adding files in Xcode (or editing
project.yml), double-check the Target Membership of those three shared files — they must be ticked for both targets.
| Item | Value |
|---|---|
| App bundle id | com.codedharmony.tabi |
| Widget bundle id | com.codedharmony.tabi.widgets |
| App Group | group.com.codedharmony.tabi |
| iCloud container | iCloud.com.codedharmony.tabi |
This project is intended to be generated with XcodeGen so the target/membership configuration above is reproducible.
# 1. Install XcodeGen (once)
brew install xcodegen
# 2. From the project root (the folder containing project.yml)
cd "Simple Subscription Tracker"
# 3. Generate the Xcode project
xcodegen generate
# 4. Open it
open SubscriptionTracker.xcodeproj
xcodegen generatereadsproject.ymland producesSubscriptionTracker.xcodeproj. Re-run it any time you add, move, or rename source files so target membership stays correct.
A few things can't be set from project.yml alone — do these once in Xcode and the Apple Developer portal:
- Signing — select each target (
SubscriptionTrackerandSubscriptionWidgets) → Signing & Capabilities → set your Development Team. Let Xcode manage signing. - App Groups capability — add the App Groups capability to both targets and enable the group
group.com.codedharmony.tabi. - iCloud capability — add the iCloud capability to the app target, enable CloudKit, and add the container
iCloud.com.codedharmony.tabi. Create the container in the CloudKit Console if it doesn't exist yet. - Push Notifications capability — add Push Notifications to the app target. CloudKit sync uses the push (remote-notification) entitlement to deliver change pushes; add the Background Modes → Remote notifications option as well.
- Notifications — no capability is required for local notifications, but the app requests user authorization at runtime via
NotificationService.requestAuthorization(). - Bundle identifiers — confirm the app is
com.codedharmony.tabiand the widget extension iscom.codedharmony.tabi.widgets.
After the first build to a device/simulator signed into iCloud, the SwiftData + CloudKit schema is created automatically on first save.
The app runs fully on-device with zero setup — clone, generate, build, run. Two optional cloud integrations enhance it, and both are safe by design:
| Integration | Purpose | Key it uses |
|---|---|---|
| Supabase | Read-only service catalog (names, categories, brand logos) powering the add/search screen | anon (publishable) key — all writes blocked by Row-Level Security |
| Firebase | Anonymous, account-free feedback submission | public Web API key — writes gated by Firestore Security Rules + anonymous auth |
No real keys are committed. Only *.example templates are tracked. To enable these locally, copy the templates to their real (git-ignored) filenames and fill in your own project values:
cp SupabaseConfig.example.plist Resources/SupabaseConfig.plist # SUPABASE_URL + anon key
cp FirebaseConfig.example.plist Resources/FirebaseConfig.plist # Firebase Web API key
cp admin/config.example.js admin/config.js # only for the web admin panelIf you skip this, the catalog and feedback features simply no-op — the rest of the app is unaffected. Why the embedded keys are safe (RLS, security rules, no service_role/private keys) is documented in supabase/README.md and firebase/README.md.
The model is configured with ModelConfiguration(cloudKitDatabase: .automatic) and built with a graceful in-memory fallback if the container fails to initialize. To stay compatible with CloudKit, Subscription follows these rules (already enforced in Models/Subscription.swift):
- No
@Attribute(.unique)— CloudKit does not support unique constraints. - Every stored property has a default value — CloudKit requires all attributes to be optional or defaulted.
- Enums are stored as raw
Strings (billingCycleRaw,categoryRaw,iconTypeRaw) with computed bridging properties, so the schema stays primitive and stable. - A designated
init(...)is provided with every parameter defaulted, matching the stored defaults.
Sync is per-Apple-ID, private database — there is no shared/public data and no server you operate. Changes propagate between the user's own devices in the background; the Settings screen shows an informational "iCloud Sync" status row.
Formatting and conversion always go through CurrencyService:
format(_:currencyCode:fractionDigits:)uses a.currencyNumberFormatterwith the current locale.convert(_:from:to:)pivots through USD.
Rates come from an injectable provider:
protocol ExchangeRateProvider {
func ratePerUSD(_ code: String) -> Double?
}The shipping default is StaticRateProvider (bundled approximate rates per 1 USD for USD, EUR, GBP, PKR, INR, CAD, AUD, AED, SAR, JPY). Because the provider is a swappable property on CurrencyService.shared, adding live rates later is a drop-in: implement ExchangeRateProvider against a rates API and assign it to CurrencyService.shared.provider. No call sites change.
When a rate is missing, convert returns the amount unchanged, so the UI degrades gracefully rather than erroring.
The "Convert to default currency" toggle (AppSettings.conversionEnabled) controls whether summaries normalize every subscription to the user's default currency, or show each subscription in its own currency (mixed totals).
No login is ever required. All import paths land in ImportPreviewView, which never auto-saves — the user reviews, edits inline, toggles rows on/off, and explicitly taps Import N.
ImportParser.detectColumns fuzzy-matches headers (case-insensitive "contains"), so exact column names are not required:
| Field | Matched header keywords |
|---|---|
| Name | name, service, subscription |
| Price | price, cost, amount |
| Currency | currency |
| Billing cycle | billing, cycle, period, frequency |
| Renewal date | renew, next, date, due |
| Category | category, type |
| Notes | note, notes |
- Billing cycle parsing:
month → monthly,year/annual → yearly,week → weekly, otherwise custom/monthly. - Price parsing strips currency symbols, letters, and thousands separators.
- Date parsing tries ISO 8601,
yyyy-MM-dd,MM/dd/yyyy, andMMM d, yyyy, falling back to today.
From Notion: open your subscriptions database → ••• → Export → Markdown & CSV → import the .csv.
From Google Sheets: File → Download → Comma-separated values (.csv), or paste a published-to-web CSV link (the app fetches it over URLSession). No account or API key needed.
From a screenshot: pick an image; OCRService (Vision, accurate recognition with language correction) reads the lines and heuristically builds drafts — a line with a price token becomes a draft, nearby text becomes the name, and cycle/date keywords are detected.
ExportService writes shareable temp files surfaced via ShareLink (save to Files, AirDrop, etc.):
- CSV →
subscriptions.csv. Header:Name, Price, Currency, Billing Cycle, Start Date, Next Renewal Date, Category, Reminder Enabled, Reminder Offset Days, Notes. Dates are ISOyyyy-MM-dd; fields containing commas, quotes, or newlines are quoted. - JSON →
subscriptions.json. Pretty-printed array ofSubscriptionDTO.
A single WidgetSnapshot (monthly/yearly totals, active count, renewals-this-week, and up to 5 upcoming items) is shared via the App Group and rendered by all widget families. The extension imports only WidgetKit / SwiftUI / Foundation and reuses Theme and Color(hex:).
- Home screen — Small: monthly total + next renewal. Medium: monthly total + next 3. Large: monthly + yearly totals + upcoming list.
- Lock screen — Rectangular: next renewal name + amount + date. Inline:
Next: <name> <amount>. Circular: count of renewals this week.
Widgets refresh roughly hourly and immediately whenever the app calls WidgetRefresher.refresh(...).
Reminders are local (no server). NotificationService.reschedule(for:) cancels any existing reminder, then — if the subscription is active, reminders are enabled, and the date is in the future — schedules a UNCalendarNotificationTrigger at 9:00 AM on nextRenewalDate − reminderOffsetDays, with id renewal-<uuid>. Offsets: same day, 1, 3, or 7 days before. The default timing is stored per device; reminders are configured per subscription.
Simple Subscription Tracker does not require an account. Your subscriptions are stored locally and can sync with iCloud using your Apple ID.
There is no backend, no analytics service, no payment processing, and no third-party data sharing. Online icon lookups (favicons / logos) are best-effort, optional, and never block the app. Light-mode-first with full dark mode throughout.
Simple Subscription Tracker/
├── project.yml # XcodeGen spec → SubscriptionTracker.xcodeproj
├── README.md
├── App/
│ ├── SubscriptionTrackerApp.swift # @main, ModelContainer (+ CloudKit)
│ ├── RootView.swift # onboarding gate + NavigationStack
│ ├── AppGroup.swift # SHARED: AppGroup, SettingsKeys, AppSettings
│ └── WidgetRefresher.swift # rebuild snapshot + reload timelines
├── Models/
│ ├── Subscription.swift # @Model + enums + computed
│ └── SubscriptionDTO.swift # Codable transfer object
├── Services/
│ ├── CurrencyService.swift
│ ├── RenewalCalculator.swift
│ ├── NotificationService.swift
│ ├── CSVService.swift
│ ├── ImportParser.swift
│ ├── OCRService.swift
│ ├── IconService.swift
│ ├── ExportService.swift
│ └── WidgetDataStore.swift # SHARED: WidgetSnapshot / WidgetItem
├── Theme/
│ └── Theme.swift # SHARED: colors, softCard, ScreenBackground…
├── Views/
│ ├── Components/ # IconBadge, StatusChip, StatCard, Shared…
│ ├── Home/ # HomeView, Summary, Row, Timeline, Empty
│ ├── AddEdit/ # SubscriptionFormView
│ ├── Detail/ # SubscriptionDetailView
│ ├── IconPicker/ # IconPickerView
│ ├── Import/ # ImportView, Preview, Screenshot
│ ├── Export/ # ExportView
│ ├── Settings/ # Settings, Currency, Reminder
│ └── Onboarding/ # OnboardingView
├── Widgets/
│ ├── SubscriptionWidgetBundle.swift # @main bundle
│ ├── WidgetProvider.swift
│ ├── SubscriptionWidgets.swift # home-screen families
│ └── LockScreenWidgets.swift # accessory families
└── Resources/
└── Assets.xcassets
- iOS 17.0+, Swift 5.9+, Xcode 15+
- An Apple Developer account (for iCloud/CloudKit, App Groups, and on-device installs)
- XcodeGen (
brew install xcodegen) to generate the project
- Regenerate after adding/removing files. XcodeGen snapshots the file list when it runs, so after adding a new source file run
xcodegen generateagain before building. - Demo / screenshot data (DEBUG only). Launch with the environment variable
SEED_SAMPLE=1to populate the dashboard with sample subscriptions and skip onboarding. The seeder lives inApp/SampleData.swift, is wrapped in#if DEBUG, no-ops if the store already has data, and is compiled out of Release builds.- In Xcode: Scheme → Run → Arguments → Environment Variables → add
SEED_SAMPLE = 1. - From the command line:
SIMCTL_CHILD_SEED_SAMPLE=1 xcrun simctl launch <device> com.codedharmony.tabi.
- In Xcode: Scheme → Run → Arguments → Environment Variables → add
- Verified: both targets compile clean for the iOS Simulator (
xcodebuild … -sdk iphonesimulator), and the app installs, launches, and renders in light and dark mode.
Contributions are welcome! A good flow:
- Fork the repo and create a branch (
git checkout -b feature/my-change). - Set your own signing team: change
DEVELOPMENT_TEAMinproject.yml(three places) to your Apple Developer Team ID, or set it in Xcode's Signing & Capabilities. xcodegen generate, build, and test in the Simulator.- Keep changes focused and match the surrounding SwiftUI style. Prefer pure, testable helpers (see
RenewalCalculator,CurrencyService). - Open a pull request describing the change and how you verified it.
Issues and feature ideas are welcome via GitHub Issues. Please don't include real personal data (subscriptions, emails) in screenshots or samples — use the SEED_SAMPLE=1 demo data instead.
Released under the MIT License. © 2026 Awais Manzoor.
The bundled service catalog references third-party brand names and logos, which remain the property of their respective owners and are used only for identification.





