diff --git a/.cursor/rules/productivity-tracker.mdc b/.cursor/rules/productivity-tracker.mdc new file mode 100644 index 0000000..84cc1ae --- /dev/null +++ b/.cursor/rules/productivity-tracker.mdc @@ -0,0 +1,22 @@ +--- +description: ProductivityTracker is a native SwiftUI iOS timer app built from Windows via Cursor Cloud and GitHub Actions. +alwaysApply: true +--- + +# ProductivityTracker project rules + +This repository is a native SwiftUI iOS app named ProductivityTracker. + +- Primary authoring host is Windows. +- Cursor Cloud Agent runs on Linux. +- Never assume local Xcode, Simulator, or `xcodebuild` on the Linux agent. +- GitHub Actions `macos-26` is the authoritative build, test, screenshot, and unsigned IPA environment. +- After compilation-affecting changes, push and inspect GitHub Actions. Fix CI yourself. +- Keep XcodeGen `project.yml` as the project source of truth. Do not hand-edit `project.pbxproj` as the normal workflow. Pin XcodeGen via `.xcodegen-version`. +- Do not introduce React Native, Expo, Flutter, Capacitor, web-view shells, Firebase, CloudKit, backends, accounts, analytics, or paid services. +- Use public Apple APIs only. No private frameworks. +- Preserve native Apple visual language: system San Francisco, tabular stopwatch digits, iOS 26 Liquid Glass (`glassEffect` / `GlassEffectContainer`), semantic Start/Stop colors. +- The timer is the primary product. Do not turn the main screen into a dashboard. +- Only the upper stopwatch region handles horizontal Space paging. The lower task panel must stay stationary. +- Core app must remain compatible with the free personal-signing / Sideloadly installation path. +- No Apple credentials, signing certificates, provisioning profiles, or secrets in source or CI. diff --git a/.github/workflows/ios-ci.yml b/.github/workflows/ios-ci.yml new file mode 100644 index 0000000..8bd355b --- /dev/null +++ b/.github/workflows/ios-ci.yml @@ -0,0 +1,139 @@ +name: iOS CI + +on: + push: + branches: [main, master, cursor/**] + pull_request: + workflow_dispatch: + +concurrency: + group: ios-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-test-package: + name: Generate, test, screenshot, package + runs-on: macos-26 + timeout-minutes: 90 + env: + SCREENSHOT_DIR: ${{ github.workspace }}/artifacts/screenshots + ARTIFACT_DIR: ${{ github.workspace }}/artifacts + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Print toolchain + run: | + echo "macOS: $(sw_vers -productVersion)" + echo "Xcode:" + xcodebuild -version + echo "Swift:" + swift --version + echo "Available Xcode apps:" + ls /Applications | grep -i xcode || true + echo "Simulator runtimes:" + xcrun simctl list runtimes || true + + - name: Select Xcode 26 + run: | + if [[ -d /Applications/Xcode_26.6.app ]]; then + sudo xcode-select -s /Applications/Xcode_26.6.app + elif [[ -d /Applications/Xcode.app ]]; then + sudo xcode-select -s /Applications/Xcode.app + fi + xcodebuild -version + + - name: Install pinned XcodeGen + run: bash Scripts/ci/install-xcodegen.sh + + - name: Generate Xcode project + run: | + xcodegen generate --spec project.yml + test -d ProductivityTracker.xcodeproj + xcodebuild -list -project ProductivityTracker.xcodeproj + + - name: Select simulator + run: bash Scripts/ci/select-simulator.sh + + - name: Build simulator + run: | + set -o pipefail + xcodebuild \ + -project ProductivityTracker.xcodeproj \ + -scheme ProductivityTracker \ + -destination "platform=iOS Simulator,id=${SIM_UDID},arch=arm64" \ + -configuration Debug \ + ONLY_ACTIVE_ARCH=YES \ + ENABLE_TESTABILITY=YES \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + build | xcbeautify --renderer github-actions + + - name: Unit and UI tests + run: | + set -o pipefail + mkdir -p "$SCREENSHOT_DIR" "$ARTIFACT_DIR" + xcodebuild \ + -project ProductivityTracker.xcodeproj \ + -scheme ProductivityTracker \ + -destination "platform=iOS Simulator,id=${SIM_UDID},arch=arm64" \ + -configuration Debug \ + -resultBundlePath "$ARTIFACT_DIR/TestResults.xcresult" \ + ONLY_ACTIVE_ARCH=YES \ + ENABLE_TESTABILITY=YES \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + test | xcbeautify --renderer github-actions + + - name: Unsigned device build + run: | + set -o pipefail + mkdir -p "$ARTIFACT_DIR/device" + xcodebuild \ + -project ProductivityTracker.xcodeproj \ + -scheme ProductivityTracker \ + -configuration Release \ + -destination "generic/platform=iOS" \ + -derivedDataPath "$ARTIFACT_DIR/DerivedData" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + AD_HOC_CODE_SIGNING_ALLOWED=YES \ + build | xcbeautify --renderer github-actions + APP="$(find "$ARTIFACT_DIR/DerivedData" -path "*.app" -type d | grep "Release-iphoneos/ProductivityTracker.app$" | head -n 1)" + echo "APP=$APP" >> "$GITHUB_ENV" + test -n "$APP" + test -d "$APP" + + - name: Package unsigned IPA + run: bash Scripts/ci/package-ipa.sh "$APP" "$ARTIFACT_DIR" + + - name: Build metadata + run: | + { + echo "commit=$(git rev-parse HEAD)" + echo "ref=${GITHUB_REF}" + echo "xcode=$(xcodebuild -version | tr '\n' ' ')" + echo "macos=$(sw_vers -productVersion)" + echo "simulator_name=${SIM_NAME}" + echo "simulator_os=${SIM_OS}" + echo "simulator_udid=${SIM_UDID}" + echo "ipa=conduit-unsigned.ipa" + echo "sha256=$(cut -d ' ' -f1 "$ARTIFACT_DIR/SHA256SUMS.txt")" + echo "UNSIGNED=must be signed with your own Apple Account before installation" + } | tee "$ARTIFACT_DIR/build-metadata.txt" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: conduit-iOS-device-unsigned + if-no-files-found: error + retention-days: 30 + path: | + artifacts/conduit-unsigned.ipa + artifacts/SHA256SUMS.txt + artifacts/build-metadata.txt + artifacts/screenshots + artifacts/TestResults.xcresult diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed97bb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Xcode +DerivedData/ +build/ +*.xcuserstate +*.xccheckout +*.xcscmblueprint +xcuserdata/ +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM +*.xcresult + +# Generated project (CI regenerates from project.yml) +*.xcodeproj/ +*.xcworkspace/ +!project.yml + +# Swift PM +.swiftpm/ +.build/ +Package.resolved + +# macOS +.DS_Store +*.swp +*~ + +# Secrets / signing +*.p12 +*.cer +*.certSigningRequest +*.mobileprovision +*.provisionprofile +AuthKey_*.p8 +*.keychain +*.keychain-db + +# Python +__pycache__/ +*.pyc + +# Local helper output +artifacts/ +dist/ +.idea/ +.vscode/ diff --git a/.xcodegen-version b/.xcodegen-version new file mode 100644 index 0000000..cc87583 --- /dev/null +++ b/.xcodegen-version @@ -0,0 +1 @@ +2.46.0 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..52944dc --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,97 @@ +# Architecture + +## Timer state model + +Each Space owns a `TimerEngine` / `TimerSnapshot`. At most one Space is `running`. + +User-visible phases match Apple Clock: + +- `idle` — `00:00.00`; Lap disabled; Start +- `running` — advancing; Lap; Stop +- `stopped` — frozen and resumable; Reset; Start + +`Stop` freezes elapsed, closes the open task interval, and leaves `Session.endedAt` nil. `Start` from stopped resumes the same session. `Reset` archives one historical session (`endedAt` set), dismisses the Live Activity immediately, and returns the display to idle. + +Persisted `"paused"` maps to `stopped`. + +## Timestamp calculation + +`TimerSnapshot` stores `accumulatedBeforeCurrentRun` plus `startedAt` while running. + +``` +idle: 0 +stopped: accumulatedBeforeCurrentRun +running: accumulated + (now - startedAt) +``` + +Truth never depends on frame count. + +## Persistence + +SwiftData models: + +- `Space` — name, accent, order, reminder seconds (`0` = Off), Focus keyword, default task, icon (SF Symbol, emoji, or monogram) +- `TaskItem` — name, order, enabled, completed +- `Session` — space, start, optional `endedAt` (nil = current/resumable), phase, accumulated, active task +- `TaskInterval` — session, task, start, optional end + +Open sessions restore per Space. If more than one was stored as running, extras are frozen. + +## Task intervals + +Start opens an interval for the selected / default / first enabled task. Direct task tap while running closes the previous interval and opens the new one at the same timestamp. Same-task tap is a no-op. Lap advances to the next enabled task. Deleting the active task moves to the next enabled task or continues with no task. + +Task-row totals are the current stopwatch session only. They clear on Reset. History keeps archived totals. + +## Task list interaction + +Each Space page includes its task list on the same full-page canvas (not a separate lower card region). + +- Single tap selects the task for timing while running. +- Double-tap toggles complete / incomplete. +- Context menu: Complete / Mark Incomplete, Rename, Enable / Disable, Delete. +- Inline **Add a task** field at the bottom of the list. + +## Space paging + +A page-style `TabView` fills the screen. Each page is one Space: header (icon + name), stopwatch, Lap/Start/Stop/Reset controls, page dots, and tasks together. Swiping a Space does not move elapsed time onto the newly visible Space. Starting Space B while A is running freezes A, then starts or resumes B. + +A trailing **New Space** page (Apple Home Screen–style extra page) lets you compose a Space before it exists: name, initial tasks, accent color, and icon (SF Symbols, emoji, or monogram from the name). Creating it selects that Space and returns to its page. + +Space tint is a top-to-bottom color wash on black, not giant tinted glass cards. + +## Live Activity + +`SessionActivityAttributes.ContentState` carries space name, current task, running flag, elapsed, tint, and workspace icon. + +Lock Screen layout: + +- Large space-colored **space name** +- Current task subtitle +- Elapsed via `Text(timerInterval:countsDown:false, showsHours:true)` while running. Stopped uses the same formatter with `pauseTime`. Resume **replaces** that view (new `displayStart` / `elapsedClockID`) instead of clearing `pauseTime` on the paused instance — the system timer does not unfreeze otherwise. +- Explicit **Stop** (`StopFromLiveActivityIntent`) or **Start** (`ResumeFromLiveActivityIntent`) plus **Reset** (`xmark`). Not a `Toggle` / `SetValueIntent` (that control can pause twice and never resume). +- Large workspace icon on the trailing edge +- Background tint from the Space color (`activityBackgroundTint`) + +Default activity taps do not mutate the timer (`LiveActivityPresentation.backgroundMutatesTimer == false`). Stop updates the activity as frozen. Reset dismisses with `.immediate`. + +## App Intents + +Intents call `AppRuntime.shared.sessionController`. Pause intents freeze like Stop. Resume intents call Start. Focus Filter selects a Space. No backend. + +## Notifications + +`Distraction Started` schedules a local reminder only if a timer is running and the Space reminder is not Off. Copy is `Work · 42:18` / `Timer is still running.` Actions: Continue, Stop. + +## Test architecture + +- Engine and formatter unit tests +- Stopwatch semantics (stop / resume / reset) +- Task interval and editing tests +- Per-Space ownership tests +- Live Activity state tests +- UI tests: full-page paging isolation, labels, long-press Lap, compose page, screenshots, Live Activity preview canvas + +## CI architecture + +Linux agent never runs Xcode. `.github/workflows/ios-ci.yml` on `macos-26` generates the Xcode project, tests, packages `conduit-unsigned.ipa` (`Payload/ProductivityTracker.app` inside), and uploads `conduit-iOS-device-unsigned`. diff --git a/Design/NATIVE_IOS_AUDIT.md b/Design/NATIVE_IOS_AUDIT.md new file mode 100644 index 0000000..a82873f --- /dev/null +++ b/Design/NATIVE_IOS_AUDIT.md @@ -0,0 +1,84 @@ +# Native iOS audit + +Research for the ProductivityTracker corrective pass. No third-party source was copied or vendored. + +## Repositories inspected + +All four requested apps are currently maintained (GitHub API, 13 Aug 2026): + +| Project | Evidence | Inspected for | +| --- | --- | --- | +| [Ranchero-Software/NetNewsWire](https://github.com/Ranchero-Software/NetNewsWire) | Last commit 12 Aug 2026 (`Update appcasts.`) | Feed list editing, grouped settings, absence of decorative cards | +| [mastodon/mastodon-ios](https://github.com/mastodon/mastodon-ios) | Last commit 10 Jul 2026 | UIKit table editing, swipe actions, system navigation | +| [Dimillian/IceCubesApp](https://github.com/Dimillian/IceCubesApp) | Last commit 9 Jun 2026; App Store client | SwiftUI lists, context menus, native sheets | +| [jellyfin/Swiftfin](https://github.com/jellyfin/Swiftfin) | Last commit 12 Aug 2026 (Weblate) | Settings forms, playback chrome vs content | + +None were stale enough to replace. + +Useful files (read on GitHub, not imported): NetNewsWire iOS Settings storyboards / inspector lists; IceCubes `App/Main/Settings`; Swiftfin Settings views. Pattern: **plain grouped lists, EditMode, swipe-to-delete, context menus**. No giant tinted content cards. + +## Apple documentation consulted + +- Human Interface Guidelines: layout, lists, buttons, haptics, motion +- SwiftUI: `List` + `EditMode`, context menus, `TimelineView(.animation)` +- WWDC25 “Build a UIKit app with the new design”: Liquid Glass is a **navigation/control layer**, not content wallpaper +- ActivityKit / Live Activities: timer-first lock screen presentation; `Text(timerInterval:countsDown: false)` for count-up; `ActivityUIDismissalPolicy.immediate` on end +- App Intents: explicit buttons only; default Live Activity tap opens the app +- Public Clock stopwatch behavior: Idle Lap+Start, Running Lap+Stop, Stopped Reset+Start; Stop freezes; Start resumes; Reset clears + +## Principles adopted + +1. Content sits on system black. Glass is not a background. +2. Stopwatch controls are independent circular buttons, not a joined glass capsule. +3. Lists use native `List` / row separators, EditMode, swipe, context menu. +4. Live Activity: elapsed time first; one explicit control; no `xmark` for Stop; no pause-on-surface-tap. +5. Motion: native paging physics; no extra springs on TabView; Reduce Motion respected. +6. Copy is functional (`Start`, `Stop`, `Reset`, `Timer is still running.`). + +## Patterns explicitly rejected + +- Tinted Liquid Glass on half-screen squarcles (`GlassSurface` on timer + task panel) +- `GlassEffectContainer` wrapping Lap/Start so they visually merge +- Stop = finalize/archive (previous model) +- Start after Stop = reset then new session +- Pause icon + X as primary Live Activity chrome +- Binding Pause to the whole Live Activity +- Tutorial empty-state paragraphs +- 0.07s periodic TimelineView (visible stepping) +- One global timer snapshot relabeled by swipe + +## How this changed ProductivityTracker + +- Per-Space `TimerEngine` ownership; swipe never relabels a running session. +- Stop freezes; Start resumes; Reset archives and clears. +- Main screen is a black Clock-like canvas. `GlassSurface` is unused on timer and task regions. +- Space accent is a 7pt indicator and page dots, not a fill. +- `TimelineView(.animation)` drives only stopwatch digits (and the live active-task time), from timestamps. +- Native Form/List editing for Spaces and tasks, including rename and reorder. +- Live Activity redesigned: timer first, one control, space name in ContentState, no pause-on-tap, immediate dismiss on Reset. + +## Follow-up — Conduit branding and UX (Aug 2026) + +Prior audit conclusions stand (native lists, per-Space engines, Stop/Start/Reset semantics). Subsequent work rebranded the product to **Conduit** on the home screen while keeping bundle ID `com.arahe.ProductivityTracker` and Xcode target names. + +**Visual** + +- App icon: liquid-glass amber **C** / conduit mark (replaces egg-timer dial). +- Main canvas: Space tint as a subtle top color wash on black, not half-screen tinted glass cards. + +**Navigation** + +- Full-page Space paging: timer, controls, and tasks share one page; swipe moves the whole Space. +- Trailing compose page (Home Screen–style “+” page): name, tasks, color, icon picker (SF Symbols, emoji, monogram). + +**Tasks** + +- Double-tap and context menu complete / uncomplete tasks. +- Inline add-task field on each Space page. + +**Live Activity** + +- Large space-colored name, current task, `Text(timerInterval:showsHours:true)` while running and with `pauseTime` when stopped (no ms). Resume uses a new clock identity, not an unpaused `pauseTime`. +- Stop / Start plus Reset controls; large workspace icon; background tint from Space color. +- Stop still freezes; Start resumes; Reset archives and dismisses the Live Activity immediately. + diff --git a/Design/references/PHYSICAL_DEVICE_AUDIT.md b/Design/references/PHYSICAL_DEVICE_AUDIT.md new file mode 100644 index 0000000..0dbd428 --- /dev/null +++ b/Design/references/PHYSICAL_DEVICE_AUDIT.md @@ -0,0 +1,52 @@ +# Physical-device observations + +Attachments were inspected from the task (main screen, Live Activity, original concept). Bytes were not always available to commit; this file records the audit. + +## Main screen (installed build — pre-fix) + +- Two large tinted rounded rectangles on black (upper timer, lower tasks). +- Space color fills those surfaces (brown/teal), not a small accent. +- Timer `00:31`-class digits sit inside a card, not on the canvas like Clock. +- Controls sit inside the same tinted card; glass wrapping is visible. +- Task rows live in a second card rather than a list on the same black field. +- Interaction felt unsmooth: hundredths stepped; paging felt like sliding cards. + +## Live Activity (installed build — pre-fix) + +- Pause + `xmark` clustered on the leading edge; timer secondary. +- `xmark` reads as dismiss, not Stop. +- Layout felt like a custom mini-app inside the system activity, not a status surface. +- Stop appeared to tear down the activity instead of freezing a resumable stopwatch. + +## Causes in code (pre-fix) + +- `GlassSurface` applied Space tint glass to both main regions. +- `GlassEffectContainer` around Lap/Start. +- `SessionController.stop()` set `endedAt` and called `liveActivity.end(... .after(.now + 8))`. +- Stopped Start called `resetStoppedDisplay()` then `start()` (new session, zeroed time). +- Single global `TimerEngine`; swipe only changed `selectedSpaceID`. +- `TimelineView(.periodic(..., by: 0.07))`. +- Lap used `simultaneousGesture(LongPressGesture)` so long-press also lapped. +- `selectTask` required an active session. +- Live Activity `HStack` put `pause.fill` + `xmark` first; attributes held a static space name. + +## Post-fix mapping + +The corrective implementation removes those structures: Apple Stopwatch semantics, per-Space engines, Clock-like composition, native editors, and a timer-first Live Activity. Physical Lock Screen tap routing still requires a real iPhone. + +## Post-fix Live Activity (current design — not yet re-audited on device) + +Documentation and simulator preview now describe this layout; a fresh physical Lock Screen pass is still recommended. + +- **Leading column:** large space-colored name, current task, elapsed via `Text(timerInterval:showsHours:true)` while running and with `pauseTime` when stopped (hh:mm:ss). Resume creates a new timer view identity so digits run again. +- **Controls:** explicit Stop or Start plus Reset (`xmark`); intents only (`openAppWhenRun = false`); surface tap opens the app without mutating the timer. +- **Trailing:** large workspace icon (SF Symbol, emoji, or monogram). +- **Background:** Space tint via `activityBackgroundTint`, not a custom mini-app chrome block. +- **Semantics:** Stop freezes and updates the activity; Start resumes; Reset archives the session and dismisses the Live Activity immediately. + +## Post-fix main screen (current design) + +- Full-page Space paging: header, stopwatch, controls, page dots, and tasks on one page. +- Trailing **New Space** compose page for name, tasks, color, and icons. +- Space accent as a top color wash, not giant glass cards. +- Double-tap / context menu task completion; inline add task. diff --git a/Design/references/README.md b/Design/references/README.md new file mode 100644 index 0000000..2c22116 --- /dev/null +++ b/Design/references/README.md @@ -0,0 +1,9 @@ +The annotated concept screenshot and the native custom-app context image were supplied in the original task prompt. + +They were not present as files in this Git clone. Implementation used: + +1. The written design specification in the task. +2. The accessible image description of the annotated Clock-style stopwatch concept. +3. Current public Apple iOS 26 Liquid Glass / SwiftUI documentation. + +Place any recovered original images in this folder for later visual comparison. diff --git a/Design/references/SPACE_IMPORT.md b/Design/references/SPACE_IMPORT.md new file mode 100644 index 0000000..36e63b3 --- /dev/null +++ b/Design/references/SPACE_IMPORT.md @@ -0,0 +1,17 @@ +# Space JSON schema + +Used by the Import Space Definition App Intent. + +```json +{ + "name": "Thermodynamics", + "color": "blue", + "tasks": ["Review lecture", "Practice problems"] +} +``` + +- `name` required, 1–80 characters +- `color` one of: orange, blue, teal, purple, green, red, yellow, indigo, pink, gray +- `tasks` 1–40 unique non-empty strings (duplicates ignored) + +Shortcuts: Use Model / ChatGPT to emit exactly this JSON, then pass it to ProductivityTracker → Import Space Definition. diff --git a/Design/references/space-import.schema.json b/Design/references/space-import.schema.json new file mode 100644 index 0000000..b1acf27 --- /dev/null +++ b/Design/references/space-import.schema.json @@ -0,0 +1,10 @@ +{ + "name": "Thermodynamics", + "color": "blue", + "tasks": [ + "Review lecture", + "Practice problems", + "Formula review", + "Assignment" + ] +} diff --git a/Design/screenshots/README.md b/Design/screenshots/README.md new file mode 100644 index 0000000..890fa2e --- /dev/null +++ b/Design/screenshots/README.md @@ -0,0 +1,6 @@ +CI simulator screenshots extracted from the green GitHub Actions xcresult (iPhone 17, iOS 26.5). + +- `ci-1.png` — task picker over the timer +- `ci-2.png` — Settings +- `ci-3.png` — main timer (Lap / Stop, `00:31.42`) +- `ci-4.png` — History diff --git a/Design/screenshots/ci-1.png b/Design/screenshots/ci-1.png new file mode 100644 index 0000000..37570f1 Binary files /dev/null and b/Design/screenshots/ci-1.png differ diff --git a/Design/screenshots/ci-2.png b/Design/screenshots/ci-2.png new file mode 100644 index 0000000..af04226 Binary files /dev/null and b/Design/screenshots/ci-2.png differ diff --git a/Design/screenshots/ci-3.png b/Design/screenshots/ci-3.png new file mode 100644 index 0000000..f722f0f Binary files /dev/null and b/Design/screenshots/ci-3.png differ diff --git a/Design/screenshots/ci-4.png b/Design/screenshots/ci-4.png new file mode 100644 index 0000000..72b676b Binary files /dev/null and b/Design/screenshots/ci-4.png differ diff --git a/INSTALL_WINDOWS.md b/INSTALL_WINDOWS.md new file mode 100644 index 0000000..420601c --- /dev/null +++ b/INSTALL_WINDOWS.md @@ -0,0 +1,62 @@ +# Install on Windows (free Apple Account) + +The IPA from GitHub Actions is **unsigned**. Sign it on this PC with your Apple Account. Do not put that password in GitHub, Cursor, or chat. + +## 1. Apple Account + +On iPhone or [developer.apple.com](https://developer.apple.com): register a free Apple developer account and accept Apple’s agreement if asked. + +## 2. Developer Mode + +iPhone: Settings → Privacy & Security → Developer Mode → On. Restart if iOS asks. + +## 3. Sideloadly + +Download current Sideloadly for Windows from [https://sideloadly.io](https://sideloadly.io) (`SideloadlySetup64.exe`). Sideloadly has no supported public CLI; use the app UI. + +## 4. Apple device support on Windows + +Sideloadly needs the **web/direct** iTunes and iCloud installers, not Microsoft Store copies. + +- If Store iTunes/iCloud are installed, uninstall them. +- Install iTunes 64-bit from Apple and iCloud for Windows (non-Store), then reboot if prompted. + +## 5. Connect the iPhone + +USB cable. Trust this computer on the iPhone. Unlock the phone. + +## 6. Download the IPA + +GitHub → this repo → Actions → latest green **iOS CI** run → artifact **`conduit-iOS-device-unsigned`**. + +Inside: `conduit-unsigned.ipa` and `SHA256SUMS.txt`. The unsigned bundle inside the IPA is still `Payload/ProductivityTracker.app`; the home screen shows **Conduit**. + +Optional helper (no Apple credentials): + +```powershell +powershell -ExecutionPolicy Bypass -File Scripts\windows\fetch-ipa.ps1 +``` + +## 7–10. Sign and install + +1. Open Sideloadly. +2. Select the iPhone. +3. Drag `conduit-unsigned.ipa` onto Sideloadly. +4. Enter your Apple Account in Sideloadly (locally). Complete 2FA in Sideloadly/Apple prompts on this PC. +5. Start. Wait until install finishes. + +Keep bundle ID `com.arahe.ProductivityTracker` so later refreshes replace the same app and keep local SwiftData. + +## 11. Trust the developer + +If iOS blocks launch: Settings → General → VPN & Device Management → your Apple Account → Trust. + +## 12. Launch + +Open **Conduit** on the home screen. Grant notifications later, when a distraction reminder is actually useful. + +## 13–15. Refresh without deleting data + +Free signing expires in **7 days**. Re-sign the same bundle ID with the same Apple Account. Enable Sideloadly automatic refresh if you want weekly re-signing while this PC and iPhone can connect (often Wi-Fi sync + Sideloadly left available). Updating from a newer CI IPA is the same install flow; do not delete the app if you want history kept. + +Never upload Apple passwords, 2FA codes, or signing keys to GitHub or Cursor. diff --git a/ProductivityTracker/App/AppRuntime.swift b/ProductivityTracker/App/AppRuntime.swift new file mode 100644 index 0000000..308ddd1 --- /dev/null +++ b/ProductivityTracker/App/AppRuntime.swift @@ -0,0 +1,10 @@ +import Foundation +import SwiftData + +@MainActor +final class AppRuntime { + static let shared = AppRuntime() + var sessionController: SessionController? + var container: ModelContainer? + private init() {} +} diff --git a/ProductivityTracker/App/LaunchConfiguration.swift b/ProductivityTracker/App/LaunchConfiguration.swift new file mode 100644 index 0000000..0331c47 --- /dev/null +++ b/ProductivityTracker/App/LaunchConfiguration.swift @@ -0,0 +1,80 @@ +import Foundation + +enum ScreenshotTimerState: String, Equatable, Sendable { + case idle + case running + case stopped +} + +struct LaunchConfiguration: Equatable, Sendable { + var uiTesting: Bool + var resetStore: Bool + var screenshotMode: Bool + var startRunning: Bool + var frozenElapsed: TimeInterval? + var inMemoryStore: Bool + var timerState: ScreenshotTimerState? + var liveActivityPreview: Bool + + init( + uiTesting: Bool = false, + resetStore: Bool = false, + screenshotMode: Bool = false, + startRunning: Bool = false, + frozenElapsed: TimeInterval? = nil, + inMemoryStore: Bool = false, + timerState: ScreenshotTimerState? = nil, + liveActivityPreview: Bool = false + ) { + self.uiTesting = uiTesting + self.resetStore = resetStore + self.screenshotMode = screenshotMode + self.startRunning = startRunning + self.frozenElapsed = frozenElapsed + self.inMemoryStore = inMemoryStore + self.timerState = timerState + self.liveActivityPreview = liveActivityPreview + } + + static let `default` = LaunchConfiguration() + + static func from(_ arguments: [String], environment: [String: String] = [:]) -> LaunchConfiguration { + var config = LaunchConfiguration.default + config.uiTesting = arguments.contains("-UITests") || arguments.contains("--uitesting") + config.resetStore = arguments.contains("-ResetStore") + config.screenshotMode = arguments.contains("-ScreenshotMode") + config.startRunning = arguments.contains("-StartRunning") + config.liveActivityPreview = arguments.contains("-LiveActivityPreview") + let persist = arguments.contains("-PersistStore") + config.inMemoryStore = (arguments.contains("-InMemoryStore") || config.uiTesting) && !persist + if let value = environment["UITEST_ELAPSED"] ?? argumentValue("-FrozenElapsed", in: arguments) { + config.frozenElapsed = TimeInterval(value) + } + if let raw = environment["UITEST_TIMER_STATE"] ?? argumentValue("-TimerState", in: arguments) { + config.timerState = ScreenshotTimerState(rawValue: raw) + } + if config.screenshotMode && config.timerState == nil { + config.timerState = .running + } + if config.timerState == .running { + config.startRunning = true + } + if config.screenshotMode && config.frozenElapsed == nil { + config.frozenElapsed = config.timerState == .idle ? 0 : 31.42 + } + if config.timerState == .idle { + config.startRunning = false + if config.frozenElapsed == nil { + config.frozenElapsed = 0 + } + } + return config + } + + private static func argumentValue(_ flag: String, in arguments: [String]) -> String? { + guard let index = arguments.firstIndex(of: flag), arguments.indices.contains(index + 1) else { + return nil + } + return arguments[index + 1] + } +} diff --git a/ProductivityTracker/App/ProductivityTrackerApp.swift b/ProductivityTracker/App/ProductivityTrackerApp.swift new file mode 100644 index 0000000..7e6f93d --- /dev/null +++ b/ProductivityTracker/App/ProductivityTrackerApp.swift @@ -0,0 +1,33 @@ +import SwiftData +import SwiftUI +import UIKit + +@main +struct ProductivityTrackerApp: App { + private let launch = LaunchConfiguration.from(ProcessInfo.processInfo.arguments, environment: ProcessInfo.processInfo.environment) + private let container: ModelContainer + private let controller: SessionController + + init() { + if launch.uiTesting { + UIView.setAnimationsEnabled(false) + } + do { + container = try PersistenceController.makeContainer(inMemory: launch.inMemoryStore) + AppRuntime.shared.container = container + let controller = SessionController(context: ModelContext(container), launch: launch) + try controller.bootstrap() + self.controller = controller + } catch { + fatalError("Unable to create ModelContainer: \(error)") + } + } + + var body: some Scene { + WindowGroup { + RootView(launch: launch, controller: controller) + .modelContainer(container) + .preferredColorScheme(.dark) + } + } +} diff --git a/ProductivityTracker/DesignSystem/AppTheme.swift b/ProductivityTracker/DesignSystem/AppTheme.swift new file mode 100644 index 0000000..91ec434 --- /dev/null +++ b/ProductivityTracker/DesignSystem/AppTheme.swift @@ -0,0 +1,48 @@ +import SwiftUI + +enum AccessibilityIDs { + static let timerCard = "timer-card" + static let taskPanel = "task-panel" + static let spaceName = "space-name" + static let stopwatch = "stopwatch-display" + static let lapButton = "lap-button" + static let resetButton = "reset-button" + static let startStopButton = "start-stop-button" + static let pageIndicator = "page-indicator" + static let settingsButton = "settings-button" + static let taskPicker = "task-picker" + static let glassSurface = "glass-surface" + static let spaceEditor = "space-editor" + static let liveActivityPreview = "live-activity-preview" + static let addSpacePage = "add-space-page" + static let addTaskInline = "add-task-inline" + static let createSpaceButton = "create-space-button" + static let spacePager = "space-pager" + static let saveTimeButton = "save-time-button" +} + +enum LayoutMetrics { + static let horizontalMargin: CGFloat = 20 + static let stackSpacing: CGFloat = 0 + static let buttonDiameter: CGFloat = 72 + static let stopwatchSize: CGFloat = 84 + static let upperFraction: CGFloat = 0.48 +} + +enum SpaceCanvas { + static func glow(_ color: Color) -> some View { + ZStack { + Color.black + LinearGradient( + stops: [ + .init(color: color.opacity(0.20), location: 0), + .init(color: color.opacity(0.06), location: 0.14), + .init(color: Color.clear, location: 0.32) + ], + startPoint: .top, + endPoint: .bottom + ) + } + .ignoresSafeArea() + } +} diff --git a/ProductivityTracker/DesignSystem/GlassSurface.swift b/ProductivityTracker/DesignSystem/GlassSurface.swift new file mode 100644 index 0000000..0afac62 --- /dev/null +++ b/ProductivityTracker/DesignSystem/GlassSurface.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct GlassSurface: View { + var tint: Color + var cornerRadius: CGFloat + @ViewBuilder var content: Content + + var body: some View { + content + .accessibilityIdentifier(AccessibilityIDs.glassSurface) + .background { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(.black.opacity(0.18)) + } + .glassEffect(.regular.tint(tint.opacity(0.22)), in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } +} diff --git a/ProductivityTracker/DesignSystem/Keyboard.swift b/ProductivityTracker/DesignSystem/Keyboard.swift new file mode 100644 index 0000000..f45460c --- /dev/null +++ b/ProductivityTracker/DesignSystem/Keyboard.swift @@ -0,0 +1,8 @@ +import SwiftUI +import UIKit + +enum Keyboard { + static func dismiss() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/ProductivityTracker/DesignSystem/StopwatchButtonStyle.swift b/ProductivityTracker/DesignSystem/StopwatchButtonStyle.swift new file mode 100644 index 0000000..e6dc921 --- /dev/null +++ b/ProductivityTracker/DesignSystem/StopwatchButtonStyle.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct StopwatchCircleButton: View { + enum Kind { + case lap + case start + case stop + case reset + + var title: String { + switch self { + case .lap: "Lap" + case .start: "Start" + case .stop: "Stop" + case .reset: "Reset" + } + } + + var fill: Color { + switch self { + case .lap, .reset: Color.gray.opacity(0.28) + case .start: Color.green.opacity(0.28) + case .stop: Color.red.opacity(0.28) + } + } + + var foreground: Color { + switch self { + case .lap, .reset: .white + case .start: .green + case .stop: .red + } + } + } + + var kind: Kind + var action: () -> Void + var diameter: CGFloat = LayoutMetrics.buttonDiameter + + var body: some View { + Button(action: action) { + Text(kind.title) + .font(.body.weight(.semibold)) + .foregroundStyle(kind.foreground) + .frame(width: diameter, height: diameter) + .background(Circle().fill(kind.fill)) + .overlay { + Circle() + .strokeBorder(kind.foreground.opacity(0.22), lineWidth: 1) + } + .glassEffect(.regular.tint(kind.foreground.opacity(0.18)).interactive(), in: .circle) + } + .buttonStyle(.plain) + .accessibilityLabel(kind.title) + .frame(minWidth: 44, minHeight: 44) + } +} diff --git a/ProductivityTracker/Intents/AppShortcuts.swift b/ProductivityTracker/Intents/AppShortcuts.swift new file mode 100644 index 0000000..8040f8d --- /dev/null +++ b/ProductivityTracker/Intents/AppShortcuts.swift @@ -0,0 +1,39 @@ +import AppIntents + +struct ProductivityTrackerShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: StartCurrentSpaceIntent(), + phrases: [ + "Start a session in \(.applicationName)", + "Start Work in \(.applicationName)" + ], + shortTitle: "Start Session", + systemImageName: "play.circle" + ) + AppShortcut( + intent: StopSessionIntent(), + phrases: [ + "Stop the timer in \(.applicationName)" + ], + shortTitle: "Stop Session", + systemImageName: "stop.circle" + ) + AppShortcut( + intent: LapIntent(), + phrases: [ + "Next task in \(.applicationName)" + ], + shortTitle: "Next Task", + systemImageName: "forward.end" + ) + AppShortcut( + intent: ImportSpaceDefinitionIntent(), + phrases: [ + "Import a space in \(.applicationName)" + ], + shortTitle: "Import Space", + systemImageName: "square.and.arrow.down" + ) + } +} diff --git a/ProductivityTracker/Intents/Entities.swift b/ProductivityTracker/Intents/Entities.swift new file mode 100644 index 0000000..0ceeeaf --- /dev/null +++ b/ProductivityTracker/Intents/Entities.swift @@ -0,0 +1,68 @@ +import AppIntents +import SwiftData + +struct SpaceEntity: AppEntity, Identifiable { + static var typeDisplayRepresentation: TypeDisplayRepresentation { "Space" } + static var defaultQuery: SpaceEntityQuery { SpaceEntityQuery() } + + var id: UUID + var name: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +struct SpaceEntityQuery: EntityQuery { + func entities(for identifiers: [UUID]) async throws -> [SpaceEntity] { + try await all().filter { identifiers.contains($0.id) } + } + + func suggestedEntities() async throws -> [SpaceEntity] { + try await all() + } + + func all() async throws -> [SpaceEntity] { + try await MainActor.run { + guard let container = AppRuntime.shared.container else { return [] } + let context = ModelContext(container) + let spaces = try context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.displayOrder)])) + return spaces.map { SpaceEntity(id: $0.id, name: $0.name) } + } + } +} + +struct TaskEntity: AppEntity, Identifiable { + static var typeDisplayRepresentation: TypeDisplayRepresentation { "Task" } + static var defaultQuery: TaskEntityQuery { TaskEntityQuery() } + + var id: UUID + var name: String + var spaceID: UUID + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +struct TaskEntityQuery: EntityQuery { + func entities(for identifiers: [UUID]) async throws -> [TaskEntity] { + try await all().filter { identifiers.contains($0.id) } + } + + func suggestedEntities() async throws -> [TaskEntity] { + try await all() + } + + func all() async throws -> [TaskEntity] { + try await MainActor.run { + guard let container = AppRuntime.shared.container else { return [] } + let context = ModelContext(container) + let tasks = try context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.displayOrder)])) + return tasks.compactMap { task in + guard let spaceID = task.space?.id else { return nil } + return TaskEntity(id: task.id, name: task.name, spaceID: spaceID) + } + } + } +} diff --git a/ProductivityTracker/Intents/FocusFilterIntent.swift b/ProductivityTracker/Intents/FocusFilterIntent.swift new file mode 100644 index 0000000..3e37930 --- /dev/null +++ b/ProductivityTracker/Intents/FocusFilterIntent.swift @@ -0,0 +1,29 @@ +import AppIntents + +struct ProductivityFocusFilter: SetFocusFilterIntent { + static var title: LocalizedStringResource { "Select a Space" } + static var description: IntentDescription? { + IntentDescription( + "When this Focus is active, ProductivityTracker can select the matching Space. The app cannot turn Focus on by itself." + ) + } + + @Parameter(title: "Space") + var space: SpaceEntity? + + var displayRepresentation: DisplayRepresentation { + if let space { + return DisplayRepresentation(title: "Space: \(space.name)") + } + return DisplayRepresentation(title: "Select a Space") + } + + func perform() async throws -> some IntentResult { + await MainActor.run { + if let space { + AppRuntime.shared.sessionController?.selectSpace(space.id) + } + } + return .result() + } +} diff --git a/ProductivityTracker/Intents/ImportAndDistractionIntents.swift b/ProductivityTracker/Intents/ImportAndDistractionIntents.swift new file mode 100644 index 0000000..53ce2de --- /dev/null +++ b/ProductivityTracker/Intents/ImportAndDistractionIntents.swift @@ -0,0 +1,105 @@ +import AppIntents +import SwiftData + +struct SelectTaskIntent: AppIntent { + static var title: LocalizedStringResource { "Select Task" } + + @Parameter(title: "Task") + var task: TaskEntity + + func perform() async throws -> some IntentResult { + try await MainActor.run { + guard let controller = AppRuntime.shared.sessionController else { + throw IntentFailure.unavailable + } + let tasks = controller.selectedSpace?.tasks ?? [] + if let match = tasks.first(where: { $0.id == task.id }) { + try controller.selectTask(match) + } + } + return .result() + } +} + +struct GetCurrentSessionIntent: AppIntent { + static var title: LocalizedStringResource { "Get Current Session" } + + func perform() async throws -> some IntentResult & ReturnsValue { + let summary = await MainActor.run { () -> String in + guard let controller = AppRuntime.shared.sessionController else { + return "Unavailable" + } + let space = controller.selectedSpace?.name ?? "None" + let task = controller.activeTask?.name ?? "None" + let elapsed = ElapsedFormatter.stopwatch(controller.displayedElapsed(at: controller.timeSource.now())) + return "Space: \(space); Task: \(task); State: \(controller.snapshot.phase.rawValue); Elapsed: \(elapsed)" + } + return .result(value: summary) + } +} + +struct CreateSpaceIntent: AppIntent { + static var title: LocalizedStringResource { "Create Space" } + + @Parameter(title: "Name") + var name: String + + @Parameter(title: "First task", default: "Task 1") + var firstTask: String + + func perform() async throws -> some IntentResult { + try await MainActor.run { + guard let controller = AppRuntime.shared.sessionController else { + throw IntentFailure.unavailable + } + _ = try controller.createSpace(name: name, tint: .blue, tasks: [firstTask]) + } + return .result() + } +} + +struct ImportSpaceDefinitionIntent: AppIntent { + static var title: LocalizedStringResource { "Import Space Definition" } + static var description: IntentDescription? { IntentDescription("Create a Space from a JSON object with name, color, and tasks.") } + + @Parameter(title: "JSON") + var json: String + + func perform() async throws -> some IntentResult & ReturnsValue { + do { + let payload = try SpaceImportPayload.parse(json: json) + let createdName = try await MainActor.run { () throws -> String in + guard let controller = AppRuntime.shared.sessionController else { + throw IntentFailure.unavailable + } + let space = try controller.importSpace(payload) + return space.name + } + return .result(value: "Created \(createdName)") + } catch let error as SpaceImportError { + throw IntentFailure.importFailed(error.errorDescription ?? "Invalid Space definition") + } + } +} + +struct DistractionStartedIntent: AppIntent { + static var title: LocalizedStringResource { "Distraction Started" } + + func perform() async throws -> some IntentResult { + await MainActor.run { + AppRuntime.shared.sessionController?.distractionStarted() + } + return .result() + } +} + +struct DistractionEndedIntent: AppIntent { + static var title: LocalizedStringResource { "Distraction Ended" } + + func perform() async throws -> some IntentResult { + await MainActor.run { + AppRuntime.shared.sessionController?.distractionEnded() + } + return .result() + } +} diff --git a/ProductivityTracker/Intents/SessionIntents.swift b/ProductivityTracker/Intents/SessionIntents.swift new file mode 100644 index 0000000..ecf84e2 --- /dev/null +++ b/ProductivityTracker/Intents/SessionIntents.swift @@ -0,0 +1,120 @@ +import AppIntents + +struct SelectSpaceIntent: AppIntent { + static var title: LocalizedStringResource { "Select Space" } + static var description: IntentDescription? { IntentDescription("Select a ProductivityTracker Space without starting the timer.") } + + @Parameter(title: "Space") + var space: SpaceEntity + + static var parameterSummary: some ParameterSummary { + Summary("Select \(\.$space)") + } + + func perform() async throws -> some IntentResult { + try await MainActor.run { + guard let controller = AppRuntime.shared.sessionController else { + throw IntentFailure.unavailable + } + controller.selectSpace(space.id) + } + return .result() + } +} + +struct StartSpaceIntent: AppIntent { + static var title: LocalizedStringResource { "Start Space" } + static var description: IntentDescription? { IntentDescription("Select a Space and start its timer.") } + + @Parameter(title: "Space") + var space: SpaceEntity + + static var parameterSummary: some ParameterSummary { + Summary("Start \(\.$space) in ProductivityTracker") + } + + func perform() async throws -> some IntentResult { + try await MainActor.run { + guard let controller = AppRuntime.shared.sessionController else { + throw IntentFailure.unavailable + } + controller.selectSpace(space.id) + try controller.start() + } + return .result() + } +} + +struct StartCurrentSpaceIntent: AppIntent { + static var title: LocalizedStringResource { "Start Session" } + static var description: IntentDescription? { IntentDescription("Start the timer for the current or default Space.") } + + func perform() async throws -> some IntentResult { + try await MainActor.run { + guard let controller = AppRuntime.shared.sessionController else { + throw IntentFailure.unavailable + } + try controller.start() + } + return .result() + } +} + +struct StopSessionIntent: AppIntent { + static var title: LocalizedStringResource { "Stop Session" } + + func perform() async throws -> some IntentResult { + try await MainActor.run { + try AppRuntime.shared.sessionController?.stop() + } + return .result() + } +} + +struct PauseSessionIntent: AppIntent { + static var title: LocalizedStringResource { "Pause Session" } + + func perform() async throws -> some IntentResult { + try await MainActor.run { + try AppRuntime.shared.sessionController?.stop() + } + return .result() + } +} + +struct ResumeSessionIntent: AppIntent { + static var title: LocalizedStringResource { "Resume Session" } + + func perform() async throws -> some IntentResult { + try await MainActor.run { + try AppRuntime.shared.sessionController?.start() + } + return .result() + } +} + +struct LapIntent: AppIntent { + static var title: LocalizedStringResource { "Next Task" } + static var description: IntentDescription? { IntentDescription("Finish the current task interval and start the next enabled task.") } + + func perform() async throws -> some IntentResult { + try await MainActor.run { + try AppRuntime.shared.sessionController?.lap() + } + return .result() + } +} + +enum IntentFailure: Error, CustomLocalizedStringResourceConvertible { + case unavailable + case importFailed(String) + + var localizedStringResource: LocalizedStringResource { + switch self { + case .unavailable: + return "ProductivityTracker is not ready." + case .importFailed(let message): + return "\(message)" + } + } +} diff --git a/ProductivityTracker/Intents/SpaceImportPayload.swift b/ProductivityTracker/Intents/SpaceImportPayload.swift new file mode 100644 index 0000000..9bb9547 --- /dev/null +++ b/ProductivityTracker/Intents/SpaceImportPayload.swift @@ -0,0 +1,99 @@ +import Foundation + +enum SpaceImportError: Error, Equatable, LocalizedError { + case malformedJSON + case missingName + case emptyTasks + case excessiveTaskCount + case invalidColor(String) + case emptyTaskName + + var errorDescription: String? { + switch self { + case .malformedJSON: + return "The Space definition is not valid JSON." + case .missingName: + return "A Space name is required." + case .emptyTasks: + return "A Space must include at least one task." + case .excessiveTaskCount: + return "A Space cannot include more than 40 tasks." + case .invalidColor(let value): + return "Unsupported color '\(value)'." + case .emptyTaskName: + return "Task names cannot be empty." + } + } +} + +struct SpaceImportPayload: Equatable, Sendable { + var name: String + var color: SpaceTint + var tasks: [String] + + static let maxTasks = 40 + static let maxNameLength = 80 + + static func parse(json: String) throws -> SpaceImportPayload { + let data = Data(json.utf8) + let object: Any + do { + object = try JSONSerialization.jsonObject(with: data) + } catch { + throw SpaceImportError.malformedJSON + } + guard let dict = object as? [String: Any] else { + throw SpaceImportError.malformedJSON + } + return try parse(dictionary: dict) + } + + static func parse(dictionary: [String: Any]) throws -> SpaceImportPayload { + guard let rawName = dictionary["name"] as? String else { + throw SpaceImportError.missingName + } + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty { + throw SpaceImportError.missingName + } + let clippedName = String(name.prefix(maxNameLength)) + + let color: SpaceTint + if let rawColor = dictionary["color"] as? String { + guard let parsed = SpaceTint.parse(rawColor) else { + throw SpaceImportError.invalidColor(rawColor) + } + color = parsed + } else { + color = .blue + } + + guard let rawTasks = dictionary["tasks"] as? [Any] else { + throw SpaceImportError.emptyTasks + } + var tasks: [String] = [] + var seen = Set() + for item in rawTasks { + guard let value = item as? String else { + throw SpaceImportError.emptyTaskName + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + throw SpaceImportError.emptyTaskName + } + let key = trimmed.lowercased() + if seen.contains(key) { + continue + } + seen.insert(key) + tasks.append(String(trimmed.prefix(maxNameLength))) + } + if tasks.isEmpty { + throw SpaceImportError.emptyTasks + } + if tasks.count > maxTasks { + throw SpaceImportError.excessiveTaskCount + } + return SpaceImportPayload(name: clippedName, color: color, tasks: tasks) + } +} diff --git a/ProductivityTracker/LiveActivity/LiveActivityPresentation.swift b/ProductivityTracker/LiveActivity/LiveActivityPresentation.swift new file mode 100644 index 0000000..7bd5bc8 --- /dev/null +++ b/ProductivityTracker/LiveActivity/LiveActivityPresentation.swift @@ -0,0 +1,194 @@ +import SwiftUI +import AppIntents + +struct LiveActivityElapsedText: View { + var state: SessionActivityAttributes.ContentState + var font: Font + + var body: some View { + Group { + if state.isRunning { + Text( + timerInterval: state.timerRange, + countsDown: false, + showsHours: true + ) + } else { + Text( + timerInterval: state.timerRange, + pauseTime: state.pauseTime, + countsDown: false, + showsHours: true + ) + } + } + .id(state.elapsedClockID) + .font(font) + .monospacedDigit() + .foregroundStyle(.white) + .minimumScaleFactor(0.45) + .lineLimit(1) + .contentTransition(.identity) + .transaction { $0.animation = nil } + .accessibilityIdentifier("live-activity-elapsed") + } +} + +struct LiveActivityLockScreen: View { + var state: SessionActivityAttributes.ContentState + var showsControls: Bool = true + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(state.spaceName) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(state.tint) + .lineLimit(1) + .minimumScaleFactor(0.75) + Text(state.taskName.isEmpty ? " " : state.taskName) + .font(.caption) + .foregroundStyle(.white.opacity(0.72)) + .lineLimit(1) + .accessibilityIdentifier("live-activity-context") + LiveActivityElapsedText( + state: state, + font: .system(size: 32, weight: .light, design: .default) + ) + if showsControls { + HStack(spacing: 10) { + liveControl + closeControl + } + .padding(.top, 6) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + SpaceIconView(icon: state.icon, tint: state.tint, pointSize: 48) + .accessibilityLabel(state.spaceName) + } + .padding(.leading, 18) + .padding(.trailing, 16) + .padding(.vertical, 14) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("live-activity-lock") + .accessibilityLabel("\(state.spaceName), \(state.taskName), \(ElapsedFormatter.compact(state.elapsedAtPause))") + } + + @ViewBuilder + private var liveControl: some View { + if state.isRunning { + Button(intent: StopFromLiveActivityIntent()) { + controlGlyph("stop.fill") + } + .buttonStyle(.plain) + .accessibilityIdentifier("live-activity-stop") + .accessibilityLabel("Stop") + } else { + Button(intent: ResumeFromLiveActivityIntent()) { + controlGlyph("play.fill") + } + .buttonStyle(.plain) + .accessibilityIdentifier("live-activity-start") + .accessibilityLabel("Start") + } + } + + private func controlGlyph(_ name: String) -> some View { + Image(systemName: name) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + + private var closeControl: some View { + Button(intent: ResetFromLiveActivityIntent()) { + Image(systemName: "xmark") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: 32, height: 32) + .background(Circle().fill(.white.opacity(0.14))) + .overlay { + Circle().strokeBorder(.white.opacity(0.22), lineWidth: 1) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("live-activity-close") + .accessibilityLabel("Reset") + } +} + +struct DynamicIslandCompactLeading: View { + var state: SessionActivityAttributes.ContentState + + var body: some View { + SpaceIconView(icon: state.icon, tint: state.tint, pointSize: 20) + .accessibilityLabel(state.spaceName) + } +} + +struct DynamicIslandCompactTrailing: View { + var state: SessionActivityAttributes.ContentState + + var body: some View { + LiveActivityElapsedText(state: state, font: .caption.weight(.semibold)) + .minimumScaleFactor(0.55) + } +} + +struct DynamicIslandMinimal: View { + var state: SessionActivityAttributes.ContentState + + var body: some View { + SpaceIconView(icon: state.icon, tint: state.tint, pointSize: 16) + } +} + +struct DynamicIslandExpandedContent: View { + var state: SessionActivityAttributes.ContentState + var showsControls: Bool = true + + var body: some View { + HStack(alignment: .center, spacing: 10) { + SpaceIconView(icon: state.icon, tint: state.tint, pointSize: 28) + VStack(alignment: .leading, spacing: 1) { + Text(state.spaceName) + .font(.caption.weight(.semibold)) + .foregroundStyle(state.tint) + .lineLimit(1) + Text(state.taskName) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 4) + LiveActivityElapsedText(state: state, font: .title3.weight(.light)) + if showsControls { + HStack(spacing: 8) { + if state.isRunning { + Button(intent: StopFromLiveActivityIntent()) { + Image(systemName: "stop.fill") + } + .buttonStyle(.plain) + .accessibilityLabel("Stop") + } else { + Button(intent: ResumeFromLiveActivityIntent()) { + Image(systemName: "play.fill") + } + .buttonStyle(.plain) + .accessibilityLabel("Start") + } + Button(intent: ResetFromLiveActivityIntent()) { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .accessibilityLabel("Reset") + } + .foregroundStyle(.white) + } + } + .padding(.horizontal, 4) + .accessibilityIdentifier("live-activity-island-expanded") + } +} diff --git a/ProductivityTracker/LiveActivity/LiveActivityPreviewScreen.swift b/ProductivityTracker/LiveActivity/LiveActivityPreviewScreen.swift new file mode 100644 index 0000000..65dd783 --- /dev/null +++ b/ProductivityTracker/LiveActivity/LiveActivityPreviewScreen.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct LiveActivityPreviewScreen: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + previewCard(title: "Lock Running", identifier: "la-lock-running") { + LiveActivityLockScreen(state: .runningPreview) + } + previewCard(title: "Lock Stopped", identifier: "la-lock-stopped") { + LiveActivityLockScreen(state: .stoppedPreview) + } + previewCard(title: "Island Compact", identifier: "la-island-compact") { + HStack { + DynamicIslandCompactLeading(state: .runningPreview) + Spacer() + DynamicIslandCompactTrailing(state: .runningPreview) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Capsule().fill(Color.black)) + } + previewCard(title: "Island Expanded", identifier: "la-island-expanded") { + DynamicIslandExpandedContent(state: .runningPreview) + } + } + .padding(16) + } + .background(Color.black.ignoresSafeArea()) + .accessibilityIdentifier(AccessibilityIDs.liveActivityPreview) + } + + private func previewCard(title: String, identifier: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + content() + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(white: 0.08))) + .accessibilityIdentifier(identifier) + } + } +} + +private extension SessionActivityAttributes.ContentState { + static let runningPreview = SessionActivityAttributes.ContentState( + spaceName: "Work", + taskName: "Deep Work", + phaseRaw: TimerPhase.running.rawValue, + displayStart: Date().addingTimeInterval(-31), + isRunning: true, + elapsedAtPause: 31, + tintRaw: SpaceTint.orange.rawValue, + iconKindRaw: SpaceIconKind.symbol.rawValue, + iconValue: SpaceIcon.work.value + ) + + static let stoppedPreview = SessionActivityAttributes.ContentState( + spaceName: "Work", + taskName: "Deep Work", + phaseRaw: TimerPhase.stopped.rawValue, + displayStart: Date().addingTimeInterval(-31), + isRunning: false, + elapsedAtPause: 31, + tintRaw: SpaceTint.orange.rawValue, + iconKindRaw: SpaceIconKind.symbol.rawValue, + iconValue: SpaceIcon.work.value + ) +} diff --git a/ProductivityTracker/Models/Session.swift b/ProductivityTracker/Models/Session.swift new file mode 100644 index 0000000..9d19670 --- /dev/null +++ b/ProductivityTracker/Models/Session.swift @@ -0,0 +1,54 @@ +import Foundation +import SwiftData + +@Model +final class Session { + var id: UUID + var startedAt: Date + var endedAt: Date? + var phaseRaw: String + var accumulatedActiveDuration: Double + var currentSegmentStartedAt: Date? + var activeTaskID: UUID? + var space: Space? + + @Relationship(deleteRule: .cascade, inverse: \TaskInterval.session) + var intervals: [TaskInterval] + + init( + id: UUID = UUID(), + startedAt: Date, + endedAt: Date? = nil, + phase: TimerPhase = .running, + accumulatedActiveDuration: Double = 0, + currentSegmentStartedAt: Date? = nil, + activeTaskID: UUID? = nil, + space: Space? = nil, + intervals: [TaskInterval] = [] + ) { + self.id = id + self.startedAt = startedAt + self.endedAt = endedAt + self.phaseRaw = phase.rawValue + self.accumulatedActiveDuration = accumulatedActiveDuration + self.currentSegmentStartedAt = currentSegmentStartedAt + self.activeTaskID = activeTaskID + self.space = space + self.intervals = intervals + } + + var phase: TimerPhase { + get { TimerPhase(persisted: phaseRaw) } + set { phaseRaw = newValue.rawValue } + } + + var isArchived: Bool { endedAt != nil } + + func elapsed(at now: Date) -> TimeInterval { + var total = accumulatedActiveDuration + if phase == .running, endedAt == nil, let start = currentSegmentStartedAt { + total += now.timeIntervalSince(start) + } + return max(0, total) + } +} diff --git a/ProductivityTracker/Models/Space.swift b/ProductivityTracker/Models/Space.swift new file mode 100644 index 0000000..f5cb0cc --- /dev/null +++ b/ProductivityTracker/Models/Space.swift @@ -0,0 +1,82 @@ +import Foundation +import SwiftData + +@Model +final class Space { + var id: UUID + var name: String + var tintRaw: String + var displayOrder: Int + var createdAt: Date + var distractionTimeoutSeconds: Double + var focusKeyword: String? + var defaultTaskID: UUID? + var iconKindRaw: String = SpaceIconKind.symbol.rawValue + var iconValue: String = "square.grid.2x2.fill" + + @Relationship(deleteRule: .cascade, inverse: \TaskItem.space) + var tasks: [TaskItem] + + @Relationship(deleteRule: .cascade, inverse: \Session.space) + var sessions: [Session] + + init( + id: UUID = UUID(), + name: String, + tint: SpaceTint, + displayOrder: Int, + createdAt: Date = Date(), + distractionTimeoutSeconds: Double = 300, + focusKeyword: String? = nil, + defaultTaskID: UUID? = nil, + icon: SpaceIcon = .fallback, + tasks: [TaskItem] = [], + sessions: [Session] = [] + ) { + self.id = id + self.name = name + self.tintRaw = tint.rawValue + self.displayOrder = displayOrder + self.createdAt = createdAt + self.distractionTimeoutSeconds = distractionTimeoutSeconds + self.focusKeyword = focusKeyword + self.defaultTaskID = defaultTaskID + self.iconKindRaw = icon.kind.rawValue + self.iconValue = icon.value + self.tasks = tasks + self.sessions = sessions + } + + var tint: SpaceTint { + get { SpaceTint(rawValue: tintRaw) ?? .orange } + set { tintRaw = newValue.rawValue } + } + + var icon: SpaceIcon { + get { + let kind = SpaceIconKind(rawValue: iconKindRaw) ?? .symbol + let value = iconValue.isEmpty ? SpaceIcon.fallback.value : iconValue + return SpaceIcon(kind: kind, value: value) + } + set { + iconKindRaw = newValue.kind.rawValue + iconValue = newValue.value + } + } + + var remindersEnabled: Bool { distractionTimeoutSeconds > 0 } + + var enabledTasksSorted: [TaskItem] { + tasks.filter(\.isEnabled).sorted { $0.displayOrder < $1.displayOrder } + } + + var timingTasksSorted: [TaskItem] { + tasks + .filter { $0.isEnabled && !$0.isCompleted } + .sorted { $0.displayOrder < $1.displayOrder } + } + + var allTasksSorted: [TaskItem] { + tasks.sorted { $0.displayOrder < $1.displayOrder } + } +} diff --git a/ProductivityTracker/Models/SpaceIcon.swift b/ProductivityTracker/Models/SpaceIcon.swift new file mode 100644 index 0000000..805e0de --- /dev/null +++ b/ProductivityTracker/Models/SpaceIcon.swift @@ -0,0 +1,69 @@ +import Foundation + +enum SpaceIconKind: String, Codable, CaseIterable, Sendable { + case symbol + case emoji + case monogram +} + +struct SpaceIcon: Equatable, Hashable, Sendable { + var kind: SpaceIconKind + var value: String + + static let work = SpaceIcon(kind: .symbol, value: "briefcase.fill") + static let chores = SpaceIcon(kind: .symbol, value: "house.fill") + static let personal = SpaceIcon(kind: .symbol, value: "heart.fill") + static let fallback = SpaceIcon(kind: .symbol, value: "square.grid.2x2.fill") + + static let symbols: [String] = [ + "briefcase.fill", + "house.fill", + "heart.fill", + "book.fill", + "laptopcomputer", + "phone.fill", + "envelope.fill", + "hammer.fill", + "leaf.fill", + "dumbbell.fill", + "figure.walk", + "paintbrush.fill", + "music.note", + "cart.fill", + "airplane", + "car.fill", + "fork.knife", + "cup.and.saucer.fill", + "moon.fill", + "sun.max.fill", + "star.fill", + "bolt.fill", + "flag.fill", + "folder.fill", + "person.fill", + "bubble.left.and.bubble.right.fill" + ] + + static let emojis: [String] = [ + "⚡️", "🎯", "📚", "🏠", "💼", "💪", "🎨", "🧪", + "✉️", "🧠", "☕️", "🎵", "🌱", "🚀", "📝", "🎧" + ] + + var monogramText: String { + let filtered = value.uppercased().filter(\.isLetter) + if filtered.isEmpty { return "AA" } + return String(filtered.prefix(3)) + } + + static func monogram(from name: String) -> SpaceIcon { + let words = name.split(separator: " ").prefix(3) + let letters: String + if words.count >= 2 { + letters = words.compactMap { $0.first }.map(String.init).joined() + } else { + letters = String(name.filter(\.isLetter).prefix(2)).uppercased() + } + let value = letters.isEmpty ? "NW" : letters.uppercased() + return SpaceIcon(kind: .monogram, value: value) + } +} diff --git a/ProductivityTracker/Models/SpaceTint.swift b/ProductivityTracker/Models/SpaceTint.swift new file mode 100644 index 0000000..40e6ddc --- /dev/null +++ b/ProductivityTracker/Models/SpaceTint.swift @@ -0,0 +1,45 @@ +import Foundation +import SwiftUI + +enum SpaceTint: String, Codable, CaseIterable, Sendable, Identifiable { + case orange + case blue + case teal + case purple + case green + case red + case yellow + case indigo + case pink + case gray + + var id: String { rawValue } + + var displayName: String { + rawValue.capitalized + } + + var color: Color { + switch self { + case .orange: Color(red: 1.00, green: 0.62, blue: 0.18) + case .blue: Color(red: 0.36, green: 0.62, blue: 1.00) + case .teal: Color(red: 0.32, green: 0.86, blue: 0.78) + case .purple: Color(red: 0.76, green: 0.52, blue: 1.00) + case .green: Color(red: 0.42, green: 0.86, blue: 0.50) + case .red: Color(red: 1.00, green: 0.42, blue: 0.40) + case .yellow: Color(red: 1.00, green: 0.84, blue: 0.28) + case .indigo: Color(red: 0.54, green: 0.56, blue: 1.00) + case .pink: Color(red: 1.00, green: 0.50, blue: 0.70) + case .gray: Color(red: 0.78, green: 0.80, blue: 0.84) + } + } + + var wash: Color { + color.opacity(0.16) + } + + static func parse(_ raw: String) -> SpaceTint? { + let key = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return SpaceTint(rawValue: key) + } +} diff --git a/ProductivityTracker/Models/TaskInterval.swift b/ProductivityTracker/Models/TaskInterval.swift new file mode 100644 index 0000000..aea6839 --- /dev/null +++ b/ProductivityTracker/Models/TaskInterval.swift @@ -0,0 +1,32 @@ +import Foundation +import SwiftData + +@Model +final class TaskInterval { + var id: UUID + var startedAt: Date + var endedAt: Date? + var session: Session? + var task: TaskItem? + + init( + id: UUID = UUID(), + startedAt: Date, + endedAt: Date? = nil, + session: Session? = nil, + task: TaskItem? = nil + ) { + self.id = id + self.startedAt = startedAt + self.endedAt = endedAt + self.session = session + self.task = task + } + + func duration(at now: Date) -> TimeInterval { + let end = endedAt ?? now + return max(0, end.timeIntervalSince(startedAt)) + } + + var isOpen: Bool { endedAt == nil } +} diff --git a/ProductivityTracker/Models/TaskItem.swift b/ProductivityTracker/Models/TaskItem.swift new file mode 100644 index 0000000..f6f1386 --- /dev/null +++ b/ProductivityTracker/Models/TaskItem.swift @@ -0,0 +1,36 @@ +import Foundation +import SwiftData + +@Model +final class TaskItem { + var id: UUID + var name: String + var displayOrder: Int + var isEnabled: Bool + var isCompleted: Bool = false + var createdAt: Date + var space: Space? + + @Relationship(deleteRule: .cascade, inverse: \TaskInterval.task) + var intervals: [TaskInterval] + + init( + id: UUID = UUID(), + name: String, + displayOrder: Int, + isEnabled: Bool = true, + isCompleted: Bool = false, + createdAt: Date = Date(), + space: Space? = nil, + intervals: [TaskInterval] = [] + ) { + self.id = id + self.name = name + self.displayOrder = displayOrder + self.isEnabled = isEnabled + self.isCompleted = isCompleted + self.createdAt = createdAt + self.space = space + self.intervals = intervals + } +} diff --git a/ProductivityTracker/Models/TimeSave.swift b/ProductivityTracker/Models/TimeSave.swift new file mode 100644 index 0000000..0a17ba9 --- /dev/null +++ b/ProductivityTracker/Models/TimeSave.swift @@ -0,0 +1,44 @@ +import Foundation +import SwiftData + +@Model +final class TimeSave { + var id: UUID + var name: String + var savedAt: Date + var elapsed: TimeInterval + var spaceID: UUID + var spaceName: String + var taskName: String? + + init( + id: UUID = UUID(), + name: String, + savedAt: Date, + elapsed: TimeInterval, + spaceID: UUID, + spaceName: String, + taskName: String? = nil + ) { + self.id = id + self.name = name + self.savedAt = savedAt + self.elapsed = elapsed + self.spaceID = spaceID + self.spaceName = spaceName + self.taskName = taskName + } + + static func makeName(space: String, task: String?, at date: Date) -> String { + let when = date.formatted(date: .abbreviated, time: .shortened) + if let task, !task.isEmpty { + return "\(space) · \(task) · \(when)" + } + return "\(space) · \(when)" + } +} + +enum SavedTimeFilter: Hashable { + case all + case space(UUID) +} diff --git a/ProductivityTracker/Models/TimerPhase.swift b/ProductivityTracker/Models/TimerPhase.swift new file mode 100644 index 0000000..f015c23 --- /dev/null +++ b/ProductivityTracker/Models/TimerPhase.swift @@ -0,0 +1,44 @@ +import Foundation + +enum TimerPhase: String, Codable, Sendable, Equatable { + case idle + case running + case stopped + + init(persisted raw: String) { + switch raw { + case "running": + self = .running + case "stopped", "paused": + self = .stopped + default: + self = .idle + } + } + + var leftControl: StopwatchLeftControl { + switch self { + case .idle: .lapDisabled + case .running: .lap + case .stopped: .reset + } + } + + var rightControl: StopwatchRightControl { + switch self { + case .idle, .stopped: .start + case .running: .stop + } + } +} + +enum StopwatchLeftControl: String, Equatable { + case lapDisabled + case lap + case reset +} + +enum StopwatchRightControl: String, Equatable { + case start + case stop +} diff --git a/ProductivityTracker/Notifications/DistractionMonitor.swift b/ProductivityTracker/Notifications/DistractionMonitor.swift new file mode 100644 index 0000000..287cf21 --- /dev/null +++ b/ProductivityTracker/Notifications/DistractionMonitor.swift @@ -0,0 +1,23 @@ +import Foundation + +struct DistractionMonitor { + var activeSessionID: UUID? + var threshold: TimeInterval + + mutating func distractionStarted( + isSessionActive: Bool, + sessionID: UUID?, + schedule: (UUID) -> Void + ) { + guard isSessionActive, let sessionID else { return } + activeSessionID = sessionID + schedule(sessionID) + } + + mutating func distractionEnded(cancel: (UUID) -> Void) { + if let sessionID = activeSessionID { + cancel(sessionID) + } + activeSessionID = nil + } +} diff --git a/ProductivityTracker/Notifications/NotificationService.swift b/ProductivityTracker/Notifications/NotificationService.swift new file mode 100644 index 0000000..5259514 --- /dev/null +++ b/ProductivityTracker/Notifications/NotificationService.swift @@ -0,0 +1,135 @@ +import Foundation +import UserNotifications + +struct NotificationIdentifiers { + static let distractionCategory = "DISTRACTION_REMINDER" + static let continueAction = "CONTINUE" + static let stopAction = "STOP" + static let pauseAction = "PAUSE" + + static func distraction(sessionID: UUID) -> String { + "distraction.session.\(sessionID.uuidString)" + } +} + +protocol NotificationScheduling: AnyObject { + func requestAuthorizationIfNeeded() + func scheduleDistractionReminder(sessionID: UUID, spaceName: String, elapsed: TimeInterval, after seconds: TimeInterval) + func cancelDistractionReminder(sessionID: UUID) + func cancelAllDistractionReminders() +} + +final class NotificationService: NSObject, NotificationScheduling, UNUserNotificationCenterDelegate { + private let center: UNUserNotificationCenter + var onContinue: (@MainActor () -> Void)? + var onStop: (@MainActor () -> Void)? + var onPause: (@MainActor () -> Void)? + + init(center: UNUserNotificationCenter = .current()) { + self.center = center + super.init() + } + + func configure() { + center.delegate = self + let continueAction = UNNotificationAction( + identifier: NotificationIdentifiers.continueAction, + title: "Continue" + ) + let stopAction = UNNotificationAction( + identifier: NotificationIdentifiers.stopAction, + title: "Stop", + options: .destructive + ) + let category = UNNotificationCategory( + identifier: NotificationIdentifiers.distractionCategory, + actions: [continueAction, stopAction], + intentIdentifiers: [], + options: [] + ) + center.setNotificationCategories([category]) + } + + func requestAuthorizationIfNeeded() { + center.getNotificationSettings { settings in + guard settings.authorizationStatus == .notDetermined else { return } + self.center.requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + } + + func scheduleDistractionReminder(sessionID: UUID, spaceName: String, elapsed: TimeInterval, after seconds: TimeInterval) { + cancelDistractionReminder(sessionID: sessionID) + let content = UNMutableNotificationContent() + content.title = "\(spaceName) · \(ElapsedFormatter.compact(elapsed))" + content.body = "Timer is still running." + content.categoryIdentifier = NotificationIdentifiers.distractionCategory + content.sound = .default + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: max(1, seconds), repeats: false) + let request = UNNotificationRequest( + identifier: NotificationIdentifiers.distraction(sessionID: sessionID), + content: content, + trigger: trigger + ) + center.add(request, withCompletionHandler: { _ in }) + } + + func cancelDistractionReminder(sessionID: UUID) { + center.removePendingNotificationRequests( + withIdentifiers: [NotificationIdentifiers.distraction(sessionID: sessionID)] + ) + } + + func cancelAllDistractionReminders() { + center.removeAllPendingNotificationRequests() + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let identifier = response.actionIdentifier + completionHandler() + Task { @MainActor in + switch identifier { + case NotificationIdentifiers.stopAction: + try? AppRuntime.shared.sessionController?.stop() + default: + break + } + } + } +} + +final class RecordingNotificationService: NotificationScheduling { + struct Request: Equatable { + var sessionID: UUID + var spaceName: String + var elapsed: TimeInterval + var after: TimeInterval + } + + var scheduled: [Request] = [] + var cancelled: [UUID] = [] + var cancelledAll = false + var authorizationRequested = false + + func requestAuthorizationIfNeeded() { + authorizationRequested = true + } + + func scheduleDistractionReminder(sessionID: UUID, spaceName: String, elapsed: TimeInterval, after seconds: TimeInterval) { + scheduled.removeAll { $0.sessionID == sessionID } + scheduled.append(Request(sessionID: sessionID, spaceName: spaceName, elapsed: elapsed, after: seconds)) + } + + func cancelDistractionReminder(sessionID: UUID) { + scheduled.removeAll { $0.sessionID == sessionID } + cancelled.append(sessionID) + } + + func cancelAllDistractionReminders() { + scheduled.removeAll() + cancelledAll = true + } +} diff --git a/ProductivityTracker/Persistence/DemoDataSeeder.swift b/ProductivityTracker/Persistence/DemoDataSeeder.swift new file mode 100644 index 0000000..b777351 --- /dev/null +++ b/ProductivityTracker/Persistence/DemoDataSeeder.swift @@ -0,0 +1,72 @@ +import Foundation +import SwiftData + +enum DemoIDs { + static let work = UUID(uuidString: "00000000-0000-4000-8000-000000000001")! + static let chores = UUID(uuidString: "00000000-0000-4000-8000-000000000002")! + static let personal = UUID(uuidString: "00000000-0000-4000-8000-000000000003")! + + static let deepWork = UUID(uuidString: "00000000-0000-4000-8000-000000000011")! + static let research = UUID(uuidString: "00000000-0000-4000-8000-000000000012")! + static let email = UUID(uuidString: "00000000-0000-4000-8000-000000000013")! +} + +enum DemoDataSeeder { + static func seedIfNeeded(context: ModelContext, force: Bool = false) throws { + let existing = try context.fetch(FetchDescriptor()) + if !existing.isEmpty && !force { + return + } + if force { + for space in existing { + context.delete(space) + } + } + + let work = Space( + id: DemoIDs.work, + name: "Work", + tint: .orange, + displayOrder: 0, + focusKeyword: "Work", + icon: .work + ) + work.tasks = [ + TaskItem(id: DemoIDs.deepWork, name: "Deep Work", displayOrder: 0, space: work), + TaskItem(id: DemoIDs.research, name: "Research", displayOrder: 1, space: work), + TaskItem(id: DemoIDs.email, name: "Email", displayOrder: 2, space: work) + ] + work.defaultTaskID = DemoIDs.deepWork + + let chores = Space( + id: DemoIDs.chores, + name: "Chores", + tint: .teal, + displayOrder: 1, + focusKeyword: "Personal", + icon: .chores + ) + chores.tasks = [ + TaskItem(name: "Kitchen", displayOrder: 0, space: chores), + TaskItem(name: "Laundry", displayOrder: 1, space: chores), + TaskItem(name: "Errands", displayOrder: 2, space: chores) + ] + + let personal = Space( + id: DemoIDs.personal, + name: "Personal", + tint: .purple, + displayOrder: 2, + icon: .personal + ) + personal.tasks = [ + TaskItem(name: "Reading", displayOrder: 0, space: personal), + TaskItem(name: "Planning", displayOrder: 1, space: personal) + ] + + context.insert(work) + context.insert(chores) + context.insert(personal) + try context.save() + } +} diff --git a/ProductivityTracker/Persistence/PersistenceController.swift b/ProductivityTracker/Persistence/PersistenceController.swift new file mode 100644 index 0000000..6d169cc --- /dev/null +++ b/ProductivityTracker/Persistence/PersistenceController.swift @@ -0,0 +1,23 @@ +import Foundation +import SwiftData + +enum PersistenceController { + static func makeContainer(inMemory: Bool, storeURL: URL? = nil) throws -> ModelContainer { + let schema = Schema([ + Space.self, + TaskItem.self, + Session.self, + TaskInterval.self, + TimeSave.self + ]) + let configuration: ModelConfiguration + if inMemory { + configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + } else if let storeURL { + configuration = ModelConfiguration(schema: schema, url: storeURL) + } else { + configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + } + return try ModelContainer(for: schema, configurations: [configuration]) + } +} diff --git a/ProductivityTracker/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ProductivityTracker/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..32ffca1 --- /dev/null +++ b/ProductivityTracker/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.180", + "green" : "0.478", + "red" : "0.980" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ProductivityTracker/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ProductivityTracker/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..cb144f4 Binary files /dev/null and b/ProductivityTracker/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ProductivityTracker/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ProductivityTracker/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/ProductivityTracker/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ProductivityTracker/Resources/Assets.xcassets/Contents.json b/ProductivityTracker/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ProductivityTracker/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ProductivityTracker/Resources/Info.plist b/ProductivityTracker/Resources/Info.plist new file mode 100644 index 0000000..b20130a --- /dev/null +++ b/ProductivityTracker/Resources/Info.plist @@ -0,0 +1,46 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Conduit + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + + UILaunchScreen + + UIColorName + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ProductivityTracker/Resources/PrivacyInfo.xcprivacy b/ProductivityTracker/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..5704bed --- /dev/null +++ b/ProductivityTracker/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/ProductivityTracker/Services/Haptics.swift b/ProductivityTracker/Services/Haptics.swift new file mode 100644 index 0000000..763f706 --- /dev/null +++ b/ProductivityTracker/Services/Haptics.swift @@ -0,0 +1,28 @@ +import Foundation +import UIKit + +enum Haptics { + static func start() { + UINotificationFeedbackGenerator().notificationOccurred(.success) + } + + static func stop() { + UINotificationFeedbackGenerator().notificationOccurred(.warning) + } + + static func lap() { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + + static func spaceChange() { + UISelectionFeedbackGenerator().selectionChanged() + } + + static func selection() { + UISelectionFeedbackGenerator().selectionChanged() + } + + static func destructive() { + UINotificationFeedbackGenerator().notificationOccurred(.error) + } +} diff --git a/ProductivityTracker/Services/LiveActivityManager.swift b/ProductivityTracker/Services/LiveActivityManager.swift new file mode 100644 index 0000000..6747171 --- /dev/null +++ b/ProductivityTracker/Services/LiveActivityManager.swift @@ -0,0 +1,165 @@ +import Foundation +@preconcurrency import ActivityKit + +@MainActor +protocol LiveActivityManaging: AnyObject { + func startOrUpdate(from controller: SessionController, at now: Date) + func startOrUpdateAndWait(from controller: SessionController, at now: Date) async + func dismissImmediate() + func dismissAndWait() async +} + +private struct LiveActivityPayload: Sendable { + var sessionID: UUID + var state: SessionActivityAttributes.ContentState +} + +@MainActor +final class LiveActivityManager: LiveActivityManaging { + private var clockAnchorBySession: [UUID: Date] = [:] + + func startOrUpdate(from controller: SessionController, at now: Date) { + guard let payload = prepare(from: controller, at: now) else { return } + Task.detached(priority: .userInitiated) { + await LiveActivityManager.publish(payload) + } + } + + func startOrUpdateAndWait(from controller: SessionController, at now: Date) async { + guard let payload = prepare(from: controller, at: now) else { return } + await LiveActivityManager.publish(payload) + } + + func dismissImmediate() { + clockAnchorBySession.removeAll() + let activities = Array(Activity.activities) + Task.detached(priority: .userInitiated) { + for activity in activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + } + + func dismissAndWait() async { + clockAnchorBySession.removeAll() + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + + private func prepare(from controller: SessionController, at now: Date) -> LiveActivityPayload? { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return nil } + guard let space = controller.liveActivitySpace() else { return nil } + let snapshot = controller.snapshot(for: space.id) + guard let sessionID = snapshot.sessionID else { return nil } + let taskName = space.tasks.first(where: { $0.id == snapshot.currentTaskID })?.name + ?? controller.activeTask?.name + ?? "Task" + let elapsed = snapshot.elapsed(at: now) + let displayStart = clockAnchor(for: snapshot, elapsed: elapsed, now: now) + let state = LiveActivityPresentation.content( + spaceName: space.name, + taskName: taskName, + phaseRaw: snapshot.phase.rawValue, + isRunning: snapshot.isRunning, + elapsed: elapsed, + now: now, + tintRaw: space.tintRaw, + iconKindRaw: space.iconKindRaw, + iconValue: space.iconValue, + displayStart: displayStart + ) + return LiveActivityPayload(sessionID: sessionID, state: state) + } + + nonisolated private static func publish(_ payload: LiveActivityPayload) async { + let content = ActivityContent(state: payload.state, staleDate: nil) + if let existing = Activity.activities.first { + await existing.update(content) + } else { + let attributes = SessionActivityAttributes(sessionID: payload.sessionID) + _ = try? await Activity.request(attributes: attributes, content: content) + } + } + + private func clockAnchor(for snapshot: TimerSnapshot, elapsed: TimeInterval, now: Date) -> Date { + if snapshot.isRunning, let started = snapshot.startedAt { + let anchor = started.addingTimeInterval(-snapshot.accumulatedBeforeCurrentRun) + if let sessionID = snapshot.sessionID { + clockAnchorBySession[sessionID] = anchor + } + return anchor + } + if let sessionID = snapshot.sessionID, let anchor = clockAnchorBySession[sessionID] { + return anchor + } + let anchor = now.addingTimeInterval(-elapsed) + if let sessionID = snapshot.sessionID { + clockAnchorBySession[sessionID] = anchor + } + return anchor + } +} + +@MainActor +final class NullLiveActivityManager: LiveActivityManaging { + var started = 0 + var dismissed = 0 + var lastState: SessionActivityAttributes.ContentState? + var states: [SessionActivityAttributes.ContentState] = [] + private var clockAnchorBySession: [UUID: Date] = [:] + + func startOrUpdate(from controller: SessionController, at now: Date) { + started += 1 + guard let space = controller.liveActivitySpace() ?? controller.selectedSpace else { return } + let snapshot = controller.snapshot(for: space.id) + let taskName = space.tasks.first(where: { $0.id == snapshot.currentTaskID })?.name ?? "Task" + let elapsed = snapshot.elapsed(at: now) + let displayStart = clockAnchor(for: snapshot, elapsed: elapsed, now: now) + lastState = LiveActivityPresentation.content( + spaceName: space.name, + taskName: taskName, + phaseRaw: snapshot.phase.rawValue, + isRunning: snapshot.isRunning, + elapsed: elapsed, + now: now, + tintRaw: space.tintRaw, + iconKindRaw: space.iconKindRaw, + iconValue: space.iconValue, + displayStart: displayStart + ) + states.append(lastState!) + } + + func startOrUpdateAndWait(from controller: SessionController, at now: Date) async { + startOrUpdate(from: controller, at: now) + } + + func dismissImmediate() { + dismissed += 1 + lastState = nil + clockAnchorBySession.removeAll() + } + + func dismissAndWait() async { + dismissImmediate() + } + + private func clockAnchor(for snapshot: TimerSnapshot, elapsed: TimeInterval, now: Date) -> Date { + if snapshot.isRunning, let started = snapshot.startedAt { + let anchor = started.addingTimeInterval(-snapshot.accumulatedBeforeCurrentRun) + if let sessionID = snapshot.sessionID { + clockAnchorBySession[sessionID] = anchor + } + return anchor + } + if let sessionID = snapshot.sessionID, let anchor = clockAnchorBySession[sessionID] { + return anchor + } + let anchor = now.addingTimeInterval(-elapsed) + if let sessionID = snapshot.sessionID { + clockAnchorBySession[sessionID] = anchor + } + return anchor + } +} diff --git a/ProductivityTracker/Services/SettingsStore.swift b/ProductivityTracker/Services/SettingsStore.swift new file mode 100644 index 0000000..a93cd4b --- /dev/null +++ b/ProductivityTracker/Services/SettingsStore.swift @@ -0,0 +1,45 @@ +import Foundation + +@MainActor +final class SettingsStore { + static let selectedSpaceKey = "selectedSpaceID" + static let distractionTimeoutKey = "distractionTimeoutSeconds" + static let preferDarkKey = "preferDarkAppearance" + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var selectedSpaceID: UUID? { + get { + defaults.string(forKey: Self.selectedSpaceKey).flatMap(UUID.init(uuidString:)) + } + set { + defaults.set(newValue?.uuidString, forKey: Self.selectedSpaceKey) + } + } + + var distractionTimeoutSeconds: TimeInterval { + get { + let value = defaults.double(forKey: Self.distractionTimeoutKey) + return value > 0 ? value : 300 + } + set { + defaults.set(newValue, forKey: Self.distractionTimeoutKey) + } + } + + var preferDarkAppearance: Bool { + get { + if defaults.object(forKey: Self.preferDarkKey) == nil { + return true + } + return defaults.bool(forKey: Self.preferDarkKey) + } + set { + defaults.set(newValue, forKey: Self.preferDarkKey) + } + } +} diff --git a/ProductivityTracker/Timer/ElapsedFormatter.swift b/ProductivityTracker/Timer/ElapsedFormatter.swift new file mode 100644 index 0000000..a0b0fa8 --- /dev/null +++ b/ProductivityTracker/Timer/ElapsedFormatter.swift @@ -0,0 +1,28 @@ +import Foundation + +enum ElapsedFormatter { + static func stopwatch(_ interval: TimeInterval) -> String { + let clamped = max(0, interval) + let totalCentiseconds = Int((clamped * 100).rounded(.towardZero)) + let hours = totalCentiseconds / 360_000 + let minutes = (totalCentiseconds % 360_000) / 6_000 + let seconds = (totalCentiseconds % 6_000) / 100 + let centiseconds = totalCentiseconds % 100 + if hours > 0 { + return String(format: "%d:%02d:%02d.%02d", hours, minutes, seconds, centiseconds) + } + return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds) + } + + static func compact(_ interval: TimeInterval) -> String { + let clamped = max(0, interval) + let totalSeconds = Int(clamped.rounded(.towardZero)) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%02d:%02d", minutes, seconds) + } +} diff --git a/ProductivityTracker/Timer/SessionController.swift b/ProductivityTracker/Timer/SessionController.swift new file mode 100644 index 0000000..0c21210 --- /dev/null +++ b/ProductivityTracker/Timer/SessionController.swift @@ -0,0 +1,848 @@ +import Foundation +import SwiftData +import Observation + +enum SessionControllerError: Error, Equatable { + case noSpace + case noEnabledTasks + case noActiveSession + case spaceHasHistory +} + +@MainActor +@Observable +final class SessionController { + private var engines: [UUID: TimerEngine] = [:] + private(set) var snapshots: [UUID: TimerSnapshot] = [:] + private(set) var spaces: [Space] = [] + private(set) var selectedSpaceID: UUID? + private(set) var lastError: String? + private(set) var mutation: UInt64 = 0 + /// Space shown on the Live Activity. Used after Stop so resume is not the selected page. + private(set) var liveActivityOwnerSpaceID: UUID? + + var timeSource: any TimeSource + var notifications: any NotificationScheduling + var liveActivity: LiveActivityManaging + var settings: SettingsStore + var distraction = DistractionMonitor(activeSessionID: nil, threshold: 300) + + private let context: ModelContext + let launch: LaunchConfiguration + + var snapshot: TimerSnapshot { + _ = mutation + guard let id = selectedSpaceID else { + return .idle(spaceID: DemoIDs.work) + } + return snapshots[id] ?? engine(for: id).snapshot + } + + var selectedSpace: Space? { + spaces.first(where: { $0.id == selectedSpaceID }) ?? spaces.first + } + + var selectedTasks: [TaskItem] { + selectedSpace?.enabledTasksSorted ?? [] + } + + func displayedTasks(in space: Space) -> [TaskItem] { + space.enabledTasksSorted + } + + var runningSpaceID: UUID? { + spaces.map(\.id).first { snapshots[$0]?.phase == .running } + } + + var activeTask: TaskItem? { + let space = selectedSpace + if let id = snapshot.currentTaskID, + let match = space?.tasks.first(where: { $0.id == id }) { + return match + } + return nil + } + + init( + context: ModelContext, + timeSource: any TimeSource = SystemTimeSource(), + notifications: any NotificationScheduling = NotificationService(), + liveActivity: LiveActivityManaging = LiveActivityManager(), + settings: SettingsStore = SettingsStore(), + launch: LaunchConfiguration = .default + ) { + self.context = context + self.timeSource = timeSource + self.notifications = notifications + self.liveActivity = liveActivity + self.settings = settings + self.launch = launch + self.distraction.threshold = settings.distractionTimeoutSeconds + } + + func bootstrap() throws { + try DemoDataSeeder.seedIfNeeded(context: context, force: launch.resetStore) + try reloadSpaces() + if launch.screenshotMode || launch.resetStore { + selectedSpaceID = DemoIDs.work + settings.selectedSpaceID = DemoIDs.work + } else if let stored = settings.selectedSpaceID, spaces.contains(where: { $0.id == stored }) { + selectedSpaceID = stored + } else { + selectedSpaceID = spaces.first?.id + } + for space in spaces { + let preferred = preferredTaskID(in: space) + let engine = engine(for: space.id) + if engine.snapshot.currentTaskID == nil { + engine.selectTask(preferred) + publish(engine) + } + } + try restoreOpenSessions() + applyLaunchOverrides() + AppRuntime.shared.sessionController = self + } + + func reloadSpaces() throws { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.displayOrder)]) + spaces = try context.fetch(descriptor) + noteChange() + } + + func selectSpace(_ id: UUID) { + selectSpace(id, haptic: false) + } + + func selectSpace(_ id: UUID, haptic: Bool) { + selectedSpaceID = id + settings.selectedSpaceID = id + if haptic { + Haptics.spaceChange() + } + noteChange() + } + + func displayOverrideElapsed(for spaceID: UUID) -> TimeInterval? { + guard launch.screenshotMode, let frozen = launch.frozenElapsed, spaceID == selectedSpaceID else { + return nil + } + if snapshot(for: spaceID).isIdle { return 0 } + return frozen + } + + func displayedElapsed(at now: Date) -> TimeInterval { + displayedElapsed(for: selectedSpaceID, at: now) + } + + func displayedElapsed(for spaceID: UUID?, at now: Date) -> TimeInterval { + guard let spaceID else { return 0 } + if let frozen = launch.frozenElapsed, launch.screenshotMode, spaceID == selectedSpaceID { + let phase = snapshot(for: spaceID).phase + if phase == .idle { return 0 } + return frozen + } + return snapshot(for: spaceID).elapsed(at: now) + } + + func snapshot(for spaceID: UUID) -> TimerSnapshot { + _ = mutation + return snapshots[spaceID] ?? engine(for: spaceID).snapshot + } + + func start() throws { + guard let space = selectedSpace else { throw SessionControllerError.noSpace } + try start(in: space) + } + + func start(in space: Space, publishLiveActivity: Bool = true, persist: Bool = true, at now: Date? = nil, haptic: Bool = true) throws { + if selectedSpaceID != space.id { + selectSpace(space.id) + } + let now = now ?? timeSource.now() + if let runningID = runningSpaceID, runningID != space.id { + try freezeSpace(runningID, at: now, haptic: false) + } + let engine = engine(for: space.id) + switch engine.snapshot.phase { + case .running: + return + case .stopped: + try resumeStopped(space: space, engine: engine, at: now) + case .idle: + try startNew(space: space, engine: engine, at: now) + } + liveActivityOwnerSpaceID = space.id + if haptic { + Haptics.start() + notifications.requestAuthorizationIfNeeded() + } + if publishLiveActivity { + liveActivity.startOrUpdate(from: self, at: now) + } + if persist { + try context.save() + } + noteChange() + } + + func stop() throws { + guard let space = selectedSpace else { throw SessionControllerError.noSpace } + try stop(in: space) + } + + func stop(in space: Space, publishLiveActivity: Bool = true, persist: Bool = true, at now: Date? = nil, haptic: Bool = true) throws { + let now = now ?? timeSource.now() + try freezeSpace(space.id, at: now, haptic: haptic) + liveActivityOwnerSpaceID = space.id + if publishLiveActivity { + liveActivity.startOrUpdate(from: self, at: now) + } + if persist { + try context.save() + } + noteChange() + } + + func pause() throws { + try stop() + } + + func resume() throws { + try start() + } + + func reset() throws { + guard let space = selectedSpace else { throw SessionControllerError.noSpace } + try reset(in: space) + } + + func reset(in space: Space, publishLiveActivity: Bool = true, persist: Bool = true) throws { + if selectedSpaceID != space.id { + selectSpace(space.id) + } + let engine = engine(for: space.id) + let now = timeSource.now() + if engine.snapshot.phase == .running { + try freezeSpace(space.id, at: now, haptic: false) + } + guard engine.snapshot.phase == .stopped else { return } + try closeOpenIntervals(sessionID: engine.snapshot.sessionID, at: now) + if let session = session(id: engine.snapshot.sessionID) { + let elapsed = engine.snapshot.elapsed(at: now) + if elapsed > 0.0005 { + session.endedAt = now + session.phase = .stopped + session.accumulatedActiveDuration = elapsed + session.currentSegmentStartedAt = nil + } else { + context.delete(session) + } + } + let keptTask = engine.snapshot.currentTaskID + engine.resetDisplay() + engine.selectTask(keptTask) + publish(engine) + if liveActivityOwnerSpaceID == space.id { + liveActivityOwnerSpaceID = nil + } + if publishLiveActivity { + liveActivity.dismissImmediate() + } + if persist { + try context.save() + } + noteChange() + } + + func lap() throws { + guard let space = selectedSpace else { throw SessionControllerError.noSpace } + try lap(in: space) + } + + func lap(in space: Space) throws { + if selectedSpaceID != space.id { + selectSpace(space.id) + } + let engine = engine(for: space.id) + guard engine.snapshot.phase == .running else { throw SessionControllerError.noActiveSession } + let tasks = space.timingTasksSorted + guard !tasks.isEmpty else { throw SessionControllerError.noEnabledTasks } + let now = timeSource.now() + try closeOpenIntervals(sessionID: engine.snapshot.sessionID, at: now) + let next = nextTask(after: engine.snapshot.currentTaskID, in: tasks) + if let session = session(id: engine.snapshot.sessionID), let next { + context.insert(TaskInterval(startedAt: now, session: session, task: next)) + session.activeTaskID = next.id + } + engine.selectTask(next?.id) + publish(engine) + Haptics.lap() + liveActivity.startOrUpdate(from: self, at: now) + try context.save() + noteChange() + } + + func selectTask(_ task: TaskItem) throws { + guard task.isEnabled, !task.isCompleted else { return } + guard let space = task.space ?? selectedSpace else { throw SessionControllerError.noSpace } + if selectedSpaceID != space.id { + selectSpace(space.id) + } + let engine = engine(for: space.id) + if engine.snapshot.currentTaskID == task.id { + return + } + let now = timeSource.now() + switch engine.snapshot.phase { + case .running: + try closeOpenIntervals(sessionID: engine.snapshot.sessionID, at: now) + if let session = session(id: engine.snapshot.sessionID) { + context.insert(TaskInterval(startedAt: now, session: session, task: task)) + session.activeTaskID = task.id + } + engine.selectTask(task.id) + publish(engine) + Haptics.selection() + liveActivity.startOrUpdate(from: self, at: now) + case .stopped: + if let session = session(id: engine.snapshot.sessionID) { + session.activeTaskID = task.id + } + engine.selectTask(task.id) + publish(engine) + Haptics.selection() + case .idle: + engine.selectTask(task.id) + publish(engine) + Haptics.selection() + } + try context.save() + noteChange() + } + + func nextTask(after currentID: UUID?, in tasks: [TaskItem]) -> TaskItem? { + guard !tasks.isEmpty else { return nil } + guard let currentID, let index = tasks.firstIndex(where: { $0.id == currentID }) else { + return tasks.first + } + return tasks[(index + 1) % tasks.count] + } + + func taskElapsedParts(for task: TaskItem) -> (closed: TimeInterval, openStartedAt: Date?) { + guard let spaceID = task.space?.id ?? selectedSpaceID else { return (0, nil) } + guard let sessionID = snapshot(for: spaceID).sessionID else { return (0, nil) } + var closed: TimeInterval = 0 + var openStartedAt: Date? + for interval in task.intervals where interval.session?.id == sessionID { + if let ended = interval.endedAt { + closed += max(0, ended.timeIntervalSince(interval.startedAt)) + } else { + openStartedAt = interval.startedAt + } + } + return (closed, openStartedAt) + } + + func accumulatedDuration(for task: TaskItem, at now: Date) -> TimeInterval { + let parts = taskElapsedParts(for: task) + var total = parts.closed + if snapshot(for: task.space?.id ?? selectedSpaceID ?? DemoIDs.work).isRunning, let start = parts.openStartedAt { + total += now.timeIntervalSince(start) + } + return total + } + + func createSpace(name: String, tint: SpaceTint, tasks: [String], icon: SpaceIcon = .fallback) throws -> Space { + try reloadSpaces() + let order = (spaces.map(\.displayOrder).max() ?? -1) + 1 + let space = Space(name: name, tint: tint, displayOrder: order, icon: icon) + let items = tasks.enumerated().map { index, taskName in + TaskItem(name: taskName, displayOrder: index, space: space) + } + space.tasks = items + space.defaultTaskID = items.first?.id + context.insert(space) + try context.save() + try reloadSpaces() + return space + } + + func importSpace(_ payload: SpaceImportPayload) throws -> Space { + try createSpace(name: payload.name, tint: payload.color, tasks: payload.tasks, icon: .monogram(from: payload.name)) + } + + func renameSpace(_ space: Space, to name: String) throws { + space.name = name + try context.save() + try reloadSpaces() + liveActivity.startOrUpdate(from: self, at: timeSource.now()) + } + + func recolorSpace(_ space: Space, tint: SpaceTint) throws { + space.tint = tint + try context.save() + try reloadSpaces() + liveActivity.startOrUpdate(from: self, at: timeSource.now()) + } + + func setSpaceIcon(_ space: Space, icon: SpaceIcon) throws { + space.icon = icon + try context.save() + try reloadSpaces() + liveActivity.startOrUpdate(from: self, at: timeSource.now()) + } + + func setDefaultTask(_ task: TaskItem?, for space: Space) throws { + space.defaultTaskID = task?.id + let engine = engine(for: space.id) + if engine.snapshot.isIdle { + engine.selectTask(task?.id ?? space.timingTasksSorted.first?.id) + publish(engine) + } + try context.save() + try reloadSpaces() + } + + func setReminder(for space: Space, seconds: Double) throws { + space.distractionTimeoutSeconds = max(0, seconds) + try context.save() + try reloadSpaces() + } + + func setFocusKeyword(_ keyword: String?, for space: Space) throws { + let trimmed = keyword?.trimmingCharacters(in: .whitespacesAndNewlines) + space.focusKeyword = (trimmed?.isEmpty == true) ? nil : trimmed + try context.save() + try reloadSpaces() + } + + func deleteSpace(_ space: Space, confirmHistory: Bool) throws { + let hasHistory = space.sessions.contains { $0.endedAt != nil || $0.endedAt == nil && $0.accumulatedActiveDuration > 0 || !$0.intervals.isEmpty } + if hasHistory && !confirmHistory { + throw SessionControllerError.spaceHasHistory + } + if snapshots[space.id]?.phase == .running { + try freezeSpace(space.id, at: timeSource.now(), haptic: false) + } + if snapshots[space.id]?.phase == .stopped { + selectedSpaceID = space.id + try? reset() + } + engines[space.id] = nil + snapshots[space.id] = nil + context.delete(space) + try context.save() + try reloadSpaces() + if selectedSpaceID == space.id { + selectedSpaceID = spaces.first?.id + settings.selectedSpaceID = selectedSpaceID + } + noteChange() + } + + func addTask(to space: Space, name: String) throws { + let order = (space.tasks.map(\.displayOrder).max() ?? -1) + 1 + let task = TaskItem(name: name, displayOrder: order, space: space) + context.insert(task) + if space.defaultTaskID == nil { + space.defaultTaskID = task.id + } + try context.save() + try reloadSpaces() + } + + func renameTask(_ task: TaskItem, to name: String) throws { + task.name = name + try context.save() + try reloadSpaces() + liveActivity.startOrUpdate(from: self, at: timeSource.now()) + } + + func setTaskEnabled(_ task: TaskItem, isEnabled: Bool) throws { + task.isEnabled = isEnabled + if !isEnabled, snapshot(for: task.space?.id ?? selectedSpaceID ?? DemoIDs.work).currentTaskID == task.id { + try handleRemovedActiveTask(task) + } + try context.save() + try reloadSpaces() + } + + func setTaskCompleted(_ task: TaskItem, isCompleted: Bool) throws { + task.isCompleted = isCompleted + if isCompleted, snapshot(for: task.space?.id ?? selectedSpaceID ?? DemoIDs.work).currentTaskID == task.id { + try handleRemovedActiveTask(task) + } + try context.save() + try reloadSpaces() + liveActivity.startOrUpdate(from: self, at: timeSource.now()) + noteChange() + } + + func moveTasks(in space: Space, from source: IndexSet, to destination: Int) throws { + var ordered = space.allTasksSorted + ordered.move(fromOffsets: source, toOffset: destination) + for (index, task) in ordered.enumerated() { + task.displayOrder = index + } + try context.save() + try reloadSpaces() + } + + func deleteTask(_ task: TaskItem) throws { + if snapshot.currentTaskID == task.id { + try handleRemovedActiveTask(task) + } + context.delete(task) + try context.save() + try reloadSpaces() + } + + func moveSpaces(from source: IndexSet, to destination: Int) throws { + var ordered = spaces + ordered.move(fromOffsets: source, toOffset: destination) + for (index, space) in ordered.enumerated() { + space.displayOrder = index + } + try context.save() + try reloadSpaces() + } + + func distractionStarted() { + let runningID = runningSpaceID + let snap = runningID.map { snapshot(for: $0) } + let active = snap?.phase == .running + let sessionID = snap?.sessionID + let space = runningID.flatMap { id in spaces.first { $0.id == id } } ?? selectedSpace + let spaceName = space?.name ?? "Timer" + let elapsed = snap?.elapsed(at: timeSource.now()) ?? 0 + let threshold = space?.distractionTimeoutSeconds ?? settings.distractionTimeoutSeconds + distraction.threshold = threshold + guard threshold > 0 else { return } + distraction.distractionStarted(isSessionActive: active, sessionID: sessionID) { id in + notifications.scheduleDistractionReminder( + sessionID: id, + spaceName: spaceName, + elapsed: elapsed, + after: threshold + ) + } + } + + func distractionEnded() { + distraction.distractionEnded { id in + notifications.cancelDistractionReminder(sessionID: id) + } + } + + func allSessions() throws -> [Session] { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.startedAt, order: .reverse)]) + return try context.fetch(descriptor) + } + + func historicalSessions() throws -> [Session] { + try allSessions().filter { $0.endedAt != nil } + } + + func saveTime(in space: Space) throws { + let now = timeSource.now() + let snap = snapshot(for: space.id) + let elapsed = snap.elapsed(at: now) + guard elapsed > 0.0005 else { return } + let taskName = space.tasks.first(where: { $0.id == snap.currentTaskID })?.name + let record = TimeSave( + name: TimeSave.makeName(space: space.name, task: taskName, at: now), + savedAt: now, + elapsed: elapsed, + spaceID: space.id, + spaceName: space.name, + taskName: taskName + ) + context.insert(record) + try context.save() + Haptics.selection() + noteChange() + } + + func savedTimes(filter: SavedTimeFilter) throws -> [TimeSave] { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.savedAt, order: .reverse)]) + let all = try context.fetch(descriptor) + switch filter { + case .all: + return all + case .space(let id): + return all.filter { $0.spaceID == id } + } + } + + func deleteSavedTime(_ record: TimeSave) throws { + context.delete(record) + try context.save() + noteChange() + } + + func startFromLiveActivity(at now: Date? = nil) async { + let instant = now ?? timeSource.now() + guard let space = liveActivitySpace() else { return } + try? start(in: space, publishLiveActivity: false, persist: true, at: instant, haptic: false) + await liveActivity.startOrUpdateAndWait(from: self, at: instant) + } + + func stopFromLiveActivity(at now: Date? = nil) async { + let instant = now ?? timeSource.now() + guard let space = liveActivitySpace() else { return } + try? stop(in: space, publishLiveActivity: false, persist: true, at: instant, haptic: false) + await liveActivity.startOrUpdateAndWait(from: self, at: instant) + } + + func resetFromLiveActivity() async { + guard let space = liveActivitySpace() else { return } + try? reset(in: space, publishLiveActivity: false, persist: true) + } + + func lapFromLiveActivity() throws { + guard let space = liveActivitySpace() else { throw SessionControllerError.noSpace } + try lap(in: space) + } + + func liveActivitySpace() -> Space? { + if let id = runningSpaceID { + return spaces.first { $0.id == id } + } + if let id = liveActivityOwnerSpaceID, + snapshots[id]?.phase == .stopped, + let space = spaces.first(where: { $0.id == id }) { + return space + } + return spaces.first { snapshots[$0.id]?.phase == .stopped } + } + + private func startNew(space: Space, engine: TimerEngine, at now: Date) throws { + let task = initialTask(in: space, engine: engine) + let session = Session( + id: UUID(), + startedAt: now, + phase: .running, + accumulatedActiveDuration: 0, + currentSegmentStartedAt: now, + activeTaskID: task?.id, + space: space + ) + context.insert(session) + if let task { + context.insert(TaskInterval(startedAt: now, session: session, task: task)) + } + engine.selectTask(task?.id) + engine.start(now: now, sessionID: session.id) + persistSession(session, from: engine) + publish(engine) + } + + private func resumeStopped(space: Space, engine: TimerEngine, at now: Date) throws { + guard let session = session(id: engine.snapshot.sessionID) else { + try startNew(space: space, engine: engine, at: now) + return + } + let taskID = engine.snapshot.currentTaskID + let task = taskID.flatMap { id in space.timingTasksSorted.first { $0.id == id } } + engine.start(now: now, sessionID: session.id) + session.phase = .running + session.currentSegmentStartedAt = now + session.activeTaskID = task?.id + if let task { + context.insert(TaskInterval(startedAt: now, session: session, task: task)) + } + persistSession(session, from: engine) + publish(engine) + } + + private func freezeSpace(_ spaceID: UUID, at now: Date, haptic: Bool) throws { + let engine = engine(for: spaceID) + guard engine.snapshot.phase == .running else { return } + engine.stop(now: now) + try closeOpenIntervals(sessionID: engine.snapshot.sessionID, at: now) + if let session = session(id: engine.snapshot.sessionID) { + session.endedAt = nil + session.phase = .stopped + session.accumulatedActiveDuration = engine.snapshot.accumulatedBeforeCurrentRun + session.currentSegmentStartedAt = nil + session.activeTaskID = engine.snapshot.currentTaskID + notifications.cancelDistractionReminder(sessionID: session.id) + } + publish(engine) + if haptic { + Haptics.stop() + } + } + + private func handleRemovedActiveTask(_ task: TaskItem) throws { + guard let space = task.space else { return } + let engine = engine(for: space.id) + let now = timeSource.now() + let remaining = space.timingTasksSorted.filter { $0.id != task.id } + let next = remaining.first + if engine.snapshot.phase == .running { + try closeOpenIntervals(sessionID: engine.snapshot.sessionID, at: now) + if let session = session(id: engine.snapshot.sessionID), let next { + context.insert(TaskInterval(startedAt: now, session: session, task: next)) + session.activeTaskID = next.id + } else if let session = session(id: engine.snapshot.sessionID) { + session.activeTaskID = nil + } + liveActivity.startOrUpdate(from: self, at: now) + } else if let session = session(id: engine.snapshot.sessionID) { + session.activeTaskID = next?.id + } + engine.selectTask(next?.id) + publish(engine) + } + + private func initialTask(in space: Space, engine: TimerEngine) -> TaskItem? { + let enabled = space.timingTasksSorted + if let current = engine.snapshot.currentTaskID, + let match = enabled.first(where: { $0.id == current }) { + return match + } + if let defaultID = space.defaultTaskID, + let match = enabled.first(where: { $0.id == defaultID }) { + return match + } + return enabled.first + } + + private func preferredTaskID(in space: Space) -> UUID? { + if let defaultID = space.defaultTaskID, + space.timingTasksSorted.contains(where: { $0.id == defaultID }) { + return defaultID + } + return space.timingTasksSorted.first?.id + } + + private func session(id: UUID?) -> Session? { + guard let id else { return nil } + let all = (try? context.fetch(FetchDescriptor())) ?? [] + return all.first(where: { $0.id == id }) + } + + private func closeOpenIntervals(sessionID: UUID?, at now: Date) throws { + guard let session = session(id: sessionID) else { return } + for interval in session.intervals where interval.isOpen { + interval.endedAt = now + } + } + + private func persistSession(_ session: Session, from engine: TimerEngine) { + session.phase = engine.snapshot.phase + session.accumulatedActiveDuration = engine.snapshot.accumulatedBeforeCurrentRun + session.currentSegmentStartedAt = engine.snapshot.startedAt + session.activeTaskID = engine.snapshot.currentTaskID + session.endedAt = nil + } + + private func restoreOpenSessions() throws { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.startedAt, order: .reverse)]) + let sessions = try context.fetch(descriptor) + let open = sessions.filter { $0.endedAt == nil && ($0.phase == .running || $0.phase == .stopped) } + var sawRunning = false + for session in open { + guard let spaceID = session.space?.id else { continue } + var restoredPhase = session.phase + if restoredPhase == .running { + if sawRunning { + restoredPhase = .stopped + session.phase = .stopped + session.currentSegmentStartedAt = nil + for interval in session.intervals where interval.isOpen { + interval.endedAt = timeSource.now() + } + } else { + sawRunning = true + } + } + let engine = engine(for: spaceID) + engine.restore( + TimerSnapshot( + phase: restoredPhase, + spaceID: spaceID, + sessionID: session.id, + currentTaskID: session.activeTaskID, + startedAt: restoredPhase == .running ? session.currentSegmentStartedAt : nil, + accumulatedBeforeCurrentRun: session.accumulatedActiveDuration, + lastTick: timeSource.now(), + liveActivityID: nil + ) + ) + publish(engine) + if restoredPhase == .running { + selectedSpaceID = spaceID + } + } + if snapshots.values.contains(where: { $0.phase == .running || $0.phase == .stopped }) { + liveActivity.startOrUpdate(from: self, at: timeSource.now()) + } + try context.save() + } + + private func applyLaunchOverrides() { + guard launch.screenshotMode || launch.startRunning || launch.timerState != nil else { return } + switch launch.timerState { + case .idle: + return + case .stopped: + if snapshot.phase == .idle { + try? start() + } + applyFrozenElapsedIfNeeded() + try? stop() + case .running, .none: + if snapshot.phase == .idle || snapshot.phase == .stopped { + try? start() + } + applyFrozenElapsedIfNeeded() + } + } + + private func applyFrozenElapsedIfNeeded() { + guard let frozen = launch.frozenElapsed, let spaceID = selectedSpaceID else { return } + let engine = engine(for: spaceID) + let now = timeSource.now() + if engine.snapshot.phase == .running { + engine.restore( + TimerSnapshot( + phase: .running, + spaceID: spaceID, + sessionID: engine.snapshot.sessionID, + currentTaskID: engine.snapshot.currentTaskID, + startedAt: now.addingTimeInterval(-frozen), + accumulatedBeforeCurrentRun: 0, + lastTick: now, + liveActivityID: engine.snapshot.liveActivityID + ) + ) + } + if let session = session(id: engine.snapshot.sessionID) { + session.accumulatedActiveDuration = engine.snapshot.phase == .running ? 0 : frozen + session.currentSegmentStartedAt = engine.snapshot.startedAt + } + publish(engine) + } + + private func engine(for spaceID: UUID) -> TimerEngine { + if let existing = engines[spaceID] { + return existing + } + let created = TimerEngine(spaceID: spaceID) + engines[spaceID] = created + snapshots[spaceID] = created.snapshot + return created + } + + private func publish(_ engine: TimerEngine) { + snapshots[engine.snapshot.spaceID] = engine.snapshot + noteChange() + } + + private func noteChange() { + mutation &+= 1 + } +} diff --git a/ProductivityTracker/Timer/TimeSource.swift b/ProductivityTracker/Timer/TimeSource.swift new file mode 100644 index 0000000..a0c4ca1 --- /dev/null +++ b/ProductivityTracker/Timer/TimeSource.swift @@ -0,0 +1,36 @@ +import Foundation + +protocol TimeSource: Sendable { + func now() -> Date +} + +struct SystemTimeSource: TimeSource { + func now() -> Date { Date() } +} + +final class ControllableTimeSource: TimeSource, @unchecked Sendable { + private let lock = NSLock() + private var _now: Date + + init(now: Date) { + self._now = now + } + + func now() -> Date { + lock.lock() + defer { lock.unlock() } + return _now + } + + func set(_ date: Date) { + lock.lock() + _now = date + lock.unlock() + } + + func advance(by interval: TimeInterval) { + lock.lock() + _now = _now.addingTimeInterval(interval) + lock.unlock() + } +} diff --git a/ProductivityTracker/Timer/TimerEngine.swift b/ProductivityTracker/Timer/TimerEngine.swift new file mode 100644 index 0000000..1b8fc8a --- /dev/null +++ b/ProductivityTracker/Timer/TimerEngine.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Timestamp-based stopwatch. Stop freezes; Start resumes; Reset returns to idle. +@MainActor +final class TimerEngine { + private(set) var snapshot: TimerSnapshot + + init(spaceID: UUID = UUID(), currentTaskID: UUID? = nil) { + self.snapshot = .idle(spaceID: spaceID, currentTaskID: currentTaskID) + } + + init(snapshot: TimerSnapshot) { + self.snapshot = snapshot + } + + func restore(_ snapshot: TimerSnapshot) { + self.snapshot = snapshot + } + + func bindSpace(_ spaceID: UUID) { + snapshot.spaceID = spaceID + } + + func selectTask(_ taskID: UUID?) { + snapshot.currentTaskID = taskID + } + + func start(now: Date, sessionID: UUID) { + switch snapshot.phase { + case .idle: + snapshot.sessionID = sessionID + snapshot.startedAt = now + snapshot.accumulatedBeforeCurrentRun = 0 + snapshot.phase = .running + snapshot.lastTick = now + case .stopped: + snapshot.startedAt = now + snapshot.phase = .running + snapshot.lastTick = now + case .running: + break + } + } + + func stop(now: Date) { + guard snapshot.phase == .running else { return } + snapshot.accumulatedBeforeCurrentRun = snapshot.elapsed(at: now) + snapshot.startedAt = nil + snapshot.phase = .stopped + snapshot.lastTick = now + } + + func resetDisplay() { + snapshot.phase = .idle + snapshot.sessionID = nil + snapshot.startedAt = nil + snapshot.accumulatedBeforeCurrentRun = 0 + snapshot.liveActivityID = nil + snapshot.lastTick = Date(timeIntervalSince1970: 0) + } + + func attachLiveActivity(_ id: String) { + snapshot.liveActivityID = id + } + + func detachLiveActivity() { + snapshot.liveActivityID = nil + } +} diff --git a/ProductivityTracker/Timer/TimerSnapshot.swift b/ProductivityTracker/Timer/TimerSnapshot.swift new file mode 100644 index 0000000..3598a2c --- /dev/null +++ b/ProductivityTracker/Timer/TimerSnapshot.swift @@ -0,0 +1,42 @@ +import Foundation + +struct TimerSnapshot: Equatable, Sendable { + var phase: TimerPhase + var spaceID: UUID + var sessionID: UUID? + var currentTaskID: UUID? + var startedAt: Date? + var accumulatedBeforeCurrentRun: TimeInterval + var lastTick: Date + var liveActivityID: String? + + static func idle(spaceID: UUID, currentTaskID: UUID? = nil) -> TimerSnapshot { + TimerSnapshot( + phase: .idle, + spaceID: spaceID, + sessionID: nil, + currentTaskID: currentTaskID, + startedAt: nil, + accumulatedBeforeCurrentRun: 0, + lastTick: Date(timeIntervalSince1970: 0), + liveActivityID: nil + ) + } + + func elapsed(at now: Date) -> TimeInterval { + switch phase { + case .idle: + return 0 + case .stopped: + return accumulatedBeforeCurrentRun + case .running: + guard let startedAt else { return accumulatedBeforeCurrentRun } + return accumulatedBeforeCurrentRun + now.timeIntervalSince(startedAt) + } + } + + var isRunning: Bool { phase == .running } + var isStopped: Bool { phase == .stopped } + var isIdle: Bool { phase == .idle } + var hasLiveActivity: Bool { liveActivityID != nil } +} diff --git a/ProductivityTracker/Views/History/HistoryView.swift b/ProductivityTracker/Views/History/HistoryView.swift new file mode 100644 index 0000000..eb71cc5 --- /dev/null +++ b/ProductivityTracker/Views/History/HistoryView.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct HistoryView: View { + @Bindable var controller: SessionController + @State private var sessions: [Session] = [] + + var body: some View { + List(sessions, id: \.id) { session in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(session.space?.name ?? "Space") + .font(.headline) + Spacer() + Text(ElapsedFormatter.compact(session.elapsed(at: session.endedAt ?? controller.timeSource.now()))) + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + } + Text(session.startedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + ForEach(grouped(session), id: \.name) { row in + HStack { + Text(row.name) + Spacer() + Text(ElapsedFormatter.compact(row.duration)) + .monospacedDigit() + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + .navigationTitle("History") + .accessibilityIdentifier("history-screen") + .onAppear { + sessions = (try? controller.historicalSessions()) ?? [] + } + } + + private func grouped(_ session: Session) -> [(name: String, duration: TimeInterval)] { + let now = session.endedAt ?? controller.timeSource.now() + var totals: [String: TimeInterval] = [:] + var order: [String] = [] + for interval in session.intervals { + let name = interval.task?.name ?? "Task" + if totals[name] == nil { + order.append(name) + } + totals[name, default: 0] += interval.duration(at: now) + } + return order.map { (name: $0, duration: totals[$0] ?? 0) } + } +} diff --git a/ProductivityTracker/Views/History/SavedTimesView.swift b/ProductivityTracker/Views/History/SavedTimesView.swift new file mode 100644 index 0000000..292fbab --- /dev/null +++ b/ProductivityTracker/Views/History/SavedTimesView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct SavedTimesView: View { + @Bindable var controller: SessionController + var initialSpaceID: UUID? + @State private var filter: SavedTimeFilter + @State private var records: [TimeSave] = [] + + init(controller: SessionController, initialSpaceID: UUID?) { + self.controller = controller + self.initialSpaceID = initialSpaceID + if let initialSpaceID { + _filter = State(initialValue: .space(initialSpaceID)) + } else { + _filter = State(initialValue: .all) + } + } + + var body: some View { + List { + if records.isEmpty { + Text("No saved times") + .foregroundStyle(.secondary) + } else { + ForEach(records, id: \.id) { record in + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(record.name) + .font(.body.weight(.medium)) + Spacer() + Text(ElapsedFormatter.stopwatch(record.elapsed)) + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + } + Text(record.savedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + .accessibilityIdentifier("saved-time-\(record.id.uuidString)") + } + .onDelete { offsets in + for index in offsets { + try? controller.deleteSavedTime(records[index]) + } + reload() + } + } + } + .navigationTitle("Saved Times") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Picker("Filter", selection: $filter) { + Text("All Spaces").tag(SavedTimeFilter.all) + ForEach(controller.spaces, id: \.id) { space in + Text(space.name).tag(SavedTimeFilter.space(space.id)) + } + } + .pickerStyle(.menu) + .accessibilityIdentifier("saved-times-filter") + } + } + .accessibilityIdentifier("saved-times-screen") + .onAppear(perform: reload) + .onChange(of: filter) { _, _ in reload() } + } + + private func reload() { + records = (try? controller.savedTimes(filter: filter)) ?? [] + } +} diff --git a/ProductivityTracker/Views/RootView.swift b/ProductivityTracker/Views/RootView.swift new file mode 100644 index 0000000..813aa34 --- /dev/null +++ b/ProductivityTracker/Views/RootView.swift @@ -0,0 +1,21 @@ +import SwiftUI +import SwiftData + +struct RootView: View { + let launch: LaunchConfiguration + @Bindable var controller: SessionController + @State private var showSettings = false + + var body: some View { + Group { + if launch.liveActivityPreview { + LiveActivityPreviewScreen() + } else { + TimerScreen(controller: controller, showSettings: $showSettings) + } + } + .sheet(isPresented: $showSettings) { + SettingsView(controller: controller) + } + } +} diff --git a/ProductivityTracker/Views/Settings/SettingsView.swift b/ProductivityTracker/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..cd29499 --- /dev/null +++ b/ProductivityTracker/Views/Settings/SettingsView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct SettingsView: View { + @Bindable var controller: SessionController + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section("Spaces") { + NavigationLink("Manage Spaces") { + SpaceListView(controller: controller) + } + .accessibilityIdentifier("manage-spaces") + } + Section("History") { + NavigationLink("Saved Times") { + SavedTimesView(controller: controller, initialSpaceID: controller.selectedSpaceID) + } + NavigationLink("Session History") { + HistoryView(controller: controller) + } + } + Section("Integrations") { + NavigationLink("Shortcuts and Focus") { + IntegrationHelpView() + } + } + Section("About") { + LabeledContent("App", value: "Conduit") + LabeledContent("Version", value: "0.1.0") + Text("Local timer. No account, no network, no analytics.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .accessibilityIdentifier("settings-screen") + } +} + +struct IntegrationHelpView: View { + var body: some View { + List { + Section("Start Work shortcut") { + Text("1. Set Focus → Work\n2. ProductivityTracker → Select Space → Work\n3. ProductivityTracker → Start Session") + } + Section("AI Space import") { + Text("Ask Shortcuts’ Use Model action to emit JSON matching the Space schema, then run Import Space Definition.") + } + Section("Focus") { + Text("The app cannot force system Focus on. Add a ProductivityTracker Focus Filter in Settings → Focus to select a Space when that Focus activates.") + } + } + .navigationTitle("Integrations") + } +} diff --git a/ProductivityTracker/Views/Spaces/SpaceComposePage.swift b/ProductivityTracker/Views/Spaces/SpaceComposePage.swift new file mode 100644 index 0000000..2532302 --- /dev/null +++ b/ProductivityTracker/Views/Spaces/SpaceComposePage.swift @@ -0,0 +1,164 @@ +import SwiftUI + +struct SpaceComposePage: View { + @Bindable var controller: SessionController + var pageIndex: Int + var pageCount: Int + var isActive: Bool + var onCreated: (Space) -> Void + + @State private var name = "" + @State private var tint: SpaceTint = .blue + @State private var icon = SpaceIcon.fallback + @State private var taskDraft = "" + @State private var tasks: [String] = ["Task"] + @FocusState private var focus: Field? + + private enum Field: Hashable { + case name + case task + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 10) { + Image(systemName: "plus") + .font(.title3.weight(.semibold)) + .foregroundStyle(.white.opacity(0.9)) + Text("New Space") + .font(.title3.weight(.semibold)) + Spacer(minLength: 48) + } + .padding(.top, 8) + + TextField("Name", text: $name) + .font(.title2.weight(.semibold)) + .textInputAutocapitalization(.words) + .focused($focus, equals: .name) + .accessibilityIdentifier("new-space-name") + + SpaceIconPicker(icon: $icon, name: name, tint: tint.color) + + VStack(alignment: .leading, spacing: 10) { + Text("Color") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 36))], spacing: 10) { + ForEach(SpaceTint.allCases) { option in + Circle() + .fill(option.color) + .frame(width: 28, height: 28) + .overlay { + if tint == option { + Image(systemName: "checkmark") + .font(.caption.bold()) + .foregroundStyle(.white) + } + } + .onTapGesture { tint = option } + .accessibilityLabel(option.displayName) + .accessibilityAddTraits(tint == option ? .isSelected : []) + } + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Tasks") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + ForEach(Array(tasks.enumerated()), id: \.offset) { index, task in + HStack { + Text(task) + Spacer() + Button { + tasks.remove(at: index) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.secondary) + } + .accessibilityLabel("Remove \(task)") + } + .font(.body) + } + HStack { + TextField("Add a task", text: $taskDraft) + .focused($focus, equals: .task) + .accessibilityIdentifier("compose-task-field") + Button("Add") { + let value = taskDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return } + tasks.append(value) + taskDraft = "" + } + .accessibilityIdentifier("compose-add-task") + } + } + + Button { + create() + } label: { + Text("Create Space") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.borderedProminent) + .tint(tint.color) + .disabled(!canCreate) + .accessibilityIdentifier(AccessibilityIDs.createSpaceButton) + + PageDots( + count: pageCount, + current: pageIndex, + accent: tint.color, + composeAtEnd: true + ) + .frame(maxWidth: .infinity) + .padding(.top, 8) + } + .padding(.horizontal, LayoutMetrics.horizontalMargin) + .padding(.bottom, 24) + } + .scrollIndicators(.hidden) + .scrollDismissesKeyboard(.immediately) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + SpaceCanvas.glow(tint.color) + } + .onChange(of: isActive) { _, active in + if !active { + focus = nil + Keyboard.dismiss() + } + } + .onDisappear { + focus = nil + Keyboard.dismiss() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier(AccessibilityIDs.addSpacePage) + } + + private var canCreate: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !tasks.isEmpty + } + + private func create() { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let taskNames = tasks.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + guard let space = try? controller.createSpace( + name: trimmed, + tint: tint, + tasks: taskNames.isEmpty ? ["Task"] : taskNames, + icon: icon + ) else { return } + onCreated(space) + name = "" + taskDraft = "" + tasks = ["Task"] + icon = .fallback + tint = .blue + } +} diff --git a/ProductivityTracker/Views/Spaces/SpaceEditorView.swift b/ProductivityTracker/Views/Spaces/SpaceEditorView.swift new file mode 100644 index 0000000..2cff6b7 --- /dev/null +++ b/ProductivityTracker/Views/Spaces/SpaceEditorView.swift @@ -0,0 +1,157 @@ +import SwiftUI + +struct SpaceEditorView: View { + @Bindable var controller: SessionController + var space: Space + @State private var name: String + @State private var newTask = "" + @State private var focusKeyword: String + + init(controller: SessionController, space: Space) { + self.controller = controller + self.space = space + _name = State(initialValue: space.name) + _focusKeyword = State(initialValue: space.focusKeyword ?? "") + } + + var body: some View { + Form { + Section("Name") { + TextField("Space", text: $name) + .onSubmit { try? controller.renameSpace(space, to: name) } + } + Section("Icon") { + SpaceIconPicker( + icon: iconBinding, + name: name, + tint: space.tint.color + ) + } + Section("Accent") { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 36))]) { + ForEach(SpaceTint.allCases) { tint in + Circle() + .fill(tint.color) + .frame(width: 28, height: 28) + .overlay { + if space.tint == tint { + Image(systemName: "checkmark") + .font(.caption.bold()) + .foregroundStyle(.white) + } + } + .onTapGesture { + try? controller.recolorSpace(space, tint: tint) + } + .accessibilityLabel(tint.displayName) + .accessibilityAddTraits(space.tint == tint ? .isSelected : []) + } + } + .padding(.vertical, 4) + } + Section("Default Task") { + Picker("Default Task", selection: defaultTaskBinding) { + Text("None").tag(UUID?.none) + ForEach(space.enabledTasksSorted, id: \.id) { task in + Text(task.name).tag(Optional(task.id)) + } + } + } + Section("Focus") { + TextField("Focus keyword", text: $focusKeyword) + .onSubmit { + try? controller.setFocusKeyword(focusKeyword, for: space) + } + } + Section("Tasks") { + ForEach(space.allTasksSorted, id: \.id) { task in + TaskEditorRow(controller: controller, task: task) + } + .onMove { source, destination in + try? controller.moveTasks(in: space, from: source, to: destination) + } + .onDelete { indexSet in + let tasks = space.allTasksSorted + for index in indexSet { + try? controller.deleteTask(tasks[index]) + } + } + HStack { + TextField("New task", text: $newTask) + .accessibilityIdentifier("new-task-field") + Button("Add") { + let value = newTask.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return } + try? controller.addTask(to: space, name: value) + newTask = "" + } + .accessibilityIdentifier("add-task-button") + } + } + } + .navigationTitle("Space") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + EditButton() + } + .accessibilityIdentifier(AccessibilityIDs.spaceEditor) + .onDisappear { + try? controller.renameSpace(space, to: name) + try? controller.setFocusKeyword(focusKeyword, for: space) + } + } + + private var iconBinding: Binding { + Binding( + get: { space.icon }, + set: { try? controller.setSpaceIcon(space, icon: $0) } + ) + } + + private var defaultTaskBinding: Binding { + Binding( + get: { space.defaultTaskID }, + set: { newValue in + let task = space.tasks.first { $0.id == newValue } + try? controller.setDefaultTask(task, for: space) + } + ) + } +} + +private struct TaskEditorRow: View { + @Bindable var controller: SessionController + var task: TaskItem + @State private var name: String + + init(controller: SessionController, task: TaskItem) { + self.controller = controller + self.task = task + _name = State(initialValue: task.name) + } + + var body: some View { + HStack { + TextField("Task", text: $name) + .onSubmit { + try? controller.renameTask(task, to: name) + } + .accessibilityIdentifier("rename-task-\(task.id.uuidString)") + Toggle("Enabled", isOn: enabledBinding) + .labelsHidden() + .accessibilityIdentifier("enable-task-\(task.name)") + } + .onChange(of: name) { _, newValue in + if newValue != task.name && !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + try? controller.renameTask(task, to: newValue) + } + } + } + + private var enabledBinding: Binding { + Binding( + get: { task.isEnabled }, + set: { try? controller.setTaskEnabled(task, isEnabled: $0) } + ) + } +} diff --git a/ProductivityTracker/Views/Spaces/SpaceIconPicker.swift b/ProductivityTracker/Views/Spaces/SpaceIconPicker.swift new file mode 100644 index 0000000..757a3df --- /dev/null +++ b/ProductivityTracker/Views/Spaces/SpaceIconPicker.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct SpaceIconPicker: View { + @Binding var icon: SpaceIcon + var name: String + var tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + SpaceIconView(icon: icon, tint: tint, pointSize: 40) + Spacer() + } + + Picker("Icon", selection: kindBinding) { + Text("Symbols").tag(SpaceIconKind.symbol) + Text("Emoji").tag(SpaceIconKind.emoji) + Text("Letters").tag(SpaceIconKind.monogram) + } + .pickerStyle(.segmented) + .accessibilityIdentifier("icon-kind-picker") + + switch icon.kind { + case .symbol: + LazyVGrid(columns: [GridItem(.adaptive(minimum: 34), spacing: 8)], spacing: 8) { + ForEach(SpaceIcon.symbols, id: \.self) { symbol in + iconCell(selected: icon.value == symbol) { + icon = SpaceIcon(kind: .symbol, value: symbol) + } content: { + Image(systemName: symbol) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white) + } + .accessibilityIdentifier("icon-symbol-\(symbol)") + } + } + case .emoji: + LazyVGrid(columns: [GridItem(.adaptive(minimum: 34), spacing: 8)], spacing: 8) { + ForEach(SpaceIcon.emojis, id: \.self) { emoji in + iconCell(selected: icon.value == emoji) { + icon = SpaceIcon(kind: .emoji, value: emoji) + } content: { + Text(emoji).font(.body) + } + .accessibilityIdentifier("icon-emoji-\(emoji)") + } + } + case .monogram: + TextField("2–3 letters", text: monogramBinding) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .accessibilityIdentifier("icon-monogram-field") + Button("Use initials") { + icon = .monogram(from: name) + } + .accessibilityIdentifier("icon-monogram-initials") + } + } + } + + private var kindBinding: Binding { + Binding( + get: { icon.kind }, + set: { kind in + switch kind { + case .symbol: + icon = SpaceIcon(kind: .symbol, value: SpaceIcon.symbols.contains(icon.value) ? icon.value : SpaceIcon.fallback.value) + case .emoji: + icon = SpaceIcon(kind: .emoji, value: SpaceIcon.emojis.contains(icon.value) ? icon.value : SpaceIcon.emojis[0]) + case .monogram: + icon = icon.kind == .monogram ? icon : .monogram(from: name) + } + } + ) + } + + private var monogramBinding: Binding { + Binding( + get: { icon.monogramText }, + set: { newValue in + let filtered = newValue.uppercased().filter(\.isLetter) + icon = SpaceIcon(kind: .monogram, value: String(filtered.prefix(3))) + } + ) + } + + private func iconCell(selected: Bool, action: @escaping () -> Void, @ViewBuilder content: () -> Content) -> some View { + Button(action: action) { + content() + .frame(width: 32, height: 32) + .background(Circle().fill(selected ? tint.opacity(0.55) : Color.white.opacity(0.08))) + .overlay { + Circle().strokeBorder(selected ? Color.white.opacity(0.85) : Color.white.opacity(0.12), lineWidth: selected ? 2 : 1) + } + } + .buttonStyle(.plain) + } +} diff --git a/ProductivityTracker/Views/Spaces/SpaceIconView.swift b/ProductivityTracker/Views/Spaces/SpaceIconView.swift new file mode 100644 index 0000000..a0067a1 --- /dev/null +++ b/ProductivityTracker/Views/Spaces/SpaceIconView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct SpaceIconView: View { + var icon: SpaceIcon + var tint: Color + var pointSize: CGFloat = 28 + + var body: some View { + Group { + switch icon.kind { + case .symbol: + Image(systemName: icon.value) + .font(.system(size: pointSize * 0.62, weight: .semibold)) + .foregroundStyle(.white) + case .emoji: + Text(icon.value) + .font(.system(size: pointSize * 0.62)) + case .monogram: + Text(icon.monogramText) + .font(.system(size: max(11, pointSize * 0.36), weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .minimumScaleFactor(0.5) + .lineLimit(1) + } + } + .frame(width: pointSize, height: pointSize) + .background { + Circle() + .fill( + RadialGradient( + colors: [tint.opacity(0.95), tint.opacity(0.55)], + center: .top, + startRadius: 0, + endRadius: pointSize + ) + ) + .shadow(color: tint.opacity(0.35), radius: max(2, pointSize * 0.08), y: 1) + } + .accessibilityHidden(true) + } +} diff --git a/ProductivityTracker/Views/Spaces/SpaceListView.swift b/ProductivityTracker/Views/Spaces/SpaceListView.swift new file mode 100644 index 0000000..b6a1a13 --- /dev/null +++ b/ProductivityTracker/Views/Spaces/SpaceListView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct SpaceListView: View { + @Bindable var controller: SessionController + @State private var newName = "" + @State private var pendingDelete: Space? + @State private var confirmHistory = false + + var body: some View { + List { + ForEach(controller.spaces, id: \.id) { space in + NavigationLink { + SpaceEditorView(controller: controller, space: space) + } label: { + HStack(spacing: 10) { + SpaceIconView(icon: space.icon, tint: space.tint.color, pointSize: 28) + Text(space.name) + } + } + .accessibilityIdentifier("space-row-\(space.name)") + } + .onMove { source, destination in + try? controller.moveSpaces(from: source, to: destination) + } + .onDelete { indexSet in + if let index = indexSet.first { + pendingDelete = controller.spaces[index] + confirmHistory = true + } + } + Section { + TextField("Name", text: $newName) + Button("Add") { + let name = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + _ = try? controller.createSpace(name: name, tint: .blue, tasks: ["Task"]) + newName = "" + } + } header: { + Text("New Space") + } + } + .navigationTitle("Spaces") + .toolbar { EditButton() } + .alert("Delete Space?", isPresented: $confirmHistory) { + Button("Cancel", role: .cancel) { pendingDelete = nil } + Button("Delete", role: .destructive) { + if let space = pendingDelete { + try? controller.deleteSpace(space, confirmHistory: true) + } + pendingDelete = nil + } + } message: { + Text("Session history for this Space will be removed.") + } + } +} diff --git a/ProductivityTracker/Views/Tasks/TaskPanel.swift b/ProductivityTracker/Views/Tasks/TaskPanel.swift new file mode 100644 index 0000000..25a7a93 --- /dev/null +++ b/ProductivityTracker/Views/Tasks/TaskPanel.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct TaskPanel: View { + @Bindable var controller: SessionController + var space: Space + var isActivePage: Bool + var onRename: (TaskItem) -> Void + @State private var draft = "" + + private var tasks: [TaskItem] { + _ = controller.mutation + return space.enabledTasksSorted + } + + var body: some View { + List { + if tasks.isEmpty { + Text("No Tasks") + .foregroundStyle(.secondary) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + ForEach(tasks, id: \.id) { task in + let snap = controller.snapshot(for: space.id) + let isSelected = task.id == snap.currentTaskID && !task.isCompleted + let parts = controller.taskElapsedParts(for: task) + TaskSelectRow( + task: task, + isActive: isSelected, + accent: space.tint.color, + isLive: isActivePage && isSelected && snap.isRunning, + closedElapsed: parts.closed, + openStartedAt: parts.openStartedAt, + onSelect: { try? controller.selectTask(task) }, + onToggleComplete: { + try? controller.setTaskCompleted(task, isCompleted: !task.isCompleted) + } + ) + .listRowInsets(EdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 4)) + .listRowBackground(Color.clear) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(task.isCompleted ? "Undo" : "Done") { + try? controller.setTaskCompleted(task, isCompleted: !task.isCompleted) + } + .tint(space.tint.color) + } + .contextMenu { + Button(task.isCompleted ? "Mark Incomplete" : "Complete") { + try? controller.setTaskCompleted(task, isCompleted: !task.isCompleted) + } + Button("Rename") { onRename(task) } + Button(task.isEnabled ? "Disable" : "Enable") { + try? controller.setTaskEnabled(task, isEnabled: !task.isEnabled) + } + Button("Delete", role: .destructive) { + try? controller.deleteTask(task) + } + } + } + addRow + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + .listStyle(.plain) + .scrollIndicators(.hidden) + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.immediately) + .accessibilityIdentifier(isActivePage ? AccessibilityIDs.taskPanel : "task-panel-idle") + } + + private var addRow: some View { + HStack { + TextField("Add a task", text: $draft) + .accessibilityIdentifier(isActivePage ? AccessibilityIDs.addTaskInline : "add-task-inline-idle") + Button("Add") { + let value = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return } + try? controller.addTask(to: space, name: value) + draft = "" + } + .accessibilityIdentifier(isActivePage ? "add-task-empty" : "add-task-empty-idle") + } + .padding(.vertical, 8) + } +} + +private struct TaskSelectRow: View { + var task: TaskItem + var isActive: Bool + var accent: Color + var isLive: Bool + var closedElapsed: TimeInterval + var openStartedAt: Date? + var onSelect: () -> Void + var onToggleComplete: () -> Void + @State private var lastTap: Date? + + var body: some View { + TaskRow( + task: task, + isActive: isActive, + accent: accent, + isLive: isLive, + closedElapsed: closedElapsed, + openStartedAt: openStartedAt + ) + .contentShape(Rectangle()) + .onTapGesture { + let now = Date() + if let lastTap, now.timeIntervalSince(lastTap) < 0.35 { + onToggleComplete() + self.lastTap = nil + } else { + self.lastTap = now + onSelect() + } + } + } +} diff --git a/ProductivityTracker/Views/Tasks/TaskRow.swift b/ProductivityTracker/Views/Tasks/TaskRow.swift new file mode 100644 index 0000000..0ecbeb4 --- /dev/null +++ b/ProductivityTracker/Views/Tasks/TaskRow.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct TaskRow: View { + var task: TaskItem + var isActive: Bool + var accent: Color + var isLive: Bool + var closedElapsed: TimeInterval + var openStartedAt: Date? + + var body: some View { + HStack(spacing: 10) { + Circle() + .fill(isActive ? accent : Color.clear) + .frame(width: 6, height: 6) + Text(task.name) + .font(.body) + .foregroundStyle(task.isCompleted ? Color.secondary : (isActive ? Color.primary : Color.secondary)) + .strikethrough(task.isCompleted, color: .secondary) + .lineLimit(1) + Spacer() + elapsedLabel + } + .padding(.horizontal, 4) + .frame(minHeight: 48) + .opacity(task.isCompleted ? 0.55 : 1) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityName) + .accessibilityAddTraits(isActive ? .isSelected : []) + .accessibilityIdentifier("task-row-\(task.name)") + } + + private var accessibilityName: String { + let elapsed = ElapsedFormatter.stopwatch(elapsed(at: Date())) + if task.isCompleted { + return "\(task.name), completed, \(elapsed)" + } + return "\(task.name), \(elapsed)" + } + + @ViewBuilder + private var elapsedLabel: some View { + if isLive { + TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: false)) { timeline in + timeText(elapsed(at: timeline.date)) + } + } else { + timeText(closedElapsed) + } + } + + private func elapsed(at now: Date) -> TimeInterval { + var total = closedElapsed + if let start = openStartedAt { + total += now.timeIntervalSince(start) + } + return total + } + + private func timeText(_ elapsed: TimeInterval) -> some View { + Text(ElapsedFormatter.stopwatch(elapsed)) + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + } +} diff --git a/ProductivityTracker/Views/Timer/ComposeIntentPaging.swift b/ProductivityTracker/Views/Timer/ComposeIntentPaging.swift new file mode 100644 index 0000000..bf2973c --- /dev/null +++ b/ProductivityTracker/Views/Timer/ComposeIntentPaging.swift @@ -0,0 +1,23 @@ +import SwiftUI + +enum ComposePull { + static func resist(_ translation: CGFloat, pageWidth: CGFloat) -> CGFloat { + let x = max(0, translation) + let width = max(pageWidth, 1) + return width * (1 - exp(-x / (width * 1.15))) + } + + static func shouldCommit( + translation: CGFloat, + predicted: CGFloat, + pageWidth: CGFloat, + relaxed: Bool + ) -> Bool { + let threshold = pageWidth * (relaxed ? 0.12 : 0.34) + return translation > threshold || predicted > pageWidth * (relaxed ? 0.18 : 0.48) + } + + static func shouldDismiss(translation: CGFloat, predicted: CGFloat, pageWidth: CGFloat) -> Bool { + translation > pageWidth * 0.18 || predicted > pageWidth * 0.28 + } +} diff --git a/ProductivityTracker/Views/Timer/PageDots.swift b/ProductivityTracker/Views/Timer/PageDots.swift new file mode 100644 index 0000000..572befc --- /dev/null +++ b/ProductivityTracker/Views/Timer/PageDots.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct PageDots: View { + var count: Int + var current: Int + var accent: Color = .white + var composeAtEnd: Bool = false + + var body: some View { + HStack(spacing: 7) { + ForEach(0.. Void + var onOpenHistory: () -> Void + + var body: some View { + let shape = RoundedRectangle(cornerRadius: 10, style: .continuous) + Button(action: onSave) { + Image(systemName: "square.and.arrow.down") + .font(.body.weight(.semibold)) + .foregroundStyle(enabled ? Color.white : Color.white.opacity(0.35)) + .frame(width: 36, height: 36) + .background(shape.fill(Color.white.opacity(enabled ? 0.16 : 0.07))) + .overlay { + shape.strokeBorder(Color.white.opacity(0.22), lineWidth: 1) + } + .glassEffect(.regular.tint(.white.opacity(0.10)).interactive(), in: shape) + } + .buttonStyle(.plain) + .disabled(!enabled) + .accessibilityLabel("Save time") + .accessibilityHint("Saves the current elapsed time without resetting. Long press to view saved times.") + .accessibilityIdentifier(isActivePage ? AccessibilityIDs.saveTimeButton : "save-time-button-idle") + .onLongPressGesture(minimumDuration: 0.45, perform: onOpenHistory) + .frame(minWidth: 44, minHeight: 44) + } +} diff --git a/ProductivityTracker/Views/Timer/SpacePage.swift b/ProductivityTracker/Views/Timer/SpacePage.swift new file mode 100644 index 0000000..cfd63a7 --- /dev/null +++ b/ProductivityTracker/Views/Timer/SpacePage.swift @@ -0,0 +1,84 @@ +import SwiftUI + +enum SpacePagerPage: Hashable { + case space(UUID) + case compose +} + +struct SpacePage: View { + @Bindable var controller: SessionController + var space: Space + var isActivePage: Bool + var pageIndex: Int + var pageCount: Int + var onLongPressLap: () -> Void + var onRenameTask: (TaskItem) -> Void + var onEditSpace: () -> Void + var onSaveTime: () -> Void + var onOpenSavedTimes: () -> Void + + var body: some View { + VStack(spacing: 0) { + header + StopwatchDisplay( + controller: controller, + spaceID: space.id, + isActivePage: isActivePage + ) + .padding(.top, 8) + StopwatchButtons( + controller: controller, + space: space, + isActivePage: isActivePage, + onLongPressLap: onLongPressLap, + onSaveTime: onSaveTime, + onOpenSavedTimes: onOpenSavedTimes + ) + .padding(.top, 18) + PageDots( + count: pageCount, + current: pageIndex, + accent: space.tint.color, + composeAtEnd: true + ) + .accessibilityIdentifier(isActivePage ? AccessibilityIDs.pageIndicator : "page-indicator-idle") + .padding(.top, 16) + .padding(.bottom, 8) + + TaskPanel( + controller: controller, + space: space, + isActivePage: isActivePage, + onRename: onRenameTask + ) + .frame(maxHeight: .infinity) + } + .padding(.horizontal, LayoutMetrics.horizontalMargin) + .padding(.top, 8) + .padding(.bottom, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background { + SpaceCanvas.glow(space.tint.color) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier(isActivePage ? AccessibilityIDs.timerCard : "timer-card-idle") + } + + private var header: some View { + HStack(spacing: 10) { + SpaceIconView(icon: space.icon, tint: space.tint.color, pointSize: 24) + Text(space.name) + .font(.title3.weight(.semibold)) + .foregroundStyle(space.tint.color) + .lineLimit(1) + .accessibilityIdentifier(isActivePage ? AccessibilityIDs.spaceName : "space-name-idle") + .accessibilityLabel(space.name) + Spacer(minLength: 48) + } + .padding(.trailing, 8) + .contentShape(Rectangle()) + .onTapGesture(perform: onEditSpace) + .accessibilityAddTraits(.isButton) + .accessibilityHint("Opens Space settings") + } +} diff --git a/ProductivityTracker/Views/Timer/StopwatchButtons.swift b/ProductivityTracker/Views/Timer/StopwatchButtons.swift new file mode 100644 index 0000000..972e60c --- /dev/null +++ b/ProductivityTracker/Views/Timer/StopwatchButtons.swift @@ -0,0 +1,118 @@ +import SwiftUI + +struct StopwatchButtons: View { + @Bindable var controller: SessionController + var space: Space + var isActivePage: Bool + var onLongPressLap: () -> Void + var onSaveTime: () -> Void + var onOpenSavedTimes: () -> Void + + private var phase: TimerPhase { + _ = controller.mutation + return controller.snapshot(for: space.id).phase + } + + var body: some View { + HStack { + leftButton + Spacer(minLength: 8) + SaveTimeButton( + enabled: canSave, + isActivePage: isActivePage, + onSave: onSaveTime, + onOpenHistory: onOpenSavedTimes + ) + Spacer(minLength: 8) + rightButton + } + .padding(.horizontal, 22) + } + + private var canSave: Bool { + _ = controller.mutation + return controller.snapshot(for: space.id).phase != .idle + } + + @ViewBuilder + private var leftButton: some View { + switch phase.leftControl { + case .lapDisabled: + StopwatchCircleButton(kind: .lap, action: {}) + .opacity(0.45) + .disabled(true) + .accessibilityIdentifier(isActivePage ? AccessibilityIDs.lapButton : "lap-button-idle") + case .lap: + LapControl( + onTap: { try? controller.lap(in: space) }, + onLongPress: onLongPressLap, + identifier: isActivePage ? AccessibilityIDs.lapButton : "lap-button-idle" + ) + case .reset: + StopwatchCircleButton(kind: .reset) { + try? controller.reset(in: space) + } + .accessibilityIdentifier(isActivePage ? AccessibilityIDs.resetButton : "reset-button-idle") + } + } + + @ViewBuilder + private var rightButton: some View { + switch phase.rightControl { + case .start: + StopwatchCircleButton(kind: .start) { + try? controller.start(in: space) + } + .accessibilityIdentifier(isActivePage ? AccessibilityIDs.startStopButton : "start-stop-button-idle") + case .stop: + StopwatchCircleButton(kind: .stop) { + try? controller.stop(in: space) + } + .accessibilityIdentifier(isActivePage ? AccessibilityIDs.startStopButton : "start-stop-button-idle") + } + } +} + +private struct LapControl: View { + var onTap: () -> Void + var onLongPress: () -> Void + var identifier: String + @State private var pressStarted: Date? + private let kind = StopwatchCircleButton.Kind.lap + + var body: some View { + Text(kind.title) + .font(.body.weight(.semibold)) + .foregroundStyle(kind.foreground) + .frame(width: LayoutMetrics.buttonDiameter, height: LayoutMetrics.buttonDiameter) + .background(Circle().fill(kind.fill)) + .overlay { + Circle() + .strokeBorder(kind.foreground.opacity(0.22), lineWidth: 1) + } + .glassEffect(.regular.tint(kind.foreground.opacity(0.18)).interactive(), in: .circle) + .contentShape(Circle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if pressStarted == nil { + pressStarted = Date() + } + } + .onEnded { _ in + let duration = Date().timeIntervalSince(pressStarted ?? Date()) + pressStarted = nil + if duration >= 0.55 { + onLongPress() + } else { + onTap() + } + } + ) + .accessibilityAddTraits(.isButton) + .accessibilityLabel(kind.title) + .accessibilityIdentifier(identifier) + .accessibilityHint("Long press to choose a task") + .frame(minWidth: 44, minHeight: 44) + } +} diff --git a/ProductivityTracker/Views/Timer/StopwatchDisplay.swift b/ProductivityTracker/Views/Timer/StopwatchDisplay.swift new file mode 100644 index 0000000..875d273 --- /dev/null +++ b/ProductivityTracker/Views/Timer/StopwatchDisplay.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct StopwatchDisplay: View { + @Bindable var controller: SessionController + var spaceID: UUID + var isActivePage: Bool + + var body: some View { + let snap = controller.snapshot(for: spaceID) + let overrideElapsed = controller.displayOverrideElapsed(for: spaceID) + let running = snap.isRunning && isActivePage && overrideElapsed == nil + Group { + if running { + TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: false)) { timeline in + digits(snap.elapsed(at: timeline.date), phase: snap.phase.rawValue) + } + } else { + digits(overrideElapsed ?? snap.elapsed(at: Date()), phase: snap.phase.rawValue) + } + } + } + + private func digits(_ elapsed: TimeInterval, phase: String) -> some View { + Text(ElapsedFormatter.stopwatch(elapsed)) + .font(.system(size: LayoutMetrics.stopwatchSize, weight: .thin, design: .default)) + .monospacedDigit() + .foregroundStyle(.white) + .minimumScaleFactor(0.45) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.horizontal, 8) + .accessibilityIdentifier(isActivePage ? AccessibilityIDs.stopwatch : "stopwatch-display-idle") + .accessibilityLabel(ElapsedFormatter.stopwatch(elapsed)) + .accessibilityValue(phase) + } +} diff --git a/ProductivityTracker/Views/Timer/TaskPickerSheet.swift b/ProductivityTracker/Views/Timer/TaskPickerSheet.swift new file mode 100644 index 0000000..e1fb7d6 --- /dev/null +++ b/ProductivityTracker/Views/Timer/TaskPickerSheet.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct TaskPickerSheet: View { + @Bindable var controller: SessionController + @Binding var isPresented: Bool + + var body: some View { + NavigationStack { + List(controller.selectedSpace?.timingTasksSorted ?? [], id: \.id) { task in + Button { + try? controller.selectTask(task) + isPresented = false + } label: { + HStack { + Text(task.name) + .foregroundStyle(.primary) + Spacer() + if task.id == controller.snapshot.currentTaskID { + Image(systemName: "checkmark") + .foregroundStyle(.secondary) + } + } + } + .accessibilityIdentifier("task-choice-\(task.name)") + } + .navigationTitle("Tasks") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { isPresented = false } + } + } + } + .presentationDetents([.medium]) + .accessibilityIdentifier(AccessibilityIDs.taskPicker) + } +} diff --git a/ProductivityTracker/Views/Timer/TimerScreen.swift b/ProductivityTracker/Views/Timer/TimerScreen.swift new file mode 100644 index 0000000..9156409 --- /dev/null +++ b/ProductivityTracker/Views/Timer/TimerScreen.swift @@ -0,0 +1,275 @@ +import SwiftUI + +struct TimerScreen: View { + @Bindable var controller: SessionController + @Binding var showSettings: Bool + @State private var page: SpacePagerPage? = .space(DemoIDs.work) + @State private var composeOpen = false + @State private var peekRaw: CGFloat = 0 + @State private var dismissDrag: CGFloat = 0 + @State private var showTaskPicker = false + @State private var showSavedTimes = false + @State private var renamingTask: TaskItem? + @State private var renameText = "" + @State private var editingSpace: Space? + + var body: some View { + GeometryReader { geo in + let pageWidth = geo.size.width + ZStack(alignment: .topTrailing) { + Color.black.ignoresSafeArea() + pager(width: pageWidth, height: geo.size.height) + .ignoresSafeArea(edges: .bottom) + + Button { + showSettings = true + } label: { + Image(systemName: "ellipsis.circle") + .font(.title3) + .foregroundStyle(.white.opacity(0.78)) + .frame(width: 44, height: 44) + } + .accessibilityIdentifier(AccessibilityIDs.settingsButton) + .accessibilityLabel("Settings") + .padding(.trailing, 4) + .zIndex(2) + } + } + .background(Color.black.ignoresSafeArea()) + .onAppear { + if let selected = controller.selectedSpaceID { + page = .space(selected) + } + } + .onChange(of: page) { _, newValue in + Keyboard.dismiss() + if case .space(let id) = newValue, id != controller.selectedSpaceID { + controller.selectSpace(id, haptic: true) + } + if newValue == .compose { + composeOpen = true + } + } + .sheet(isPresented: $showTaskPicker) { + TaskPickerSheet(controller: controller, isPresented: $showTaskPicker) + } + .sheet(isPresented: $showSavedTimes) { + NavigationStack { + SavedTimesView(controller: controller, initialSpaceID: historySpaceID) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showSavedTimes = false } + } + } + } + } + .sheet(isPresented: Binding( + get: { editingSpace != nil }, + set: { if !$0 { editingSpace = nil } } + )) { + if let space = editingSpace { + NavigationStack { + SpaceEditorView(controller: controller, space: space) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { editingSpace = nil } + } + } + } + } + } + .alert("Rename", isPresented: Binding( + get: { renamingTask != nil }, + set: { if !$0 { renamingTask = nil } } + )) { + TextField("Name", text: $renameText) + Button("Cancel", role: .cancel) { renamingTask = nil } + Button("Save") { + if let task = renamingTask { + try? controller.renameTask(task, to: renameText) + } + renamingTask = nil + } + } + } + + private var historySpaceID: UUID? { + if case .space(let id) = page { return id } + return controller.selectedSpaceID + } + + private var pageCount: Int { controller.spaces.count + 1 } + + private var currentSpaceID: UUID? { + if case .space(let id) = page { return id } + return controller.spaces.last?.id + } + + private var isOnLastSpace: Bool { + !composeOpen && currentSpaceID == controller.spaces.last?.id + } + + private func pageIndex(for id: UUID) -> Int { + controller.spaces.firstIndex(where: { $0.id == id }) ?? 0 + } + + @ViewBuilder + private func pager(width: CGFloat, height: CGFloat) -> some View { + let reveal = composeReveal(pageWidth: width) + ZStack(alignment: .topLeading) { + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(controller.spaces, id: \.id) { space in + SpacePage( + controller: controller, + space: space, + isActivePage: currentSpaceID == space.id && !composeOpen, + pageIndex: pageIndex(for: space.id), + pageCount: pageCount, + onLongPressLap: { showTaskPicker = true }, + onRenameTask: { task in + renamingTask = task + renameText = task.name + }, + onEditSpace: { editingSpace = space }, + onSaveTime: { try? controller.saveTime(in: space) }, + onOpenSavedTimes: { showSavedTimes = true } + ) + .frame(width: width, height: height) + .id(SpacePagerPage.space(space.id)) + } + if controller.launch.uiTesting { + composePage(width: width, height: height) + .id(SpacePagerPage.compose) + } + } + .scrollTargetLayout() + } + .scrollIndicators(.hidden) + .scrollTargetBehavior(.paging) + .scrollPosition(id: $page) + .scrollDismissesKeyboard(.immediately) + .simultaneousGesture( + peekGesture(pageWidth: width), + including: (isOnLastSpace && !controller.launch.uiTesting) ? .all : .none + ) + + if !controller.launch.uiTesting, reveal > 0.5 { + composePage(width: width, height: height) + .offset(x: width - reveal) + .allowsHitTesting(composeOpen) + .gesture(composeDismissGesture(pageWidth: width)) + } + } + .frame(width: width, height: height) + .clipped() + .accessibilityIdentifier(AccessibilityIDs.spacePager) + } + + private func composePage(width: CGFloat, height: CGFloat) -> some View { + SpaceComposePage( + controller: controller, + pageIndex: max(pageCount - 1, 0), + pageCount: pageCount, + isActive: composeOpen || page == .compose, + onCreated: { space in + closeCompose(animated: false) + controller.selectSpace(space.id, haptic: true) + page = .space(space.id) + } + ) + .frame(width: width, height: height) + } + + private func peekGesture(pageWidth: CGFloat) -> some Gesture { + DragGesture(minimumDistance: 12) + .onChanged { value in + guard isOnLastSpace, value.translation.width < 0 else { return } + peekRaw = -value.translation.width + } + .onEnded { value in + guard isOnLastSpace else { + peekRaw = 0 + return + } + finishPeek( + translation: max(0, -value.translation.width), + predicted: max(0, -value.predictedEndTranslation.width), + pageWidth: pageWidth + ) + } + } + + private func composeReveal(pageWidth: CGFloat) -> CGFloat { + if composeOpen { + return max(0, pageWidth - dismissDrag) + } + return ComposePull.resist(peekRaw, pageWidth: pageWidth) + } + + private func finishPeek(translation: CGFloat, predicted: CGFloat, pageWidth: CGFloat) { + if ComposePull.shouldCommit( + translation: translation, + predicted: predicted, + pageWidth: pageWidth, + relaxed: controller.launch.uiTesting + ) { + Keyboard.dismiss() + withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.9)) { + composeOpen = true + peekRaw = 0 + dismissDrag = 0 + page = .compose + } + } else { + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { + peekRaw = 0 + } + } + } + + private func composeDismissGesture(pageWidth: CGFloat) -> some Gesture { + DragGesture(minimumDistance: 12) + .onChanged { value in + dismissDrag = max(0, value.translation.width) + } + .onEnded { value in + let predicted = max(0, value.predictedEndTranslation.width) + finishDismiss(translation: max(0, value.translation.width), predicted: predicted, pageWidth: pageWidth) + } + } + + private func finishDismiss(translation: CGFloat, predicted: CGFloat, pageWidth: CGFloat) { + if ComposePull.shouldDismiss(translation: translation, predicted: predicted, pageWidth: pageWidth) { + Keyboard.dismiss() + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.92)) { + composeOpen = false + peekRaw = 0 + dismissDrag = 0 + if let last = controller.spaces.last?.id { + page = .space(last) + } + } + } else { + withAnimation(.interactiveSpring(response: 0.24, dampingFraction: 0.9)) { + dismissDrag = 0 + } + } + } + + private func closeCompose(animated: Bool) { + let apply = { + composeOpen = false + peekRaw = 0 + dismissDrag = 0 + if case .compose = page, let last = controller.spaces.last?.id { + page = .space(last) + } + } + if animated { + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.94), apply) + } else { + apply() + } + } +} diff --git a/ProductivityTrackerLiveActivity/ActivityAttributes/SessionActivityAttributes.swift b/ProductivityTrackerLiveActivity/ActivityAttributes/SessionActivityAttributes.swift new file mode 100644 index 0000000..c56ca9d --- /dev/null +++ b/ProductivityTrackerLiveActivity/ActivityAttributes/SessionActivityAttributes.swift @@ -0,0 +1,108 @@ +import Foundation +import ActivityKit +import SwiftUI + +struct SessionActivityAttributes: ActivityAttributes, Sendable { + var sessionID: UUID + + struct ContentState: Codable, Hashable, Sendable { + var spaceName: String + var taskName: String + var phaseRaw: String + var displayStart: Date + var isRunning: Bool + var elapsedAtPause: TimeInterval + var tintRaw: String + var iconKindRaw: String + var iconValue: String + } +} + +enum LiveActivityControl: String, Equatable { + case stop + case start +} + +enum LiveActivityPresentation { + /// Ordinary taps on the activity surface must not mutate the timer. + static let backgroundMutatesTimer = false + static let backgroundIntentName: String? = nil + + static func exclusiveControl(isRunning: Bool) -> LiveActivityControl { + isRunning ? .stop : .start + } + + static func exclusiveControlIntentName(isRunning: Bool) -> String { + isRunning ? "StopFromLiveActivityIntent" : "ResumeFromLiveActivityIntent" + } + + static func content( + spaceName: String, + taskName: String, + phaseRaw: String, + isRunning: Bool, + elapsed: TimeInterval, + now: Date, + tintRaw: String = SpaceTint.orange.rawValue, + iconKindRaw: String = SpaceIconKind.symbol.rawValue, + iconValue: String = SpaceIcon.work.value, + displayStart: Date? = nil + ) -> SessionActivityAttributes.ContentState { + SessionActivityAttributes.ContentState( + spaceName: spaceName, + taskName: taskName, + phaseRaw: phaseRaw, + displayStart: displayStart ?? now.addingTimeInterval(-elapsed), + isRunning: isRunning, + elapsedAtPause: elapsed, + tintRaw: tintRaw, + iconKindRaw: iconKindRaw, + iconValue: iconValue + ) + } +} + +extension SessionActivityAttributes.ContentState { + var tint: Color { + (SpaceTint.parse(tintRaw) ?? .orange).color + } + + var icon: SpaceIcon { + SpaceIcon(kind: SpaceIconKind(rawValue: iconKindRaw) ?? .symbol, value: iconValue) + } + + var pauseTime: Date? { + isRunning ? nil : displayStart.addingTimeInterval(elapsedAtPause) + } + + /// Identity for the lock-screen timer view. Pause and resume must not reuse the + /// same `Text(timerInterval:pauseTime:)` instance — once that view has a pause + /// time, the system clock stays frozen even if `pauseTime` later becomes nil. + var elapsedClockID: String { + "\(isRunning ? "run" : "stop")-\(displayStart.timeIntervalSince1970)-\(elapsedAtPause)" + } + + var timerRange: ClosedRange { + displayStart...displayStart.addingTimeInterval(60 * 60 * 24 * 14) + } + + /// Freeze the system timer in place. Keeps `displayStart` so the digits do not rebuild. + func paused(at now: Date) -> SessionActivityAttributes.ContentState { + guard isRunning else { return self } + var next = self + next.isRunning = false + next.elapsedAtPause = now.timeIntervalSince(displayStart) + next.phaseRaw = "stopped" + return next + } + + /// Continue from the frozen elapsed time. Re-anchors `displayStart` to `now - elapsed`. + func resumed(at now: Date) -> SessionActivityAttributes.ContentState { + guard !isRunning else { return self } + var next = self + next.displayStart = now.addingTimeInterval(-elapsedAtPause) + next.isRunning = true + next.phaseRaw = "running" + return next + } +} diff --git a/ProductivityTrackerLiveActivity/DynamicIsland/DynamicIslandViews.swift b/ProductivityTrackerLiveActivity/DynamicIsland/DynamicIslandViews.swift new file mode 100644 index 0000000..f4ee574 --- /dev/null +++ b/ProductivityTrackerLiveActivity/DynamicIsland/DynamicIslandViews.swift @@ -0,0 +1,20 @@ +import SwiftUI +import WidgetKit + +struct DynamicIslandViews { + static func compactLeading(context: ActivityViewContext) -> some View { + DynamicIslandCompactLeading(state: context.state) + } + + static func compactTrailing(context: ActivityViewContext) -> some View { + DynamicIslandCompactTrailing(state: context.state) + } + + static func minimal(context: ActivityViewContext) -> some View { + DynamicIslandMinimal(state: context.state) + } + + static func expanded(context: ActivityViewContext) -> some View { + DynamicIslandExpandedContent(state: context.state) + } +} diff --git a/ProductivityTrackerLiveActivity/Info.plist b/ProductivityTrackerLiveActivity/Info.plist new file mode 100644 index 0000000..91e46e9 --- /dev/null +++ b/ProductivityTrackerLiveActivity/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDisplayName + Conduit + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + + + diff --git a/ProductivityTrackerLiveActivity/Intents/LiveActivityClock.swift b/ProductivityTrackerLiveActivity/Intents/LiveActivityClock.swift new file mode 100644 index 0000000..9ec274a --- /dev/null +++ b/ProductivityTrackerLiveActivity/Intents/LiveActivityClock.swift @@ -0,0 +1,27 @@ +import Foundation +@preconcurrency import ActivityKit + +/// Pushes stopwatch pause/resume straight into ActivityKit from the current Live Activity +/// content. Pause freezes the current interval; resume publishes a new `displayStart` so +/// the lock-screen timer view is a different instance, not an unpaused `pauseTime`. +enum LiveActivityClock { + static func pause(at now: Date = Date()) async { + guard let activity = Activity.activities.first else { return } + let next = activity.content.state.paused(at: now) + guard next != activity.content.state else { return } + await activity.update(ActivityContent(state: next, staleDate: nil)) + } + + static func resume(at now: Date = Date()) async { + guard let activity = Activity.activities.first else { return } + let next = activity.content.state.resumed(at: now) + guard next != activity.content.state else { return } + await activity.update(ActivityContent(state: next, staleDate: nil)) + } + + static func dismiss() async { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + } +} diff --git a/ProductivityTrackerLiveActivity/Intents/LiveActivityIntents.swift b/ProductivityTrackerLiveActivity/Intents/LiveActivityIntents.swift new file mode 100644 index 0000000..90e67e5 --- /dev/null +++ b/ProductivityTrackerLiveActivity/Intents/LiveActivityIntents.swift @@ -0,0 +1,60 @@ +import AppIntents + +struct StopFromLiveActivityIntent: LiveActivityIntent { + static var title: LocalizedStringResource { "Stop" } + static var openAppWhenRun: Bool { false } + static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } + + func perform() async throws -> some IntentResult { + let now = Date() + await LiveActivityClock.pause(at: now) + #if APP_TARGET + await AppRuntime.shared.sessionController?.stopFromLiveActivity(at: now) + #endif + return .result() + } +} + +struct ResumeFromLiveActivityIntent: LiveActivityIntent { + static var title: LocalizedStringResource { "Start" } + static var openAppWhenRun: Bool { false } + static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } + + func perform() async throws -> some IntentResult { + let now = Date() + await LiveActivityClock.resume(at: now) + #if APP_TARGET + await AppRuntime.shared.sessionController?.startFromLiveActivity(at: now) + #endif + return .result() + } +} + +struct LapFromLiveActivityIntent: LiveActivityIntent { + static var title: LocalizedStringResource { "Next Task" } + static var openAppWhenRun: Bool { false } + static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } + + func perform() async throws -> some IntentResult { + #if APP_TARGET + await MainActor.run { + try? AppRuntime.shared.sessionController?.lapFromLiveActivity() + } + #endif + return .result() + } +} + +struct ResetFromLiveActivityIntent: LiveActivityIntent { + static var title: LocalizedStringResource { "Reset" } + static var openAppWhenRun: Bool { false } + static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } + + func perform() async throws -> some IntentResult { + await LiveActivityClock.dismiss() + #if APP_TARGET + await AppRuntime.shared.sessionController?.resetFromLiveActivity() + #endif + return .result() + } +} diff --git a/ProductivityTrackerLiveActivity/LiveActivityView/LiveActivityView.swift b/ProductivityTrackerLiveActivity/LiveActivityView/LiveActivityView.swift new file mode 100644 index 0000000..826dc73 --- /dev/null +++ b/ProductivityTrackerLiveActivity/LiveActivityView/LiveActivityView.swift @@ -0,0 +1,10 @@ +import SwiftUI +import WidgetKit + +struct LiveActivityView: View { + let context: ActivityViewContext + + var body: some View { + LiveActivityLockScreen(state: context.state) + } +} diff --git a/ProductivityTrackerLiveActivity/ProductivityTrackerLiveActivityBundle.swift b/ProductivityTrackerLiveActivity/ProductivityTrackerLiveActivityBundle.swift new file mode 100644 index 0000000..aa05d73 --- /dev/null +++ b/ProductivityTrackerLiveActivity/ProductivityTrackerLiveActivityBundle.swift @@ -0,0 +1,25 @@ +import SwiftUI +import WidgetKit + +@main +struct ProductivityTrackerLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: SessionActivityAttributes.self) { context in + LiveActivityView(context: context) + .activityBackgroundTint(Color.black) + .activitySystemActionForegroundColor(.white) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.center) { + DynamicIslandViews.expanded(context: context) + } + } compactLeading: { + DynamicIslandViews.compactLeading(context: context) + } compactTrailing: { + DynamicIslandViews.compactTrailing(context: context) + } minimal: { + DynamicIslandViews.minimal(context: context) + } + } + } +} diff --git a/ProductivityTrackerTests/ComposePullTests.swift b/ProductivityTrackerTests/ComposePullTests.swift new file mode 100644 index 0000000..c0dd89c --- /dev/null +++ b/ProductivityTrackerTests/ComposePullTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import ProductivityTracker + +final class ComposePullTests: XCTestCase { + func testResistanceIsBelowRawTranslationAndApproachesPageWidth() { + let width: CGFloat = 390 + XCTAssertEqual(ComposePull.resist(0, pageWidth: width), 0, accuracy: 0.001) + let small = ComposePull.resist(40, pageWidth: width) + XCTAssertLessThan(small, 40) + XCTAssertGreaterThan(small, 0) + let large = ComposePull.resist(width * 4, pageWidth: width) + XCTAssertLessThan(large, width) + XCTAssertGreaterThan(large, width * 0.9) + } + + func testCommitRequiresADeliberatePullUnlessRelaxed() { + let width: CGFloat = 390 + XCTAssertFalse( + ComposePull.shouldCommit(translation: 40, predicted: 50, pageWidth: width, relaxed: false) + ) + XCTAssertTrue( + ComposePull.shouldCommit(translation: width * 0.4, predicted: 0, pageWidth: width, relaxed: false) + ) + XCTAssertTrue( + ComposePull.shouldCommit(translation: width * 0.15, predicted: 0, pageWidth: width, relaxed: true) + ) + } +} diff --git a/ProductivityTrackerTests/LiveActivityStateTests.swift b/ProductivityTrackerTests/LiveActivityStateTests.swift new file mode 100644 index 0000000..55c5a32 --- /dev/null +++ b/ProductivityTrackerTests/LiveActivityStateTests.swift @@ -0,0 +1,308 @@ +import XCTest +import SwiftData +@testable import ProductivityTracker + +@MainActor +final class LiveActivityStateTests: XCTestCase { + func testLiveActivityFollowsStopResumeResetAndTaskSwitch() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 2000)) + let live = NullLiveActivityManager() + let controller = SessionController( + context: ModelContext(try PersistenceController.makeContainer(inMemory: true)), + timeSource: time, + notifications: RecordingNotificationService(), + liveActivity: live, + settings: SettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!), + launch: LaunchConfiguration( + uiTesting: true, + resetStore: true, + screenshotMode: false, + startRunning: false, + frozenElapsed: nil, + inMemoryStore: true, + timerState: nil, + liveActivityPreview: false + ) + ) + try controller.bootstrap() + try controller.start() + XCTAssertEqual(live.lastState?.isRunning, true) + XCTAssertEqual(live.lastState?.spaceName, "Work") + XCTAssertEqual(live.lastState?.taskName, "Deep Work") + XCTAssertEqual(live.dismissed, 0) + let runningAnchor = live.lastState?.displayStart + + time.advance(by: 10) + try controller.stop() + XCTAssertEqual(live.lastState?.isRunning, false) + XCTAssertEqual(live.lastState?.elapsedAtPause ?? 0, 10, accuracy: 0.01) + XCTAssertNotNil(live.lastState?.pauseTime) + XCTAssertEqual(live.lastState?.displayStart, runningAnchor) + XCTAssertEqual(live.lastState?.tintRaw, SpaceTint.orange.rawValue) + XCTAssertEqual(live.lastState?.iconValue, SpaceIcon.work.value) + XCTAssertEqual(live.dismissed, 0) + time.advance(by: 20) + try controller.start() + XCTAssertEqual(live.lastState?.isRunning, true) + XCTAssertEqual(live.dismissed, 0) + + let email = controller.selectedTasks.first { $0.name == "Email" }! + try controller.selectTask(email) + XCTAssertEqual(live.lastState?.taskName, "Email") + XCTAssertEqual(live.lastState?.spaceName, "Work") + + try controller.stop() + try controller.reset() + XCTAssertEqual(live.dismissed, 1) + XCTAssertNil(live.lastState) + } + + func testSurfaceTapDoesNotCarryPauseOrResumeIntent() { + XCTAssertFalse(LiveActivityPresentation.backgroundMutatesTimer) + XCTAssertNil(LiveActivityPresentation.backgroundIntentName) + XCTAssertEqual(LiveActivityPresentation.exclusiveControlIntentName(isRunning: true), "StopFromLiveActivityIntent") + XCTAssertEqual(LiveActivityPresentation.exclusiveControlIntentName(isRunning: false), "ResumeFromLiveActivityIntent") + XCTAssertEqual(LiveActivityPresentation.exclusiveControl(isRunning: true), .stop) + XCTAssertEqual(LiveActivityPresentation.exclusiveControl(isRunning: false), .start) + XCTAssertEqual(String(describing: StopFromLiveActivityIntent.self), "StopFromLiveActivityIntent") + XCTAssertEqual(String(describing: ResumeFromLiveActivityIntent.self), "ResumeFromLiveActivityIntent") + } + + func testElapsedClockIdentityChangesOnPauseAndResume() { + let start = Date(timeIntervalSince1970: 4000) + let running = LiveActivityPresentation.content( + spaceName: "Work", + taskName: "Deep Work", + phaseRaw: TimerPhase.running.rawValue, + isRunning: true, + elapsed: 12, + now: start.addingTimeInterval(12), + displayStart: start + ) + let paused = running.paused(at: start.addingTimeInterval(12.5)) + let resumed = paused.resumed(at: start.addingTimeInterval(42.5)) + XCTAssertNotEqual(running.elapsedClockID, paused.elapsedClockID) + XCTAssertNotEqual(paused.elapsedClockID, resumed.elapsedClockID) + XCTAssertTrue(running.elapsedClockID.hasPrefix("run-")) + XCTAssertTrue(paused.elapsedClockID.hasPrefix("stop-")) + XCTAssertTrue(resumed.elapsedClockID.hasPrefix("run-")) + XCTAssertNotEqual(running.displayStart, resumed.displayStart) + } + + func testLiveActivityContentIncludesTintAndIconFields() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 2000)) + let live = NullLiveActivityManager() + let controller = SessionController( + context: ModelContext(try PersistenceController.makeContainer(inMemory: true)), + timeSource: time, + notifications: RecordingNotificationService(), + liveActivity: live, + settings: SettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!), + launch: LaunchConfiguration( + uiTesting: true, + resetStore: true, + screenshotMode: false, + startRunning: false, + frozenElapsed: nil, + inMemoryStore: true, + timerState: nil, + liveActivityPreview: false + ) + ) + try controller.bootstrap() + try controller.start() + XCTAssertEqual(live.lastState?.tintRaw, SpaceTint.orange.rawValue) + XCTAssertEqual(live.lastState?.iconKindRaw, SpaceIconKind.symbol.rawValue) + XCTAssertEqual(live.lastState?.iconValue, SpaceIcon.work.value) + XCTAssertEqual(live.lastState?.icon, .work) + } + + func testLiveActivityRunningAndStoppedShareTimerIntervalModel() { + let now = Date(timeIntervalSince1970: 3000) + let running = LiveActivityPresentation.content( + spaceName: "Work", + taskName: "Deep Work", + phaseRaw: TimerPhase.running.rawValue, + isRunning: true, + elapsed: 42, + now: now, + tintRaw: SpaceTint.orange.rawValue, + iconKindRaw: SpaceIconKind.symbol.rawValue, + iconValue: SpaceIcon.work.value + ) + XCTAssertTrue(running.isRunning) + XCTAssertEqual(running.elapsedAtPause, 42, accuracy: 0.001) + XCTAssertNil(running.pauseTime) + XCTAssertEqual( + running.timerRange.lowerBound.timeIntervalSince1970, + now.addingTimeInterval(-42).timeIntervalSince1970, + accuracy: 0.001 + ) + + let stopped = LiveActivityPresentation.content( + spaceName: "Work", + taskName: "Deep Work", + phaseRaw: TimerPhase.stopped.rawValue, + isRunning: false, + elapsed: 42, + now: now, + tintRaw: SpaceTint.orange.rawValue, + iconKindRaw: SpaceIconKind.symbol.rawValue, + iconValue: SpaceIcon.work.value + ) + XCTAssertFalse(stopped.isRunning) + XCTAssertEqual(stopped.elapsedAtPause, 42, accuracy: 0.001) + XCTAssertNotNil(stopped.pauseTime) + XCTAssertEqual( + stopped.pauseTime!.timeIntervalSince1970, + stopped.displayStart.addingTimeInterval(stopped.elapsedAtPause).timeIntervalSince1970, + accuracy: 0.001 + ) + XCTAssertEqual( + stopped.timerRange.lowerBound.timeIntervalSince1970, + running.timerRange.lowerBound.timeIntervalSince1970, + accuracy: 0.001 + ) + } + + func testPauseKeepsClockAnchorAndResumeReanchorsElapsed() { + let start = Date(timeIntervalSince1970: 4000) + let running = LiveActivityPresentation.content( + spaceName: "Work", + taskName: "Deep Work", + phaseRaw: TimerPhase.running.rawValue, + isRunning: true, + elapsed: 12, + now: start.addingTimeInterval(12), + displayStart: start + ) + let pausedAt = start.addingTimeInterval(12.5) + let paused = running.paused(at: pausedAt) + XCTAssertFalse(paused.isRunning) + XCTAssertEqual(paused.displayStart, start) + XCTAssertEqual(paused.elapsedAtPause, 12.5, accuracy: 0.0001) + XCTAssertNotNil(paused.pauseTime) + XCTAssertEqual(paused.pauseTime!.timeIntervalSince1970, pausedAt.timeIntervalSince1970, accuracy: 0.0001) + + let resumeAt = pausedAt.addingTimeInterval(30) + let resumed = paused.resumed(at: resumeAt) + XCTAssertTrue(resumed.isRunning) + XCTAssertNil(resumed.pauseTime) + XCTAssertEqual(resumed.elapsedAtPause, 12.5, accuracy: 0.0001) + XCTAssertEqual( + resumeAt.timeIntervalSince(resumed.displayStart), + 12.5, + accuracy: 0.0001 + ) + XCTAssertNotEqual(paused.elapsedClockID, resumed.elapsedClockID) + } + + func testLockScreenStopThenStartRepublishesRunningClock() async throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 5000)) + let live = NullLiveActivityManager() + let controller = SessionController( + context: ModelContext(try PersistenceController.makeContainer(inMemory: true)), + timeSource: time, + notifications: RecordingNotificationService(), + liveActivity: live, + settings: SettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!), + launch: LaunchConfiguration( + uiTesting: true, + resetStore: true, + screenshotMode: false, + startRunning: false, + frozenElapsed: nil, + inMemoryStore: true, + timerState: nil, + liveActivityPreview: false + ) + ) + try controller.bootstrap() + try controller.start() + time.advance(by: 10) + let runningAnchor = live.lastState?.displayStart + await controller.stopFromLiveActivity(at: time.now()) + XCTAssertEqual(controller.snapshot.phase, .stopped) + XCTAssertEqual(live.lastState?.isRunning, false) + XCTAssertEqual(live.lastState?.displayStart, runningAnchor) + XCTAssertNotNil(live.lastState?.pauseTime) + let pausedID = live.lastState?.elapsedClockID + + time.advance(by: 20) + await controller.startFromLiveActivity(at: time.now()) + XCTAssertEqual(controller.snapshot.phase, .running) + XCTAssertEqual(live.lastState?.isRunning, true) + XCTAssertNil(live.lastState?.pauseTime) + XCTAssertNotEqual(live.lastState?.displayStart, runningAnchor) + XCTAssertNotEqual(live.lastState?.elapsedClockID, pausedID) + XCTAssertEqual( + time.now().timeIntervalSince(live.lastState!.displayStart), + 10, + accuracy: 0.01 + ) + time.advance(by: 5) + XCTAssertEqual(controller.displayedElapsed(at: time.now()), 15, accuracy: 0.01) + } + + func testLockScreenResumeUsesLiveActivityOwnerNotSelectedSpace() async throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 6000)) + let live = NullLiveActivityManager() + let controller = SessionController( + context: ModelContext(try PersistenceController.makeContainer(inMemory: true)), + timeSource: time, + notifications: RecordingNotificationService(), + liveActivity: live, + settings: SettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!), + launch: LaunchConfiguration( + uiTesting: true, + resetStore: true, + screenshotMode: false, + startRunning: false, + frozenElapsed: nil, + inMemoryStore: true, + timerState: nil, + liveActivityPreview: false + ) + ) + try controller.bootstrap() + try controller.start() + time.advance(by: 8) + await controller.stopFromLiveActivity(at: time.now()) + controller.selectSpace(DemoIDs.chores) + await controller.startFromLiveActivity(at: time.now()) + XCTAssertEqual(controller.snapshot(for: DemoIDs.work).phase, .running) + XCTAssertEqual(controller.snapshot(for: DemoIDs.chores).phase, .idle) + XCTAssertEqual(live.lastState?.spaceName, "Work") + XCTAssertEqual(live.lastState?.isRunning, true) + } + + func testSpaceOwnershipStaysWithRunningSpace() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 2000)) + let live = NullLiveActivityManager() + let controller = SessionController( + context: ModelContext(try PersistenceController.makeContainer(inMemory: true)), + timeSource: time, + notifications: RecordingNotificationService(), + liveActivity: live, + settings: SettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!), + launch: LaunchConfiguration( + uiTesting: true, + resetStore: true, + screenshotMode: false, + startRunning: false, + frozenElapsed: nil, + inMemoryStore: true, + timerState: nil, + liveActivityPreview: false + ) + ) + try controller.bootstrap() + try controller.start() + let publishes = live.started + controller.selectSpace(DemoIDs.chores) + XCTAssertEqual(live.started, publishes) + live.startOrUpdate(from: controller, at: time.now()) + XCTAssertEqual(live.lastState?.spaceName, "Work") + XCTAssertEqual(controller.liveActivitySpace()?.name, "Work") + } +} diff --git a/ProductivityTrackerTests/MultiSpaceTimerTests.swift b/ProductivityTrackerTests/MultiSpaceTimerTests.swift new file mode 100644 index 0000000..ed02d0f --- /dev/null +++ b/ProductivityTrackerTests/MultiSpaceTimerTests.swift @@ -0,0 +1,79 @@ +import XCTest +import SwiftData +@testable import ProductivityTracker + +@MainActor +final class MultiSpaceTimerTests: XCTestCase { + private func makeController(time: ControllableTimeSource) throws -> SessionController { + let controller = SessionController( + context: ModelContext(try PersistenceController.makeContainer(inMemory: true)), + timeSource: time, + notifications: RecordingNotificationService(), + liveActivity: NullLiveActivityManager(), + settings: SettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!), + launch: LaunchConfiguration( + uiTesting: true, + resetStore: true, + screenshotMode: false, + startRunning: false, + frozenElapsed: nil, + inMemoryStore: true, + timerState: nil, + liveActivityPreview: false + ) + ) + try controller.bootstrap() + return controller + } + + func testSwipeDoesNotRelabelRunningSession() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + time.advance(by: 12) + let workElapsed = controller.displayedElapsed(at: time.now()) + controller.selectSpace(DemoIDs.chores) + XCTAssertEqual(controller.selectedSpace?.name, "Chores") + XCTAssertEqual(controller.snapshot.phase, .idle) + XCTAssertEqual(controller.displayedElapsed(at: time.now()), 0, accuracy: 0.001) + XCTAssertEqual(controller.snapshot(for: DemoIDs.work).phase, .running) + XCTAssertEqual(controller.snapshot(for: DemoIDs.work).elapsed(at: time.now()), workElapsed, accuracy: 0.001) + XCTAssertEqual(controller.runningSpaceID, DemoIDs.work) + } + + func testStartingAnotherSpaceFreezesTheRunningOne() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + time.advance(by: 9) + controller.selectSpace(DemoIDs.chores) + try controller.start() + XCTAssertEqual(controller.snapshot(for: DemoIDs.work).phase, .stopped) + XCTAssertEqual(controller.snapshot(for: DemoIDs.work).elapsed(at: time.now()), 9, accuracy: 0.001) + XCTAssertEqual(controller.snapshot(for: DemoIDs.chores).phase, .running) + XCTAssertEqual(controller.runningSpaceID, DemoIDs.chores) + time.advance(by: 4) + XCTAssertEqual(controller.snapshot(for: DemoIDs.chores).elapsed(at: time.now()), 4, accuracy: 0.001) + XCTAssertEqual(controller.snapshot(for: DemoIDs.work).elapsed(at: time.now()), 9, accuracy: 0.001) + } + + func testSpaceCustomization() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + let work = controller.spaces.first { $0.name == "Work" }! + try controller.renameSpace(work, to: "Studio") + try controller.recolorSpace(work, tint: .blue) + try controller.setReminder(for: work, seconds: 0) + try controller.setFocusKeyword("Work", for: work) + let email = work.tasks.first { $0.name == "Email" }! + try controller.setDefaultTask(email, for: work) + XCTAssertEqual(controller.spaces.first { $0.id == work.id }?.name, "Studio") + XCTAssertEqual(work.tint, .blue) + XCTAssertEqual(work.distractionTimeoutSeconds, 0) + XCTAssertEqual(work.focusKeyword, "Work") + XCTAssertEqual(work.defaultTaskID, email.id) + controller.selectSpace(work.id) + try controller.start() + XCTAssertEqual(controller.activeTask?.name, "Email") + } +} diff --git a/ProductivityTrackerTests/NotificationAndImportTests.swift b/ProductivityTrackerTests/NotificationAndImportTests.swift new file mode 100644 index 0000000..cf19f8c --- /dev/null +++ b/ProductivityTrackerTests/NotificationAndImportTests.swift @@ -0,0 +1,102 @@ +import XCTest +import SwiftData +@testable import ProductivityTracker + +final class NotificationAndImportTests: XCTestCase { + func testDistractionSchedulesOnlyWhenRunning() { + var monitor = DistractionMonitor(activeSessionID: nil, threshold: 300) + var scheduled: UUID? + monitor.distractionStarted(isSessionActive: false, sessionID: UUID()) { scheduled = $0 } + XCTAssertNil(scheduled) + + let session = UUID() + monitor.distractionStarted(isSessionActive: true, sessionID: session) { scheduled = $0 } + XCTAssertEqual(scheduled, session) + XCTAssertEqual(NotificationIdentifiers.distraction(sessionID: session), "distraction.session.\(session.uuidString)") + } + + func testDistractionCancellation() { + var monitor = DistractionMonitor(activeSessionID: nil, threshold: 300) + let session = UUID() + monitor.distractionStarted(isSessionActive: true, sessionID: session) { _ in } + var cancelled: UUID? + monitor.distractionEnded { cancelled = $0 } + XCTAssertEqual(cancelled, session) + XCTAssertNil(monitor.activeSessionID) + } + + @MainActor + func testNotificationServiceRecordsActiveTimerOnly() throws { + let notifications = RecordingNotificationService() + let container = try PersistenceController.makeContainer(inMemory: true) + let controller = SessionController( + context: ModelContext(container), + timeSource: ControllableTimeSource(now: Date(timeIntervalSince1970: 1)), + notifications: notifications, + liveActivity: NullLiveActivityManager(), + settings: SettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!), + launch: LaunchConfiguration(uiTesting: true, resetStore: true, screenshotMode: false, startRunning: false, frozenElapsed: nil, inMemoryStore: true) + ) + try controller.bootstrap() + controller.distractionStarted() + XCTAssertTrue(notifications.scheduled.isEmpty) + try controller.start() + controller.distractionStarted() + XCTAssertEqual(notifications.scheduled.count, 1) + XCTAssertEqual(notifications.scheduled.first?.after, 300) + controller.distractionEnded() + XCTAssertTrue(notifications.scheduled.isEmpty) + } + + func testValidSpaceJSON() throws { + let json = """ + {"name":"Thermodynamics","color":"blue","tasks":["Review lecture","Practice problems","Formula review","Assignment"]} + """ + let payload = try SpaceImportPayload.parse(json: json) + XCTAssertEqual(payload.name, "Thermodynamics") + XCTAssertEqual(payload.color, .blue) + XCTAssertEqual(payload.tasks.count, 4) + } + + func testMalformedJSON() { + XCTAssertThrowsError(try SpaceImportPayload.parse(json: "{not json")) { error in + XCTAssertEqual(error as? SpaceImportError, .malformedJSON) + } + } + + func testMissingName() { + XCTAssertThrowsError(try SpaceImportPayload.parse(json: #"{"color":"blue","tasks":["A"]}"#)) { error in + XCTAssertEqual(error as? SpaceImportError, .missingName) + } + } + + func testEmptyTasks() { + XCTAssertThrowsError(try SpaceImportPayload.parse(json: #"{"name":"Work","tasks":[]}"#)) { error in + XCTAssertEqual(error as? SpaceImportError, .emptyTasks) + } + } + + func testExcessiveTaskCount() { + let tasks = (1...41).map { "\"T\($0)\"" }.joined(separator: ",") + XCTAssertThrowsError(try SpaceImportPayload.parse(json: "{\"name\":\"X\",\"tasks\":[\(tasks)]}")) { error in + XCTAssertEqual(error as? SpaceImportError, .excessiveTaskCount) + } + } + + func testDuplicateTaskHandling() throws { + let payload = try SpaceImportPayload.parse(json: #"{"name":"Work","color":"orange","tasks":["Deep Work","deep work","Email"]}"#) + XCTAssertEqual(payload.tasks, ["Deep Work", "Email"]) + } + + func testInvalidColor() { + XCTAssertThrowsError(try SpaceImportPayload.parse(json: #"{"name":"Work","color":"neon","tasks":["A"]}"#)) { error in + XCTAssertEqual(error as? SpaceImportError, .invalidColor("neon")) + } + } + + func testLaunchConfigurationScreenshot() { + let config = LaunchConfiguration.from(["-ScreenshotMode"]) + XCTAssertEqual(config.frozenElapsed, 31.42) + XCTAssertTrue(config.startRunning) + } +} diff --git a/ProductivityTrackerTests/SpacePersistenceTests.swift b/ProductivityTrackerTests/SpacePersistenceTests.swift new file mode 100644 index 0000000..d5c1d8a --- /dev/null +++ b/ProductivityTrackerTests/SpacePersistenceTests.swift @@ -0,0 +1,116 @@ +import XCTest +import SwiftData +@testable import ProductivityTracker + +@MainActor +final class SpacePersistenceTests: XCTestCase { + private func makeController() throws -> SessionController { + let container = try PersistenceController.makeContainer(inMemory: true) + let context = ModelContext(container) + let controller = SessionController( + context: context, + timeSource: ControllableTimeSource(now: Date(timeIntervalSince1970: 50)), + notifications: RecordingNotificationService(), + liveActivity: NullLiveActivityManager(), + settings: SettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!), + launch: LaunchConfiguration(uiTesting: true, resetStore: true, screenshotMode: false, startRunning: false, frozenElapsed: nil, inMemoryStore: true) + ) + try controller.bootstrap() + return controller + } + + func testDefaultSpacesAndSelection() throws { + let controller = try makeController() + XCTAssertEqual(controller.spaces.map(\.name), ["Work", "Chores", "Personal"]) + XCTAssertEqual(controller.selectedSpace?.name, "Work") + controller.selectSpace(DemoIDs.chores) + XCTAssertEqual(controller.selectedSpace?.name, "Chores") + } + + func testOrdering() throws { + let controller = try makeController() + try controller.moveSpaces(from: IndexSet(integer: 0), to: 3) + XCTAssertEqual(controller.spaces.map(\.name), ["Chores", "Personal", "Work"]) + } + + func testDeletionSafeguard() throws { + let controller = try makeController() + try controller.start() + try controller.stop() + let work = controller.spaces.first { $0.name == "Work" }! + XCTAssertThrowsError(try controller.deleteSpace(work, confirmHistory: false)) + try controller.deleteSpace(work, confirmHistory: true) + XCTAssertFalse(controller.spaces.contains(where: { $0.name == "Work" })) + } + + func testTintPersistence() throws { + let controller = try makeController() + let work = controller.spaces.first { $0.name == "Work" }! + try controller.recolorSpace(work, tint: .blue) + XCTAssertEqual(controller.spaces.first { $0.name == "Work" }?.tint, .blue) + } + + func testDefaultSpaceIconsSeed() throws { + let controller = try makeController() + XCTAssertEqual(controller.spaces.first { $0.name == "Work" }?.icon, .work) + XCTAssertEqual(controller.spaces.first { $0.name == "Chores" }?.icon, .chores) + XCTAssertEqual(controller.spaces.first { $0.name == "Personal" }?.icon, .personal) + XCTAssertEqual(controller.spaces.first { $0.name == "Work" }?.icon.value, "briefcase.fill") + XCTAssertEqual(controller.spaces.first { $0.name == "Chores" }?.icon.value, "house.fill") + XCTAssertEqual(controller.spaces.first { $0.name == "Personal" }?.icon.value, "heart.fill") + } + + func testSetSpaceIconPersists() throws { + let controller = try makeController() + let work = controller.spaces.first { $0.name == "Work" }! + let custom = SpaceIcon(kind: .emoji, value: "🎯") + try controller.setSpaceIcon(work, icon: custom) + XCTAssertEqual(controller.spaces.first { $0.id == work.id }?.icon, custom) + } + + func testCreateSpaceWithIcon() throws { + let controller = try makeController() + let space = try controller.createSpace(name: "Studio", tint: .blue, tasks: ["Sketch"], icon: .personal) + XCTAssertEqual(space.icon, .personal) + XCTAssertEqual(controller.spaces.first { $0.name == "Studio" }?.icon, .personal) + } + + func testCreateSpaceDefaultIconCompiles() throws { + let controller = try makeController() + let space = try controller.createSpace(name: "Thermodynamics", tint: .indigo, tasks: ["Review", "Problems"]) + XCTAssertEqual(space.tasks.count, 2) + XCTAssertEqual(space.icon, .fallback) + try controller.renameSpace(space, to: "Math") + XCTAssertTrue(controller.spaces.contains(where: { $0.name == "Math" })) + } + + func testActiveSessionRecovery() throws { + let container = try PersistenceController.makeContainer(inMemory: true) + let context = ModelContext(container) + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 10)) + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let first = SessionController( + context: context, + timeSource: time, + notifications: RecordingNotificationService(), + liveActivity: NullLiveActivityManager(), + settings: SettingsStore(defaults: defaults), + launch: LaunchConfiguration(uiTesting: true, resetStore: true, screenshotMode: false, startRunning: false, frozenElapsed: nil, inMemoryStore: true) + ) + try first.bootstrap() + try first.start() + time.advance(by: 12) + + let restored = SessionController( + context: context, + timeSource: time, + notifications: RecordingNotificationService(), + liveActivity: NullLiveActivityManager(), + settings: SettingsStore(defaults: defaults), + launch: LaunchConfiguration(uiTesting: true, resetStore: false, screenshotMode: false, startRunning: false, frozenElapsed: nil, inMemoryStore: true) + ) + try restored.bootstrap() + XCTAssertEqual(restored.snapshot.phase, .running) + XCTAssertGreaterThan(restored.snapshot.elapsed(at: time.now()), 0) + } +} diff --git a/ProductivityTrackerTests/StopwatchSemanticsTests.swift b/ProductivityTrackerTests/StopwatchSemanticsTests.swift new file mode 100644 index 0000000..00b74ac --- /dev/null +++ b/ProductivityTrackerTests/StopwatchSemanticsTests.swift @@ -0,0 +1,127 @@ +import XCTest +import SwiftData +@testable import ProductivityTracker + +@MainActor +final class StopwatchSemanticsTests: XCTestCase { + private func makeController(time: ControllableTimeSource) throws -> SessionController { + let live = NullLiveActivityManager() + let controller = SessionController( + context: ModelContext(try PersistenceController.makeContainer(inMemory: true)), + timeSource: time, + notifications: RecordingNotificationService(), + liveActivity: live, + settings: SettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!), + launch: LaunchConfiguration( + uiTesting: true, + resetStore: true, + screenshotMode: false, + startRunning: false, + frozenElapsed: nil, + inMemoryStore: true, + timerState: nil, + liveActivityPreview: false + ) + ) + try controller.bootstrap() + return controller + } + + func testRequiredStopResumeResetSequence() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + XCTAssertEqual(controller.snapshot.phase, .running) + time.advance(by: 10) + XCTAssertEqual(controller.displayedElapsed(at: time.now()), 10, accuracy: 0.001) + try controller.stop() + XCTAssertEqual(controller.snapshot.phase, .stopped) + XCTAssertEqual(controller.displayedElapsed(at: time.now()), 10, accuracy: 0.001) + time.advance(by: 20) + XCTAssertEqual(controller.displayedElapsed(at: time.now()), 10, accuracy: 0.001) + try controller.start() + time.advance(by: 5) + XCTAssertEqual(controller.displayedElapsed(at: time.now()), 15, accuracy: 0.001) + try controller.stop() + XCTAssertEqual(controller.displayedElapsed(at: time.now()), 15, accuracy: 0.001) + try controller.reset() + XCTAssertEqual(controller.snapshot.phase, .idle) + XCTAssertEqual(controller.displayedElapsed(at: time.now()), 0, accuracy: 0.001) + let historical = try controller.historicalSessions() + XCTAssertEqual(historical.count, 1) + XCTAssertEqual(historical[0].elapsed(at: historical[0].endedAt ?? time.now()), 15, accuracy: 0.05) + XCTAssertEqual(try controller.allSessions().count, 1) + } + + func testStopDoesNotCreateHistory() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + time.advance(by: 8) + try controller.stop() + XCTAssertTrue(try controller.historicalSessions().isEmpty) + XCTAssertEqual(try controller.allSessions().count, 1) + XCTAssertNil(try controller.allSessions().first?.endedAt) + } + + func testResumeDoesNotCreateSecondSession() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + let sessionID = controller.snapshot.sessionID + try controller.stop() + try controller.start() + XCTAssertEqual(controller.snapshot.sessionID, sessionID) + XCTAssertEqual(try controller.allSessions().count, 1) + } + + func testButtonSemantics() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + XCTAssertEqual(controller.snapshot.phase.leftControl, .lapDisabled) + XCTAssertEqual(controller.snapshot.phase.rightControl, .start) + try controller.start() + XCTAssertEqual(controller.snapshot.phase.leftControl, .lap) + XCTAssertEqual(controller.snapshot.phase.rightControl, .stop) + try controller.stop() + XCTAssertEqual(controller.snapshot.phase.leftControl, .reset) + XCTAssertEqual(controller.snapshot.phase.rightControl, .start) + } + + func testResetWhileRunningArchivesAndClears() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let live = NullLiveActivityManager() + let controller = SessionController( + context: ModelContext(try PersistenceController.makeContainer(inMemory: true)), + timeSource: time, + notifications: RecordingNotificationService(), + liveActivity: live, + settings: SettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!), + launch: LaunchConfiguration(uiTesting: true, resetStore: true, screenshotMode: false, startRunning: false, frozenElapsed: nil, inMemoryStore: true) + ) + try controller.bootstrap() + try controller.start() + time.advance(by: 12) + try controller.reset() + XCTAssertEqual(controller.snapshot.phase, .idle) + XCTAssertEqual(controller.displayedElapsed(at: time.now()), 0, accuracy: 0.001) + XCTAssertEqual(try controller.historicalSessions().count, 1) + XCTAssertEqual(live.dismissed, 1) + } + + func testSaveTimeDoesNotReset() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1_700_000_000)) + let controller = try makeController(time: time) + try controller.start() + time.advance(by: 9) + try controller.saveTime(in: controller.selectedSpace!) + XCTAssertEqual(controller.snapshot.phase, .running) + XCTAssertEqual(controller.displayedElapsed(at: time.now()), 9, accuracy: 0.001) + let saved = try controller.savedTimes(filter: .space(DemoIDs.work)) + XCTAssertEqual(saved.count, 1) + XCTAssertEqual(saved[0].elapsed, 9, accuracy: 0.001) + XCTAssertTrue(saved[0].name.contains("Work")) + XCTAssertTrue(try controller.savedTimes(filter: .all).count == 1) + XCTAssertTrue(try controller.savedTimes(filter: .space(DemoIDs.chores)).isEmpty) + } +} diff --git a/ProductivityTrackerTests/TaskIntervalTests.swift b/ProductivityTrackerTests/TaskIntervalTests.swift new file mode 100644 index 0000000..893b9ec --- /dev/null +++ b/ProductivityTrackerTests/TaskIntervalTests.swift @@ -0,0 +1,205 @@ +import XCTest +import SwiftData +@testable import ProductivityTracker + +@MainActor +final class TaskIntervalTests: XCTestCase { + private func makeController(time: ControllableTimeSource) throws -> SessionController { + return try SessionController( + context: ModelContext(try PersistenceController.makeContainer(inMemory: true)), + timeSource: time, + notifications: RecordingNotificationService(), + liveActivity: NullLiveActivityManager(), + settings: SettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!), + launch: LaunchConfiguration( + uiTesting: true, + resetStore: true, + screenshotMode: false, + startRunning: false, + frozenElapsed: nil, + inMemoryStore: true, + timerState: nil, + liveActivityPreview: false + ) + ).bootstrapAndReturn() + } + + func testFirstTaskStarts() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + XCTAssertEqual(controller.activeTask?.name, "Deep Work") + XCTAssertEqual(controller.snapshot.phase, .running) + } + + func testLapClosesOldAndStartsNext() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + time.advance(by: 10) + try controller.lap() + XCTAssertEqual(controller.activeTask?.name, "Research") + let session = try controller.allSessions().first + let closed = session?.intervals.filter { $0.endedAt != nil } ?? [] + let open = session?.intervals.filter { $0.isOpen } ?? [] + XCTAssertEqual(closed.count, 1) + XCTAssertEqual(open.count, 1) + XCTAssertEqual(closed.first?.task?.name, "Deep Work") + XCTAssertEqual(open.first?.task?.name, "Research") + XCTAssertEqual(controller.snapshot.elapsed(at: time.now()), 10, accuracy: 0.001) + } + + func testManualTaskSelectionWhileRunning() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + let email = controller.selectedTasks.first { $0.name == "Email" }! + try controller.selectTask(email) + XCTAssertEqual(controller.activeTask?.name, "Email") + let intervals = try controller.allSessions().first?.intervals ?? [] + XCTAssertEqual(intervals.filter(\.isOpen).count, 1) + XCTAssertEqual(intervals.filter(\.isOpen).first?.task?.name, "Email") + XCTAssertEqual(intervals.filter { !$0.isOpen }.count, 1) + } + + func testSameTaskTapDoesNotDuplicateInterval() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + let deep = controller.selectedTasks.first { $0.name == "Deep Work" }! + try controller.selectTask(deep) + XCTAssertEqual(try controller.allSessions().first?.intervals.count, 1) + } + + func testSelectTaskWhileIdleDoesNotStart() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + let email = controller.selectedTasks.first { $0.name == "Email" }! + try controller.selectTask(email) + XCTAssertEqual(controller.snapshot.phase, .idle) + XCTAssertEqual(controller.snapshot.currentTaskID, email.id) + XCTAssertTrue(try controller.allSessions().isEmpty) + try controller.start() + XCTAssertEqual(controller.activeTask?.name, "Email") + } + + func testSelectTaskWhileStoppedThenResume() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + time.advance(by: 4) + try controller.stop() + let email = controller.selectedTasks.first { $0.name == "Email" }! + try controller.selectTask(email) + XCTAssertEqual(controller.snapshot.phase, .stopped) + XCTAssertEqual(controller.displayedElapsed(at: time.now()), 4, accuracy: 0.001) + try controller.start() + let open = try controller.allSessions().first?.intervals.filter(\.isOpen) ?? [] + XCTAssertEqual(open.count, 1) + XCTAssertEqual(open.first?.task?.name, "Email") + } + + func testNoOverlappingActiveIntervals() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + try controller.lap() + try controller.lap() + let open = try controller.allSessions().first?.intervals.filter(\.isOpen) ?? [] + XCTAssertEqual(open.count, 1) + } + + func testStopClosesActiveIntervalWithoutArchiving() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + time.advance(by: 5) + try controller.stop() + let intervals = try controller.allSessions().first?.intervals ?? [] + XCTAssertTrue(intervals.allSatisfy { !$0.isOpen }) + XCTAssertEqual(controller.snapshot.phase, .stopped) + XCTAssertNil(try controller.allSessions().first?.endedAt) + } + + func testLapWrapsToFirstTask() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + try controller.lap() + try controller.lap() + try controller.lap() + XCTAssertEqual(controller.activeTask?.name, "Deep Work") + } + + func testDeleteActiveTaskContinuesWithoutFabricating() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + let deep = controller.selectedTasks.first { $0.name == "Deep Work" }! + try controller.deleteTask(deep) + XCTAssertEqual(controller.activeTask?.name, "Research") + XCTAssertEqual(try controller.allSessions().first?.intervals.filter(\.isOpen).count, 1) + for task in controller.selectedTasks { + try controller.deleteTask(task) + } + XCTAssertNil(controller.activeTask) + XCTAssertEqual(controller.snapshot.phase, .running) + } + + func testRenameAndReorderTasks() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + let space = controller.selectedSpace! + let deep = space.allTasksSorted[0] + try controller.renameTask(deep, to: "Focus") + XCTAssertEqual(space.allTasksSorted[0].name, "Focus") + try controller.moveTasks(in: space, from: IndexSet(integer: 0), to: 3) + XCTAssertEqual(space.allTasksSorted.map(\.name), ["Research", "Email", "Focus"]) + try controller.setTaskEnabled(space.allTasksSorted[0], isEnabled: false) + XCTAssertEqual(controller.selectedTasks.map(\.name), ["Email", "Focus"]) + } + + func testCompletingActiveRunningTaskMovesToNextIncompleteTask() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + try controller.start() + let deep = controller.selectedTasks.first { $0.name == "Deep Work" }! + try controller.setTaskCompleted(deep, isCompleted: true) + XCTAssertEqual(controller.activeTask?.name, "Research") + let open = try controller.allSessions().first?.intervals.filter(\.isOpen) ?? [] + XCTAssertEqual(open.count, 1) + XCTAssertEqual(open.first?.task?.name, "Research") + } + + func testCompletedTasksAreNotSelectableForTiming() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + let deep = controller.selectedTasks.first { $0.name == "Deep Work" }! + let research = controller.selectedTasks.first { $0.name == "Research" }! + try controller.setTaskCompleted(deep, isCompleted: true) + try controller.selectTask(deep) + XCTAssertNotEqual(controller.snapshot.currentTaskID, deep.id) + XCTAssertEqual(controller.snapshot.currentTaskID, research.id) + try controller.start() + XCTAssertEqual(controller.activeTask?.name, "Research") + } + + func testUncompletingTaskRestoresTimingAvailability() throws { + let time = ControllableTimeSource(now: Date(timeIntervalSince1970: 1000)) + let controller = try makeController(time: time) + let deep = controller.selectedTasks.first { $0.name == "Deep Work" }! + try controller.setTaskCompleted(deep, isCompleted: true) + try controller.setTaskCompleted(deep, isCompleted: false) + try controller.start() + try controller.selectTask(deep) + XCTAssertEqual(controller.activeTask?.name, "Deep Work") + XCTAssertTrue(deep.isCompleted == false) + } +} + +private extension SessionController { + func bootstrapAndReturn() throws -> SessionController { + try bootstrap() + return self + } +} diff --git a/ProductivityTrackerTests/TimerEngineTests.swift b/ProductivityTrackerTests/TimerEngineTests.swift new file mode 100644 index 0000000..b3a0407 --- /dev/null +++ b/ProductivityTrackerTests/TimerEngineTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import ProductivityTracker + +@MainActor +final class TimerEngineTests: XCTestCase { + private let t0 = Date(timeIntervalSince1970: 1_700_000_000) + + func testStartFromIdle() { + let engine = TimerEngine() + engine.start(now: t0, sessionID: UUID()) + XCTAssertEqual(engine.snapshot.phase, .running) + XCTAssertEqual(engine.snapshot.elapsed(at: t0.addingTimeInterval(12.5)), 12.5, accuracy: 0.0001) + } + + func testStopFreezesElapsed() { + let engine = TimerEngine() + engine.start(now: t0, sessionID: UUID()) + engine.stop(now: t0.addingTimeInterval(31.42)) + XCTAssertEqual(engine.snapshot.phase, .stopped) + XCTAssertEqual(engine.snapshot.elapsed(at: t0.addingTimeInterval(100)), 31.42, accuracy: 0.0001) + } + + func testStartAfterStopResumesSameSession() { + let engine = TimerEngine() + let session = UUID() + engine.start(now: t0, sessionID: session) + engine.stop(now: t0.addingTimeInterval(10)) + engine.start(now: t0.addingTimeInterval(30), sessionID: UUID()) + XCTAssertEqual(engine.snapshot.sessionID, session) + XCTAssertEqual(engine.snapshot.elapsed(at: t0.addingTimeInterval(35)), 15, accuracy: 0.0001) + } + + func testResetClearsDisplay() { + let engine = TimerEngine() + engine.start(now: t0, sessionID: UUID()) + engine.stop(now: t0.addingTimeInterval(8)) + engine.resetDisplay() + XCTAssertEqual(engine.snapshot.phase, .idle) + XCTAssertEqual(engine.snapshot.elapsed(at: t0.addingTimeInterval(40)), 0, accuracy: 0.0001) + XCTAssertNil(engine.snapshot.sessionID) + } + + func testStartWhileRunningIsNoOp() { + let engine = TimerEngine() + let session = UUID() + engine.start(now: t0, sessionID: session) + engine.start(now: t0.addingTimeInterval(1), sessionID: UUID()) + XCTAssertEqual(engine.snapshot.sessionID, session) + XCTAssertEqual(engine.snapshot.elapsed(at: t0.addingTimeInterval(5)), 5, accuracy: 0.0001) + } + + func testBackgroundElapsedUsesTimestamps() { + let engine = TimerEngine() + engine.start(now: t0, sessionID: UUID()) + XCTAssertEqual(engine.snapshot.elapsed(at: t0.addingTimeInterval(3600)), 3600, accuracy: 0.001) + } + + func testRestoration() { + let engine = TimerEngine() + let session = UUID() + engine.restore( + TimerSnapshot( + phase: .running, + spaceID: UUID(), + sessionID: session, + currentTaskID: nil, + startedAt: t0.addingTimeInterval(20), + accumulatedBeforeCurrentRun: 20, + lastTick: t0, + liveActivityID: nil + ) + ) + XCTAssertEqual(engine.snapshot.elapsed(at: t0.addingTimeInterval(30)), 30, accuracy: 0.0001) + } + + func testPausedPersistenceMapsToStopped() { + XCTAssertEqual(TimerPhase(persisted: "paused"), .stopped) + XCTAssertEqual(TimerPhase(persisted: "stopped"), .stopped) + } + + func testFormatter() { + XCTAssertEqual(ElapsedFormatter.stopwatch(31.42), "00:31.42") + XCTAssertEqual(ElapsedFormatter.stopwatch(0), "00:00.00") + XCTAssertEqual(ElapsedFormatter.stopwatch(3661.07), "1:01:01.07") + } +} diff --git a/ProductivityTrackerUITests/ProductivityTrackerUITests.swift b/ProductivityTrackerUITests/ProductivityTrackerUITests.swift new file mode 100644 index 0000000..db71ab9 --- /dev/null +++ b/ProductivityTrackerUITests/ProductivityTrackerUITests.swift @@ -0,0 +1,279 @@ +import XCTest + +final class ProductivityTrackerUITests: XCTestCase { + private var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["-UITests", "-ResetStore", "-InMemoryStore"] + app.launch() + } + + func testLaunchShowsDefaultSpace() { + let space = app.descendants(matching: .any)["space-name"].firstMatch + XCTAssertTrue(space.waitForExistence(timeout: 10)) + XCTAssertEqual(space.label, "Work") + XCTAssertTrue(app.descendants(matching: .any)["space-pager"].firstMatch.waitForExistence(timeout: 5)) + XCTAssertTrue(app.descendants(matching: .any)["timer-card"].firstMatch.waitForExistence(timeout: 5)) + XCTAssertTrue(app.descendants(matching: .any)["task-panel"].exists) + XCTAssertTrue(app.descendants(matching: .any)["start-stop-button"].firstMatch.waitForExistence(timeout: 5)) + let lap = app.descendants(matching: .any)["lap-button"].firstMatch + XCTAssertTrue(lap.exists) + XCTAssertFalse(lap.isEnabled) + XCTAssertFalse(app.descendants(matching: .any)["glass-surface"].firstMatch.exists) + XCTAssertTrue(app.staticTexts["stopwatch-display"].waitForExistence(timeout: 2)) + XCTAssertTrue(app.descendants(matching: .any)["save-time-button"].firstMatch.exists) + } + + func testSwipeTimerOrTaskPanelChangesSpace() { + let space = app.descendants(matching: .any)["space-name"].firstMatch + XCTAssertTrue(space.waitForExistence(timeout: 10)) + XCTAssertEqual(space.label, "Work") + let pager = app.descendants(matching: .any)["space-pager"].firstMatch + XCTAssertTrue(pager.waitForExistence(timeout: 5)) + + swipe(element: pager, from: 0.85, to: 0.15) + XCTAssertTrue(space.waitUntilLabelEquals("Chores", timeout: 3)) + + let panel = app.descendants(matching: .any)["task-panel"].firstMatch + XCTAssertTrue(panel.waitForExistence(timeout: 2)) + swipe(element: panel, from: 0.85, to: 0.15) + XCTAssertTrue(space.waitUntilLabelEquals("Personal", timeout: 3)) + } + + func testSwipePastLastSpaceShowsAddSpacePage() { + let pager = app.descendants(matching: .any)["space-pager"].firstMatch + XCTAssertTrue(pager.waitForExistence(timeout: 5)) + swipe(element: pager, from: 0.92, to: 0.05) + swipe(element: pager, from: 0.92, to: 0.05) + swipe(element: pager, from: 0.92, to: 0.05) + XCTAssertTrue(app.descendants(matching: .any)["add-space-page"].firstMatch.waitForExistence(timeout: 4)) + XCTAssertTrue(app.descendants(matching: .any)["create-space-button"].firstMatch.exists) + } + + func testTapSpaceNameOpensEditor() { + let space = app.descendants(matching: .any)["space-name"].firstMatch + XCTAssertTrue(space.waitForExistence(timeout: 10)) + space.tap() + XCTAssertTrue(app.descendants(matching: .any)["space-editor"].firstMatch.waitForExistence(timeout: 4)) + } + + func testInlineAddTask() { + let inline = app.descendants(matching: .any)["add-task-inline"].firstMatch + XCTAssertTrue(inline.waitForExistence(timeout: 5)) + inline.tap() + inline.typeText("New Task") + app.descendants(matching: .any)["add-task-empty"].firstMatch.tap() + XCTAssertTrue(app.descendants(matching: .any)["task-row-New Task"].firstMatch.waitForExistence(timeout: 3)) + } + + func testDoubleTapTaskTogglesCompletion() { + let deep = app.descendants(matching: .any)["task-row-Deep Work"].firstMatch + XCTAssertTrue(deep.waitForExistence(timeout: 5)) + deep.doubleTap() + XCTAssertTrue(deep.label.lowercased().contains("completed")) + deep.doubleTap() + XCTAssertFalse(deep.label.lowercased().contains("completed")) + } + + private func control(_ identifier: String) -> XCUIElement { + app.descendants(matching: .any)[identifier].firstMatch + } + + func testStartStopResetLabels() { + let startStop = control("start-stop-button") + XCTAssertTrue(startStop.waitForExistence(timeout: 5)) + XCTAssertEqual(startStop.label, "Start") + startStop.tap() + XCTAssertTrue(startStop.waitUntilLabelEquals("Stop", timeout: 3)) + XCTAssertEqual(app.staticTexts["stopwatch-display"].value as? String, "running") + XCTAssertTrue(control("lap-button").isEnabled) + startStop.tap() + XCTAssertEqual(app.staticTexts["stopwatch-display"].value as? String, "stopped") + XCTAssertEqual(startStop.label, "Start") + XCTAssertTrue(control("reset-button").waitForExistence(timeout: 2)) + XCTAssertEqual(control("reset-button").label, "Reset") + } + + func testStartElapsedLapStop() { + control("start-stop-button").tap() + XCTAssertTrue(control("start-stop-button").waitUntilLabelEquals("Stop", timeout: 3)) + XCTAssertTrue(app.staticTexts["stopwatch-display"].waitForExistence(timeout: 2)) + XCTAssertEqual(app.staticTexts["stopwatch-display"].value as? String, "running") + control("lap-button").tap() + XCTAssertTrue(app.descendants(matching: .any)["task-row-Research"].firstMatch.exists) + control("start-stop-button").tap() + XCTAssertEqual(app.staticTexts["stopwatch-display"].value as? String, "stopped") + } + + func testTaskTapSelectsTask() { + control("start-stop-button").tap() + XCTAssertTrue(control("start-stop-button").waitUntilLabelEquals("Stop", timeout: 3)) + let email = app.descendants(matching: .any)["task-row-Email"].firstMatch + XCTAssertTrue(email.waitForExistence(timeout: 2)) + email.tap() + XCTAssertTrue(email.waitUntilSelected(timeout: 3)) + } + + func testLongPressLapPresentsTaskPickerWithoutLapping() { + control("start-stop-button").tap() + XCTAssertTrue(control("start-stop-button").waitUntilLabelEquals("Stop", timeout: 3)) + let deep = app.descendants(matching: .any)["task-row-Deep Work"].firstMatch + XCTAssertTrue(deep.waitForExistence(timeout: 2)) + control("lap-button").press(forDuration: 1.0) + let picker = app.otherElements["task-picker"].firstMatch + let emailChoice = app.buttons["task-choice-Email"].firstMatch + XCTAssertTrue(picker.waitForExistence(timeout: 5) || emailChoice.waitForExistence(timeout: 5)) + XCTAssertFalse(app.descendants(matching: .any)["task-row-Research"].firstMatch.isSelected) + if app.buttons["Close"].exists { + app.buttons["Close"].tap() + } else { + app.swipeDown() + } + XCTAssertTrue(deep.isSelected || !app.descendants(matching: .any)["task-row-Research"].firstMatch.isSelected) + } + + func testSettingsHistoryAndSpaceEditor() { + app.buttons["settings-button"].tap() + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 3) || app.otherElements["settings-screen"].waitForExistence(timeout: 3)) + app.buttons["Manage Spaces"].tap() + XCTAssertTrue(app.descendants(matching: .any)["space-row-Work"].firstMatch.waitForExistence(timeout: 3)) + app.descendants(matching: .any)["space-row-Work"].firstMatch.tap() + XCTAssertTrue(app.descendants(matching: .any)["space-editor"].firstMatch.waitForExistence(timeout: 3)) + app.navigationBars.buttons.firstMatch.tap() + app.navigationBars.buttons.firstMatch.tap() + app.buttons["Session History"].tap() + XCTAssertTrue(app.navigationBars["History"].waitForExistence(timeout: 3) || app.otherElements["history-screen"].waitForExistence(timeout: 3)) + } + + func testControlsAreAboveHomeIndicator() { + let start = app.descendants(matching: .any)["start-stop-button"].firstMatch + XCTAssertTrue(start.waitForExistence(timeout: 5)) + let frame = start.frame + let window = app.windows.firstMatch.frame + XCTAssertGreaterThan(frame.minY, 40) + XCTAssertLessThan(frame.maxY, window.maxY - 12) + } + + func testPersistenceAcrossRelaunch() { + app.terminate() + app.launchArguments = ["-UITests", "-ResetStore", "-PersistStore"] + app.launch() + XCTAssertTrue(app.descendants(matching: .any)["start-stop-button"].firstMatch.waitForExistence(timeout: 8)) + app.descendants(matching: .any)["start-stop-button"].firstMatch.tap() + XCTAssertTrue(app.descendants(matching: .any)["start-stop-button"].firstMatch.waitUntilLabelEquals("Stop", timeout: 3)) + let runningValue = app.staticTexts["stopwatch-display"].value as? String + XCTAssertEqual(runningValue, "running") + app.terminate() + app.launchArguments = ["-UITests", "-PersistStore"] + app.launch() + XCTAssertTrue(app.descendants(matching: .any)["space-name"].firstMatch.waitForExistence(timeout: 5)) + XCTAssertEqual(app.staticTexts["stopwatch-display"].value as? String, "running") + } + + private func swipe(element: XCUIElement, from startX: CGFloat, to endX: CGFloat) { + let start = element.coordinate(withNormalizedOffset: CGVector(dx: startX, dy: 0.5)) + let end = element.coordinate(withNormalizedOffset: CGVector(dx: endX, dy: 0.5)) + start.press(forDuration: 0.08, thenDragTo: end) + } +} + +extension XCUIElement { + func waitUntilLabelEquals(_ label: String, timeout: TimeInterval) -> Bool { + let predicate = NSPredicate(format: "label == %@", label) + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } + + func waitUntilSelected(timeout: TimeInterval) -> Bool { + let predicate = NSPredicate(format: "isSelected == true") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } +} + +final class ScreenshotUITests: XCTestCase { + func testCaptureDeterministicScreens() throws { + let screenshotDir = ProcessInfo.processInfo.environment["SCREENSHOT_DIR"] ?? NSTemporaryDirectory() + try FileManager.default.createDirectory(atPath: screenshotDir, withIntermediateDirectories: true) + + capture(arguments: ["-UITests", "-ResetStore", "-InMemoryStore", "-ScreenshotMode", "-TimerState", "idle"], name: "01-timer-idle", directory: screenshotDir) + capture(arguments: ["-UITests", "-ResetStore", "-InMemoryStore", "-ScreenshotMode", "-TimerState", "running"], environment: ["UITEST_ELAPSED": "31.42"], name: "02-timer-running", directory: screenshotDir) + + let running = launch(arguments: ["-UITests", "-ResetStore", "-InMemoryStore", "-ScreenshotMode", "-TimerState", "running"], environment: ["UITEST_ELAPSED": "31.42"]) + running.descendants(matching: .any)["start-stop-button"].firstMatch.tap() + save(running.screenshot(), name: "03-timer-stopped", directory: screenshotDir) + running.terminate() + + let pickerApp = launch(arguments: ["-UITests", "-ResetStore", "-InMemoryStore", "-ScreenshotMode", "-TimerState", "running"], environment: ["UITEST_ELAPSED": "31.42"]) + pickerApp.descendants(matching: .any)["lap-button"].firstMatch.press(forDuration: 0.8) + sleep(1) + save(XCUIScreen.main.screenshot(), name: "04-task-picker", directory: screenshotDir) + pickerApp.terminate() + + let settingsApp = launch(arguments: ["-UITests", "-ResetStore", "-InMemoryStore", "-ScreenshotMode", "-TimerState", "idle"]) + settingsApp.buttons["settings-button"].tap() + XCTAssertTrue(settingsApp.navigationBars["Settings"].waitForExistence(timeout: 3) || settingsApp.otherElements["settings-screen"].waitForExistence(timeout: 3)) + save(XCUIScreen.main.screenshot(), name: "05-settings", directory: screenshotDir) + settingsApp.buttons["Manage Spaces"].tap() + settingsApp.descendants(matching: .any)["space-row-Work"].firstMatch.tap() + XCTAssertTrue(settingsApp.descendants(matching: .any)["space-editor"].firstMatch.waitForExistence(timeout: 3)) + save(XCUIScreen.main.screenshot(), name: "06-space-editor", directory: screenshotDir) + if settingsApp.buttons["Edit"].exists { + settingsApp.buttons["Edit"].tap() + save(XCUIScreen.main.screenshot(), name: "07-task-reorder", directory: screenshotDir) + } + settingsApp.navigationBars.buttons.firstMatch.tap() + settingsApp.navigationBars.buttons.firstMatch.tap() + settingsApp.buttons["Session History"].tap() + save(XCUIScreen.main.screenshot(), name: "08-history", directory: screenshotDir) + settingsApp.terminate() + + let addSpaceApp = launch(arguments: ["-UITests", "-ResetStore", "-InMemoryStore", "-ScreenshotMode", "-TimerState", "idle"]) + let pager = addSpaceApp.descendants(matching: .any)["space-pager"].firstMatch + XCTAssertTrue(pager.waitForExistence(timeout: 5)) + swipe(element: pager, from: 0.92, to: 0.05) + swipe(element: pager, from: 0.92, to: 0.05) + swipe(element: pager, from: 0.92, to: 0.05) + XCTAssertTrue(addSpaceApp.descendants(matching: .any)["add-space-page"].firstMatch.waitForExistence(timeout: 4)) + save(addSpaceApp.screenshot(), name: "10-add-space", directory: screenshotDir) + addSpaceApp.terminate() + + let live = launch(arguments: ["-UITests", "-ResetStore", "-InMemoryStore", "-LiveActivityPreview"]) + XCTAssertTrue(live.descendants(matching: .any)["live-activity-preview"].firstMatch.waitForExistence(timeout: 5)) + save(live.screenshot(), name: "09-live-activity-previews", directory: screenshotDir) + live.terminate() + } + + private func launch(arguments: [String], environment: [String: String] = [:]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = arguments + app.launchEnvironment = environment + app.launch() + _ = app.descendants(matching: .any)["space-name"].firstMatch.waitForExistence(timeout: 5) + || app.descendants(matching: .any)["live-activity-preview"].firstMatch.waitForExistence(timeout: 5) + return app + } + + private func capture(arguments: [String], environment: [String: String] = [:], name: String, directory: String) { + let app = launch(arguments: arguments, environment: environment) + save(app.screenshot(), name: name, directory: directory) + app.terminate() + } + + private func save(_ screenshot: XCUIScreenshot, name: String, directory: String) { + let url = URL(fileURLWithPath: directory).appendingPathComponent("\(name).png") + try? screenshot.pngRepresentation.write(to: url) + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + private func swipe(element: XCUIElement, from startX: CGFloat, to endX: CGFloat) { + let start = element.coordinate(withNormalizedOffset: CGVector(dx: startX, dy: 0.5)) + let end = element.coordinate(withNormalizedOffset: CGVector(dx: endX, dy: 0.5)) + start.press(forDuration: 0.08, thenDragTo: end) + } +} diff --git a/README.md b/README.md index c90825f..73ce17b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,99 @@ # Conduit -A project by arahe-dev. +Private, local iPhone stopwatch for timing work across Spaces and tasks. + +Home screen name: **Conduit**. Bundle ID remains `com.arahe.ProductivityTracker` (Xcode target and `.app` payload names are unchanged). + +The product is the timer. Spaces, tasks, history, Live Activities, Shortcuts, and notifications exist only to keep that timer useful. + +There is no account, no network requirement after install, no analytics, and no backend. + +## Architecture + +See [ARCHITECTURE.md](ARCHITECTURE.md). + +SwiftUI + SwiftData + a timestamp-based timer state machine. Elapsed time is always `now - timestamps`, never a once-per-second counter. + +## Project structure + +``` +project.yml +ProductivityTracker/ App, models, timer, views, intents +ProductivityTrackerLiveActivity/ +ProductivityTrackerTests/ +ProductivityTrackerUITests/ +Scripts/ci/ XcodeGen, simulator, IPA packaging +Scripts/windows/ IPA download helper (no credentials) +.github/workflows/ios-ci.yml +``` + +## Requirements + +- iOS 26.0 +- Xcode 26 (CI uses the GitHub-hosted `macos-26` image, currently Xcode 26.6 / iOS 26.5 SDK) +- XcodeGen 2.46.0 (pinned in `.xcodegen-version`) + +## Why XcodeGen + +The day-to-day environment is Windows + Cursor Cloud (Linux). The Xcode project is generated in GitHub Actions from `project.yml` so nobody has to edit `project.pbxproj` by hand. + +## Development workflow + +1. Edit Swift sources and `project.yml` on the Linux cloud agent or Windows. +2. Commit and push. +3. GitHub Actions on `macos-26` generates the Xcode project, compiles, tests, captures screenshots, and packages an unsigned IPA. +4. Download `conduit-iOS-device-unsigned`. +5. Sign and install locally on Windows. See [INSTALL_WINDOWS.md](INSTALL_WINDOWS.md). + +This repository never uses a Mac owned by the app user. CI is the Xcode machine. + +## CI commands + +```bash +bash Scripts/ci/install-xcodegen.sh +xcodegen generate --spec project.yml +xcodebuild -project ProductivityTracker.xcodeproj -scheme ProductivityTracker \ + -destination "id=$SIM_UDID" CODE_SIGNING_ALLOWED=NO test +xcodebuild -project ProductivityTracker.xcodeproj -scheme ProductivityTracker \ + -destination "generic/platform=iOS" -configuration Release \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" build +bash Scripts/ci/package-ipa.sh "$APP" artifacts +``` + +## Artifacts + +Workflow: `.github/workflows/ios-ci.yml` +Artifact name: `conduit-iOS-device-unsigned` + +Contains: + +- `conduit-unsigned.ipa` — **UNSIGNED — must be signed with your own Apple Account before installation** (`.app` inside: `Payload/ProductivityTracker.app`) +- `SHA256SUMS.txt` +- `build-metadata.txt` +- simulator screenshots +- `TestResults.xcresult` + +## Free signing + +A free Apple Account can sign the IPA locally (Sideloadly). Apps expire after 7 days and must be refreshed. This project does not use TestFlight or a paid Apple Developer Program membership. + +## Testing + +- Unit tests cover Apple Stopwatch semantics, per-Space ownership, task editing, Live Activity state, persistence, notifications, and JSON import. +- UI tests cover Start/Stop/Reset labels, full-page Space paging (including the trailing compose page), long-press Lap without advancing, Space editor, and screenshot/Live Activity preview canvases (`-ScreenshotMode`, `-TimerState`, `-LiveActivityPreview`). + +## Screenshots + +CI writes PNGs under `artifacts/screenshots` when `SCREENSHOT_DIR` is set. After a green run they are in the Actions artifact. + +## Known limitations + +- Live Activity explicit Stop/Start/Reset controls use Live Activity intents with `openAppWhenRun = false`. A normal tap on the activity surface is intended only to open the app; it must not pause or resume. Physical Lock Screen routing still needs a real iPhone. +- Distraction detection is a Shortcuts approximation (`Distraction Started` / `Distraction Ended`), not Screen Time / FamilyControls. +- The app cannot force system Focus on. A Focus Filter can select a Space when a Focus you configured becomes active. +- Unsigned CI artifacts cannot be installed until you sign them with your Apple Account on your computer. +- The timer UI prefers dark appearance, matching Clock’s stopwatch presentation. + +## Visual references + +The original annotated concept images were not in the git clone. See `Design/references/README.md`. App icon: liquid-glass amber **C** / conduit mark (not the earlier egg-timer dial). diff --git a/Scripts/ci/install-xcodegen.sh b/Scripts/ci/install-xcodegen.sh new file mode 100755 index 0000000..570fa07 --- /dev/null +++ b/Scripts/ci/install-xcodegen.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +VERSION="$(tr -d '[:space:]' < .xcodegen-version)" +PREFIX="${RUNNER_TEMP:-/tmp}/xcodegen-${VERSION}" +mkdir -p "$PREFIX" +ARCHIVE="$PREFIX/xcodegen.zip" +curl -fsSL "https://github.com/yonaskolb/XcodeGen/releases/download/${VERSION}/xcodegen.zip" -o "$ARCHIVE" +rm -rf "$PREFIX/extracted" +mkdir -p "$PREFIX/extracted" +unzip -q "$ARCHIVE" -d "$PREFIX/extracted" +BIN="$(find "$PREFIX/extracted" -type f -name xcodegen | head -n 1)" +if [[ -z "$BIN" ]]; then + echo "XcodeGen binary not found in release archive" >&2 + exit 1 +fi +install -m 755 "$BIN" /usr/local/bin/xcodegen +xcodegen --version diff --git a/Scripts/ci/package-ipa.sh b/Scripts/ci/package-ipa.sh new file mode 100755 index 0000000..669684f --- /dev/null +++ b/Scripts/ci/package-ipa.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail +APP_PATH="$1" +OUT_DIR="$2" +mkdir -p "$OUT_DIR/Payload" +rm -rf "$OUT_DIR/Payload/"* +cp -R "$APP_PATH" "$OUT_DIR/Payload/ProductivityTracker.app" +( + cd "$OUT_DIR" + rm -f conduit-unsigned.ipa ProductivityTracker-unsigned.ipa + zip -qry conduit-unsigned.ipa Payload +) +python3 - <<'PY' "$OUT_DIR/conduit-unsigned.ipa" "$OUT_DIR/SHA256SUMS.txt" +import hashlib, sys +from pathlib import Path +ipa = Path(sys.argv[1]) +digest = hashlib.sha256(ipa.read_bytes()).hexdigest() +Path(sys.argv[2]).write_text(f"{digest} {ipa.name}\n") +print(digest) +PY +python3 - <<'PY' "$OUT_DIR/conduit-unsigned.ipa" +import sys, zipfile +from pathlib import Path +ipa = Path(sys.argv[1]) +with zipfile.ZipFile(ipa) as zf: + names = zf.namelist() +print("IPA entries:") +for name in names: + print(name) +assert any(name == "Payload/ProductivityTracker.app/" or name.startswith("Payload/ProductivityTracker.app/") for name in names), "Missing app payload" +assert any("ProductivityTracker.app/Info.plist" in name for name in names), "Missing Info.plist" +appex = [name for name in names if name.endswith(".appex/") or ".appex/" in name] +print("appex entries:", appex) +PY diff --git a/Scripts/ci/select-simulator.sh b/Scripts/ci/select-simulator.sh new file mode 100755 index 0000000..610c767 --- /dev/null +++ b/Scripts/ci/select-simulator.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 - <<'PY' +import json, os, subprocess, sys + +raw = subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"], text=True) +data = json.loads(raw) +preferred_models = ["iPhone 17", "iPhone 16", "iPhone 16e", "iPhone 17e", "iPhone Air"] + +candidates = [] +for runtime, devices in data.get("devices", {}).items(): + if "iOS" not in runtime and "iphoneos" not in runtime.lower() and "iOS-" not in runtime: + # simctl JSON keys look like com.apple.CoreSimulator.SimRuntime.iOS-26-5 + if "iOS-" not in runtime: + continue + for device in devices: + if not device.get("isAvailable", True): + continue + name = device.get("name", "") + udid = device.get("udid") + if not udid: + continue + candidates.append((runtime, name, udid)) + +def ios_rank(runtime: str) -> tuple: + # Prefer newest iOS runtime. + parts = runtime.replace("iOS-", " ").replace("iOS ", " ").split("-") + nums = [] + for token in runtime.replace(".", "-").split("-"): + if token.isdigit(): + nums.append(int(token)) + return tuple(nums) if nums else (0,) + +def model_rank(name: str) -> int: + try: + return preferred_models.index(name) + except ValueError: + return 100 if name.startswith("iPhone") else 1000 + +iphones = [c for c in candidates if c[1].startswith("iPhone")] +if not iphones: + print("No iPhone simulators found", file=sys.stderr) + sys.exit(1) + +iphones.sort(key=lambda c: (-sum(x * 100 ** i for i, x in enumerate(reversed(ios_rank(c[0])))), model_rank(c[1]), c[1])) +# Prefer preferred model on the newest runtime +best = None +newest = max(ios_rank(c[0]) for c in iphones) +newest_runtime_iphones = [c for c in iphones if ios_rank(c[0]) == newest] +for model in preferred_models: + match = next((c for c in newest_runtime_iphones if c[1] == model), None) + if match: + best = match + break +if best is None: + best = newest_runtime_iphones[0] if newest_runtime_iphones else iphones[0] + +runtime, name, udid = best +print(f"Selected simulator: {name} ({runtime}) {udid}") +env_path = os.environ.get("GITHUB_ENV") +if env_path: + with open(env_path, "a", encoding="utf-8") as fh: + fh.write(f"SIM_NAME={name}\n") + fh.write(f"SIM_OS={runtime}\n") + fh.write(f"SIM_UDID={udid}\n") +else: + print(f"SIM_NAME={name}") + print(f"SIM_OS={runtime}") + print(f"SIM_UDID={udid}") +PY diff --git a/Scripts/windows/fetch-ipa.ps1 b/Scripts/windows/fetch-ipa.ps1 new file mode 100644 index 0000000..0d6bd2f --- /dev/null +++ b/Scripts/windows/fetch-ipa.ps1 @@ -0,0 +1,82 @@ +param( + [string]$Repo = "", + [string]$ArtifactName = "conduit-iOS-device-unsigned", + [string]$OutDir = (Join-Path (Get-Location) "artifacts") +) + +$ErrorActionPreference = "Stop" + +function Require-Command($name) { + if (-not (Get-Command $name -ErrorAction SilentlyContinue)) { + throw "Missing command '$name'. Install GitHub CLI from https://cli.github.com and run 'gh auth login'." + } +} + +function Find-Ipa($root) { + $preferred = Get-ChildItem -Path $root -Recurse -Filter "conduit-unsigned.ipa" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($preferred) { return $preferred } + + $legacy = Get-ChildItem -Path $root -Recurse -Filter "ProductivityTracker-unsigned.ipa" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($legacy) { + Write-Host "Note: using legacy IPA name ProductivityTracker-unsigned.ipa (conduit-unsigned.ipa not found)." + return $legacy + } + + return $null +} + +Require-Command gh + +if (-not $Repo) { + $Repo = (gh repo view --json nameWithOwner --jq .nameWithOwner) +} + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +Write-Host "Looking up latest successful workflow run for $Repo ..." +$runId = gh run list --repo $Repo --workflow "iOS CI" --status success --limit 1 --json databaseId --jq ".[0].databaseId" +if (-not $runId) { + throw "No successful iOS CI run found." +} + +Write-Host "Downloading artifact $ArtifactName from run $runId ..." +try { + gh run download $runId --repo $Repo --name $ArtifactName --dir $OutDir +} catch { + if ($ArtifactName -eq "conduit-iOS-device-unsigned") { + Write-Host "Artifact conduit-iOS-device-unsigned not found; trying legacy ProductivityTracker-iOS-device-unsigned ..." + gh run download $runId --repo $Repo --name "ProductivityTracker-iOS-device-unsigned" --dir $OutDir + } else { + throw + } +} + +$ipa = Find-Ipa $OutDir +if (-not $ipa) { + throw "IPA not found in artifact (expected conduit-unsigned.ipa or ProductivityTracker-unsigned.ipa)." +} + +$sumFile = Get-ChildItem -Path $OutDir -Recurse -Filter "SHA256SUMS.txt" | Select-Object -First 1 +$actual = (Get-FileHash -Algorithm SHA256 $ipa.FullName).Hash.ToLower() +Write-Host "IPA: $($ipa.FullName)" +Write-Host "SHA-256: $actual" + +if ($sumFile) { + $expected = ((Get-Content $sumFile.FullName | Select-Object -First 1) -split "\s+")[0].ToLower() + if ($expected -and $expected -ne $actual) { + throw "Checksum mismatch. Expected $expected" + } + Write-Host "Checksum OK." +} + +Write-Host "" +Write-Host "Next (Sideloadly UI only; this script does not accept Apple credentials):" +Write-Host "1. Connect iPhone and trust the computer" +Write-Host "2. Open Sideloadly" +Write-Host "3. Select the iPhone" +Write-Host "4. Drag $($ipa.Name) into Sideloadly" +Write-Host "5. Sign in with your Apple Account locally and complete 2FA locally" +Write-Host "6. Install, then trust the developer profile on iPhone if asked" +Write-Host "7. Launch Conduit on the home screen (bundle ID com.arahe.ProductivityTracker)" + +Invoke-Item $ipa.DirectoryName diff --git a/artifacts/conduit-iOS-device-unsigned.zip b/artifacts/conduit-iOS-device-unsigned.zip new file mode 100644 index 0000000..f28717f Binary files /dev/null and b/artifacts/conduit-iOS-device-unsigned.zip differ diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..b31c457 --- /dev/null +++ b/project.yml @@ -0,0 +1,126 @@ +name: ProductivityTracker +options: + bundleIdPrefix: com.arahe + deploymentTarget: + iOS: "26.0" + xcodeVersion: "26.0" + createIntermediateGroups: true + defaultConfig: Debug + groupSortPosition: top +settings: + base: + SWIFT_VERSION: "6.0" + IPHONEOS_DEPLOYMENT_TARGET: "26.0" + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_MACCATALYST: "NO" + SWIFT_STRICT_CONCURRENCY: targeted + ENABLE_USER_SCRIPT_SANDBOXING: "NO" + CLANG_ENABLE_MODULES: "YES" + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: "" + CODE_SIGN_IDENTITY: "" + configs: + Debug: + ENABLE_TESTABILITY: YES + SWIFT_OPTIMIZATION_LEVEL: "-Onone" +configs: + Debug: debug + Release: release +targets: + ProductivityTracker: + type: application + platform: iOS + sources: + - path: ProductivityTracker + - path: ProductivityTrackerLiveActivity/ActivityAttributes + - path: ProductivityTrackerLiveActivity/Intents + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.arahe.ProductivityTracker + PRODUCT_NAME: ProductivityTracker + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) APP_TARGET" + INFOPLIST_FILE: ProductivityTracker/Resources/Info.plist + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: Conduit + INFOPLIST_KEY_LSRequiresIPhoneOS: YES + INFOPLIST_KEY_NSSupportsLiveActivities: YES + INFOPLIST_KEY_NSSupportsLiveActivitiesFrequentUpdates: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: "NO" + dependencies: + - target: ProductivityTrackerLiveActivity + embed: true + scheme: + testTargets: + - ProductivityTrackerTests + - ProductivityTrackerUITests + gatherCoverageData: false + ProductivityTrackerLiveActivity: + type: app-extension + platform: iOS + sources: + - path: ProductivityTrackerLiveActivity + - path: ProductivityTracker/Timer/ElapsedFormatter.swift + - path: ProductivityTracker/LiveActivity/LiveActivityPresentation.swift + - path: ProductivityTracker/Models/SpaceTint.swift + - path: ProductivityTracker/Models/SpaceIcon.swift + - path: ProductivityTracker/Views/Spaces/SpaceIconView.swift + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.arahe.ProductivityTracker.LiveActivity + PRODUCT_NAME: ProductivityTrackerLiveActivity + PRODUCT_MODULE_NAME: ProductivityTrackerLiveActivity + INFOPLIST_FILE: ProductivityTrackerLiveActivity/Info.plist + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: Conduit + SKIP_INSTALL: YES + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks" + TARGETED_DEVICE_FAMILY: "1" + ProductivityTrackerTests: + type: bundle.unit-test + platform: iOS + sources: + - path: ProductivityTrackerTests + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.arahe.ProductivityTracker.tests + PRODUCT_NAME: ProductivityTrackerTests + PRODUCT_MODULE_NAME: ProductivityTrackerTests + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ProductivityTracker.app/ProductivityTracker" + BUNDLE_LOADER: "$(TEST_HOST)" + dependencies: + - target: ProductivityTracker + ProductivityTrackerUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: ProductivityTrackerUITests + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.arahe.ProductivityTracker.uitests + PRODUCT_NAME: ProductivityTrackerUITests + PRODUCT_MODULE_NAME: ProductivityTrackerUITests + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: ProductivityTracker + dependencies: + - target: ProductivityTracker +schemes: + ProductivityTracker: + build: + targets: + ProductivityTracker: all + ProductivityTrackerLiveActivity: all + ProductivityTrackerTests: [test] + ProductivityTrackerUITests: [test] + test: + targets: + - ProductivityTrackerTests + - ProductivityTrackerUITests + gatherCoverageData: false