Skip to content

iam-dev0/tabi

Repository files navigation

Tabi app icon

Tabi — Subscription Tracker

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.

Download on the App Store



iOS 17+ Swift 5.9 SwiftUI · SwiftData · CloudKit License: MIT

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

All your subscriptions in one place   See the real monthly and yearly cost   Never miss a renewal

Automatic service logos from a shared catalog   Add a subscription in seconds   Private by design


Table of contents


Highlights

  • 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 list (mapped to files)

App lifecycle & root

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

Data model

Feature File
@Model Subscription, BillingCycle, IconType, SubscriptionCategory, computed costs/status Models/Subscription.swift
Codable transfer object for export/import (SubscriptionDTO) Models/SubscriptionDTO.swift

Services

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)

Theme & shared components

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

Screens

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

Widgets (extension)

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

Architecture

                       ┌─────────────────────────────────────────┐
                       │              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

  • StateAppSettings is an ObservableObject injected 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 upcoming WidgetItems) and persists it to the shared App Group via WidgetDataStore. 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()) and NotificationService (reschedule / cancel reminders).
  • Calculation is pure. RenewalCalculator, SummaryCalculator, and CurrencyService are deterministic and side-effect-free, which keeps the views thin.

Targets & file membership

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.swift
    • Services/WidgetDataStore.swift
    • Theme/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.


Bundle IDs, App Group & iCloud container

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

Generating the Xcode project

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 generate reads project.yml and produces SubscriptionTracker.xcodeproj. Re-run it any time you add, move, or rename source files so target membership stays correct.


Required manual Xcode steps

A few things can't be set from project.yml alone — do these once in Xcode and the Apple Developer portal:

  1. Signing — select each target (SubscriptionTracker and SubscriptionWidgets) → Signing & Capabilities → set your Development Team. Let Xcode manage signing.
  2. App Groups capability — add the App Groups capability to both targets and enable the group group.com.codedharmony.tabi.
  3. 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.
  4. 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.
  5. Notifications — no capability is required for local notifications, but the app requests user authorization at runtime via NotificationService.requestAuthorization().
  6. Bundle identifiers — confirm the app is com.codedharmony.tabi and the widget extension is com.codedharmony.tabi.widgets.

After the first build to a device/simulator signed into iCloud, the SwiftData + CloudKit schema is created automatically on first save.


Configuration (optional backends)

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 panel

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


CloudKit + SwiftData notes

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.


Currency conversion & the live-rate seam

Formatting and conversion always go through CurrencyService:

  • format(_:currencyCode:fractionDigits:) uses a .currency NumberFormatter with 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).


Import / export formats

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.

CSV columns (Notion / Google Sheets)

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, and MMM d, yyyy, falling back to today.

From Notion: open your subscriptions database → •••ExportMarkdown & 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.

Export

ExportService writes shareable temp files surfaced via ShareLink (save to Files, AirDrop, etc.):

  • CSVsubscriptions.csv. Header: Name, Price, Currency, Billing Cycle, Start Date, Next Renewal Date, Category, Reminder Enabled, Reminder Offset Days, Notes. Dates are ISO yyyy-MM-dd; fields containing commas, quotes, or newlines are quoted.
  • JSONsubscriptions.json. Pretty-printed array of SubscriptionDTO.

Widgets

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 screenSmall: monthly total + next renewal. Medium: monthly total + next 3. Large: monthly + yearly totals + upcoming list.
  • Lock screenRectangular: 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(...).


Notifications

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.


Privacy

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.


Project layout

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

Requirements

  • 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

Developer notes

  • Regenerate after adding/removing files. XcodeGen snapshots the file list when it runs, so after adding a new source file run xcodegen generate again before building.
  • Demo / screenshot data (DEBUG only). Launch with the environment variable SEED_SAMPLE=1 to populate the dashboard with sample subscriptions and skip onboarding. The seeder lives in App/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.
  • Verified: both targets compile clean for the iOS Simulator (xcodebuild … -sdk iphonesimulator), and the app installs, launches, and renders in light and dark mode.

Contributing

Contributions are welcome! A good flow:

  1. Fork the repo and create a branch (git checkout -b feature/my-change).
  2. Set your own signing team: change DEVELOPMENT_TEAM in project.yml (three places) to your Apple Developer Team ID, or set it in Xcode's Signing & Capabilities.
  3. xcodegen generate, build, and test in the Simulator.
  4. Keep changes focused and match the surrounding SwiftUI style. Prefer pure, testable helpers (see RenewalCalculator, CurrencyService).
  5. 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.


License

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.

About

IOS subscription tracker app.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors