Time Tracker

Personal time tracking, project billing, and invoicing app — Flutter, iOS & macOS.
v1.0.0+1 Flutter / Dart iOS 16.0+ macOS 13.0+ Docs generated 2026-07-13

1. Getting Started

Before you start tracking time, set yourself up in this order:

  1. Go to Settings and enter your personal information — the details your clients need to know about you, such as your email address and phone number.
  2. Go to the Clients tab (the third tab) and enter your client's information. This appears on your invoices when you bill them — the client's name, a contact person, email, phone number, etc.
  3. Tap the client you just added to confirm the information is correct. Then, below that, add a project: enter the project name and hourly rate.
  4. Go to the Home tab and select the project. You're ready to start tracking time.

2. Overview

Time Tracker is a personal, offline-first time-tracking app built for freelance billing. It tracks time against Projects (optionally linked to Clients) and Categories, calculates earnings from an hourly rate, and generates PDF invoices and time reports per client, per month. All data lives locally in a SQLite database on-device; macOS builds additionally sync that data to iCloud.

The app was built from a design spec and task-by-task implementation plan (both preserved under docs/superpowers/), then diverged from that original plan in a few notable ways during a later "UI overhaul" pass — most importantly, the original CSV-email reporting flow was replaced by an in-app PDF invoice generator, and a full Clients feature was added that wasn't in the original design. Section 12 covers what's actually shipped versus what was originally planned.

🗂 Core entities

Clients → Projects → Categories → Time Entries. One active timer at a time.

🧾 Output

Monthly PDF "Time Report" or "Invoice", per client, previewed in-app.

☁️ Sync

iCloud/CloudKit — macOS only. Disabled on iOS due to an Apple bug (see §8).

🎨 Theming

Catppuccin Mocha dark palette, plus an iOS-style light theme, user-toggleable.

3. Architecture

State management is Riverpod, persistence is Drift (a typed SQLite ORM), and navigation is GoRouter with a single adaptive shell that shows a bottom tab bar on iOS and a sidebar (NavigationRail) on macOS. The folder layout cleanly separates data, features, shared UI, services, and sync:

  • lib/
    • main.dart — app entry, startup logging, error zone, splash wrapper
    • app/
      • app.dart — root widget, theme mode, sync trigger, stale-timer dialog
      • router.dart — GoRouter routes & shell branches
    • data/
      • database.dart, database.g.dart (generated)
      • tables/ — clients, projects, categories, time_entries
      • repositories/ — client, project, category, timer
    • features/
      • dashboard/, entries/, projects/, categories/, clients/, reports/, settings/, splash/
    • services/notification_service.dart
    • shared/
      • providers.dart, theme.dart, widgets/ (app_scaffold.dart, timer_display.dart)
    • sync/cloudkit_sync.dart

Navigation map

RouteScreenTab
/dashboardDashboard (live timer + stats)Home
/entriesTime Entries listEntries
/clients, /clients/new, /clients/:id, /clients/:id/editClients list / form / detailClients
/projects, /projects/new, /projects/:id/editProjects list / form (nested in the Clients tab branch — no independent tab)— (via Clients)
/reportsReports / Invoice screenBills
/settings, /settings/categoriesSettings, CategoriesSettings

Heads up: Projects has no top-level tab of its own — the bottom bar shows Home, Entries, Clients, Bills, Settings. Projects are reached through the Clients tab (a client's detail screen, or its "linked projects" list) and Categories lives one level under Settings, not as its own tab.

4. Data Model

SQLite database via Drift, currently at schema version 5. Migration history:

VersionChange
v2Add categories.sortOrder
v3Add categories.projectId (per-project category scoping)
v4Create clients table; add projects.clientId
v5Add clients.company

Clients

Added later — not in the original design spec.

FieldTypeNotes
idTEXT (PK)UUID
nameTEXTrequired
companyTEXT?added in v5
email, phoneTEXT?
street, city, state, zipTEXT?mailing address, all optional
notesTEXT?free text
createdAtDateTime

Projects

FieldTypeNotes
idTEXT (PK)UUID
nameTEXT
hourlyRateREALused for earnings calc
colorHexTEXTone of 8 fixed swatches
isArchivedBOOL, default falsehides from active pickers
clientIdTEXT?FK → Clients.id, added v4 — projects can be unlinked
createdAtDateTime

Fixed project color palette (Catppuccin Mocha accents):

#CBA6F7 #89B4FA #A6E3A1 #F38BA8 #89DCEB #F9E2AF #FAB387 #CDD6F4

Categories

FieldTypeNotes
idTEXT (PK)UUID
nameTEXTe.g. Coding, Email, Meetings
colorHexTEXT
sortOrderINT, default 0manual drag-reorder
projectIdTEXT?FK → Projects.id, added v3 — nullable, so categories can be global or project-scoped

Time Entries

FieldTypeNotes
idTEXT (PK)UUID
projectIdTEXTFK → Projects.id, required
categoryIdTEXT?FK → Categories.id
startTimeDateTime
endTimeDateTime?null = timer currently running
noteTEXT?free text

Derived (not stored):

durationSeconds = endTime - startTime   (or now - startTime while running)
earnings        = durationSeconds / 3600 * project.hourlyRate

Relationships: Client 1—* Project (nullable FK — projects may be unlinked) · Project 1—* Category (nullable — categories may also be global) · Project 1—* TimeEntry · Category 0/1—* TimeEntry.

The original design spec called for a local-only MonthlyReport table tracking a sentAt timestamp per project/month. This was never implemented. Invoice numbering instead lives in SharedPreferences as a simple incrementing counter (see §7).

5. Features & Screens

Dashboard Home tab

  • Large live timer display (HH:MM:SS) — highlighted purple while running, dimmed when idle.
  • Project dropdown (auto-selects the first project if none chosen) and category dropdown, with an inline "Add new…" option.
  • Start / Stop control. There is no visible Pause button, even though a pauseTimer() method exists in the repository layer (see §6).
  • Three stat cards: Today, This Week, Earned (today's earnings on the selected project).
  • Empty state: "Add a project to get started" when no projects exist yet.

Time Entries Entries tab

  • Lists only completed entries (running entry is excluded), grouped by project, most-recently-active project first.
  • Each row: date, start–end time range, category, duration, delete icon.
  • Swipe-to-delete plus an explicit delete button.
  • Empty state: "No entries yet — Start a timer from the Home tab."

The original spec called for tap-to-edit and filtering by project/category/date range on this screen. Neither is implemented — entries can only be viewed and deleted, not edited or filtered.

Projects (reached via Clients tab)

  • List with color-swatch avatar, name, $X/hr subtitle, edit icon.
  • Form: name, hourly rate, optional client picker, color swatch (8 fixed colors).
  • Empty state: "No projects yet — Tap + to add one."

ProjectRepository.archive() and .delete() exist in code but no button in the UI calls them — there is currently no way to archive or delete a project from the app itself.

Categories (Settings → Categories)

  • Drag-to-reorder list, scoped to whichever project is currently selected on the Dashboard.
  • Add/edit via dialog with name + color picker; delete via icon button.
  • Each row shows a running total of time logged in that category (including live elapsed time if the active timer is on it).
  • Empty state: "No categories yet — Tap + to add one."

Clients Clients tab Added post-spec

  • List: avatar initial, name + company/email/phone/city-state summary, delete (with confirmation), tap → detail.
  • Also surfaces an "Unlinked Projects" section for projects with no client assigned.
  • Detail screen: contact info card, linked-projects list (edit / unlink), and an "Add" menu to create a new project for this client or link an existing unlinked one.
  • Form fields: name*, company, email, phone, street/city/state/zip, notes.

Reports "Bills" tab

See §7 for full detail — this is the biggest divergence from the original design.

Settings

  • Your info (shown on invoices): name, phone.
  • Report email: stored, but not currently wired to any send/share action (see §7).
  • Appearance: Dark mode switch, persisted.
  • Notifications: monthly report reminder toggle (see §9).
  • Data: link into Categories.

Splash screen

A custom-painted analog clock (hour/minute hands, tick marks, a small "crown"), app title, and a "© Copyright by John T. Draper" footer. Fades out after a fixed 10-second delay before revealing the app. Not part of the original design spec's screen list, but present in shipped code and wraps the entire app in main.dart.

Theming

Two full themes, switchable via the Settings dark-mode toggle: the original Catppuccin Mocha dark palette, and an iOS-style light theme (white background, light gray cards, black text, a lighter primary purple). Theme mode is persisted in SharedPreferences.

6. Timer Logic

All timer state lives in TimerRepository, a thin wrapper around the timeEntries table:

watchActiveTimer()      // stream: row where endTime IS NULL (or null)
startTimer(...)          // throws StateError('Timer already running') if one is active
stopTimer()              // sets endTime = now() on the open row
pauseTimer() => stopTimer();   // literally an alias — see below
watchEntriesForMonth(y, m)
watchEntriesInRange(start, end)
watchAllEntries()
updateEntry(entry)       // upsert
deleteEntry(id)

There is no real "pause." pauseTimer() is a straight alias for stopTimer() — both close the open entry by writing an endTime. Resuming later starts a brand-new TimeEntry row rather than continuing the previous one. In practice this is fine since the Dashboard UI doesn't expose a Pause button at all, but it's worth knowing if you ever add one — right now it would just behave like Stop.

The "only one active timer" rule is enforced purely in application code (a query for an existing open row before inserting a new one), not by a database constraint — a theoretical race condition exists but is very unlikely given the app is single-writer/single-device for local data.

Stale-timer detection

On app launch, if the active timer's startTime is more than 24 hours old, a dialog appears: "Timer still running — A timer has been running since [date]. Stop it now?" with "Keep running" / "Stop" actions. This check runs once at launch only — it does not re-check every time the app is foregrounded.

7. Reports & Invoicing

This is the area that changed most from the original plan. The design spec described a CSV export delivered through the native share sheet (share_plus → Mail). That is not what ships today.

What actually happens

  1. Completed time entries are grouped by project for the selected month; any project with no linked client is skipped entirely.
  2. For each remaining project, the app builds a per-category breakdown (hours × rate = amount) and shows one card per client: hours, earnings, category lines.
  3. Two buttons per card: "Time Report" and "Invoice". Both generate a PDF (via the pdf package):
    • Time Report — categories × hours × rate table, titled "TIME ACCOUNTING", no amount column.
    • Invoice — adds an Amount column and a "TOTAL DUE" box, titled "INVOICE [YYYY-MM-###]" with an auto-incrementing invoice number.
  4. The PDF opens in an in-app preview (printing package's PdfPreview widget) — with printing and sharing both disabled on that screen. You can view it, but there's currently no button to export, save, print, or email it from there.
  5. Your name/phone from Settings populate the invoice's "From" block; the client's contact info populates "Bill To".

Invoice numbering: a counter in SharedPreferences (invoice_counter) is peeked (not incremented) when previewing, and only committed after a PDF successfully generates — so a failed/aborted generation doesn't burn a number.

Dead code: csv_exporter.dart still exists and correctly produces the spec's original CSV format — but nothing in the app's UI calls it; it's exercised only by its own unit test. The share_plus package is a dependency but is used nowhere in lib/. The "Report Email" field in Settings is stored but not connected to any actual send mechanism.

8. iCloud Sync

iOS: disabled. Sync only runs when defaultTargetPlatform == TargetPlatform.macOS. This is a deliberate fix for a real bug: the cloud_kit plugin relies on deprecated native APIs that Apple's iOS 26 changes broke, causing native crashes on device. This is an Apple/plugin-side issue, not a bug in this app's sync logic.

Future<void> _sync() async {
  // CloudKit sync only runs on macOS — the cloud_kit package uses deprecated
  // APIs that cause native crashes on iOS 26.
  if (defaultTargetPlatform != TargetPlatform.macOS) return;
  if (kDebugMode) return;
  try {
    await ref.read(cloudKitSyncProvider).syncAll();
  } catch (_) {
    // CloudKit unavailable — continue offline
  }
}

Sync is triggered on app launch and every time the app returns to the foreground. It also skips in debug builds (kDebugMode check) — matching the fact that the macOS DebugProfile.entitlements file has no iCloud/CloudKit capability at all; only Release.entitlements does. Errors are swallowed so the app always continues fully offline with no visible error.

The cloud_kit package is treated as a flat key-value store (save/get/getAll/delete), with records namespaced by prefix: Client:<id>, Project:<id>, Category:<id>, TimeEntry:<id>, each value a JSON-encoded field map, all under container iCloud.com.johndraper.timetracker.

EntityPush behaviorPull behavior
ClientsAlways overwrite remoteInsert any remote record missing locally
ProjectsAlways overwrite remote (so edits/client changes propagate)Insert missing records
CategoriesOnly pushed if the remote key doesn't exist yetInsert missing records
Time EntriesOnly completed entries (endTime != null); also only if remote key doesn't exist yetInsert missing records

For Categories and Time Entries, "only push if the remote key doesn't exist" means local edits to an already-synced record are never pushed again. This only matters on macOS (where sync is active) and is a latent bug worth fixing if cross-device edits to categories or entries need to propagate after the first sync.

9. Notifications

Built on flutter_local_notifications + timezone. The Settings screen's "Monthly report reminder" switch is the only trigger in the app:

  • Turning it on requests notification permission, then schedules a recurring notification for the 1st of every month at 9:00 AM local time ("Monthly Time Report — Your time report is ready to send.").
  • Turning it off cancels that scheduled notification.
  • Nothing else in the app re-checks or re-schedules this — if permission is later revoked or the OS drops the notification, there's no self-healing check on next launch.

The setting defaults to "on" in stored preferences, but nothing schedules the actual notification automatically at first launch — only flipping the switch does. If you're not sure the reminder is actually scheduled, toggle it off and back on once.

10. Platforms & Build

SettingValue
App version1.0.0+1
Bundle IDcom.johndraper.timetracker
iOS deployment target16.0+
macOS deployment target13.0+
Dart SDK constraint>=3.0.0 <4.0.0
CloudKit containeriCloud.com.johndraper.timetracker (both platforms)
Android / WebNot targeted — no platform folders present

iOS entitlements grant full iCloud/CloudKit capability (unused at runtime, see §8). macOS has two entitlement profiles: DebugProfile.entitlements (no iCloud capability — matches the debug-mode sync skip) and Release.entitlements (full iCloud/CloudKit, used for the release builds where sync actually runs).

main.dart currently includes verbose [STARTUP] print statements and a runZonedGuarded global error handler that just prints — left over from diagnosing an earlier startup crash. Harmless, but worth cleaning up before a wider release if you want quieter logs.

11. Testing

9 test files, 19 test cases plus one empty placeholder widget test:

FileCoversTests
data/database_test.dartRaw insert/read for Projects and Time Entries2
data/repositories/project_repository_test.dartwatchAll, archive, delete3
data/repositories/timer_repository_test.dartstart/stop, double-start throws3
features/dashboard/timer_logic_test.dartElapsed-time formatting1
features/projects/projects_provider_test.dartAdd project via Riverpod container1
features/reports/csv_exporter_test.dartCSV format & totals (tests dead code, see §7)3
services/notification_service_test.dartNext-1st-of-month edge cases3
shared/theme_test.dartTheme colors/build3
widget_test.dartEmpty placeholder0

No test coverage exists for: Clients (repository/provider/screens), the Categories provider, Reports/invoice generation, Settings provider, CloudKit sync, the app shell/router, or the stale-timer dialog. Coverage is concentrated on the earliest-built core (database, repositories, theme) and thins out for everything added later.

12. Known Issues & Divergences from Original Plan

ItemDetail
Pause = StoppauseTimer() is an alias for stopTimer(); no real pause/resume of a single entry.
iCloud sync (iOS)Disabled entirely — Apple iOS 26 broke the cloud_kit plugin's deprecated APIs.
Category/entry re-syncOn macOS, edits to an already-synced category or time entry are never pushed again (only new records sync).
CSV export & emailBoth replaced by PDF invoices; csv_exporter.dart and the share_plus dependency are unused dead code; the Settings "report email" field does nothing.
PDF export/shareInvoice/Time Report PDFs can be previewed in-app only — no print, save, or share button on that screen.
No entry editing/filteringTime Entries screen supports delete only — no tap-to-edit, no filter by project/category/date.
No project archive/delete UIRepository methods exist; no button calls them.
Notification self-healingReminder is only (re)scheduled by manually toggling the Settings switch; nothing checks/repairs it on launch.
Light modeAdded in the UI overhaul; original spec said dark-only for v1 — this is a positive addition, not a bug.
Clients featureNot in the original spec at all — fully implemented CRUD + linking, a net-new capability.

13. Build History

17 commits, oldest → newest, grouped into build phases:

1. Planning

chore: add design spec and implementation plan

2. Scaffold & config

feat: scaffold Flutter project with dependencies and iCloud entitlements → fix: correct bundle ID case, deployment targets, and iOS entitlement wiring

3. Foundations

feat: add Catppuccin Mocha theme → feat: add Drift database schema with Projects, Categories, TimeEntries tables → feat: add project, category, and timer repositories with tests → feat: add Riverpod providers, GoRouter navigation, and adaptive shell

4. Core CRUD

feat: implement Projects CRUD feature → feat: implement Categories feature → feat: implement Dashboard with live timer and stats

5. Entries + Reports v1

feat: implement Time Entries list with grouped view and swipe-to-delete → feat: implement Reports screen with CSV export and share_plus delivery superseded, see §7

6. Settings & scheduling

feat: add Settings screen with email config and monthly notification scheduling → fix: use super parameter syntax in AppDatabase.forTesting

7. Sync

feat: add CloudKit sync with offline fallback and stale timer detection

8. macOS polish

feat: verify macOS layout and fix desktop-specific UX issues

9. UI overhaul latest

UI overhaul: light/dark mode, iPhone deployment, timer fixes — this single commit is where the biggest divergences from the original spec landed: light theme, the entire Clients feature (schema v4/v5), the PDF invoice generator replacing CSV/share_plus, Categories moved under Settings, the "Bills" tab relabel, and the iOS CloudKit restriction for the iOS 26 bug.

14. Appendix: Dependencies

PackageVersionPurpose
flutter_riverpod^2.5.1State management
riverpod_annotation^2.3.5Riverpod codegen annotations
drift^2.18.0Typed SQLite ORM
sqlite3_flutter_libs^0.5.24Native SQLite binaries
path_provider / path^2.1.3 / ^1.9.0Filesystem paths for the DB file
go_router^14.1.4Navigation / routing
cloud_kit^1.1.0iCloud sync (macOS only in practice, see §8)
share_plus^10.0.0Present but unused in lib/
flutter_local_notifications^17.2.1Monthly reminder notification
shared_preferences^2.3.1Theme mode, invoice counter, settings
timezone^0.9.4Notification scheduling timezone math
uuid^4.4.0Client-side ID generation
intl^0.19.0Date/number formatting
pdf^3.10.8Invoice / Time Report PDF generation
printing^5.13.1In-app PDF preview

Dev dependencies

drift_dev ^2.18.0 · build_runner ^2.4.11 · mocktail ^1.0.4 · flutter_lints ^4.0.0