diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..693a291 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(git add *)", + "Bash(git commit -m ' *)", + "Bash(git push *)", + "Bash(xcodebuild build *)", + "WebFetch(domain:api.anthropic.com)", + "Bash(./vendor/bin/sail test *)", + "Bash(swift test *)", + "Bash(tuist generate *)", + "Bash(xcodebuild test *)", + "Bash(xcodebuild -workspace Spark.xcworkspace -list)" + ] + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..770af67 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +Spark is a Tuist-generated native Swift iOS workspace. `Project.swift` defines the app, extension, watch, and package targets; run `tuist generate` after changing target membership or settings. Main app code lives in `SparkApp/Sources/`, grouped by feature (`Today/`, `Settings/`, `Map/`, `Onboarding/`), with assets and icons in `SparkApp/Resources/`. App extensions live under `Extensions/`, watch targets under `Watch/`, and reusable Swift packages under `Packages/` (`SparkKit`, `SparkUI`, `SparkHealth`, `SparkSync`, `SparkLocation`, `SparkIntelligence`). Cross-target app tests are in `Tests/SparkAppTests/`; package tests live in each package's `Tests/` directory. + +## Build, Test, and Development Commands + +- `tuist generate` regenerates `Spark.xcworkspace` from `Project.swift`. +- `open Spark.xcworkspace` opens the generated workspace for Xcode development. +- `xcodebuild build -workspace Spark.xcworkspace -scheme SparkApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' -configuration Debug` builds the main app. +- `cd Packages/SparkKit && swift test` runs the fastest package-level unit tests. +- `xcodebuild -workspace Spark.xcworkspace -scheme SparkApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' -skipPackagePluginValidation -skipMacroValidation test` runs the app test scheme. + +## Coding Style & Naming Conventions + +Use Swift 6.2 with strict concurrency; `Project.swift` treats Swift and GCC warnings as errors. Follow the existing Swift style: four-space indentation, `UpperCamelCase` types, `lowerCamelCase` properties and functions, and feature-focused filenames like `TodayViewModel.swift` or `EventDetailView.swift`. Keep UI in app or extension targets, reusable domain/networking/persistence code in `SparkKit`, and shared visual components in `SparkUI`. + +## Testing Guidelines + +Tests use Swift Testing (`import Testing`, `@Suite`, `@Test`, `#expect`). Name tests after observable behavior, for example `productionEnvironmentPointsAtProductionHost`. Add package tests beside the package they cover, such as `Packages/SparkKit/Tests/SparkKitTests/`, and use `Tests/SparkAppTests/` for workspace-level smoke or integration coverage. + +## Commit & Pull Request Guidelines + +History uses short gitmoji-style subjects, often with scope or phase context, such as `:sparkles: Phase 2 Week 3 D12: Notification preferences` or `:bug: Fix handling bugs`. Keep commits focused and imperative. Pull requests should describe the user-visible change, list testing performed, link the issue or phase task, and include screenshots or recordings for UI changes. + +## Security & Configuration Tips + +Do not commit personal provisioning changes or local backend URLs. Environment overrides belong in the shared App Group `UserDefaults` keys documented in `README.md`; erase those keys to return to production. Treat release credentials such as `SENTRY_AUTH_TOKEN` as environment variables, not source files. diff --git a/CLAUDE.md b/CLAUDE.md index 44f381c..fccea5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Tech Stack - **Language**: Swift 6.2 with strict concurrency enforcement (`SWIFT_STRICT_CONCURRENCY=complete`) -- **Minimum OS**: iOS 26.0 (also watchOS 26.0 for Phase 5) +- **Minimum OS**: iOS 26.4 (also watchOS 26.0 for Phase 5) - **Project generation**: Tuist 4.x (not native Xcode workspace) - **Package management**: SPM (Swift Package Manager) with 6 local packages + Sentry remote dependency - **Data persistence**: SwiftData with App Group shared container @@ -96,18 +96,14 @@ tuist generate This creates `Spark.xcworkspace`. Open in Xcode 26. -### Provisioning (Personal Team Setup) +### Provisioning Each target shares: -- **App Group**: `group.co.cronx.spark` +- **App Group**: `group.co.cronx.sparkapp` - **Keychain access group**: `$(AppIdentifierPrefix)co.cronx.spark` - **Associated domain**: `applinks:spark.cronx.co` -In Xcode, for each target: -1. Select target → Signing & Capabilities → pick your Team -2. Xcode auto-registers App Group, Keychain Sharing, Associated Domains, Push Notifications, HealthKit - -No Project.swift changes needed; `DEVELOPMENT_TEAM` is auto-read from Xcode user settings. +The William Scott development team is declared in `Project.swift`, so `tuist generate` preserves signing automatically. Xcode can then auto-register App Group, Keychain Sharing, Associated Domains, Push Notifications, and HealthKit as needed. ## Build & Test Commands @@ -121,11 +117,11 @@ tuist generate xcodebuild build \ -workspace Spark.xcworkspace \ -scheme SparkApp \ - -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=26.0' \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' \ -configuration Debug # Build from Xcode -# Select SparkApp scheme → iPhone 16 Pro simulator → ⌘B +# Select SparkApp scheme → iPhone 17 Pro simulator → ⌘B ``` ### Test @@ -138,13 +134,13 @@ cd Packages/SparkKit && swift test xcodebuild \ -workspace Spark.xcworkspace \ -scheme SparkApp \ - -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=26.0' \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' \ -skipPackagePluginValidation \ -skipMacroValidation \ test # From Xcode -# Select SparkApp scheme → iPhone 16 Pro simulator → ⌘U +# Select SparkApp scheme → iPhone 17 Pro simulator → ⌘U ``` ### Lint & Code Quality @@ -157,7 +153,7 @@ swiftformat --lint . xcodebuild \ -workspace Spark.xcworkspace \ -scheme SparkApp \ - -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=26.0' \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' \ build \ -skipPackagePluginValidation ``` @@ -167,7 +163,7 @@ xcodebuild \ To point at a local backend instead of `spark.cronx.co`, write to the shared App Group `UserDefaults`: ```swift -let defaults = UserDefaults(suiteName: "group.co.cronx.spark")! +let defaults = UserDefaults(suiteName: "group.co.cronx.sparkapp")! defaults.set("http://192.168.1.42:8000/api/v1/mobile", forKey: "spark.env.baseURL") defaults.set("http://192.168.1.42:8000/oauth/authorize", forKey: "spark.env.oauthURL") defaults.set("lan", forKey: "spark.env.name") @@ -295,7 +291,7 @@ Tests cover: xcodebuild \ -workspace Spark.xcworkspace \ -scheme SparkApp \ - -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=26.0' \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' \ test ``` @@ -312,7 +308,7 @@ Test every family × size class × light/dark × extreme Dynamic Type before eac - Runs on every push to `main` / `dev` and every PR - Caches DerivedData + SPM packages - Runs `swift test` on SparkKit -- Runs `xcodebuild test` on SparkApp (iPhone 16 Pro, iOS 26 simulator) +- Runs `xcodebuild test` on SparkApp (iPhone 17 Pro, iOS 26.4.1 simulator) - Uploads xcresult on failure ## Version & Release @@ -403,4 +399,3 @@ If backend changes compact resource format: - **SwiftData docs**: https://developer.apple.com/swiftdata/ - **ActivityKit docs**: https://developer.apple.com/activitykit/ - **Liquid Glass**: iOS 26 Glass Effect (`GlassEffectContainer`, `.glassEffect()`) - diff --git a/Extensions/SparkControls/Sources/SparkControlsBundle.swift b/Extensions/SparkControls/Sources/SparkControlsBundle.swift index 7fa788e..1dcb5c9 100644 --- a/Extensions/SparkControls/Sources/SparkControlsBundle.swift +++ b/Extensions/SparkControls/Sources/SparkControlsBundle.swift @@ -15,7 +15,7 @@ struct SparkControlsBundle: WidgetBundle { struct QuickCheckInControl: ControlWidget { var body: some ControlWidgetConfiguration { - StaticControlConfiguration(kind: "co.cronx.spark.controls.checkin") { + StaticControlConfiguration(kind: "co.cronx.sparkapp.controls.checkin") { ControlWidgetButton(action: QuickCheckInAction()) { Label("Check In", systemImage: "plus.circle.fill") } @@ -39,7 +39,7 @@ struct QuickCheckInAction: AppIntent { struct OpenTodayControl: ControlWidget { var body: some ControlWidgetConfiguration { - StaticControlConfiguration(kind: "co.cronx.spark.controls.open-today") { + StaticControlConfiguration(kind: "co.cronx.sparkapp.controls.open-today") { ControlWidgetButton(action: OpenTodayAction()) { Label("Open Spark", systemImage: "sparkles") } @@ -60,7 +60,7 @@ struct OpenTodayAction: AppIntent { struct FocusDomainControl: ControlWidget { var body: some ControlWidgetConfiguration { - StaticControlConfiguration(kind: "co.cronx.spark.controls.focus-domain") { + StaticControlConfiguration(kind: "co.cronx.sparkapp.controls.focus-domain") { ControlWidgetButton(action: FocusDomainAction()) { Label("Focus", systemImage: "scope") } diff --git a/Extensions/SparkControls/SparkControls.entitlements b/Extensions/SparkControls/SparkControls.entitlements index 868ed52..5ffae07 100644 --- a/Extensions/SparkControls/SparkControls.entitlements +++ b/Extensions/SparkControls/SparkControls.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.co.cronx.spark + group.co.cronx.sparkapp keychain-access-groups - $(AppIdentifierPrefix)co.cronx.spark + $(AppIdentifierPrefix)co.cronx.sparkapp diff --git a/Extensions/SparkIntents/SparkIntents.entitlements b/Extensions/SparkIntents/SparkIntents.entitlements index 868ed52..5ffae07 100644 --- a/Extensions/SparkIntents/SparkIntents.entitlements +++ b/Extensions/SparkIntents/SparkIntents.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.co.cronx.spark + group.co.cronx.sparkapp keychain-access-groups - $(AppIdentifierPrefix)co.cronx.spark + $(AppIdentifierPrefix)co.cronx.sparkapp diff --git a/Extensions/SparkLiveActivities/SparkLiveActivities.entitlements b/Extensions/SparkLiveActivities/SparkLiveActivities.entitlements index 868ed52..5ffae07 100644 --- a/Extensions/SparkLiveActivities/SparkLiveActivities.entitlements +++ b/Extensions/SparkLiveActivities/SparkLiveActivities.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.co.cronx.spark + group.co.cronx.sparkapp keychain-access-groups - $(AppIdentifierPrefix)co.cronx.spark + $(AppIdentifierPrefix)co.cronx.sparkapp diff --git a/Extensions/SparkNotificationService/Sources/NotificationService.swift b/Extensions/SparkNotificationService/Sources/NotificationService.swift index 7608b3e..b8e9d4d 100644 --- a/Extensions/SparkNotificationService/Sources/NotificationService.swift +++ b/Extensions/SparkNotificationService/Sources/NotificationService.swift @@ -49,7 +49,7 @@ final class NotificationService: UNNotificationServiceExtension, @unchecked Send private func downloadAttachment(from url: URL, notificationID: String) async -> UNNotificationAttachment? { let cacheDir = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: "group.co.cronx.spark")? + .containerURL(forSecurityApplicationGroupIdentifier: "group.co.cronx.sparkapp")? .appendingPathComponent("NotificationMedia", isDirectory: true) ?? FileManager.default.temporaryDirectory diff --git a/Extensions/SparkNotificationService/SparkNotificationService.entitlements b/Extensions/SparkNotificationService/SparkNotificationService.entitlements index 868ed52..5ffae07 100644 --- a/Extensions/SparkNotificationService/SparkNotificationService.entitlements +++ b/Extensions/SparkNotificationService/SparkNotificationService.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.co.cronx.spark + group.co.cronx.sparkapp keychain-access-groups - $(AppIdentifierPrefix)co.cronx.spark + $(AppIdentifierPrefix)co.cronx.sparkapp diff --git a/Extensions/SparkShare/Sources/ShareViewController.swift b/Extensions/SparkShare/Sources/ShareViewController.swift index ab16977..f7a8412 100644 --- a/Extensions/SparkShare/Sources/ShareViewController.swift +++ b/Extensions/SparkShare/Sources/ShareViewController.swift @@ -91,7 +91,7 @@ final class ShareViewController: UIViewController { private func shareImageData(_ data: Data) { let dir = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: "group.co.cronx.spark")? + .containerURL(forSecurityApplicationGroupIdentifier: "group.co.cronx.sparkapp")? .appendingPathComponent("ShareUploads", isDirectory: true) ?? FileManager.default.temporaryDirectory try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) @@ -110,8 +110,24 @@ final class ShareViewController: UIViewController { request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") - let config = URLSessionConfiguration.background(withIdentifier: "co.cronx.spark.share.upload") - config.sharedContainerIdentifier = "group.co.cronx.spark" + let config = URLSessionConfiguration.background(withIdentifier: "co.cronx.sparkapp.share.upload") + config.sharedContainerIdentifier = "group.co.cronx.sparkapp" + let fileSize = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + Task { + await APITelemetry.shared.capture( + APITelemetryEvent( + operation: "http.client.background_upload.schedule", + method: request.httpMethod ?? "POST", + url: APITelemetryRedactor.url(uploadURL), + endpointPath: "/check-ins/media", + requiresAuth: true, + requestHeaders: APITelemetryRedactor.headers(request.allHTTPHeaderFields ?? [:]), + responseSizeBytes: fileSize, + durationMillis: 0, + outcome: .success + ) + ) + } URLSession(configuration: config).uploadTask(with: request, fromFile: fileURL).resume() } @@ -140,8 +156,8 @@ final class ShareViewController: UIViewController { private func syncAccessToken() -> String? { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, - kSecAttrService: "co.cronx.spark.accessToken", - kSecAttrAccessGroup: "$(AppIdentifierPrefix)co.cronx.spark", + kSecAttrService: "co.cronx.sparkapp.accessToken", + kSecAttrAccessGroup: "$(AppIdentifierPrefix)co.cronx.sparkapp", kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne, ] diff --git a/Extensions/SparkShare/SparkShare.entitlements b/Extensions/SparkShare/SparkShare.entitlements index 868ed52..5ffae07 100644 --- a/Extensions/SparkShare/SparkShare.entitlements +++ b/Extensions/SparkShare/SparkShare.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.co.cronx.spark + group.co.cronx.sparkapp keychain-access-groups - $(AppIdentifierPrefix)co.cronx.spark + $(AppIdentifierPrefix)co.cronx.sparkapp diff --git a/Extensions/SparkWidgets/Sources/LockScreenWidgets.swift b/Extensions/SparkWidgets/Sources/LockScreenWidgets.swift index 4c9d943..f87ae05 100644 --- a/Extensions/SparkWidgets/Sources/LockScreenWidgets.swift +++ b/Extensions/SparkWidgets/Sources/LockScreenWidgets.swift @@ -4,7 +4,7 @@ import WidgetKit // MARK: - Circular (sleep ring + steps ring) struct SleepCircularWidget: Widget { - let kind = "co.cronx.spark.widgets.sleep-circular" + let kind = "co.cronx.sparkapp.widgets.sleep-circular" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in @@ -45,7 +45,7 @@ struct SleepCircularView: View { } struct StepsCircularWidget: Widget { - let kind = "co.cronx.spark.widgets.steps-circular" + let kind = "co.cronx.sparkapp.widgets.steps-circular" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in @@ -84,7 +84,7 @@ struct StepsCircularView: View { // MARK: - Rectangular (top metric) struct TopMetricRectangularWidget: Widget { - let kind = "co.cronx.spark.widgets.top-metric-rect" + let kind = "co.cronx.sparkapp.widgets.top-metric-rect" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in @@ -130,7 +130,7 @@ struct TopMetricRectangularView: View { // MARK: - Inline (next event) struct NextEventInlineWidget: Widget { - let kind = "co.cronx.spark.widgets.next-event-inline" + let kind = "co.cronx.sparkapp.widgets.next-event-inline" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in @@ -147,11 +147,14 @@ struct NextEventInlineView: View { var body: some View { let snap = entry.snapshot - if let title = snap.nextEventTitle { - let time = snap.nextEventStart.map { " · \($0)" } ?? "" - Label("\(title)\(time)", systemImage: "calendar") - } else { - Label("No upcoming events", systemImage: "calendar") + Group { + if let title = snap.nextEventTitle { + let time = snap.nextEventStart.map { " · \($0)" } ?? "" + Label("\(title)\(time)", systemImage: "calendar") + } else { + Label("No upcoming events", systemImage: "calendar") + } } + .widgetURL(URL(string: "https://spark.cronx.co/today")) } } diff --git a/Extensions/SparkWidgets/Sources/NextEventWidget.swift b/Extensions/SparkWidgets/Sources/NextEventWidget.swift index 05726d4..af0d572 100644 --- a/Extensions/SparkWidgets/Sources/NextEventWidget.swift +++ b/Extensions/SparkWidgets/Sources/NextEventWidget.swift @@ -2,7 +2,7 @@ import SwiftUI import WidgetKit struct NextEventWidget: Widget { - let kind = "co.cronx.spark.widgets.nextevent" + let kind = "co.cronx.sparkapp.widgets.nextevent" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in diff --git a/Extensions/SparkWidgets/Sources/PlumbingSmokeTestWidget.swift b/Extensions/SparkWidgets/Sources/PlumbingSmokeTestWidget.swift index 48ef858..fbf79a4 100644 --- a/Extensions/SparkWidgets/Sources/PlumbingSmokeTestWidget.swift +++ b/Extensions/SparkWidgets/Sources/PlumbingSmokeTestWidget.swift @@ -7,7 +7,7 @@ import WidgetKit /// Keychain are reachable from an extension before we start building real /// widget content in Phase 3. struct PlumbingSmokeTestWidget: Widget { - let kind: String = "co.cronx.spark.widgets.plumbing" + let kind: String = "co.cronx.sparkapp.widgets.plumbing" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: PlumbingProvider()) { entry in diff --git a/Extensions/SparkWidgets/Sources/SleepScoreWidget.swift b/Extensions/SparkWidgets/Sources/SleepScoreWidget.swift index 52073f5..9f48800 100644 --- a/Extensions/SparkWidgets/Sources/SleepScoreWidget.swift +++ b/Extensions/SparkWidgets/Sources/SleepScoreWidget.swift @@ -2,7 +2,7 @@ import SwiftUI import WidgetKit struct SleepScoreWidget: Widget { - let kind = "co.cronx.spark.widgets.sleep" + let kind = "co.cronx.sparkapp.widgets.sleep" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in diff --git a/Extensions/SparkWidgets/Sources/SpendTodayWidget.swift b/Extensions/SparkWidgets/Sources/SpendTodayWidget.swift index 93e8ebc..9451f09 100644 --- a/Extensions/SparkWidgets/Sources/SpendTodayWidget.swift +++ b/Extensions/SparkWidgets/Sources/SpendTodayWidget.swift @@ -2,7 +2,7 @@ import SwiftUI import WidgetKit struct SpendTodayWidget: Widget { - let kind = "co.cronx.spark.widgets.spend" + let kind = "co.cronx.sparkapp.widgets.spend" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in diff --git a/Extensions/SparkWidgets/Sources/StandByWidget.swift b/Extensions/SparkWidgets/Sources/StandByWidget.swift index 3b12148..c2f6546 100644 --- a/Extensions/SparkWidgets/Sources/StandByWidget.swift +++ b/Extensions/SparkWidgets/Sources/StandByWidget.swift @@ -5,7 +5,7 @@ import WidgetKit /// with large readable text. iOS rotates between multiple systemSmall widgets /// in the StandBy widget carousel automatically. struct StandByWidget: Widget { - let kind = "co.cronx.spark.widgets.standby" + let kind = "co.cronx.sparkapp.widgets.standby" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in diff --git a/Extensions/SparkWidgets/Sources/StepsRingWidget.swift b/Extensions/SparkWidgets/Sources/StepsRingWidget.swift index 3a05d9e..605bb62 100644 --- a/Extensions/SparkWidgets/Sources/StepsRingWidget.swift +++ b/Extensions/SparkWidgets/Sources/StepsRingWidget.swift @@ -2,7 +2,7 @@ import SwiftUI import WidgetKit struct StepsRingWidget: Widget { - let kind = "co.cronx.spark.widgets.steps" + let kind = "co.cronx.sparkapp.widgets.steps" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in diff --git a/Extensions/SparkWidgets/Sources/TodayDashboardWidget.swift b/Extensions/SparkWidgets/Sources/TodayDashboardWidget.swift index 7785650..0e440f4 100644 --- a/Extensions/SparkWidgets/Sources/TodayDashboardWidget.swift +++ b/Extensions/SparkWidgets/Sources/TodayDashboardWidget.swift @@ -5,7 +5,7 @@ import SwiftUI import WidgetKit struct TodayDashboardWidget: Widget { - let kind = "co.cronx.spark.widgets.today-dashboard" + let kind = "co.cronx.sparkapp.widgets.today-dashboard" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in @@ -87,17 +87,19 @@ struct TodayDashboardWidgetView: View { } private func metricTile(icon: String, color: Color, value: String, sub: String, url: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Label(value, systemImage: icon) - .font(.system(size: 15, weight: .bold, design: .rounded)) - .foregroundStyle(color) - .lineLimit(1) - .minimumScaleFactor(0.8) - Text(sub) - .font(.caption2) - .foregroundStyle(.secondary) + Link(destination: URL(string: url)!) { + VStack(alignment: .leading, spacing: 2) { + Label(value, systemImage: icon) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(color) + .lineLimit(1) + .minimumScaleFactor(0.8) + Text(sub) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) } private func anomalyList(_ anomalies: [Anomaly]) -> some View { diff --git a/Extensions/SparkWidgets/Sources/TodayGlanceWidget.swift b/Extensions/SparkWidgets/Sources/TodayGlanceWidget.swift index 6861ae5..1ed03bc 100644 --- a/Extensions/SparkWidgets/Sources/TodayGlanceWidget.swift +++ b/Extensions/SparkWidgets/Sources/TodayGlanceWidget.swift @@ -2,7 +2,7 @@ import SwiftUI import WidgetKit struct TodayGlanceWidget: Widget { - let kind = "co.cronx.spark.widgets.today-glance" + let kind = "co.cronx.sparkapp.widgets.today-glance" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in @@ -21,28 +21,36 @@ struct TodayGlanceWidgetView: View { var body: some View { let snap = entry.snapshot HStack(spacing: 0) { - tileView( - systemImage: "moon.fill", - color: .indigo, - value: snap.sleepScore.map { "\($0)" } ?? "—", - label: "Sleep" - ) + Link(destination: URL(string: "https://spark.cronx.co/metrics/sleep.score")!) { + tileView( + systemImage: "moon.fill", + color: .indigo, + value: snap.sleepScore.map { "\($0)" } ?? "—", + label: "Sleep" + ) + } Divider().frame(maxHeight: 60).opacity(0.3) - tileView( - systemImage: "figure.walk", - color: .green, - value: snap.stepsDisplay, - label: "Steps" - ) + Link(destination: URL(string: "https://spark.cronx.co/metrics/health.steps")!) { + tileView( + systemImage: "figure.walk", + color: .green, + value: snap.stepsDisplay, + label: "Steps" + ) + } Divider().frame(maxHeight: 60).opacity(0.3) - tileView( - systemImage: "creditcard.fill", - color: .orange, - value: snap.spentTodayDisplay ?? "—", - label: "Spend" - ) + Link(destination: URL(string: "https://spark.cronx.co/metrics/money.spend")!) { + tileView( + systemImage: "creditcard.fill", + color: .orange, + value: snap.spentTodayDisplay ?? "—", + label: "Spend" + ) + } Divider().frame(maxHeight: 60).opacity(0.3) - nextEventTile(snap) + Link(destination: URL(string: "https://spark.cronx.co/today")!) { + nextEventTile(snap) + } } .containerBackground(for: .widget) { Color(.systemBackground) } .widgetURL(URL(string: "https://spark.cronx.co/today")) diff --git a/Extensions/SparkWidgets/SparkWidgets.entitlements b/Extensions/SparkWidgets/SparkWidgets.entitlements index 868ed52..5ffae07 100644 --- a/Extensions/SparkWidgets/SparkWidgets.entitlements +++ b/Extensions/SparkWidgets/SparkWidgets.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.co.cronx.spark + group.co.cronx.sparkapp keychain-access-groups - $(AppIdentifierPrefix)co.cronx.spark + $(AppIdentifierPrefix)co.cronx.sparkapp diff --git a/Packages/SparkHealth/Sources/SparkHealth/HealthKitAnchorStore.swift b/Packages/SparkHealth/Sources/SparkHealth/HealthKitAnchorStore.swift index 8a531b1..079fc45 100644 --- a/Packages/SparkHealth/Sources/SparkHealth/HealthKitAnchorStore.swift +++ b/Packages/SparkHealth/Sources/SparkHealth/HealthKitAnchorStore.swift @@ -4,7 +4,7 @@ import HealthKit /// Persists HKQueryAnchor per type identifier to App Group UserDefaults. /// Encoded with NSKeyedArchiver (HKQueryAnchor is NSSecureCoding). public final class HealthKitAnchorStore: Sendable { - private static let suiteName = "group.co.cronx.spark" + private static let suiteName = "group.co.cronx.sparkapp" private static let keyPrefix = "hk.anchor." public static let shared = HealthKitAnchorStore() diff --git a/Packages/SparkHealth/Sources/SparkHealth/HealthKitObserver.swift b/Packages/SparkHealth/Sources/SparkHealth/HealthKitObserver.swift index b8abd29..c387161 100644 --- a/Packages/SparkHealth/Sources/SparkHealth/HealthKitObserver.swift +++ b/Packages/SparkHealth/Sources/SparkHealth/HealthKitObserver.swift @@ -9,6 +9,7 @@ import SparkKit /// Observer queries do not fire on the simulator — test on device. public final class HealthKitObserver: @unchecked Sendable { public static let shared = HealthKitObserver() + public static let uploadEnabledKey = "health.upload.enabled" private let store = HKHealthStore() private let anchorStore = HealthKitAnchorStore.shared @@ -70,7 +71,11 @@ public final class HealthKitObserver: @unchecked Sendable { if let newAnchor { let converted = self.convert(samples: samples ?? [], key: key) if !converted.isEmpty { - self.uploader.upload(samples: converted) + let enabled = UserDefaults(suiteName: "group.co.cronx.sparkapp")? + .bool(forKey: Self.uploadEnabledKey) == true + if enabled { + self.uploader.upload(samples: converted) + } self.anchorStore.save(newAnchor, for: key) } } diff --git a/Packages/SparkHealth/Sources/SparkHealth/HealthKitPermissionManager.swift b/Packages/SparkHealth/Sources/SparkHealth/HealthKitPermissionManager.swift index 617ce49..e6953c2 100644 --- a/Packages/SparkHealth/Sources/SparkHealth/HealthKitPermissionManager.swift +++ b/Packages/SparkHealth/Sources/SparkHealth/HealthKitPermissionManager.swift @@ -24,7 +24,7 @@ public final class HealthKitPermissionManager { public private(set) var advancedState: AuthState = .notDetermined private let store = HKHealthStore() - private let defaults = UserDefaults(suiteName: "group.co.cronx.spark") + private let defaults = UserDefaults(suiteName: "group.co.cronx.sparkapp") public static let shared = HealthKitPermissionManager() diff --git a/Packages/SparkHealth/Sources/SparkHealth/HealthKitTypeMap.swift b/Packages/SparkHealth/Sources/SparkHealth/HealthKitTypeMap.swift index 5951fa5..8e07a48 100644 --- a/Packages/SparkHealth/Sources/SparkHealth/HealthKitTypeMap.swift +++ b/Packages/SparkHealth/Sources/SparkHealth/HealthKitTypeMap.swift @@ -45,7 +45,7 @@ public enum HealthKitTypeMap { case .distanceWalkingRunning: return (.meter(), "m") case .appleExerciseTime: return (.minute(), "min") case .heartRateVariabilitySDNN: return (HKUnit(from: "ms"), "ms") - case .vo2Max: return (HKUnit(from: "ml/kg/min"), "ml/kg/min") + case .vo2Max: return (HKUnit.literUnit(with: .milli).unitDivided(by: HKUnit.gramUnit(with: .kilo).unitMultiplied(by: .minute())), "ml/kg/min") case .respiratoryRate: return (.count().unitDivided(by: .minute()), "count/min") case .oxygenSaturation: return (.percent(), "%") default: return (.count(), "count") diff --git a/Packages/SparkHealth/Sources/SparkHealth/HealthSampleUploader.swift b/Packages/SparkHealth/Sources/SparkHealth/HealthSampleUploader.swift index abba5af..cd39c3e 100644 --- a/Packages/SparkHealth/Sources/SparkHealth/HealthSampleUploader.swift +++ b/Packages/SparkHealth/Sources/SparkHealth/HealthSampleUploader.swift @@ -6,8 +6,8 @@ import SparkKit public final class HealthSampleUploader: NSObject, @unchecked Sendable { public static let shared = HealthSampleUploader() - private static let sessionIdentifier = "co.cronx.spark.health-upload" - private static let suiteName = "group.co.cronx.spark" + private static let sessionIdentifier = "co.cronx.sparkapp.health-upload" + private static let suiteName = "group.co.cronx.sparkapp" private lazy var session: URLSession = { let config = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier) @@ -18,6 +18,7 @@ public final class HealthSampleUploader: NSObject, @unchecked Sendable { private let lock = NSLock() private var completionHandlers: [String: @Sendable () -> Void] = [:] + private var telemetryByTaskIdentifier: [Int: PendingTelemetry] = [:] private var environment: APIEnvironment = .current() private var accessToken: String? @@ -66,6 +67,13 @@ public final class HealthSampleUploader: NSObject, @unchecked Sendable { let task = session.uploadTask(with: request, fromFile: tmpURL) task.taskDescription = tmpURL.lastPathComponent + let pending = PendingTelemetry( + startedAt: Date(), + request: request, + body: body, + fileSizeBytes: body.count + ) + lock.withLock { telemetryByTaskIdentifier[task.taskIdentifier] = pending } task.resume() } @@ -87,6 +95,13 @@ public final class HealthSampleUploader: NSObject, @unchecked Sendable { } } +private struct PendingTelemetry: Sendable { + let startedAt: Date + let request: URLRequest + let body: Data + let fileSizeBytes: Int +} + // MARK: - URLSessionDelegate extension HealthSampleUploader: URLSessionDelegate, URLSessionTaskDelegate { @@ -103,10 +118,58 @@ extension HealthSampleUploader: URLSessionDelegate, URLSessionTaskDelegate { task: URLSessionTask, didCompleteWithError error: Error? ) { + let pending = lock.withLock { + telemetryByTaskIdentifier.removeValue(forKey: task.taskIdentifier) + } + if let pending { + let response = task.response as? HTTPURLResponse + Task { + await APITelemetry.shared.capture( + APITelemetryEvent( + operation: "http.client.background_upload", + method: pending.request.httpMethod ?? "POST", + url: APITelemetryRedactor.url(pending.request.url ?? URL(string: "about:blank")!), + endpointPath: "/health/samples", + requiresAuth: true, + requestHeaders: APITelemetryRedactor.headers(pending.request.allHTTPHeaderFields ?? [:]), + requestBody: APITelemetryRedactor.body(pending.body, contentType: pending.request.value(forHTTPHeaderField: "Content-Type")), + statusCode: response?.statusCode, + responseHeaders: APITelemetryRedactor.headers(response?.stringHeaderFields ?? [:]), + responseBody: nil, + responseSizeBytes: pending.fileSizeBytes, + durationMillis: Date().timeIntervalSince(pending.startedAt) * 1_000, + outcome: Self.outcome(response: response, error: error), + errorDescription: error.map { String(describing: $0) } + ) + ) + } + } + guard let fileName = task.taskDescription else { return } if error == nil, (task.response as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? false { let tmpURL = cacheURL(for: String(fileName.dropLast(5))) // strip .json try? FileManager.default.removeItem(at: tmpURL) } } + + private nonisolated static func outcome( + response: HTTPURLResponse?, + error: Error? + ) -> APITelemetryEvent.Outcome { + if error != nil { return .transportError } + guard let response else { return .noData } + if (200..<300).contains(response.statusCode) { return .success } + if response.statusCode == 401 { return .unauthorized } + if response.statusCode == 304 { return .notModified } + return .httpError + } +} + +private extension HTTPURLResponse { + var stringHeaderFields: [String: String] { + Dictionary(uniqueKeysWithValues: allHeaderFields.compactMap { key, value in + guard let key = key as? String else { return nil } + return (key, String(describing: value)) + }) + } } diff --git a/Packages/SparkIntelligence/Sources/SparkIntelligence/ActionIntents.swift b/Packages/SparkIntelligence/Sources/SparkIntelligence/ActionIntents.swift index 9b5a917..7b4bda2 100644 --- a/Packages/SparkIntelligence/Sources/SparkIntelligence/ActionIntents.swift +++ b/Packages/SparkIntelligence/Sources/SparkIntelligence/ActionIntents.swift @@ -6,47 +6,48 @@ import SparkKit public struct LogCheckInIntent: AppIntent { public static let title: LocalizedStringResource = "Log Check-In" - public static let description = IntentDescription("Log a mood check-in in Spark.") + public static let description = IntentDescription("Open Spark to log a check-in.") public static let openAppWhenRun: Bool = true - @Parameter(title: "Mood", optionsProvider: MoodOptionsProvider()) - public var mood: String + @Parameter(title: "Physical Energy (1–5)") + public var physical: Int + + @Parameter(title: "Mental Energy (1–5)") + public var mental: Int @Parameter(title: "Note") public var note: String? - public init() {} - public init(mood: String, note: String? = nil) { - self.mood = mood + public init() { + self.physical = 3 + self.mental = 3 + } + public init(physical: Int, mental: Int, note: String? = nil) { + self.physical = max(1, min(5, physical)) + self.mental = max(1, min(5, mental)) self.note = note } public func perform() async throws -> some IntentResult & ProvidesDialog { let service = await IntentService() - let checkIn = CheckIn( - slot: currentSlot(), - mood: mood, - tags: [], - note: note - ) - _ = try? await service.apiClient.request(CheckInsEndpoint.create(checkIn)) - return .result(dialog: "Check-in logged. Feeling \(mood).") - } - - private func currentSlot() -> String { let hour = Calendar.current.component(.hour, from: .now) - switch hour { - case 5..<12: return "morning" - case 12..<17: return "afternoon" - case 17..<21: return "evening" - default: return "night" - } + let period: CheckInPeriod = hour < 12 ? .morning : .afternoon + let dateKey = Self.isoDate(.now) + let request = CheckInRequest( + period: period, + physical: max(1, min(5, physical)), + mental: max(1, min(5, mental)), + date: dateKey, + notes: note + ) + _ = try? await service.apiClient.request(CheckInsEndpoint.submit(request)) + return .result(dialog: "Check-in logged. Physical \(physical)/5, mental \(mental)/5.") } -} -private struct MoodOptionsProvider: DynamicOptionsProvider { - func results() async throws -> [String] { - ["great", "good", "okay", "low", "stressed", "tired", "energised", "calm", "anxious", "grateful"] + private static func isoDate(_ date: Date) -> String { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f.string(from: date) } } diff --git a/Packages/SparkIntelligence/Sources/SparkIntelligence/IntentService.swift b/Packages/SparkIntelligence/Sources/SparkIntelligence/IntentService.swift index 2ea343a..de21fad 100644 --- a/Packages/SparkIntelligence/Sources/SparkIntelligence/IntentService.swift +++ b/Packages/SparkIntelligence/Sources/SparkIntelligence/IntentService.swift @@ -35,7 +35,7 @@ public struct IntentService { // MARK: - UserDefaults routing (for open-app intents) public static func setPendingRoute(_ route: String) { - UserDefaults(suiteName: "group.co.cronx.spark")? + UserDefaults(suiteName: "group.co.cronx.sparkapp")? .set(route, forKey: "spark.pendingRoute") } diff --git a/Packages/SparkIntelligence/Sources/SparkIntelligence/SpotlightIndexer.swift b/Packages/SparkIntelligence/Sources/SparkIntelligence/SpotlightIndexer.swift index 8312d04..ce6fe44 100644 --- a/Packages/SparkIntelligence/Sources/SparkIntelligence/SpotlightIndexer.swift +++ b/Packages/SparkIntelligence/Sources/SparkIntelligence/SpotlightIndexer.swift @@ -51,22 +51,22 @@ public enum SpotlightIndexer { let eventDesc = FetchDescriptor(predicate: #Predicate { $0.lastSyncedAt < cutoff }) if let stale = try? context.fetch(eventDesc) { - identifiers += stale.map { "co.cronx.spark.event.\($0.id)" } + identifiers += stale.map { "co.cronx.sparkapp.event.\($0.id)" } } let blockDesc = FetchDescriptor(predicate: #Predicate { $0.lastSyncedAt < cutoff }) if let stale = try? context.fetch(blockDesc) { - identifiers += stale.map { "co.cronx.spark.block.\($0.id)" } + identifiers += stale.map { "co.cronx.sparkapp.block.\($0.id)" } } let placeDesc = FetchDescriptor(predicate: #Predicate { $0.lastSyncedAt < cutoff }) if let stale = try? context.fetch(placeDesc) { - identifiers += stale.map { "co.cronx.spark.place.\($0.id)" } + identifiers += stale.map { "co.cronx.sparkapp.place.\($0.id)" } } let integDesc = FetchDescriptor(predicate: #Predicate { $0.lastSyncedAt < cutoff }) if let stale = try? context.fetch(integDesc) { - identifiers += stale.map { "co.cronx.spark.integration.\($0.service)" } + identifiers += stale.map { "co.cronx.sparkapp.integration.\($0.service)" } } if !identifiers.isEmpty { @@ -86,8 +86,8 @@ public enum SpotlightIndexer { attrs.lastUsedDate = event.time attrs.contentURL = URL(string: "https://spark.cronx.co/events/\(event.id)") return CSSearchableItem( - uniqueIdentifier: "co.cronx.spark.event.\(event.id)", - domainIdentifier: "co.cronx.spark.events", + uniqueIdentifier: "co.cronx.sparkapp.event.\(event.id)", + domainIdentifier: "co.cronx.sparkapp.events", attributeSet: attrs ) } @@ -100,8 +100,8 @@ public enum SpotlightIndexer { attrs.keywords = [block.blockType] attrs.contentURL = URL(string: "https://spark.cronx.co/blocks/\(block.id)") return CSSearchableItem( - uniqueIdentifier: "co.cronx.spark.block.\(block.id)", - domainIdentifier: "co.cronx.spark.blocks", + uniqueIdentifier: "co.cronx.sparkapp.block.\(block.id)", + domainIdentifier: "co.cronx.sparkapp.blocks", attributeSet: attrs ) } @@ -114,8 +114,8 @@ public enum SpotlightIndexer { if let lon = place.longitude { attrs.longitude = NSNumber(value: lon) } attrs.contentURL = URL(string: "https://spark.cronx.co/places/\(place.id)") return CSSearchableItem( - uniqueIdentifier: "co.cronx.spark.place.\(place.id)", - domainIdentifier: "co.cronx.spark.places", + uniqueIdentifier: "co.cronx.sparkapp.place.\(place.id)", + domainIdentifier: "co.cronx.sparkapp.places", attributeSet: attrs ) } @@ -127,8 +127,8 @@ public enum SpotlightIndexer { attrs.contentURL = URL(string: "https://spark.cronx.co/integrations/\(integration.service)/details") // Use service name as identifier (matches DeepLink routing by service). return CSSearchableItem( - uniqueIdentifier: "co.cronx.spark.integration.\(integration.service)", - domainIdentifier: "co.cronx.spark.integrations", + uniqueIdentifier: "co.cronx.sparkapp.integration.\(integration.service)", + domainIdentifier: "co.cronx.sparkapp.integrations", attributeSet: attrs ) } diff --git a/Packages/SparkKit/Package.swift b/Packages/SparkKit/Package.swift index bd3d065..f3445d2 100644 --- a/Packages/SparkKit/Package.swift +++ b/Packages/SparkKit/Package.swift @@ -4,6 +4,7 @@ import PackageDescription let package = Package( name: "SparkKit", platforms: [ + .macOS(.v15), .iOS(.v26), .watchOS(.v26), ], diff --git a/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift b/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift index 5751732..a3b8e03 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift @@ -11,6 +11,81 @@ public enum APIError: Error, Sendable { case noData } +extension APIError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidURL: + return "The API URL is invalid." + case .transport(let error): + return "Network error: \(error.localizedDescription)" + case .unauthorized: + return "Your session has expired. Please sign in again." + case .notModified: + return "The requested data has not changed." + case .httpStatus(let status, let data, let url): + return Self.httpStatusDescription(status: status, data: data, url: url) + case .decoding(let error): + return "The server response could not be read: \(error.localizedDescription)" + case .noData: + return "The server returned an invalid response." + } + } + + private static func httpStatusDescription(status: Int, data: Data?, url: URL) -> String { + let serverMessage = data.flatMap(Self.serverMessage) + let base = "HTTP \(status) from \(url.path)" + guard let serverMessage, serverMessage.isEmpty == false else { + return base + } + return "\(base): \(serverMessage)" + } + + private static func serverMessage(from data: Data) -> String? { + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + for key in ["message", "error", "detail", "title"] { + if let message = object[key] as? String { + return message + } + } + } + + guard let text = String(data: data, encoding: .utf8) else { + return nil + } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +public extension APIError { + var isCancellation: Bool { + switch self { + case .transport(let error): + return error.isAPICancellation + default: + return false + } + } +} + +public extension Error { + var isAPICancellation: Bool { + if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) { + if self is CancellationError { + return true + } + } + if let apiError = self as? APIError { + return apiError.isCancellation + } + if let urlError = self as? URLError { + return urlError.code == .cancelled + } + let nsError = self as NSError + return nsError.domain == NSURLErrorDomain && nsError.code == URLError.cancelled.rawValue + } +} + /// Generic async/await HTTP client with: /// - `If-None-Match` / 304 short-circuit via `ETagCache` /// - automatic 401 → token refresh → retry once @@ -23,20 +98,24 @@ public actor APIClient { private let session: URLSession private let tokenStore: KeychainTokenStore private let etagCache: ETagCache + private let telemetry: APITelemetry private let decoder: JSONDecoder private let encoder: JSONEncoder - private let logger = Logger(subsystem: "co.cronx.spark", category: "APIClient") + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "APIClient") + private static let refreshCoordinator = TokenRefreshCoordinator() public init( environment: APIEnvironment = .current(), session: URLSession = .shared, tokenStore: KeychainTokenStore, - etagCache: ETagCache = ETagCache() + etagCache: ETagCache = ETagCache(), + telemetry: APITelemetry = .shared ) { self.environment = environment self.session = session self.tokenStore = tokenStore self.etagCache = etagCache + self.telemetry = telemetry self.decoder = JSONDecoder() self.decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() @@ -71,7 +150,9 @@ public actor APIClient { private func perform( _ endpoint: Endpoint, absoluteBase: Bool, - allowRefresh: Bool + allowRefresh: Bool, + attempt: Int = 1, + isRefreshRequest: Bool = false ) async throws -> Response { let url = try buildURL(endpoint: endpoint, absoluteBase: absoluteBase) var request = URLRequest(url: url) @@ -81,37 +162,112 @@ public actor APIClient { request.httpBody = body request.setValue(endpoint.contentType ?? "application/json", forHTTPHeaderField: "Content-Type") } - if endpoint.requiresAuth, let token = await tokenStore.accessToken() { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let accessToken = endpoint.requiresAuth ? await tokenStore.accessToken() : nil + if let accessToken { + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } if let etag = await etagCache.etag(for: url) { request.setValue(etag, forHTTPHeaderField: "If-None-Match") } let (data, response): (Data, URLResponse) + let metricsCollector = APITaskMetricsCollector() + let startedAt = Date() do { - (data, response) = try await session.data(for: request) + (data, response) = try await session.data(for: request, delegate: metricsCollector) } catch { + if error.isAPICancellation { + throw APIError.transport(error) + } + await captureTelemetry( + operation: "http.client", + endpoint: endpoint, + request: request, + url: url, + attempt: attempt, + isRefreshRequest: isRefreshRequest, + startedAt: startedAt, + metrics: metricsCollector.snapshot, + outcome: .transportError, + errorDescription: String(describing: error) + ) throw APIError.transport(error) } guard let http = response as? HTTPURLResponse else { + await captureTelemetry( + operation: "http.client", + endpoint: endpoint, + request: request, + url: url, + attempt: attempt, + isRefreshRequest: isRefreshRequest, + startedAt: startedAt, + metrics: metricsCollector.snapshot, + outcome: .noData, + errorDescription: "Response was not HTTPURLResponse" + ) throw APIError.noData } if http.statusCode == 304 { + await captureTelemetry( + operation: "http.client", + endpoint: endpoint, + request: request, + url: url, + attempt: attempt, + isRefreshRequest: isRefreshRequest, + startedAt: startedAt, + response: http, + data: data, + metrics: metricsCollector.snapshot, + outcome: .notModified + ) throw APIError.notModified } if http.statusCode == 401 { + await captureTelemetry( + operation: "http.client", + endpoint: endpoint, + request: request, + url: url, + attempt: attempt, + isRefreshRequest: isRefreshRequest, + startedAt: startedAt, + response: http, + data: data, + metrics: metricsCollector.snapshot, + outcome: .unauthorized + ) if allowRefresh, await tokenStore.hasRefreshToken() { - let refreshed = try await refreshAndRetry(endpoint, absoluteBase: absoluteBase) + let refreshed = try await refreshAndRetry( + endpoint, + absoluteBase: absoluteBase, + retryAttempt: attempt + 1, + tokenUsedForRequest: accessToken + ) return refreshed } throw APIError.unauthorized } guard (200..<300).contains(http.statusCode) else { + await captureTelemetry( + operation: "http.client", + endpoint: endpoint, + request: request, + url: url, + attempt: attempt, + isRefreshRequest: isRefreshRequest, + startedAt: startedAt, + response: http, + data: data, + metrics: metricsCollector.snapshot, + outcome: .httpError, + errorDescription: "HTTP \(http.statusCode)" + ) throw APIError.httpStatus(http.statusCode, data, url) } @@ -125,44 +281,160 @@ public actor APIClient { #endif if data.isEmpty, let empty = EmptyResponse() as? Response { + await captureTelemetry( + operation: "http.client", + endpoint: endpoint, + request: request, + url: url, + attempt: attempt, + isRefreshRequest: isRefreshRequest, + startedAt: startedAt, + response: http, + data: data, + metrics: metricsCollector.snapshot, + outcome: .success + ) return empty } do { - return try decoder.decode(Response.self, from: data) + let decodeStartedAt = Date() + let decoded = try decoder.decode(Response.self, from: data) + await captureTelemetry( + operation: "http.client", + endpoint: endpoint, + request: request, + url: url, + attempt: attempt, + isRefreshRequest: isRefreshRequest, + startedAt: startedAt, + response: http, + data: data, + metrics: metricsCollector.snapshot, + outcome: .success, + decodeDurationMillis: Date().timeIntervalSince(decodeStartedAt) * 1_000 + ) + return decoded } catch { let bodyString = String(data: data, encoding: .utf8) ?? "" logger.error("Decoding failed for \(endpoint.path, privacy: .public): \(error.localizedDescription, privacy: .public) — body: \(bodyString, privacy: .public)") + await captureTelemetry( + operation: "http.client", + endpoint: endpoint, + request: request, + url: url, + attempt: attempt, + isRefreshRequest: isRefreshRequest, + startedAt: startedAt, + response: http, + data: data, + metrics: metricsCollector.snapshot, + outcome: .decodingError, + errorDescription: String(describing: error) + ) throw APIError.decoding(error) } } private func refreshAndRetry( _ endpoint: Endpoint, - absoluteBase: Bool + absoluteBase: Bool, + retryAttempt: Int, + tokenUsedForRequest: String? ) async throws -> Response { + if let tokenUsedForRequest, + let currentAccessToken = await tokenStore.accessToken(), + currentAccessToken != tokenUsedForRequest { + return try await perform(endpoint, absoluteBase: absoluteBase, allowRefresh: false, attempt: retryAttempt) + } + + _ = try await refreshTokens() + return try await perform(endpoint, absoluteBase: absoluteBase, allowRefresh: false, attempt: retryAttempt) + } + + private func refreshTokens() async throws -> AuthTokens { guard let refreshToken = await tokenStore.refreshToken() else { throw APIError.unauthorized } - let tokens = try await perform( - AuthEndpoint.refresh(refreshToken: refreshToken), - absoluteBase: true, - allowRefresh: false - ) - await tokenStore.store( - access: tokens.accessToken, - refresh: tokens.refreshToken, - expiresIn: tokens.expiresIn + + do { + return try await Self.refreshCoordinator.refresh(refreshToken: refreshToken) { [tokenStore] in + let tokens = try await self.perform( + AuthEndpoint.refresh(refreshToken: refreshToken), + absoluteBase: true, + allowRefresh: false, + isRefreshRequest: true + ) + let authTokens = AuthTokens( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn: tokens.expiresIn + ) + await tokenStore.store( + access: authTokens.accessToken, + refresh: authTokens.refreshToken, + expiresIn: authTokens.expiresIn + ) + return authTokens + } + } catch { + if case APIError.unauthorized = error { + let currentRefreshToken = await tokenStore.refreshToken() + if currentRefreshToken == nil || currentRefreshToken == refreshToken { + await tokenStore.clear() + } + } + throw error + } + } + + private func captureTelemetry( + operation: String, + endpoint: Endpoint, + request: URLRequest, + url: URL, + attempt: Int, + isRefreshRequest: Bool, + startedAt: Date, + response: HTTPURLResponse? = nil, + data: Data? = nil, + metrics: APITaskMetrics? = nil, + outcome: APITelemetryEvent.Outcome, + errorDescription: String? = nil, + decodeDurationMillis: Double? = nil + ) async { + let requestHeaders = APITelemetryRedactor.headers(request.allHTTPHeaderFields ?? [:]) + let responseHeaders = APITelemetryRedactor.headers(response?.stringHeaderFields ?? [:]) + let contentType = request.value(forHTTPHeaderField: "Content-Type") + let responseContentType = response?.value(forHTTPHeaderField: "Content-Type") + + let event = APITelemetryEvent( + operation: operation, + method: request.httpMethod ?? endpoint.method.rawValue, + url: APITelemetryRedactor.url(url), + endpointPath: endpoint.path, + requiresAuth: endpoint.requiresAuth, + attempt: attempt, + isRefreshRequest: isRefreshRequest, + requestHeaders: requestHeaders, + requestBody: APITelemetryRedactor.body(request.httpBody, contentType: contentType), + statusCode: response?.statusCode, + responseHeaders: responseHeaders, + responseBody: APITelemetryRedactor.body(data, contentType: responseContentType), + responseSizeBytes: data?.count ?? 0, + durationMillis: Date().timeIntervalSince(startedAt) * 1_000, + decodeDurationMillis: decodeDurationMillis, + metrics: metrics, + outcome: outcome, + errorDescription: errorDescription ) - return try await perform(endpoint, absoluteBase: absoluteBase, allowRefresh: false) + await telemetry.capture(event) } private func buildURL(endpoint: Endpoint, absoluteBase: Bool) throws -> URL { let base: URL if absoluteBase { - base = environment.baseURL - .deletingLastPathComponent() // /api/v1 - .deletingLastPathComponent() // /api (oauth lives here, not at site root) + base = oauthSiteRootURL() } else { base = environment.baseURL } @@ -207,6 +479,42 @@ public actor APIClient { } } +private actor TokenRefreshCoordinator { + private var tasks: [String: Task] = [:] + + func refresh( + refreshToken: String, + operation: @escaping @Sendable () async throws -> AuthTokens + ) async throws -> AuthTokens { + if let task = tasks[refreshToken] { + return try await task.value + } + + let task = Task { + try await operation() + } + tasks[refreshToken] = task + + do { + let tokens = try await task.value + tasks[refreshToken] = nil + return tokens + } catch { + tasks[refreshToken] = nil + throw error + } + } +} + +private extension HTTPURLResponse { + var stringHeaderFields: [String: String] { + Dictionary(uniqueKeysWithValues: allHeaderFields.compactMap { key, value in + guard let key = key as? String else { return nil } + return (key, String(describing: value)) + }) + } +} + /// Sentinel for endpoints that return an empty 204. public struct EmptyResponse: Codable, Sendable { public init() {} diff --git a/Packages/SparkKit/Sources/SparkKit/API/APIEnvironment.swift b/Packages/SparkKit/Sources/SparkKit/API/APIEnvironment.swift index 9e1491c..c78ec4f 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/APIEnvironment.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/APIEnvironment.swift @@ -90,6 +90,6 @@ public extension UserDefaults { /// The App Group UserDefaults. Writing via this suite lets widgets, /// extensions and the main app share preferences and ETags. nonisolated(unsafe) static let sparkAppGroup: UserDefaults = { - UserDefaults(suiteName: "group.co.cronx.spark") ?? .standard + UserDefaults(suiteName: "group.co.cronx.sparkapp") ?? .standard }() } diff --git a/Packages/SparkKit/Sources/SparkKit/API/APITelemetry.swift b/Packages/SparkKit/Sources/SparkKit/API/APITelemetry.swift new file mode 100644 index 0000000..1ac4145 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/APITelemetry.swift @@ -0,0 +1,266 @@ +import Foundation + +public protocol APITelemetrySink: Sendable { + func capture(_ event: APITelemetryEvent) async +} + +public actor APITelemetry { + public static let shared = APITelemetry() + + private var sink: APITelemetrySink? + + public func setSink(_ sink: APITelemetrySink?) { + self.sink = sink + } + + public func capture(_ event: APITelemetryEvent) async { + await sink?.capture(event) + } +} + +public struct APITelemetryEvent: Sendable { + public enum Outcome: Sendable, Equatable { + case success + case notModified + case unauthorized + case httpError + case transportError + case decodingError + case noData + } + + public let id: UUID + public let operation: String + public let method: String + public let url: URL + public let endpointPath: String? + public let requiresAuth: Bool + public let attempt: Int + public let isRefreshRequest: Bool + public let requestHeaders: [String: String] + public let requestBody: Data? + public let statusCode: Int? + public let responseHeaders: [String: String] + public let responseBody: Data? + public let responseSizeBytes: Int + public let durationMillis: Double + public let decodeDurationMillis: Double? + public let metrics: APITaskMetrics? + public let outcome: Outcome + public let errorDescription: String? + + public init( + id: UUID = UUID(), + operation: String, + method: String, + url: URL, + endpointPath: String? = nil, + requiresAuth: Bool = false, + attempt: Int = 1, + isRefreshRequest: Bool = false, + requestHeaders: [String: String] = [:], + requestBody: Data? = nil, + statusCode: Int? = nil, + responseHeaders: [String: String] = [:], + responseBody: Data? = nil, + responseSizeBytes: Int = 0, + durationMillis: Double, + decodeDurationMillis: Double? = nil, + metrics: APITaskMetrics? = nil, + outcome: Outcome, + errorDescription: String? = nil + ) { + self.id = id + self.operation = operation + self.method = method + self.url = url + self.endpointPath = endpointPath + self.requiresAuth = requiresAuth + self.attempt = attempt + self.isRefreshRequest = isRefreshRequest + self.requestHeaders = requestHeaders + self.requestBody = requestBody + self.statusCode = statusCode + self.responseHeaders = responseHeaders + self.responseBody = responseBody + self.responseSizeBytes = responseSizeBytes + self.durationMillis = durationMillis + self.decodeDurationMillis = decodeDurationMillis + self.metrics = metrics + self.outcome = outcome + self.errorDescription = errorDescription + } +} + +public struct APITaskMetrics: Sendable, Equatable { + public let transactionCount: Int + public let redirects: Int + public let requestBodyBytesSent: Int64 + public let responseBodyBytesReceived: Int64 + public let fetchStartMillis: Double? + public let domainLookupMillis: Double? + public let connectMillis: Double? + public let secureConnectionMillis: Double? + public let requestMillis: Double? + public let responseMillis: Double? + + init(_ metrics: URLSessionTaskMetrics) { + transactionCount = metrics.transactionMetrics.count + redirects = metrics.redirectCount + if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) { + requestBodyBytesSent = metrics.transactionMetrics.reduce(0) { $0 + $1.countOfRequestBodyBytesSent } + responseBodyBytesReceived = metrics.transactionMetrics.reduce(0) { $0 + $1.countOfResponseBodyBytesReceived } + } else { + requestBodyBytesSent = 0 + responseBodyBytesReceived = 0 + } + + let transactions = metrics.transactionMetrics + fetchStartMillis = Self.intervalMillis( + start: transactions.compactMap(\.fetchStartDate).min(), + end: transactions.compactMap(\.responseEndDate).max() + ) + domainLookupMillis = Self.sumMillis(transactions, start: \.domainLookupStartDate, end: \.domainLookupEndDate) + connectMillis = Self.sumMillis(transactions, start: \.connectStartDate, end: \.connectEndDate) + secureConnectionMillis = Self.sumMillis(transactions, start: \.secureConnectionStartDate, end: \.secureConnectionEndDate) + requestMillis = Self.sumMillis(transactions, start: \.requestStartDate, end: \.requestEndDate) + responseMillis = Self.sumMillis(transactions, start: \.responseStartDate, end: \.responseEndDate) + } + + private static func sumMillis( + _ transactions: [URLSessionTaskTransactionMetrics], + start: KeyPath, + end: KeyPath + ) -> Double? { + let values = transactions.compactMap { intervalMillis(start: $0[keyPath: start], end: $0[keyPath: end]) } + guard !values.isEmpty else { return nil } + return values.reduce(0, +) + } + + private static func intervalMillis(start: Date?, end: Date?) -> Double? { + guard let start, let end else { return nil } + return end.timeIntervalSince(start) * 1_000 + } +} + +public enum APITelemetryRedactor { + private static let sensitiveHeaderNames: Set = [ + "authorization", + "cookie", + "set-cookie", + "proxy-authorization", + "x-api-key", + "x-csrf-token", + "x-xsrf-token", + ] + + private static let sensitiveBodyKeyFragments = [ + "access_token", + "refresh_token", + "id_token", + "token", + "secret", + "password", + "authorization", + "code", + "verifier", + "api_key", + "apikey", + "cookie", + ] + + public static func headers(_ headers: [String: String]) -> [String: String] { + Dictionary(uniqueKeysWithValues: headers.map { key, value in + if sensitiveHeaderNames.contains(key.lowercased()) { + return (key, "") + } + return (key, value) + }) + } + + public static func queryItems(_ items: [URLQueryItem]) -> [URLQueryItem] { + items.map { item in + guard isSensitiveKey(item.name) else { return item } + return URLQueryItem(name: item.name, value: "") + } + } + + public static func body(_ data: Data?, contentType: String?) -> Data? { + guard let data, !data.isEmpty else { return data } + let lowerContentType = contentType?.lowercased() ?? "" + + if lowerContentType.contains("json"), + let object = try? JSONSerialization.jsonObject(with: data), + JSONSerialization.isValidJSONObject(object) { + let redacted = redactJSON(object) + return try? JSONSerialization.data(withJSONObject: redacted, options: [.sortedKeys]) + } + + if lowerContentType.contains("x-www-form-urlencoded"), + let string = String(data: data, encoding: .utf8) { + var components = URLComponents() + components.queryItems = string + .split(separator: "&") + .map { pair in + let pieces = pair.split(separator: "=", maxSplits: 1).map(String.init) + let name = pieces.first?.removingPercentEncoding ?? "" + let value = pieces.count > 1 ? pieces[1].removingPercentEncoding : nil + return URLQueryItem(name: name, value: value) + } + components.queryItems = queryItems(components.queryItems ?? []) + return components.percentEncodedQuery?.data(using: .utf8) + } + + return data + } + + public static func url(_ url: URL) -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } + components.queryItems = queryItems(components.queryItems ?? []) + return components.url ?? url + } + + private static func redactJSON(_ object: Any) -> Any { + if let dictionary = object as? [String: Any] { + let redacted: [(String, Any)] = dictionary.map { key, value in + if isSensitiveKey(key) { + return (key, "") + } + return (key, redactJSON(value)) + } + return Dictionary(uniqueKeysWithValues: redacted) + } + + if let array = object as? [Any] { + return array.map(redactJSON) + } + + return object + } + + private static func isSensitiveKey(_ key: String) -> Bool { + let normalized = key.lowercased() + return sensitiveBodyKeyFragments.contains { normalized.contains($0) } + } +} + +final class APITaskMetricsCollector: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private let lock = NSLock() + private var collectedMetrics: URLSessionTaskMetrics? + + var snapshot: APITaskMetrics? { + lock.withLock { + collectedMetrics.map(APITaskMetrics.init) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didFinishCollecting metrics: URLSessionTaskMetrics + ) { + lock.withLock { + collectedMetrics = metrics + } + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/CheckInsEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/CheckInsEndpoint.swift index 06856f7..c688428 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/CheckInsEndpoint.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/CheckInsEndpoint.swift @@ -1,16 +1,40 @@ import Foundation public enum CheckInsEndpoint { - /// GET /check-ins?date=YYYY-MM-DD - public static func list(date: String) -> Endpoint<[CheckIn]> { - Endpoint(method: .get, path: "/check-ins", query: [URLQueryItem(name: "date", value: date)]) + private static let encoder: JSONEncoder = { + let e = JSONEncoder() + e.dateEncodingStrategy = .iso8601 + return e + }() + + /// POST /check-ins — submit or update a morning/afternoon check-in. + public static func submit(_ request: CheckInRequest) -> Endpoint { + Endpoint( + method: .post, + path: "/check-ins", + body: try? encoder.encode(request), + contentType: "application/json" + ) + } + + /// GET /check-ins?date=YYYY-MM-DD — completion status for both periods on a date. + public static func today(date: String) -> Endpoint { + Endpoint( + method: .get, + path: "/check-ins", + query: [URLQueryItem(name: "date", value: date)] + ) } - /// POST /check-ins - public static func create(_ checkIn: CheckIn) -> Endpoint { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let body = try? encoder.encode(checkIn) - return Endpoint(method: .post, path: "/check-ins", body: body, contentType: "application/json") + /// GET /check-ins/history?from=YYYY-MM-DD&to=YYYY-MM-DD — day-by-day history (max 90 days). + public static func history(from: String, to: String) -> Endpoint { + Endpoint( + method: .get, + path: "/check-ins/history", + query: [ + URLQueryItem(name: "from", value: from), + URLQueryItem(name: "to", value: to), + ] + ) } } diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/DevicesEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/DevicesEndpoint.swift index 6b40213..b247f49 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/DevicesEndpoint.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/DevicesEndpoint.swift @@ -6,19 +6,69 @@ public enum DevicesEndpoint { Endpoint(method: .get, path: "/devices") } - /// POST /devices — register this device. Returns the created record. - public static func register(name: String, platform: String) -> Endpoint { - let body = try? JSONEncoder().encode(RegisterRequest(name: name, platform: platform)) + /// POST /devices — register this device. Success is enough; the app does not consume the response body. + public static func register( + name: String, + platform: String, + apnsToken: String, + appEnvironment: String, + appVersion: String, + bundleId: String, + osVersion: String + ) -> Endpoint { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + let body = try? encoder.encode(RegisterRequest( + deviceName: name, platform: platform, + apnsToken: apnsToken, appEnvironment: appEnvironment, + appVersion: appVersion, bundleId: bundleId, osVersion: osVersion + )) return Endpoint(method: .post, path: "/devices", body: body, contentType: "application/json") } + /// POST /devices/test — send a diagnostic APNs notification to this user. + public static func sendTestPush() -> Endpoint { + Endpoint(method: .post, path: "/devices/test") + } + /// DELETE /devices/{id} public static func revoke(id: String) -> Endpoint { Endpoint(method: .delete, path: "/devices/\(id)") } private struct RegisterRequest: Encodable { - let name: String + let deviceName: String let platform: String + let apnsToken: String + let appEnvironment: String + let appVersion: String + let bundleId: String + let osVersion: String + } +} + +public struct DeviceRegistrationResponse: Decodable, Sendable { + public let id: String + public let deviceType: String + public let endpoint: String + public let appEnvironment: String + + enum CodingKeys: String, CodingKey { + case id + case deviceType = "device_type" + case endpoint + case appEnvironment = "app_environment" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let stringID = try? container.decode(String.self, forKey: .id) { + id = stringID + } else { + id = String(try container.decode(Int.self, forKey: .id)) + } + deviceType = try container.decode(String.self, forKey: .deviceType) + endpoint = try container.decode(String.self, forKey: .endpoint) + appEnvironment = try container.decode(String.self, forKey: .appEnvironment) } } diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/EventsEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/EventsEndpoint.swift index c589afd..1062088 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/EventsEndpoint.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/EventsEndpoint.swift @@ -5,4 +5,19 @@ public enum EventsEndpoint { public static func detail(id: String) -> Endpoint { Endpoint(method: .get, path: "/events/\(id)") } + + /// PATCH /events/{id}/note + public static func updateNote(id: String, note: String?) -> Endpoint { + let body = try? JSONEncoder().encode(UpdateNoteRequest(note: note)) + return Endpoint(method: .patch, path: "/events/\(id)/note", body: body) + } + + /// POST /knowledge/events/{id}/reprocess + public static func reprocessKnowledgeEvent(id: String) -> Endpoint { + Endpoint(method: .post, path: "/knowledge/events/\(id)/reprocess") + } +} + +private struct UpdateNoteRequest: Encodable { + let note: String? } diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FeedEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FeedEndpoint.swift index 5d32ce7..25c2242 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FeedEndpoint.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FeedEndpoint.swift @@ -3,7 +3,8 @@ import Foundation public enum FeedEndpoint { /// GET /feed — cursor-paginated reverse-chronological event feed. /// Pass `domain` to filter by domain (e.g. "knowledge", "money"). - public static func feed(cursor: String? = nil, limit: Int = 20, domain: String? = nil) -> Endpoint> { + /// Pass `date` as `YYYY-MM-DD` to restrict results to one calendar day. + public static func feed(cursor: String? = nil, limit: Int = 20, domain: String? = nil, date: String? = nil) -> Endpoint> { var query: [URLQueryItem] = [] if let cursor { query.append(URLQueryItem(name: "cursor", value: cursor)) @@ -12,6 +13,9 @@ public enum FeedEndpoint { if let domain { query.append(URLQueryItem(name: "domain", value: domain)) } + if let date { + query.append(URLQueryItem(name: "date", value: date)) + } return Endpoint(method: .get, path: "/feed", query: query) } } diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FlintEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FlintEndpoint.swift new file mode 100644 index 0000000..976888e --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FlintEndpoint.swift @@ -0,0 +1,50 @@ +import Foundation + +public enum FlintEndpoint { + public static func digests( + date: String? = nil, + period: FlintDigestPeriod? = nil, + all: Bool = true + ) -> Endpoint { + Endpoint(method: .get, path: "/flint/digests", query: digestQuery(date: date, period: period, all: all)) + } + + public static func latestDigest(date: String? = nil, period: FlintDigestPeriod? = nil) -> Endpoint { + Endpoint(method: .get, path: "/flint/digests", query: digestQuery(date: date, period: period, all: false)) + } + + public static func digest(id: String) -> Endpoint { + Endpoint(method: .get, path: "/flint/digests/\(id)") + } + + public static func answerQuestion( + blockID: String, + _ request: FlintQuestionAnswerRequest + ) -> Endpoint { + let body = try? JSONEncoder().encode(request) + return Endpoint( + method: .post, + path: "/flint/questions/\(blockID)/answer", + body: body, + contentType: "application/json" + ) + } + + private static func digestQuery( + date: String?, + period: FlintDigestPeriod?, + all: Bool + ) -> [URLQueryItem] { + var query: [URLQueryItem] = [] + if let date { + query.append(URLQueryItem(name: "date", value: date)) + } + if let period { + query.append(URLQueryItem(name: "period", value: period.rawValue)) + } + if all { + query.append(URLQueryItem(name: "all", value: "true")) + } + return query + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/IntegrationsEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/IntegrationsEndpoint.swift index a916a31..389dc77 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/IntegrationsEndpoint.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/IntegrationsEndpoint.swift @@ -1,8 +1,12 @@ import Foundation public enum IntegrationsEndpoint { + public struct ListResponse: Decodable, Sendable { + public let data: [Integration] + } + /// GET /integrations - public static func list() -> Endpoint<[Integration]> { + public static func list() -> Endpoint { Endpoint(method: .get, path: "/integrations") } diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MapEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MapEndpoint.swift index c3f261c..ec4ab3d 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MapEndpoint.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MapEndpoint.swift @@ -2,7 +2,7 @@ import Foundation public enum MapEndpoint { /// GET /map/data?bbox=lat1,lng1,lat2,lng2[&date=YYYY-MM-DD] - public static func points(bbox: BoundingBox, date: Date? = nil) -> Endpoint<[MapDataPoint]> { + public static func points(bbox: BoundingBox, date: Date? = nil) -> Endpoint { var query: [URLQueryItem] = [ URLQueryItem(name: "bbox", value: bbox.queryValue) ] diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MetricsEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MetricsEndpoint.swift index 81e2b30..bdcd167 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MetricsEndpoint.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MetricsEndpoint.swift @@ -17,12 +17,26 @@ public enum MetricsEndpoint { } } + /// GET /metrics + public static func list() -> Endpoint<[Metric]> { + Endpoint(method: .get, path: "/metrics") + } + /// GET /metrics/{identifier}?range=… public static func detail(identifier: String, range: Range = .thirtyDays) -> Endpoint { Endpoint( method: .get, - path: "/metrics/\(identifier)", + path: "/metrics/\(canonicalIdentifier(identifier))", query: [URLQueryItem(name: "range", value: range.rawValue)] ) } + + public static func canonicalIdentifier(_ identifier: String) -> String { + switch identifier { + case "sleep.score": "oura.sleep_score" + case "health.steps": "oura.steps" + case "money.spend": "monzo.spend_daily" + default: identifier + } + } } diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MoneyEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MoneyEndpoint.swift new file mode 100644 index 0000000..0e5cc12 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MoneyEndpoint.swift @@ -0,0 +1,63 @@ +import Foundation + +public enum MoneyEndpoint { + private static let encoder: JSONEncoder = { + let e = JSONEncoder() + e.dateEncodingStrategy = .iso8601 + return e + }() + + /// GET /money/accounts — all non-archived accounts with latest balance. + public static func accounts() -> Endpoint { + Endpoint(method: .get, path: "/money/accounts") + } + + /// GET /money/accounts/{id} — single account with latest balance. + public static func account(id: String) -> Endpoint { + Endpoint(method: .get, path: "/money/accounts/\(id)") + } + + /// GET /money/accounts/{id}/balances — cursor-paginated balance history. + public static func balances(accountId: String, cursor: String? = nil) -> Endpoint> { + var query: [URLQueryItem] = [] + if let cursor { + query.append(URLQueryItem(name: "cursor", value: cursor)) + } + return Endpoint(method: .get, path: "/money/accounts/\(accountId)/balances", query: query) + } + + /// POST /money/accounts — create a manual account. + public static func createAccount(_ request: CreateAccountRequest) -> Endpoint { + Endpoint( + method: .post, + path: "/money/accounts", + body: try? encoder.encode(request), + contentType: "application/json" + ) + } + + /// PATCH /money/accounts/{id} — update a manual account. + public static func updateAccount(id: String, _ request: UpdateAccountRequest) -> Endpoint { + Endpoint( + method: .patch, + path: "/money/accounts/\(id)", + body: try? encoder.encode(request), + contentType: "application/json" + ) + } + + /// DELETE /money/accounts/{id} — archive a manual account. + public static func deleteAccount(id: String) -> Endpoint { + Endpoint(method: .delete, path: "/money/accounts/\(id)") + } + + /// POST /money/accounts/{id}/balances — add a balance update. + public static func addBalance(accountId: String, _ request: AddBalanceRequest) -> Endpoint { + Endpoint( + method: .post, + path: "/money/accounts/\(accountId)/balances", + body: try? encoder.encode(request), + contentType: "application/json" + ) + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Auth/KeychainTokenStore.swift b/Packages/SparkKit/Sources/SparkKit/Auth/KeychainTokenStore.swift index 2180359..6b61aaa 100644 --- a/Packages/SparkKit/Sources/SparkKit/Auth/KeychainTokenStore.swift +++ b/Packages/SparkKit/Sources/SparkKit/Auth/KeychainTokenStore.swift @@ -30,7 +30,7 @@ public actor KeychainTokenStore { private var cachedTokens: AuthTokens? public init( - service: String = "co.cronx.spark.oauth", + service: String = "co.cronx.sparkapp.oauth", account: String = "primary", accessGroup: String? = nil ) { @@ -46,8 +46,10 @@ public actor KeychainTokenStore { public func hasRefreshToken() -> Bool { tokens()?.refreshToken.isEmpty == false } public func tokens() -> AuthTokens? { - if let cachedTokens { return cachedTokens } - guard let data = read() else { return nil } + guard let data = read() else { + cachedTokens = nil + return nil + } let decoded = try? JSONDecoder().decode(AuthTokens.self, from: data) cachedTokens = decoded return decoded diff --git a/Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift b/Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift index c94f754..3c8d408 100644 --- a/Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift +++ b/Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift @@ -52,7 +52,7 @@ public enum DeepLink: Sendable, Equatable { return .block(id: parts[1]) case "metrics", "metric": guard parts.count >= 2 else { return nil } - return .metric(identifier: parts[1]) + return .metric(identifier: MetricsEndpoint.canonicalIdentifier(parts[1])) case "places", "place": guard parts.count >= 2 else { return nil } return .place(id: parts[1]) diff --git a/Packages/SparkKit/Sources/SparkKit/Models/Block.swift b/Packages/SparkKit/Sources/SparkKit/Models/Block.swift index 81a0e59..ebf2930 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/Block.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/Block.swift @@ -10,9 +10,10 @@ public struct Block: Codable, Sendable, Hashable, Identifiable { public let value: String? public let unit: String? public let mediaUrl: String? + public let references: [EntityReference]? enum CodingKeys: String, CodingKey { - case id, title, time, content, value, unit + case id, title, time, content, value, unit, references case blockType = "block_type" case mediaUrl = "media_url" } @@ -25,7 +26,8 @@ public struct Block: Codable, Sendable, Hashable, Identifiable { content: String? = nil, value: String? = nil, unit: String? = nil, - mediaUrl: String? = nil + mediaUrl: String? = nil, + references: [EntityReference]? = nil ) { self.id = id self.blockType = blockType @@ -35,5 +37,30 @@ public struct Block: Codable, Sendable, Hashable, Identifiable { self.value = value self.unit = unit self.mediaUrl = mediaUrl + self.references = references + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + blockType = try container.decode(String.self, forKey: .blockType) + title = try container.decode(String.self, forKey: .title) + time = try container.decodeIfPresent(Date.self, forKey: .time) + content = try container.decodeIfPresent(String.self, forKey: .content) + unit = try container.decodeIfPresent(String.self, forKey: .unit) + mediaUrl = try container.decodeIfPresent(String.self, forKey: .mediaUrl) + references = try container.decodeIfPresent([EntityReference].self, forKey: .references) + + if let stringValue = try? container.decodeIfPresent(String.self, forKey: .value) { + value = stringValue + } else if let intValue = try? container.decodeIfPresent(Int.self, forKey: .value) { + value = String(intValue) + } else if let doubleValue = try? container.decodeIfPresent(Double.self, forKey: .value) { + value = String(doubleValue) + } else if let boolValue = try? container.decodeIfPresent(Bool.self, forKey: .value) { + value = String(boolValue) + } else { + value = nil + } } } diff --git a/Packages/SparkKit/Sources/SparkKit/Models/BlockDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/BlockDetail.swift index ff86425..0423656 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/BlockDetail.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/BlockDetail.swift @@ -20,4 +20,25 @@ public struct BlockDetail: Codable, Sendable, Hashable, Identifiable { self.event = event self.aiSummary = aiSummary } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let wrappedBlock = try container.decodeIfPresent(Block.self, forKey: .block) { + block = wrappedBlock + event = try container.decodeIfPresent(Event.self, forKey: .event) + aiSummary = try container.decodeIfPresent(String.self, forKey: .aiSummary) + } else { + block = try Block(from: decoder) + event = nil + aiSummary = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(block, forKey: .block) + try container.encodeIfPresent(event, forKey: .event) + try container.encodeIfPresent(aiSummary, forKey: .aiSummary) + } } diff --git a/Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift b/Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift index ddf1ffb..d5de22d 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift @@ -1,22 +1,138 @@ import Foundation -public struct CheckIn: Codable, Sendable { - public let slot: String - public let mood: String - public let tags: [String] - public let note: String? - public let loggedAt: Date +// MARK: - Period + +public enum CheckInPeriod: String, Codable, Sendable, CaseIterable { + case morning + case afternoon +} + +// MARK: - Submission + +public struct CheckInRequest: Encodable, Sendable { + public let period: CheckInPeriod + public let physical: Int + public let mental: Int + public let date: String + public let latitude: Double? + public let longitude: Double? + public let address: String? + public let notes: String? + + public init( + period: CheckInPeriod, + physical: Int, + mental: Int, + date: String, + latitude: Double? = nil, + longitude: Double? = nil, + address: String? = nil, + notes: String? = nil + ) { + self.period = period + self.physical = physical + self.mental = mental + self.date = date + self.latitude = latitude + self.longitude = longitude + self.address = address + self.notes = notes + } + + enum CodingKeys: String, CodingKey { + case period, physical, mental, date, latitude, longitude, address, notes + } +} + +// MARK: - Check-in event (POST response + GET status event field) + +/// Subset of the CompactEvent returned by the check-in endpoints. +/// Includes blocks so callers can extract physical/mental scores. +public struct CheckInEvent: Codable, Sendable { + public let id: String + public let action: String + public let value: String? + public let blocks: [Block] enum CodingKeys: String, CodingKey { - case slot, mood, tags, note - case loggedAt = "logged_at" + case id, action, value, blocks + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + action = try c.decode(String.self, forKey: .action) + blocks = try c.decodeIfPresent([Block].self, forKey: .blocks) ?? [] + if let s = try? c.decodeIfPresent(String.self, forKey: .value) { + value = s + } else if let i = try? c.decodeIfPresent(Int.self, forKey: .value) { + value = String(i) + } else { + value = nil + } } - public init(slot: String, mood: String, tags: [String], note: String?, loggedAt: Date = .now) { - self.slot = slot - self.mood = mood - self.tags = tags - self.note = note - self.loggedAt = loggedAt + public func physical() -> Int? { + blocks.first(where: { $0.blockType == "physical_energy" })?.value.flatMap { Int($0) } } + + public func mental() -> Int? { + blocks.first(where: { $0.blockType == "mental_energy" })?.value.flatMap { Int($0) } + } +} + +// MARK: - Today status response + +public struct CheckInPeriodDetail: Codable, Sendable { + public let completed: Bool + public let event: CheckInEvent? +} + +public struct CheckInDayResponse: Codable, Sendable { + public let date: String + public let morning: CheckInPeriodDetail + public let afternoon: CheckInPeriodDetail +} + +// MARK: - History response + +public struct CheckInHistoryPeriod: Codable, Sendable { + public let completed: Bool + public let physical: Int? + public let mental: Int? + public let combined: Int? + public let notes: String? + public let eventId: String? + + public init(completed: Bool, physical: Int? = nil, mental: Int? = nil, combined: Int? = nil, notes: String? = nil, eventId: String? = nil) { + self.completed = completed + self.physical = physical + self.mental = mental + self.combined = combined + self.notes = notes + self.eventId = eventId + } + + enum CodingKeys: String, CodingKey { + case completed, physical, mental, combined, notes + case eventId = "event_id" + } +} + +public struct CheckInHistoryDay: Codable, Sendable { + public let date: String + public let morning: CheckInHistoryPeriod + public let afternoon: CheckInHistoryPeriod + + public init(date: String, morning: CheckInHistoryPeriod, afternoon: CheckInHistoryPeriod) { + self.date = date + self.morning = morning + self.afternoon = afternoon + } +} + +public struct CheckInHistoryResponse: Codable, Sendable { + public let from: String + public let to: String + public let days: [CheckInHistoryDay] } diff --git a/Packages/SparkKit/Sources/SparkKit/Models/EntityReference.swift b/Packages/SparkKit/Sources/SparkKit/Models/EntityReference.swift new file mode 100644 index 0000000..be186b8 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/EntityReference.swift @@ -0,0 +1,56 @@ +import Foundation + +/// A reference from prose/insight content to another Spark entity. +/// Mirrors the `references` array emitted by `EntityReferenceResolver` +/// on the backend (and the web `` chip cards). +public struct EntityReference: Codable, Sendable, Hashable, Identifiable { + public let type: EntityReferenceType + public let id: String + public let title: String + public let service: String? + public let domain: String? + + enum CodingKeys: String, CodingKey { + case type, id, title, service, domain + } + + public init( + type: EntityReferenceType, + id: String, + title: String, + service: String? = nil, + domain: String? = nil + ) { + self.type = type + self.id = id + self.title = title + self.service = service + self.domain = domain + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decode(EntityReferenceType.self, forKey: .type) + id = try container.decode(String.self, forKey: .id) + title = try container.decode(String.self, forKey: .title) + service = try container.decodeIfPresent(String.self, forKey: .service) + domain = try container.decodeIfPresent(String.self, forKey: .domain) + } +} + +/// Entity kinds a reference can point at. Unknown values decode to `.unknown` +/// so a new backend type never breaks digest decoding. +public enum EntityReferenceType: String, Codable, Sendable, Hashable, CaseIterable { + case event + case object + case block + case metric + case place + case integration + case unknown + + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = EntityReferenceType(rawValue: raw) ?? .unknown + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/Event.swift b/Packages/SparkKit/Sources/SparkKit/Models/Event.swift index c1f7015..5901a79 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/Event.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/Event.swift @@ -10,29 +10,41 @@ public struct Event: Codable, Sendable, Hashable, Identifiable { public let value: String? public let unit: String? public let url: String? + public let displayName: String? + public let hidden: Bool + public let displayWithObject: Bool + public let displayValue: String? + public let tags: [EventTag] public let tldr: String? + public let blocksCount: Int? public let actor: ActorTarget? public let target: ActorTarget? enum CodingKeys: String, CodingKey { - case id, time, service, domain, action, value, unit, url, tldr, actor, target + case id, time, service, domain, action, value, unit, url, hidden, tags, tldr, actor, target + case displayName = "display_name" + case displayWithObject = "display_with_object" + case displayValue = "display_value" + case blocksCount = "blocks_count" } public struct ActorTarget: Codable, Sendable, Hashable { public let id: String public let title: String public let concept: String + public let type: String? public let mediaUrl: String? enum CodingKeys: String, CodingKey { - case id, title, concept + case id, title, concept, type case mediaUrl = "media_url" } - public init(id: String, title: String, concept: String, mediaUrl: String? = nil) { + public init(id: String, title: String, concept: String, type: String? = nil, mediaUrl: String? = nil) { self.id = id self.title = title self.concept = concept + self.type = type self.mediaUrl = mediaUrl } } @@ -46,7 +58,13 @@ public struct Event: Codable, Sendable, Hashable, Identifiable { value: String? = nil, unit: String? = nil, url: String? = nil, + displayName: String? = nil, + hidden: Bool = false, + displayWithObject: Bool = false, + displayValue: String? = nil, + tags: [EventTag] = [], tldr: String? = nil, + blocksCount: Int? = nil, actor: ActorTarget? = nil, target: ActorTarget? = nil ) { @@ -58,7 +76,13 @@ public struct Event: Codable, Sendable, Hashable, Identifiable { self.value = value self.unit = unit self.url = url + self.displayName = displayName + self.hidden = hidden + self.displayWithObject = displayWithObject + self.displayValue = displayValue + self.tags = tags self.tldr = tldr + self.blocksCount = blocksCount self.actor = actor self.target = target } @@ -72,7 +96,13 @@ public struct Event: Codable, Sendable, Hashable, Identifiable { action = try container.decode(String.self, forKey: .action) unit = try container.decodeIfPresent(String.self, forKey: .unit) url = try container.decodeIfPresent(String.self, forKey: .url) + displayName = try container.decodeIfPresent(String.self, forKey: .displayName) + hidden = try container.decodeIfPresent(Bool.self, forKey: .hidden) ?? false + displayWithObject = try container.decodeIfPresent(Bool.self, forKey: .displayWithObject) ?? false + displayValue = try container.decodeIfPresent(String.self, forKey: .displayValue) + tags = try container.decodeIfPresent([EventTag].self, forKey: .tags) ?? [] tldr = try container.decodeIfPresent(String.self, forKey: .tldr) + blocksCount = try container.decodeIfPresent(Int.self, forKey: .blocksCount) actor = try container.decodeIfPresent(ActorTarget.self, forKey: .actor) target = try container.decodeIfPresent(ActorTarget.self, forKey: .target) diff --git a/Packages/SparkKit/Sources/SparkKit/Models/EventDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/EventDetail.swift index 9d43006..8e28861 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/EventDetail.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/EventDetail.swift @@ -11,9 +11,11 @@ public struct EventDetail: Codable, Sendable, Hashable, Identifiable { public let target: ActorTarget? public let blocks: [Block] public let related: [RelatedEvent] - public let tags: [String] + public let tags: [EventTag] public let aiSummary: String? public let location: Location? + public let note: String? + public let metadata: [AnyCodable]? public var id: String { event.id } @@ -23,13 +25,22 @@ public struct EventDetail: Codable, Sendable, Hashable, Identifiable { public let subtitle: String? public let concept: String? public let type: String? + public let content: String? + public let mediaUrl: String? - public init(id: String? = nil, title: String, subtitle: String? = nil, concept: String? = nil, type: String? = nil) { + enum CodingKeys: String, CodingKey { + case id, title, subtitle, concept, type, content + case mediaUrl = "media_url" + } + + public init(id: String? = nil, title: String, subtitle: String? = nil, concept: String? = nil, type: String? = nil, content: String? = nil, mediaUrl: String? = nil) { self.id = id self.title = title self.subtitle = subtitle self.concept = concept self.type = type + self.content = content + self.mediaUrl = mediaUrl } } @@ -58,19 +69,25 @@ public struct EventDetail: Codable, Sendable, Hashable, Identifiable { } enum CodingKeys: String, CodingKey { - case event, actor, target, blocks, related, tags, location + case event, actor, target, blocks, related, tags, location, note, metadata case aiSummary = "summary_ai" } + enum NoteAliasCodingKeys: String, CodingKey { + case notes + } + public init( event: Event, actor: ActorTarget? = nil, target: ActorTarget? = nil, blocks: [Block] = [], related: [RelatedEvent] = [], - tags: [String] = [], + tags: [EventTag] = [], aiSummary: String? = nil, - location: Location? = nil + location: Location? = nil, + note: String? = nil, + metadata: [AnyCodable]? = nil ) { self.event = event self.actor = actor @@ -80,10 +97,13 @@ public struct EventDetail: Codable, Sendable, Hashable, Identifiable { self.tags = tags self.aiSummary = aiSummary self.location = location + self.note = note + self.metadata = metadata } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + let noteAliases = try decoder.container(keyedBy: NoteAliasCodingKeys.self) // Backend may return either an EventDetail envelope or a flat Event payload. let rootEvent = try container.decodeIfPresent(Event.self, forKey: .event) ?? Event(from: decoder) @@ -91,16 +111,19 @@ public struct EventDetail: Codable, Sendable, Hashable, Identifiable { actor = try container.decodeIfPresent(ActorTarget.self, forKey: .actor) ?? rootEvent.actor.map { - ActorTarget(id: $0.id, title: $0.title, subtitle: nil, concept: $0.concept, type: nil) + ActorTarget(id: $0.id, title: $0.title, subtitle: nil, concept: $0.concept, type: $0.type, mediaUrl: $0.mediaUrl) } target = try container.decodeIfPresent(ActorTarget.self, forKey: .target) ?? rootEvent.target.map { - ActorTarget(id: $0.id, title: $0.title, subtitle: nil, concept: $0.concept, type: nil) + ActorTarget(id: $0.id, title: $0.title, subtitle: nil, concept: $0.concept, type: $0.type, mediaUrl: $0.mediaUrl) } blocks = try container.decodeIfPresent([Block].self, forKey: .blocks) ?? [] related = try container.decodeIfPresent([RelatedEvent].self, forKey: .related) ?? [] - tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + tags = try container.decodeIfPresent([EventTag].self, forKey: .tags) ?? rootEvent.tags aiSummary = try container.decodeIfPresent(String.self, forKey: .aiSummary) location = try container.decodeIfPresent(Location.self, forKey: .location) + note = try container.decodeIfPresent(String.self, forKey: .note) + ?? noteAliases.decodeIfPresent(String.self, forKey: .notes) + metadata = try container.decodeIfPresent([AnyCodable].self, forKey: .metadata) } } diff --git a/Packages/SparkKit/Sources/SparkKit/Models/EventTag.swift b/Packages/SparkKit/Sources/SparkKit/Models/EventTag.swift new file mode 100644 index 0000000..5ef3a56 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/EventTag.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Tag metadata attached to compact event payloads. +/// +/// The backend now returns `{ name, type }` objects, but older endpoints and +/// cached fixtures may still return plain strings. Decode both shapes so API +/// rollout does not break list/detail screens. +public struct EventTag: Codable, Sendable, Hashable, Identifiable { + public let name: String + public let type: String? + + public var id: String { "\(type ?? ""):\(name)" } + + public init(name: String, type: String? = nil) { + self.name = name + self.type = type + } + + enum CodingKeys: String, CodingKey { + case name, type + } + + public init(from decoder: Decoder) throws { + if let string = try? decoder.singleValueContainer().decode(String.self) { + name = string + type = nil + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + type = try container.decodeIfPresent(String.self, forKey: .type) + } +} + +public extension Array where Element == EventTag { + var names: [String] { + map(\.name) + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/FlintBriefingFacts.swift b/Packages/SparkKit/Sources/SparkKit/Models/FlintBriefingFacts.swift new file mode 100644 index 0000000..7bcd496 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/FlintBriefingFacts.swift @@ -0,0 +1,201 @@ +import Foundation + +public struct FlintBriefingFacts: Sendable, Hashable { + public enum SummaryLineContext: Sendable, Hashable { + case daySoFar + case dayInReview + } + + public let date: String + public let timezone: String + public let lines: [String] + public let staleSources: [String] + public let anomalies: [String] + + public init(summary: DaySummary) { + date = summary.date + timezone = summary.timezone + staleSources = summary.syncStatus.stale ?? [] + anomalies = summary.anomalies.prefix(5).map(Self.describe(anomaly:)) + + var facts: [String] = [ + "Date: \(summary.date)", + "Timezone: \(summary.timezone)", + ] + + if let upToDate = summary.syncStatus.upToDate { + facts.append("Sync status: \(upToDate ? "up to date" : "not fully up to date")") + } + if let lastEventAt = summary.syncStatus.lastEventAt { + facts.append("Last synced event: \(ISO8601DateFormatter().string(from: lastEventAt))") + } + if !staleSources.isEmpty { + facts.append("Stale sources: \(staleSources.joined(separator: ", "))") + } + + facts.append(contentsOf: Self.describeSection("Health", summary.sections.health)) + facts.append(contentsOf: Self.describeSection("Activity", summary.sections.activity)) + facts.append(contentsOf: Self.describeSection("Money", summary.sections.money)) + facts.append(contentsOf: Self.describeSection("Media", summary.sections.media)) + facts.append(contentsOf: Self.describeSection("Knowledge", summary.sections.knowledge)) + + if anomalies.isEmpty { + facts.append("Anomalies: none reported") + } else { + facts.append("Anomalies: \(anomalies.joined(separator: "; "))") + } + + lines = facts + } + + public var promptText: String { + lines.joined(separator: "\n") + } + + public var fallbackNote: FlintDailyNote { + let highlights = Array(lines.filter { line in + !line.hasPrefix("Date:") + && !line.hasPrefix("Timezone:") + && !line.hasPrefix("Sync status:") + && !line.hasPrefix("Last synced event:") + && !line.hasPrefix("Stale sources:") + && !line.hasPrefix("Anomalies:") + }.prefix(4)) + + let watchouts: [String] + if !anomalies.isEmpty { + watchouts = anomalies + } else if !staleSources.isEmpty { + watchouts = ["Some sources are stale: \(staleSources.joined(separator: ", "))."] + } else { + watchouts = [] + } + + return FlintDailyNote( + title: "Your day so far", + summary: highlights.first ?? "Flint has your briefing data, but there is not enough signal yet for a richer note.", + highlights: highlights, + watchouts: watchouts, + suggestedActions: watchouts.isEmpty ? ["Check back after your next sync."] : ["Review the watchouts before planning the rest of your day."] + ) + } + + public func fallbackSummaryLine(context: SummaryLineContext) -> String? { + let signal = lines.first { line in + !line.hasPrefix("Date:") + && !line.hasPrefix("Timezone:") + && !line.hasPrefix("Sync status:") + && !line.hasPrefix("Last synced event:") + && !line.hasPrefix("Stale sources:") + && !line.hasPrefix("Anomalies:") + && !line.hasSuffix(": no data") + } + + if let anomaly = anomalies.first { + return switch context { + case .daySoFar: + "\(anomaly) is the main signal to keep an eye on so far." + case .dayInReview: + "\(anomaly) stood out in the day's signals." + } + } + + guard let signal else { return nil } + let cleanedSignal = cleanedSignalLine(signal) + return switch context { + case .daySoFar: + "\(cleanedSignal) is shaping the day so far." + case .dayInReview: + "\(cleanedSignal) shaped the day in review." + } + } + + private static func describeSection(_ title: String, _ section: AnyCodable?) -> [String] { + guard let object = section?.objectValue, !object.isEmpty else { + return ["\(title): no data"] + } + + let facts = object + .sorted { $0.key < $1.key } + .compactMap { key, value -> String? in + guard let text = conciseDescription(for: value), !text.isEmpty else { return nil } + return "\(humanize(key)): \(text)" + } + .prefix(6) + + let joined = facts.joined(separator: "; ") + return joined.isEmpty ? ["\(title): data present"] : ["\(title): \(joined)"] + } + + private static func conciseDescription(for value: AnyCodable) -> String? { + switch value.value { + case .null: + return nil + case let .bool(value): + return value ? "yes" : "no" + case let .int(value): + return NumberFormatter.localizedString(from: NSNumber(value: value), number: .decimal) + case let .double(value): + return NumberFormatter.localizedString(from: NSNumber(value: value), number: .decimal) + case let .string(value): + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + case let .array(values): + let items = values.compactMap(conciseDescription(for:)).prefix(3) + return items.isEmpty ? "\(values.count) items" : items.joined(separator: ", ") + case let .object(object): + if let display = firstString(in: object, keys: ["display", "display_name", "displayName", "title", "name", "summary", "label"]) { + return display + } + let parts = object + .sorted { $0.key < $1.key } + .compactMap { key, value -> String? in + guard let text = conciseDescription(for: value), !text.isEmpty else { return nil } + return "\(humanize(key)) \(text)" + } + .prefix(3) + return parts.isEmpty ? nil : parts.joined(separator: ", ") + } + } + + private static func firstString(in object: [String: AnyCodable], keys: [String]) -> String? { + for key in keys { + if let value = object[key]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty { + return value + } + } + return nil + } + + private static func describe(anomaly: Anomaly) -> String { + var parts: [String] = [] + parts.append(anomaly.displayName ?? anomaly.metric ?? "Anomaly") + if let direction = anomaly.direction { + parts.append(direction) + } + if let streakDays = anomaly.streakDays { + parts.append("\(streakDays)-day streak") + } + return parts.joined(separator: " ") + } + + private static func humanize(_ key: String) -> String { + key + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + } + + private func cleanedSignalLine(_ line: String) -> String { + let prefixes = ["Health: ", "Activity: ", "Money: ", "Media: ", "Knowledge: "] + var cleaned = line + for prefix in prefixes where cleaned.hasPrefix(prefix) { + cleaned.removeFirst(prefix.count) + break + } + if let semicolon = cleaned.firstIndex(of: ";") { + cleaned = String(cleaned[.. String { + if let string = try? decode(String.self, forKey: key) { + return string + } + if let int = try? decode(Int.self, forKey: key) { + return String(int) + } + if let double = try? decode(Double.self, forKey: key) { + return String(double) + } + throw DecodingError.typeMismatch( + String.self, + DecodingError.Context( + codingPath: codingPath + [key], + debugDescription: "Expected string-compatible value" + ) + ) + } + + func decodeLossyStringIfPresent(forKey key: Key) throws -> String? { + if try decodeNil(forKey: key) { + return nil + } + if let string = try? decode(String.self, forKey: key) { + return string + } + if let int = try? decode(Int.self, forKey: key) { + return String(int) + } + if let double = try? decode(Double.self, forKey: key) { + return String(double) + } + return nil + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/Integration.swift b/Packages/SparkKit/Sources/SparkKit/Models/Integration.swift index 09d8965..1163950 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/Integration.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/Integration.swift @@ -6,7 +6,12 @@ public struct Integration: Codable, Sendable, Hashable, Identifiable { public let service: String public let name: String public let instanceType: String? - public let status: String + public let status: String? + + public var statusValue: String { + guard let status, !status.isEmpty else { return "unknown" } + return status + } enum CodingKeys: String, CodingKey { case id, service, name, status @@ -18,7 +23,7 @@ public struct Integration: Codable, Sendable, Hashable, Identifiable { service: String, name: String, instanceType: String? = nil, - status: String + status: String? = nil ) { self.id = id self.service = service diff --git a/Packages/SparkKit/Sources/SparkKit/Models/IntegrationDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/IntegrationDetail.swift index 48f49f4..2032ac8 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/IntegrationDetail.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/IntegrationDetail.swift @@ -4,6 +4,7 @@ public enum IntegrationStatus: Sendable, Hashable { case upToDate case syncing case needsReauth + case unknown case error(String) public var label: String { @@ -11,6 +12,7 @@ public enum IntegrationStatus: Sendable, Hashable { case .upToDate: "Up to date" case .syncing: "Syncing" case .needsReauth: "Reauth required" + case .unknown: "Unknown" case .error(let msg): msg } } @@ -32,11 +34,12 @@ public struct IntegrationDetail: Codable, Sendable, Hashable, Identifiable { public var id: String { integration.id } public var status: IntegrationStatus { - switch integration.status.lowercased() { + switch integration.statusValue.lowercased() { case "up_to_date", "ok", "active": .upToDate case "syncing", "running": .syncing case "needs_reauth", "reauth", "expired": .needsReauth - default: .error(statusMessage ?? integration.status) + case "unknown": .unknown + default: .error(statusMessage ?? integration.statusValue) } } diff --git a/Packages/SparkKit/Sources/SparkKit/Models/MapDataPoint.swift b/Packages/SparkKit/Sources/SparkKit/Models/MapDataPoint.swift index e3cdebb..91caf95 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/MapDataPoint.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/MapDataPoint.swift @@ -65,3 +65,13 @@ public struct BoundingBox: Sendable, Hashable { "\(southWest.lat),\(southWest.lng),\(northEast.lat),\(northEast.lng)" } } + +public struct MapDataResponse: Decodable, Sendable { + public struct Markers: Decodable, Sendable { + public let events: [MapDataPoint] + public let places: [MapDataPoint] + } + public let markers: Markers + + public var allPoints: [MapDataPoint] { markers.events + markers.places } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/Metric.swift b/Packages/SparkKit/Sources/SparkKit/Models/Metric.swift index 90a34b6..c765f18 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/Metric.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/Metric.swift @@ -6,6 +6,7 @@ public struct Metric: Codable, Sendable, Hashable, Identifiable { public let identifier: String public let displayName: String public let service: String + public let domain: String? public let action: String public let unit: String? public let eventCount: Int @@ -13,7 +14,7 @@ public struct Metric: Codable, Sendable, Hashable, Identifiable { public let lastEventAt: Date? enum CodingKeys: String, CodingKey { - case id, identifier, service, action, unit, mean + case id, identifier, service, domain, action, unit, mean case displayName = "display_name" case eventCount = "event_count" case lastEventAt = "last_event_at" @@ -24,6 +25,7 @@ public struct Metric: Codable, Sendable, Hashable, Identifiable { identifier: String, displayName: String, service: String, + domain: String? = nil, action: String, unit: String? = nil, eventCount: Int, @@ -34,6 +36,7 @@ public struct Metric: Codable, Sendable, Hashable, Identifiable { self.identifier = identifier self.displayName = displayName self.service = service + self.domain = domain self.action = action self.unit = unit self.eventCount = eventCount diff --git a/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift index ebc40ea..146b35b 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift @@ -96,6 +96,8 @@ extension MetricDetail: Codable { let metric: String let service: String let action: String + let displayName: String? + let domain: String? let unit: String? let dailyValues: [DailyValue] let summary: Summary? @@ -103,7 +105,7 @@ extension MetricDetail: Codable { struct DailyValue: Codable { let date: String - let value: Double + let value: Double? let isAnomaly: Bool enum CodingKeys: String, CodingKey { @@ -127,7 +129,8 @@ extension MetricDetail: Codable { } enum CodingKeys: String, CodingKey { - case metric, service, action, unit, summary, baseline + case metric, service, action, domain, unit, summary, baseline + case displayName = "display_name" case dailyValues = "daily_values" } } @@ -144,7 +147,7 @@ extension MetricDetail: Codable { let api = try APIResponse(from: decoder) id = api.metric - domain = api.service + domain = api.domain ?? api.service unit = api.unit average30d = api.summary?.mean compares = nil @@ -152,7 +155,7 @@ extension MetricDetail: Codable { // Derive a human-readable title from the action field. // e.g. "had_sleep_score" → "Sleep Score", "had_heart_rate" → "Heart Rate" let stripped = api.action.hasPrefix("had_") ? String(api.action.dropFirst(4)) : api.action - title = stripped.split(separator: "_").map { $0.capitalized }.joined(separator: " ") + title = api.displayName ?? stripped.split(separator: "_").map { $0.capitalized }.joined(separator: " ") if let lo = api.baseline?.normalLower, let hi = api.baseline?.normalUpper { baseline = Baseline(low: lo, high: hi) @@ -162,8 +165,8 @@ extension MetricDetail: Codable { let fmt = Self.dateFormatter series = api.dailyValues.compactMap { dv in - guard let date = fmt.date(from: dv.date) else { return nil } - return Point(date: date, value: dv.value) + guard let date = fmt.date(from: dv.date), let value = dv.value else { return nil } + return Point(date: date, value: value) } today = series.last?.value diff --git a/Packages/SparkKit/Sources/SparkKit/Models/MetricIdentifier.swift b/Packages/SparkKit/Sources/SparkKit/Models/MetricIdentifier.swift new file mode 100644 index 0000000..c220a1d --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/MetricIdentifier.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum MetricIdentifier { + public static func from(event: Event) -> String { + "\(event.service).\(event.action)" + } + + public static func isValid(_ identifier: String) -> Bool { + split(identifier) != nil + } + + public static func split(_ identifier: String) -> (service: String, action: String)? { + let parts = identifier.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 2, + let service = parts.first, + let action = parts.last, + !service.isEmpty, + !action.isEmpty + else { + return nil + } + return (String(service), String(action)) + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/MoneyAccount.swift b/Packages/SparkKit/Sources/SparkKit/Models/MoneyAccount.swift new file mode 100644 index 0000000..b87b89a --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/MoneyAccount.swift @@ -0,0 +1,175 @@ +import Foundation + +public struct MoneyAccount: Codable, Sendable, Identifiable { + public let id: String + public let title: String + public let kind: String + public let accountType: String? + public let currency: String + public let isNegativeBalance: Bool + public let provider: String? + public let accountNumber: String? + public let sortCode: String? + public let interestRate: Double? + public let startDate: String? + public let integrationId: String? + public let latestBalance: BalanceEntry? + public let updatedAt: Date + + public init( + id: String, title: String, kind: String, accountType: String?, + currency: String, isNegativeBalance: Bool, provider: String?, + accountNumber: String?, sortCode: String?, interestRate: Double?, + startDate: String?, integrationId: String?, + latestBalance: BalanceEntry?, updatedAt: Date + ) { + self.id = id; self.title = title; self.kind = kind + self.accountType = accountType; self.currency = currency + self.isNegativeBalance = isNegativeBalance; self.provider = provider + self.accountNumber = accountNumber; self.sortCode = sortCode + self.interestRate = interestRate; self.startDate = startDate + self.integrationId = integrationId; self.latestBalance = latestBalance + self.updatedAt = updatedAt + } + + enum CodingKeys: String, CodingKey { + case id, title, kind, currency, provider + case accountType = "account_type" + case isNegativeBalance = "is_negative_balance" + case accountNumber = "account_number" + case sortCode = "sort_code" + case interestRate = "interest_rate" + case startDate = "start_date" + case integrationId = "integration_id" + case latestBalance = "latest_balance" + case updatedAt = "updated_at" + } +} + +public struct BalanceEntry: Codable, Sendable, Identifiable { + public let id: String + public let balance: Double + public let currency: String + public let time: Date + public let notes: String? +} + +public struct MoneyAccountsResponse: Codable, Sendable { + public let data: [MoneyAccount] +} + +public struct MoneyAccountResponse: Codable, Sendable { + public let data: MoneyAccount +} + +public struct BalanceEntryResponse: Codable, Sendable { + public let data: BalanceEntry +} + +public struct CreateAccountRequest: Encodable, Sendable { + public let name: String + public let accountType: String + public let currency: String + public let provider: String? + public let accountNumber: String? + public let sortCode: String? + public let interestRate: Double? + public let startDate: String? + public let isNegativeBalance: Bool + + public init( + name: String, + accountType: String, + currency: String, + provider: String? = nil, + accountNumber: String? = nil, + sortCode: String? = nil, + interestRate: Double? = nil, + startDate: String? = nil, + isNegativeBalance: Bool = false + ) { + self.name = name + self.accountType = accountType + self.currency = currency + self.provider = provider + self.accountNumber = accountNumber + self.sortCode = sortCode + self.interestRate = interestRate + self.startDate = startDate + self.isNegativeBalance = isNegativeBalance + } + + enum CodingKeys: String, CodingKey { + case name + case accountType = "account_type" + case currency + case provider + case accountNumber = "account_number" + case sortCode = "sort_code" + case interestRate = "interest_rate" + case startDate = "start_date" + case isNegativeBalance = "is_negative_balance" + } +} + +public struct UpdateAccountRequest: Encodable, Sendable { + public let name: String? + public let accountType: String? + public let currency: String? + public let provider: String? + public let accountNumber: String? + public let sortCode: String? + public let interestRate: Double? + public let startDate: String? + public let isNegativeBalance: Bool? + + public init( + name: String? = nil, + accountType: String? = nil, + currency: String? = nil, + provider: String? = nil, + accountNumber: String? = nil, + sortCode: String? = nil, + interestRate: Double? = nil, + startDate: String? = nil, + isNegativeBalance: Bool? = nil + ) { + self.name = name + self.accountType = accountType + self.currency = currency + self.provider = provider + self.accountNumber = accountNumber + self.sortCode = sortCode + self.interestRate = interestRate + self.startDate = startDate + self.isNegativeBalance = isNegativeBalance + } + + enum CodingKeys: String, CodingKey { + case name + case accountType = "account_type" + case currency + case provider + case accountNumber = "account_number" + case sortCode = "sort_code" + case interestRate = "interest_rate" + case startDate = "start_date" + case isNegativeBalance = "is_negative_balance" + } +} + +public struct MessageResponse: Decodable, Sendable { + public let message: String +} + +public struct AddBalanceRequest: Encodable, Sendable { + public let balance: Double + public let date: String + public let notes: String? + + public init(balance: Double, date: String, notes: String? = nil) { + self.balance = balance + self.date = date + self.notes = notes + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/ObjectDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/ObjectDetail.swift index 9a523ad..5c57118 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/ObjectDetail.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/ObjectDetail.swift @@ -45,4 +45,17 @@ public struct ObjectDetail: Codable, Sendable, Hashable, Identifiable { self.tags = tags self.aiSummary = aiSummary } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Backend may return either an ObjectDetail envelope or a flat + // EventObject payload with detail fields at the root. + object = try container.decodeIfPresent(EventObject.self, forKey: .object) + ?? EventObject(from: decoder) + recentEvents = try container.decodeIfPresent([Event].self, forKey: .recentEvents) ?? [] + relatedObjects = try container.decodeIfPresent([Related].self, forKey: .relatedObjects) ?? [] + tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + aiSummary = try container.decodeIfPresent(String.self, forKey: .aiSummary) + } } diff --git a/Packages/SparkKit/Sources/SparkKit/Models/RegisteredDevice.swift b/Packages/SparkKit/Sources/SparkKit/Models/RegisteredDevice.swift index 98b3685..750d94c 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/RegisteredDevice.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/RegisteredDevice.swift @@ -13,6 +13,19 @@ public struct RegisteredDevice: Codable, Sendable, Identifiable { case isCurrentDevice = "is_current_device" } + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let stringID = try? container.decode(String.self, forKey: .id) { + id = stringID + } else { + id = String(try container.decode(Int.self, forKey: .id)) + } + name = try container.decode(String.self, forKey: .name) + platform = try container.decode(String.self, forKey: .platform) + lastSeenAt = try container.decodeIfPresent(Date.self, forKey: .lastSeenAt) + isCurrentDevice = try container.decodeIfPresent(Bool.self, forKey: .isCurrentDevice) ?? false + } + public init(id: String, name: String, platform: String, lastSeenAt: Date? = nil, isCurrentDevice: Bool = false) { self.id = id self.name = name diff --git a/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift b/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift index a3eeaff..da5bacf 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift @@ -154,16 +154,16 @@ public enum SearchResult: Codable, Sendable, Hashable, Identifiable { } /// Search payload returned by `/search`. -/// Backend can return either a raw array (`[SearchResult]`) or an envelope -/// containing the array under a known key. +/// Backend returns a grouped object: `{ mode, query, events: [...], objects: [...], integrations: [...], metrics: [...] }`. +/// Legacy flat-array and wrapped-array formats are also accepted for backwards compatibility. public struct SearchResponse: Codable, Sendable, Hashable { public let results: [SearchResult] enum CodingKeys: String, CodingKey { - case results - case data - case items - case hits + // Grouped backend format + case events, objects, integrations, metrics + // Legacy wrapped formats + case results, data, items, hits } public init(results: [SearchResult]) { @@ -171,34 +171,75 @@ public struct SearchResponse: Codable, Sendable, Hashable { } public init(from decoder: Decoder) throws { + // 1. Raw array (must be checked before requesting a keyed container) if let direct = try? [SearchResult](from: decoder) { results = direct return } let container = try decoder.container(keyedBy: CodingKeys.self) - if let wrapped = try container.decodeIfPresent([SearchResult].self, forKey: .results) { - results = wrapped + + // 2. Grouped backend format: { events: [...], objects: [...], ... } + if container.contains(.events) || container.contains(.objects) + || container.contains(.integrations) || container.contains(.metrics) { + var all: [SearchResult] = [] + + for e in (try container.decodeIfPresent([BackendEvent].self, forKey: .events)) ?? [] { + all.append(.event(SearchResult.EventHit( + id: e.id ?? "", + title: e.target?.title ?? e.action ?? e.service ?? e.id ?? "", + subtitle: e.domain, + domain: e.domain + ))) + } + for o in (try container.decodeIfPresent([BackendObject].self, forKey: .objects)) ?? [] { + all.append(.object(SearchResult.ObjectHit( + id: o.id ?? "", + title: o.title ?? o.concept ?? o.id ?? "", + subtitle: o.concept, + concept: o.concept + ))) + } + for i in (try container.decodeIfPresent([BackendIntegration].self, forKey: .integrations)) ?? [] { + all.append(.integration(SearchResult.IntegrationHit( + id: i.id ?? "", + title: i.name ?? i.service ?? i.id ?? "", + subtitle: i.service, + service: i.service + ))) + } + for m in (try container.decodeIfPresent([BackendMetric].self, forKey: .metrics)) ?? [] { + all.append(.metric(SearchResult.MetricHit( + identifier: m.identifier ?? "", + title: m.displayName ?? m.identifier ?? "", + subtitle: m.unit, + domain: m.domain + ))) + } + + results = all return } + + // 3. Legacy wrapped formats + if let wrapped = try container.decodeIfPresent([SearchResult].self, forKey: .results) { + results = wrapped; return + } if let wrapped = try container.decodeIfPresent([SearchResult].self, forKey: .data) { - results = wrapped - return + results = wrapped; return } if let wrapped = try container.decodeIfPresent([SearchResult].self, forKey: .items) { - results = wrapped - return + results = wrapped; return } if let wrapped = try container.decodeIfPresent([SearchResult].self, forKey: .hits) { - results = wrapped - return + results = wrapped; return } throw DecodingError.typeMismatch( [SearchResult].self, DecodingError.Context( codingPath: decoder.codingPath, - debugDescription: "Expected search payload as array or wrapped array under results/data/items/hits." + debugDescription: "Expected search payload as grouped object, array, or wrapped array." ) ) } @@ -208,3 +249,40 @@ public struct SearchResponse: Codable, Sendable, Hashable { try single.encode(results) } } + +// MARK: - Private backend compact types + +private struct BackendEvent: Decodable { + let id: String? + let service: String? + let domain: String? + let action: String? + let target: TargetRef? + struct TargetRef: Decodable { let title: String? } +} + +private struct BackendObject: Decodable { + let id: String? + let title: String? + let concept: String? +} + +private struct BackendIntegration: Decodable { + let id: String? + let name: String? + let service: String? +} + +private struct BackendMetric: Decodable { + let identifier: String? + let displayName: String? + let unit: String? + let domain: String? + + enum CodingKeys: String, CodingKey { + case identifier + case displayName = "display_name" + case unit + case domain + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/SpendWidget.swift b/Packages/SparkKit/Sources/SparkKit/Models/SpendWidget.swift index 70b0efa..9d83abb 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/SpendWidget.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/SpendWidget.swift @@ -12,14 +12,33 @@ public struct SpendWidget: Codable, Sendable { public struct Merchant: Codable, Sendable, Identifiable { public let name: String public let total: Double - public let count: Int + public let count: Int? public var id: String { name } - public init(name: String, total: Double, count: Int) { + public init(name: String, total: Double, count: Int? = nil) { self.name = name self.total = total self.count = count } + + enum CodingKeys: String, CodingKey { + case name, total, amount, count + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + total = try container.decodeIfPresent(Double.self, forKey: .total) + ?? container.decode(Double.self, forKey: .amount) + count = try container.decodeIfPresent(Int.self, forKey: .count) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(total, forKey: .total) + try container.encodeIfPresent(count, forKey: .count) + } } enum CodingKeys: String, CodingKey { diff --git a/Packages/SparkKit/Sources/SparkKit/Models/String+ActionTitle.swift b/Packages/SparkKit/Sources/SparkKit/Models/String+ActionTitle.swift new file mode 100644 index 0000000..0f67293 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/String+ActionTitle.swift @@ -0,0 +1,39 @@ +import Foundation + +public extension String { + var sparkActionTitle: String { + let headline = replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .split(whereSeparator: \.isWhitespace) + .map { word in + let lower = word.lowercased() + guard let first = lower.first else { return "" } + return first.uppercased() + String(lower.dropFirst()) + } + + return headline.enumerated() + .map { index, word in + let lower = word.lowercased() + if index != 0, Self.minorWords.contains(lower) { + return lower + } + return word + } + .joined(separator: " ") + } + + private static var minorWords: Set { + [ + "and", "as", "but", "for", "if", "nor", "or", "so", "yet", + "a", "an", "the", + "about", "above", "across", "after", "against", "along", "among", "around", + "at", "before", "behind", "below", "beneath", "beside", "besides", "between", + "beyond", "by", "concerning", "considering", "despite", "down", "during", + "except", "following", "from", "in", "inside", "into", "like", "near", + "of", "off", "on", "onto", "opposite", "outside", "over", "past", "per", + "plus", "regarding", "round", "since", "than", "through", "to", "toward", + "under", "underneath", "unlike", "until", "up", "upon", "via", "with", + "within", "without", + ] + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/String+HTMLFragment.swift b/Packages/SparkKit/Sources/SparkKit/Models/String+HTMLFragment.swift new file mode 100644 index 0000000..7c4d6ae --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/String+HTMLFragment.swift @@ -0,0 +1,68 @@ +import Foundation + +public extension String { + var sparkPlainTextFromHTMLFragment: String { + SparkHTMLFragmentText.plainText(from: self) + } +} + +private enum SparkHTMLFragmentText { + static func plainText(from html: String) -> String { + var text = html.decodingHTMLEntities() + text = text.replacingHTMLMatches(pattern: #"(?is)<(script|style)\b[^>]*>.*?"#, with: " ") + text = text.replacingHTMLMatches(pattern: #"(?s)"#, with: " ") + text = text.replacingHTMLMatches(pattern: #"(?i)"#, with: " ") + text = text.replacingHTMLMatches(pattern: #"(?i)]*>"#, with: " ") + text = text.replacingHTMLMatches(pattern: #"<[^>]+>"#, with: "") + text = text.decodingHTMLEntities() + text = text.replacingOccurrences(of: "\u{00a0}", with: " ") + text = text.replacingHTMLMatches(pattern: #"\s+"#, with: " ") + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +private extension String { + func replacingHTMLMatches(pattern: String, with template: String) -> String { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return self } + let range = NSRange(startIndex.. String { + let namedEntities = [ + "amp": "&", + "apos": "'", + "gt": ">", + "lt": "<", + "nbsp": " ", + "quot": "\"" + ] + + var text = self + for (entity, value) in namedEntities { + text = text.replacingOccurrences(of: "&\(entity);", with: value) + } + + guard let regex = try? NSRegularExpression(pattern: #"&#(x[0-9A-Fa-f]+|\d+);"#) else { + return text + } + + let mutable = NSMutableString(string: text) + let range = NSRange(location: 0, length: mutable.length) + let matches = regex.matches(in: text, range: range) + for match in matches.reversed() { + let raw = mutable.substring(with: match.range(at: 1)) + let codePoint: UInt32? + if raw.lowercased().hasPrefix("x") { + codePoint = UInt32(raw.dropFirst(), radix: 16) + } else { + codePoint = UInt32(raw, radix: 10) + } + + guard let codePoint, let scalar = UnicodeScalar(codePoint) else { continue } + mutable.replaceCharacters(in: match.range, with: String(Character(scalar))) + } + + return mutable as String + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedCheckIn.swift b/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedCheckIn.swift new file mode 100644 index 0000000..fb795a9 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedCheckIn.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftData + +@Model +public final class CachedCheckIn { + @Attribute(.unique) public var compositeKey: String // "\(date)_\(period)" + public var date: String // YYYY-MM-DD + public var period: String // "morning" | "afternoon" + public var completed: Bool + public var physical: Int? + public var mental: Int? + public var notes: String? + public var eventId: String? + public var lastSyncedAt: Date + + public init( + date: String, + period: String, + completed: Bool, + physical: Int? = nil, + mental: Int? = nil, + notes: String? = nil, + eventId: String? = nil, + lastSyncedAt: Date = .now + ) { + self.compositeKey = "\(date)_\(period)" + self.date = date + self.period = period + self.completed = completed + self.physical = physical + self.mental = mental + self.notes = notes + self.eventId = eventId + self.lastSyncedAt = lastSyncedAt + } + + public static func upsert( + date: String, + period: CheckInPeriod, + completed: Bool, + physical: Int? = nil, + mental: Int? = nil, + notes: String? = nil, + eventId: String? = nil, + in context: ModelContext + ) { + let key = "\(date)_\(period.rawValue)" + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.compositeKey == key } + ) + if let existing = (try? context.fetch(descriptor))?.first { + existing.completed = completed + existing.physical = physical + existing.mental = mental + existing.notes = notes + existing.eventId = eventId + existing.lastSyncedAt = .now + } else { + context.insert(CachedCheckIn( + date: date, + period: period.rawValue, + completed: completed, + physical: physical, + mental: mental, + notes: notes, + eventId: eventId + )) + } + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedEvent.swift b/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedEvent.swift index 592b040..21f8476 100644 --- a/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedEvent.swift +++ b/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedEvent.swift @@ -13,8 +13,18 @@ public final class CachedEvent { public var value: String? public var unit: String? public var url: String? + public var displayName: String? + public var hidden: Bool = false + public var displayWithObject: Bool = false + public var displayValue: String? + public var tagNames: String? + public var blocksCount: Int? public var actorTitle: String? + public var actorType: String? + public var actorMediaUrl: String? public var targetTitle: String? + public var targetType: String? + public var targetMediaUrl: String? public var lastSyncedAt: Date public init( @@ -26,8 +36,18 @@ public final class CachedEvent { value: String? = nil, unit: String? = nil, url: String? = nil, + displayName: String? = nil, + hidden: Bool = false, + displayWithObject: Bool = false, + displayValue: String? = nil, + tagNames: String? = nil, + blocksCount: Int? = nil, actorTitle: String? = nil, + actorType: String? = nil, + actorMediaUrl: String? = nil, targetTitle: String? = nil, + targetType: String? = nil, + targetMediaUrl: String? = nil, lastSyncedAt: Date = .init() ) { self.id = id @@ -38,8 +58,33 @@ public final class CachedEvent { self.value = value self.unit = unit self.url = url + self.displayName = displayName + self.hidden = hidden + self.displayWithObject = displayWithObject + self.displayValue = displayValue + self.tagNames = tagNames + self.blocksCount = blocksCount self.actorTitle = actorTitle + self.actorType = actorType + self.actorMediaUrl = actorMediaUrl self.targetTitle = targetTitle + self.targetType = targetType + self.targetMediaUrl = targetMediaUrl self.lastSyncedAt = lastSyncedAt } } + +public extension CachedEvent { + var decodedTagNames: [String] { + guard let tagNames else { return [] } + return tagNames + .split(separator: "\u{1F}") + .map(String.init) + .filter { !$0.isEmpty } + } + + static func encodeTagNames(_ tags: [EventTag]) -> String? { + let names = tags.map(\.name).filter { !$0.isEmpty } + return names.isEmpty ? nil : names.joined(separator: "\u{1F}") + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedMoneyAccount.swift b/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedMoneyAccount.swift new file mode 100644 index 0000000..1cf6bde --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedMoneyAccount.swift @@ -0,0 +1,75 @@ +import Foundation +import SwiftData + +@Model +public final class CachedMoneyAccount { + @Attribute(.unique) public var id: String + public var title: String + public var kind: String + public var accountType: String? + public var currency: String + public var isNegativeBalance: Bool + public var provider: String? + public var latestBalance: Double? + public var latestBalanceTime: Date? + public var updatedAt: Date + public var lastSyncedAt: Date + + public init( + id: String, + title: String, + kind: String, + accountType: String? = nil, + currency: String, + isNegativeBalance: Bool, + provider: String? = nil, + latestBalance: Double? = nil, + latestBalanceTime: Date? = nil, + updatedAt: Date, + lastSyncedAt: Date = .now + ) { + self.id = id + self.title = title + self.kind = kind + self.accountType = accountType + self.currency = currency + self.isNegativeBalance = isNegativeBalance + self.provider = provider + self.latestBalance = latestBalance + self.latestBalanceTime = latestBalanceTime + self.updatedAt = updatedAt + self.lastSyncedAt = lastSyncedAt + } + + public static func upsert(_ account: MoneyAccount, in context: ModelContext) { + let id = account.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + if let existing = (try? context.fetch(descriptor))?.first { + existing.title = account.title + existing.kind = account.kind + existing.accountType = account.accountType + existing.currency = account.currency + existing.isNegativeBalance = account.isNegativeBalance + existing.provider = account.provider + existing.latestBalance = account.latestBalance?.balance + existing.latestBalanceTime = account.latestBalance?.time + existing.updatedAt = account.updatedAt + existing.lastSyncedAt = .now + } else { + context.insert(CachedMoneyAccount( + id: account.id, + title: account.title, + kind: account.kind, + accountType: account.accountType, + currency: account.currency, + isNegativeBalance: account.isNegativeBalance, + provider: account.provider, + latestBalance: account.latestBalance?.balance, + latestBalanceTime: account.latestBalance?.time, + updatedAt: account.updatedAt + )) + } + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Persistence/SchemaV2.swift b/Packages/SparkKit/Sources/SparkKit/Persistence/SchemaV2.swift new file mode 100644 index 0000000..c1474e6 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Persistence/SchemaV2.swift @@ -0,0 +1,10 @@ +import Foundation +import SwiftData + +public enum SparkSchemaV2: VersionedSchema { + public static let versionIdentifier = Schema.Version(2, 0, 0) + + public static var models: [any PersistentModel.Type] { + SparkSchemaV1.models + [CachedCheckIn.self] + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Persistence/SchemaV3.swift b/Packages/SparkKit/Sources/SparkKit/Persistence/SchemaV3.swift new file mode 100644 index 0000000..1bd73a1 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Persistence/SchemaV3.swift @@ -0,0 +1,10 @@ +import Foundation +import SwiftData + +public enum SparkSchemaV3: VersionedSchema { + public static let versionIdentifier = Schema.Version(3, 0, 0) + + public static var models: [any PersistentModel.Type] { + SparkSchemaV2.models + [CachedMoneyAccount.self] + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Persistence/SparkDataStore.swift b/Packages/SparkKit/Sources/SparkKit/Persistence/SparkDataStore.swift index 0146857..8c36165 100644 --- a/Packages/SparkKit/Sources/SparkKit/Persistence/SparkDataStore.swift +++ b/Packages/SparkKit/Sources/SparkKit/Persistence/SparkDataStore.swift @@ -5,7 +5,7 @@ import SwiftData /// container so the main app, widgets, and other extensions all read/write the /// same cache. public enum SparkDataStore { - public static let appGroupIdentifier = "group.co.cronx.spark" + public static let appGroupIdentifier = "group.co.cronx.sparkapp" public static let storeFilename = "Spark.sqlite" public enum StoreError: Error { @@ -25,7 +25,8 @@ public enum SparkDataStore { let url = try storeURL() let configuration = ModelConfiguration(url: url) return try ModelContainer( - for: Schema(versionedSchema: SparkSchemaV1.self), + for: Schema(versionedSchema: SparkSchemaV3.self), + migrationPlan: SparkMigrationPlan.self, configurations: configuration ) } @@ -34,7 +35,7 @@ public enum SparkDataStore { public static func makeInMemoryContainer() throws -> ModelContainer { let configuration = ModelConfiguration(isStoredInMemoryOnly: true) return try ModelContainer( - for: Schema(versionedSchema: SparkSchemaV1.self), + for: Schema(versionedSchema: SparkSchemaV3.self), configurations: configuration ) } diff --git a/Packages/SparkKit/Sources/SparkKit/Persistence/SparkMigrationPlan.swift b/Packages/SparkKit/Sources/SparkKit/Persistence/SparkMigrationPlan.swift new file mode 100644 index 0000000..0089b12 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Persistence/SparkMigrationPlan.swift @@ -0,0 +1,15 @@ +import Foundation +import SwiftData + +public enum SparkMigrationPlan: SchemaMigrationPlan { + public static var schemas: [any VersionedSchema.Type] { + [SparkSchemaV1.self, SparkSchemaV2.self, SparkSchemaV3.self] + } + + public static var stages: [MigrationStage] { + [ + .lightweight(fromVersion: SparkSchemaV1.self, toVersion: SparkSchemaV2.self), + .lightweight(fromVersion: SparkSchemaV2.self, toVersion: SparkSchemaV3.self), + ] + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/APIClientTests.swift b/Packages/SparkKit/Tests/SparkKitTests/APIClientTests.swift index 1e85659..1921972 100644 --- a/Packages/SparkKit/Tests/SparkKitTests/APIClientTests.swift +++ b/Packages/SparkKit/Tests/SparkKitTests/APIClientTests.swift @@ -5,7 +5,7 @@ import Testing @Suite("APIClient", .serialized) struct APIClientTests { private func makeStore() -> KeychainTokenStore { - let service = "co.cronx.spark.tests.api.\(UUID().uuidString)" + let service = "co.cronx.sparkapp.tests.api.\(UUID().uuidString)" return KeychainTokenStore(service: service, account: "test", accessGroup: nil) } @@ -16,9 +16,9 @@ struct APIClientTests { return ETagCache(defaults: defaults) } - private func makeSession() -> URLSession { + private func makeSession(protocolClasses: [AnyClass] = [StubURLProtocol.self]) -> URLSession { let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [StubURLProtocol.self] + config.protocolClasses = protocolClasses return URLSession(configuration: config) } @@ -27,14 +27,18 @@ struct APIClientTests { baseURL: URL(string: "https://test.spark.cronx.co/api/v1/mobile")!, oauthAuthorizeURL: URL(string: "https://test.spark.cronx.co/oauth/authorize")!, name: "test" - ) + ), + telemetry: APITelemetry = APITelemetry(), + session: URLSession? = nil, + tokenStore: KeychainTokenStore? = nil ) -> (APIClient, KeychainTokenStore) { - let tokenStore = makeStore() + let tokenStore = tokenStore ?? makeStore() let client = APIClient( environment: environment, - session: makeSession(), + session: session ?? makeSession(), tokenStore: tokenStore, - etagCache: makeCache() + etagCache: makeCache(), + telemetry: telemetry ) return (client, tokenStore) } @@ -109,6 +113,183 @@ struct APIClientTests { #expect(retryRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer new") } + @Test("concurrent 401s share one refresh request") + func concurrentUnauthorizedRequestsShareRefresh() async throws { + let (client, tokenStore) = makeClient() + await tokenStore.store(access: "old", refresh: "r-1", expiresIn: 60) + + actor Stats { + private(set) var refreshCount = 0 + private(set) var protectedAuthorizations: [String?] = [] + + func recordRefresh() -> Int { + refreshCount += 1 + return refreshCount + } + + func recordProtected(_ authorization: String?) { + protectedAuthorizations.append(authorization) + } + } + let stats = Stats() + + await StubURLProtocol.set { request in + if request.url?.path.hasSuffix("/oauth/refresh") == true { + _ = await stats.recordRefresh() + try? await Task.sleep(nanoseconds: 100_000_000) + let json = """ + {"token_type":"Bearer","access_token":"new","refresh_token":"r-2","expires_in":3600} + """.data(using: .utf8)! + return (json, 200, [:]) + } + + await stats.recordProtected(request.value(forHTTPHeaderField: "Authorization")) + if request.value(forHTTPHeaderField: "Authorization") == "Bearer old" { + return (Data(), 401, [:]) + } + + let payload = """ + {"date":"2026-04-19","timezone":"UTC","sync_status":{"in_flight":false,"last_synced_at":null,"anomaly_count":0},"sections":{},"anomalies":[]} + """.data(using: .utf8)! + return (payload, 200, [:]) + } + + let dates = try await withThrowingTaskGroup(of: String.self) { group in + for _ in 0..<5 { + group.addTask { + try await client.request(BriefingEndpoint.today()).date + } + } + + var dates: [String] = [] + for try await date in group { + dates.append(date) + } + return dates + } + + #expect(dates == Array(repeating: "2026-04-19", count: 5)) + #expect(await tokenStore.accessToken() == "new") + #expect(await tokenStore.refreshToken() == "r-2") + #expect(await stats.refreshCount == 1) + + let protectedAuthorizations = await stats.protectedAuthorizations + #expect(protectedAuthorizations.filter { $0 == "Bearer old" }.count == 5) + #expect(protectedAuthorizations.filter { $0 == "Bearer new" }.count == 5) + } + + @Test("concurrent 401s across clients share one refresh request") + func concurrentUnauthorizedRequestsAcrossClientsShareRefresh() async throws { + let tokenStore = makeStore() + await tokenStore.store(access: "old", refresh: "r-1", expiresIn: 60) + let (clientA, _) = makeClient(tokenStore: tokenStore) + let (clientB, _) = makeClient(tokenStore: tokenStore) + + actor Stats { + private(set) var refreshCount = 0 + private(set) var protectedAuthorizations: [String?] = [] + + func recordRefresh() { + refreshCount += 1 + } + + func recordProtected(_ authorization: String?) { + protectedAuthorizations.append(authorization) + } + } + let stats = Stats() + + await StubURLProtocol.set { request in + if request.url?.path.hasSuffix("/oauth/refresh") == true { + await stats.recordRefresh() + try? await Task.sleep(nanoseconds: 100_000_000) + let json = """ + {"token_type":"Bearer","access_token":"new","refresh_token":"r-2","expires_in":3600} + """.data(using: .utf8)! + return (json, 200, [:]) + } + + await stats.recordProtected(request.value(forHTTPHeaderField: "Authorization")) + if request.value(forHTTPHeaderField: "Authorization") == "Bearer old" { + return (Data(), 401, [:]) + } + + let payload = """ + {"date":"2026-04-19","timezone":"UTC","sync_status":{"in_flight":false,"last_synced_at":null,"anomaly_count":0},"sections":{},"anomalies":[]} + """.data(using: .utf8)! + return (payload, 200, [:]) + } + + let dates = try await withThrowingTaskGroup(of: String.self) { group in + group.addTask { try await clientA.request(BriefingEndpoint.today()).date } + group.addTask { try await clientB.request(BriefingEndpoint.today()).date } + + var dates: [String] = [] + for try await date in group { + dates.append(date) + } + return dates + } + + #expect(dates.sorted() == ["2026-04-19", "2026-04-19"]) + #expect(await tokenStore.accessToken() == "new") + #expect(await tokenStore.refreshToken() == "r-2") + #expect(await stats.refreshCount == 1) + + let protectedAuthorizations = await stats.protectedAuthorizations + #expect(protectedAuthorizations.filter { $0 == "Bearer old" }.count == 2) + #expect(protectedAuthorizations.filter { $0 == "Bearer new" }.count == 2) + } + + @Test("failed refresh clears stored tokens") + func failedRefreshClearsTokens() async { + let (client, tokenStore) = makeClient() + await tokenStore.store(access: "old", refresh: "r-1", expiresIn: 60) + + await StubURLProtocol.set { request in + if request.url?.path.hasSuffix("/oauth/refresh") == true { + return ( + Data(#"{"error":"invalid_grant","error_description":"Refresh token already used; all device tokens revoked."}"#.utf8), + 401, + ["Content-Type": "application/json"] + ) + } + return (Data(), 401, [:]) + } + + await #expect(throws: APIError.self) { + _ = try await client.request(BriefingEndpoint.today()) + } + + #expect(await tokenStore.accessToken() == nil) + #expect(await tokenStore.refreshToken() == nil) + } + + @Test("stale failed refresh does not clear newer stored tokens") + func staleFailedRefreshDoesNotClearNewerStoredTokens() async { + let (client, tokenStore) = makeClient() + await tokenStore.store(access: "old", refresh: "r-1", expiresIn: 60) + + await StubURLProtocol.set { request in + if request.url?.path.hasSuffix("/oauth/refresh") == true { + await tokenStore.store(access: "new", refresh: "r-2", expiresIn: 3600) + return ( + Data(#"{"error":"invalid_grant","error_description":"Refresh token already used."}"#.utf8), + 401, + ["Content-Type": "application/json"] + ) + } + return (Data(), 401, [:]) + } + + await #expect(throws: APIError.self) { + _ = try await client.request(BriefingEndpoint.today()) + } + + #expect(await tokenStore.accessToken() == "new") + #expect(await tokenStore.refreshToken() == "r-2") + } + @Test("401 without refresh token surfaces .unauthorized") func unauthorizedWithoutRefresh() async { let (client, _) = makeClient() @@ -168,4 +349,120 @@ struct APIClientTests { #expect(request.url?.host == "auth.spark.cronx.co") #expect(request.url?.path == "/oauth/token") } + + @Test("telemetry captures request and response metadata with redacted credentials") + func telemetryRedactsCredentials() async throws { + struct Response: Decodable, Sendable { + let safe: String + let accessToken: String + + enum CodingKeys: String, CodingKey { + case safe + case accessToken = "access_token" + } + } + + let sink = TestTelemetrySink() + let telemetry = APITelemetry() + await telemetry.setSink(sink) + let (client, tokenStore) = makeClient(telemetry: telemetry) + await tokenStore.store(access: "bearer-secret", refresh: "refresh-secret", expiresIn: 60) + + let requestBody = """ + {"content":"hello","refresh_token":"refresh-secret","nested":{"api_key":"key-secret"}} + """.data(using: .utf8)! + let endpoint = Endpoint( + method: .post, + path: "/telemetry", + body: requestBody, + contentType: "application/json" + ) + + await StubURLProtocol.set { _ in + ( + Data(#"{"safe":"ok","access_token":"response-secret"}"#.utf8), + 200, + ["Content-Type": "application/json", "Set-Cookie": "session=secret"] + ) + } + + let response = try await client.request(endpoint) + #expect(response.safe == "ok") + + let event = try await #require(sink.events().first) + #expect(event.outcome == .success) + #expect(event.method == "POST") + #expect(event.statusCode == 200) + #expect(event.requestHeaders["Authorization"] == "") + #expect(event.responseHeaders["Set-Cookie"] == "") + + let capturedRequestBody = String(data: try #require(event.requestBody), encoding: .utf8) ?? "" + let capturedResponseBody = String(data: try #require(event.responseBody), encoding: .utf8) ?? "" + #expect(capturedRequestBody.contains(#""content":"hello""#)) + #expect(capturedRequestBody.contains(#""refresh_token":"""#)) + #expect(capturedRequestBody.contains(#""api_key":"""#)) + #expect(capturedResponseBody.contains(#""access_token":"""#)) + #expect(!capturedRequestBody.contains("refresh-secret")) + #expect(!capturedResponseBody.contains("response-secret")) + } + + @Test("telemetry captures failed HTTP responses") + func telemetryCapturesHTTPFailures() async throws { + let sink = TestTelemetrySink() + let telemetry = APITelemetry() + await telemetry.setSink(sink) + let (client, _) = makeClient(telemetry: telemetry) + + await StubURLProtocol.set { _ in + (Data(#"{"message":"broken"}"#.utf8), 500, ["Content-Type": "application/json"]) + } + + await #expect(throws: APIError.self) { + _ = try await client.request(BriefingEndpoint.today()) + } + + let event = try await #require(sink.events().first) + #expect(event.outcome == .httpError) + #expect(event.statusCode == 500) + #expect(event.responseSizeBytes == #"{"message":"broken"}"#.utf8.count) + #expect(event.durationMillis >= 0) + } + + @Test("cancelled requests are not captured as telemetry failures") + func cancellationDoesNotCaptureTelemetryFailure() async throws { + let sink = TestTelemetrySink() + let telemetry = APITelemetry() + await telemetry.setSink(sink) + let session = makeSession(protocolClasses: [CancelledURLProtocol.self]) + let (client, _) = makeClient(telemetry: telemetry, session: session) + + await #expect(throws: APIError.self) { + _ = try await client.request(BriefingEndpoint.today()) + } + + #expect(await sink.events().isEmpty) + } +} + +private actor TestTelemetrySink: APITelemetrySink { + private var captured: [APITelemetryEvent] = [] + + func capture(_ event: APITelemetryEvent) { + captured.append(event) + } + + func events() -> [APITelemetryEvent] { + captured + } +} + +private final class CancelledURLProtocol: URLProtocol, @unchecked Sendable { + override class func canInit(with _: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + client?.urlProtocol(self, didFailWithError: URLError(.cancelled)) + } + + override func stopLoading() {} } diff --git a/Packages/SparkKit/Tests/SparkKitTests/ActionTitleFormattingTests.swift b/Packages/SparkKit/Tests/SparkKitTests/ActionTitleFormattingTests.swift new file mode 100644 index 0000000..f8298e0 --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/ActionTitleFormattingTests.swift @@ -0,0 +1,19 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("Action title formatting") +struct ActionTitleFormattingTests { + @Test("formats snake case action titles") + func formatsSnakeCaseActionTitles() { + #expect("direct_debit".sparkActionTitle == "Direct Debit") + #expect("card_payment".sparkActionTitle == "Card Payment") + } + + @Test("lowercases minor words outside first position") + func lowercasesMinorWordsOutsideFirstPosition() { + #expect("pot_transfer_to".sparkActionTitle == "Pot Transfer to") + #expect("transfer_of_money_with_card".sparkActionTitle == "Transfer of Money with Card") + #expect("to_account".sparkActionTitle == "To Account") + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/BlockDetailDecodingTests.swift b/Packages/SparkKit/Tests/SparkKitTests/BlockDetailDecodingTests.swift new file mode 100644 index 0000000..9a16d10 --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/BlockDetailDecodingTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("BlockDetail decoding") +struct BlockDetailDecodingTests { + @Test("decodes schema compact block payload") + func decodesSchemaCompactBlockPayload() throws { + let json = """ + { + "block_type": "fetch_summary_paragraph", + "content": "Thousands of young African men have signed up to fight in Moscow's war against Ukraine.", + "id": "3ffff171-d586-4f5f-977e-a8482a8995d0", + "time": "2026-05-04T15:01:08+00:00", + "title": "Paragraph Summary" + } + """ + + let detail = try makeDecoder().decode(BlockDetail.self, from: Data(json.utf8)) + + #expect(detail.id == "3ffff171-d586-4f5f-977e-a8482a8995d0") + #expect(detail.block.blockType == "fetch_summary_paragraph") + #expect(detail.block.title == "Paragraph Summary") + #expect(detail.block.content?.hasPrefix("Thousands of young African men") == true) + #expect(detail.event == nil) + #expect(detail.aiSummary == nil) + } + + @Test("decodes wrapped block detail payload") + func decodesWrappedBlockDetailPayload() throws { + let json = """ + { + "block": { + "id": "block_wrapped", + "block_type": "note", + "title": "Wrapped Block", + "time": null, + "content": "Wrapped content" + }, + "summary_ai": "Summary text" + } + """ + + let detail = try makeDecoder().decode(BlockDetail.self, from: Data(json.utf8)) + + #expect(detail.id == "block_wrapped") + #expect(detail.block.content == "Wrapped content") + #expect(detail.aiSummary == "Summary text") + } + + private func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: string) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot parse date" + ) + } + return decoder + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/DeepLinkTests.swift b/Packages/SparkKit/Tests/SparkKitTests/DeepLinkTests.swift index 44713f8..e06a026 100644 --- a/Packages/SparkKit/Tests/SparkKitTests/DeepLinkTests.swift +++ b/Packages/SparkKit/Tests/SparkKitTests/DeepLinkTests.swift @@ -83,6 +83,15 @@ struct DeepLinkTests { #expect(DeepLink.parse(url) == .metric(identifier: "sleep_score")) } + @Test("canonicalizes legacy metric deeplink aliases") + func canonicalizesLegacyMetricAliases() throws { + let sleep = try #require(URL(string: "https://spark.cronx.co/metrics/sleep.score")) + let steps = try #require(URL(string: "https://spark.cronx.co/metrics/health.steps")) + + #expect(DeepLink.parse(sleep) == .metric(identifier: "oura.sleep_score")) + #expect(DeepLink.parse(steps) == .metric(identifier: "oura.steps")) + } + @Test("parses /places/:id") func place() throws { let url = try #require(URL(string: "https://spark.cronx.co/places/plc_42")) diff --git a/Packages/SparkKit/Tests/SparkKitTests/DevicesEndpointTests.swift b/Packages/SparkKit/Tests/SparkKitTests/DevicesEndpointTests.swift new file mode 100644 index 0000000..d012662 --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/DevicesEndpointTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("Devices endpoint") +struct DevicesEndpointTests { + @Test("register endpoint decodes trace response") + func registerEndpointDecodesTraceResponse() throws { + let endpoint: Endpoint = DevicesEndpoint.register( + name: "iPhone", + platform: "ios", + apnsToken: "token", + appEnvironment: "sandbox", + appVersion: "0.1.0", + bundleId: "co.cronx.sparkapp", + osVersion: "26.5" + ) + let json = """ + { + "app_environment": "sandbox", + "device_type": "ios", + "endpoint": "805d058ee0072e38ed393c0969c22fb9c46bd76d9b5676ac5f9361722ddf04e1", + "id": 3 + } + """ + + #expect(endpoint.method == .post) + #expect(endpoint.path == "/devices") + let response = try JSONDecoder().decode(DeviceRegistrationResponse.self, from: Data(json.utf8)) + #expect(response.id == "3") + #expect(response.deviceType == "ios") + #expect(response.endpoint == "805d058ee0072e38ed393c0969c22fb9c46bd76d9b5676ac5f9361722ddf04e1") + #expect(response.appEnvironment == "sandbox") + } + + @Test("registered device decodes numeric IDs as strings") + func registeredDeviceDecodesNumericID() throws { + let json = """ + { + "id": 3, + "name": "iPhone", + "platform": "ios", + "last_seen_at": null, + "is_current_device": true + } + """ + + let device = try JSONDecoder().decode(RegisteredDevice.self, from: Data(json.utf8)) + + #expect(device.id == "3") + #expect(device.name == "iPhone") + #expect(device.platform == "ios") + #expect(device.isCurrentDevice) + } + + @Test("registered device keeps string IDs unchanged") + func registeredDeviceDecodesStringID() throws { + let json = """ + { + "id": "device_3", + "name": "iPhone", + "platform": "ios", + "last_seen_at": null, + "is_current_device": false + } + """ + + let device = try JSONDecoder().decode(RegisteredDevice.self, from: Data(json.utf8)) + + #expect(device.id == "device_3") + #expect(device.isCurrentDevice == false) + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/EventDetailDecodingTests.swift b/Packages/SparkKit/Tests/SparkKitTests/EventDetailDecodingTests.swift index 9458563..73bc429 100644 --- a/Packages/SparkKit/Tests/SparkKitTests/EventDetailDecodingTests.swift +++ b/Packages/SparkKit/Tests/SparkKitTests/EventDetailDecodingTests.swift @@ -4,6 +4,15 @@ import Testing @Suite("EventDetail decoding") struct EventDetailDecodingTests { + @Test("knowledge reprocess endpoint posts to knowledge event path") + func knowledgeReprocessEndpoint() { + let endpoint = EventsEndpoint.reprocessKnowledgeEvent(id: "evt_article") + + #expect(endpoint.method == .post) + #expect(endpoint.path == "/knowledge/events/evt_article/reprocess") + #expect(endpoint.query.isEmpty) + } + @Test("decodes wrapped detail payload") func decodesWrappedPayload() throws { let json = """ @@ -25,7 +34,7 @@ struct EventDetailDecodingTests { let detail = try JSONDecoder().decode(EventDetail.self, from: Data(json.utf8)) #expect(detail.id == "evt_wrapped") #expect(detail.event.service == "calendar") - #expect(detail.tags == ["news"]) + #expect(detail.tags.names == ["news"]) #expect(detail.aiSummary == "Summary text") } @@ -59,4 +68,216 @@ struct EventDetailDecodingTests { #expect(detail.actor?.title == "The Times") #expect(detail.target?.title == "Aurora Watch") } + + @Test("decodes compact event display metadata") + func decodesCompactEventDisplayMetadata() throws { + let json = """ + { + "id": "evt_1", + "time": null, + "service": "monzo", + "domain": "money", + "action": "card_payment", + "display_name": "Card Payment", + "hidden": false, + "display_with_object": true, + "value": -10.5, + "display_value": "£10.50", + "tags": [{ "name": "coffee", "type": "merchant_category" }], + "blocks_count": 2, + "tldr": "Coffee at Prufrock.", + "actor": { + "id": "acct_1", + "title": "Monzo", + "concept": "account", + "type": "bank_account", + "media_url": "https://cdn.example.com/monzo.png" + }, + "target": { + "id": "merchant_1", + "title": "Prufrock", + "concept": "merchant", + "type": "place", + "media_url": null + } + } + """ + + let event = try JSONDecoder().decode(Event.self, from: Data(json.utf8)) + + #expect(event.displayName == "Card Payment") + #expect(event.hidden == false) + #expect(event.displayWithObject == true) + #expect(event.displayValue == "£10.50") + #expect(event.value == "-10.5") + #expect(event.tags.names == ["coffee"]) + #expect(event.tags.first?.type == "merchant_category") + #expect(event.blocksCount == 2) + #expect(event.actor?.type == "bank_account") + #expect(event.actor?.mediaUrl == "https://cdn.example.com/monzo.png") + #expect(event.target?.type == "place") + } + + @Test("decodes legacy string tags") + func decodesLegacyStringTags() throws { + let json = """ + { + "event": { + "id": "evt_tags", + "time": null, + "service": "fetch", + "domain": "knowledge", + "action": "saved", + "tags": ["news", "swift"] + }, + "blocks": [], + "related": [], + "tags": ["news", "swift"] + } + """ + + let detail = try JSONDecoder().decode(EventDetail.self, from: Data(json.utf8)) + + #expect(detail.event.tags.names == ["news", "swift"]) + #expect(detail.tags.names == ["news", "swift"]) + } + + @Test("decodes knowledge article summary, content, raw, and takeaway blocks") + func decodesKnowledgeArticleBlocks() throws { + let json = """ + { + "event": { + "id": "evt_article", + "time": null, + "service": "fetch", + "domain": "knowledge", + "action": "saved" + }, + "blocks": [ + { + "id": "blk_summary", + "block_type": "fetch_summary_paragraph", + "title": "Summary", + "content": "A concise paragraph summary." + }, + { + "id": "blk_newsletter_summary", + "block_type": "newsletter_summary_paragraph", + "title": "Newsletter Summary", + "content": "A newsletter style summary." + }, + { + "id": "blk_content", + "block_type": "fetch_content", + "title": "Article Body", + "content": "# Heading\\n\\nReadable article body." + }, + { + "id": "blk_raw", + "block_type": "fetch_raw_content", + "title": "Raw Body", + "content": "Raw article body" + }, + { + "id": "blk_takeaways", + "block_type": "key_takeaways", + "title": "Key Takeaways", + "content": "First takeaway\\nSecond takeaway" + } + ], + "related": [], + "tags": [] + } + """ + + let detail = try JSONDecoder().decode(EventDetail.self, from: Data(json.utf8)) + + #expect(detail.blocks.map(\.id) == [ + "blk_summary", + "blk_newsletter_summary", + "blk_content", + "blk_raw", + "blk_takeaways", + ]) + #expect(detail.blocks.map(\.blockType) == [ + "fetch_summary_paragraph", + "newsletter_summary_paragraph", + "fetch_content", + "fetch_raw_content", + "key_takeaways", + ]) + #expect(detail.blocks.first?.content == "A concise paragraph summary.") + #expect(detail.blocks[2].content == "# Heading\n\nReadable article body.") + #expect(detail.blocks[3].content == "Raw article body") + #expect(detail.blocks[4].content == "First takeaway\nSecond takeaway") + } + + @Test("decodes numeric block values from event detail payloads") + func decodesNumericBlockValues() throws { + let json = """ + { + "action": "pot_transfer_to", + "blocks": [ + { + "block_type": "pot_transfer", + "id": "b63b9459-aa79-4fb8-b881-163ab82e2ada", + "time": null, + "title": "Pot Transfer", + "unit": "GBP", + "value": 2.48 + } + ], + "display_name": "Pot Transfer", + "display_value": "£<\\/span>2.48", + "domain": "money", + "hidden": false, + "id": "0c6a0d23-d5ef-497e-b969-58e6d40bb8f6", + "service": "monzo", + "tags": [], + "time": null, + "unit": "GBP", + "value": 2.48 + } + """ + + let detail = try JSONDecoder().decode(EventDetail.self, from: Data(json.utf8)) + + #expect(detail.event.value == "2.48") + #expect(detail.blocks.first?.value == "2.48") + #expect(detail.blocks.first?.unit == "GBP") + } + + @Test("decodes hidden as suppress from default feed") + func decodesHiddenAsSuppressFromDefaultFeed() throws { + let hiddenJSON = """ + { + "id": "evt_hidden", + "time": null, + "service": "monzo", + "domain": "money", + "action": "balance_update", + "display_name": "Balance Update", + "hidden": true + } + """ + let visibleJSON = """ + { + "id": "evt_visible", + "time": null, + "service": "monzo", + "domain": "money", + "action": "card_payment", + "display_name": "Card Payment", + "hidden": false + } + """ + + let hidden = try JSONDecoder().decode(Event.self, from: Data(hiddenJSON.utf8)) + let visible = try JSONDecoder().decode(Event.self, from: Data(visibleJSON.utf8)) + + #expect(hidden.hidden == true) + #expect(visible.hidden == false) + #expect(hidden.displayWithObject == false) + #expect(visible.displayWithObject == false) + } } diff --git a/Packages/SparkKit/Tests/SparkKitTests/FeedEndpointTests.swift b/Packages/SparkKit/Tests/SparkKitTests/FeedEndpointTests.swift new file mode 100644 index 0000000..5e20af4 --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/FeedEndpointTests.swift @@ -0,0 +1,18 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("Feed endpoints") +struct FeedEndpointTests { + @Test("feed endpoint carries date filter") + func feedEndpointDateFilter() { + let endpoint = FeedEndpoint.feed(cursor: "cur_1", limit: 50, domain: "money", date: "2026-05-04") + + #expect(endpoint.method == .get) + #expect(endpoint.path == "/feed") + #expect(endpoint.query.first { $0.name == "cursor" }?.value == "cur_1") + #expect(endpoint.query.first { $0.name == "limit" }?.value == "50") + #expect(endpoint.query.first { $0.name == "domain" }?.value == "money") + #expect(endpoint.query.first { $0.name == "date" }?.value == "2026-05-04") + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/FlintBriefingFactsTests.swift b/Packages/SparkKit/Tests/SparkKitTests/FlintBriefingFactsTests.swift new file mode 100644 index 0000000..8087aee --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/FlintBriefingFactsTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("Flint briefing facts") +struct FlintBriefingFactsTests { + @Test("maps key briefing sections into concise prompt facts") + func mapsSections() { + let summary = DaySummary( + date: "2026-05-07", + timezone: "Europe/London", + syncStatus: .init(upToDate: true, stale: [], lastEventAt: nil), + sections: .init( + health: .init(.object([ + "sleep_score": .init(.object(["score": .init(.int(82))])), + "sleep_duration": .init(.object(["duration_seconds": .init(.int(27_000))])), + ])), + activity: .init(.object([ + "steps": .init(.object(["value": .init(.int(8_400)), "goal": .init(.int(10_000))])), + ])), + money: .init(.object([ + "total_spend": .init(.double(24.5)), + ])), + media: nil, + knowledge: nil + ), + anomalies: [] + ) + + let facts = FlintBriefingFacts(summary: summary) + + #expect(facts.promptText.contains("Date: 2026-05-07")) + #expect(facts.promptText.contains("Health:")) + #expect(facts.promptText.contains("sleep score")) + #expect(facts.promptText.contains("Activity:")) + #expect(facts.promptText.contains("Money:")) + #expect(facts.promptText.contains("Anomalies: none reported")) + } + + @Test("fallback note includes anomalies as watchouts") + func fallbackUsesAnomalies() { + let summary = DaySummary( + date: "2026-05-07", + timezone: "Europe/London", + syncStatus: .init(upToDate: false, stale: ["healthkit"], lastEventAt: nil), + sections: .init(health: nil, activity: nil, money: nil, media: nil, knowledge: nil), + anomalies: [ + Anomaly( + id: "resting-heart-rate", + metric: "resting_heart_rate", + displayName: "Resting heart rate", + direction: "up", + deviation: 0.18, + streakDays: 2 + ), + ] + ) + + let note = FlintBriefingFacts(summary: summary).fallbackNote + + #expect(note.watchouts.contains { $0.contains("Resting heart rate") }) + #expect(note.suggestedActions.contains { $0.contains("watchouts") }) + } +} + diff --git a/Packages/SparkKit/Tests/SparkKitTests/FlintEndpointTests.swift b/Packages/SparkKit/Tests/SparkKitTests/FlintEndpointTests.swift new file mode 100644 index 0000000..fffc94c --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/FlintEndpointTests.swift @@ -0,0 +1,191 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("Flint endpoint") +struct FlintEndpointTests { + @Test("all digests endpoint includes date period and all flag") + func allDigestsEndpoint() { + let endpoint = FlintEndpoint.digests(date: "2026-05-16", period: .morning) + + #expect(endpoint.method == .get) + #expect(endpoint.path == "/flint/digests") + #expect(endpoint.query.contains(URLQueryItem(name: "date", value: "2026-05-16"))) + #expect(endpoint.query.contains(URLQueryItem(name: "period", value: "morning"))) + #expect(endpoint.query.contains(URLQueryItem(name: "all", value: "true"))) + } + + @Test("latest endpoint omits period and all flag by default") + func latestDigestEndpoint() { + let endpoint = FlintEndpoint.latestDigest(date: "2026-05-16") + + #expect(endpoint.method == .get) + #expect(endpoint.path == "/flint/digests") + #expect(endpoint.query == [URLQueryItem(name: "date", value: "2026-05-16")]) + } + + @Test("answer endpoint encodes snake case body") + func answerEndpoint() throws { + let endpoint = FlintEndpoint.answerQuestion( + blockID: "block-1", + FlintQuestionAnswerRequest(answer: "Yes", answerNote: "Felt good") + ) + let body = try #require(endpoint.body) + let object = try JSONSerialization.jsonObject(with: body) as? [String: String] + + #expect(endpoint.method == .post) + #expect(endpoint.path == "/flint/questions/block-1/answer") + #expect(endpoint.contentType == "application/json") + #expect(object?["answer"] == "Yes") + #expect(object?["answer_note"] == "Felt good") + } + + @Test("digest decodes question and content blocks") + func decodesDigest() throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let json = """ + { + "event_id": 42, + "digest_object_id": "digest-1", + "date": "2026-05-16", + "period": "morning", + "title": "Morning Digest", + "summary": "Start here.", + "created_at": "2026-05-16T08:30:00Z", + "block_count": 2, + "unanswered_question_count": 1, + "blocks": [ + { + "id": "q-1", + "block_type": "flint_user_question", + "title": "Sleep Check", + "time": "2026-05-16T08:30:00Z", + "question": "Did you sleep well?", + "topic": "health", + "priority": "high", + "answer_options": ["Yes", "No"], + "answer": null, + "answer_note": null, + "answered_at": null, + "answered": false + }, + { + "id": "note-1", + "block_type": "flint_editorial_note", + "title": "Context", + "time": "2026-05-16T08:31:00Z", + "content": "**Hydrate** early." + } + ] + } + """ + + let digest = try decoder.decode(FlintDigest.self, from: Data(json.utf8)) + + #expect(digest.eventID == "42") + #expect(digest.period == .morning) + #expect(digest.unansweredQuestionCount == 1) + #expect(digest.blocks[0].isQuestion) + #expect(digest.blocks[0].priority == .high) + #expect(digest.blocks[0].answerOptions == ["Yes", "No"]) + #expect(digest.blocks[1].blockType == "flint_editorial_note") + #expect(digest.blocks[1].content == "**Hydrate** early.") + } + + @Test("content block decodes entity references with unknown-type fallback") + func decodesBlockReferences() throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let json = """ + { + "event_id": "e-1", + "digest_object_id": null, + "date": "2026-05-16", + "period": "morning", + "title": "Morning Digest", + "created_at": "2026-05-16T08:30:00Z", + "block_count": 1, + "unanswered_question_count": 0, + "blocks": [ + { + "id": "ins-1", + "block_type": "flint_health_insight", + "title": "Health", + "content": "You did a [Morning Walk](https://spark.cronx.co/event/abc) today.", + "references": [ + {"type": "event", "id": "abc", "title": "Morning Walk", "service": "Strava", "domain": "health"}, + {"type": "starship", "id": "xyz", "title": "Future Thing"} + ] + } + ] + } + """ + + let digest = try decoder.decode(FlintDigest.self, from: Data(json.utf8)) + let refs = try #require(digest.blocks[0].references) + + #expect(refs.count == 2) + #expect(refs[0].type == .event) + #expect(refs[0].id == "abc") + #expect(refs[0].title == "Morning Walk") + #expect(refs[0].service == "Strava") + #expect(refs[0].domain == "health") + #expect(refs[1].type == .unknown) + #expect(refs[1].service == nil) + } + + @Test("content block without references decodes to nil") + func decodesBlockWithoutReferences() throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let json = """ + { + "event_id": "e-1", + "digest_object_id": null, + "date": "2026-05-16", + "title": "Digest", + "created_at": "2026-05-16T08:30:00Z", + "block_count": 1, + "unanswered_question_count": 0, + "blocks": [ + {"id": "n-1", "block_type": "flint_editorial_note", "title": "Note", "content": "Plain."} + ] + } + """ + + let digest = try decoder.decode(FlintDigest.self, from: Data(json.utf8)) + #expect(digest.blocks[0].references == nil) + } + + @Test("all response decodes digest list") + func decodesDigestList() throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let json = """ + { + "date": "2026-05-16", + "count": 1, + "digests": [ + { + "event_id": "event-1", + "digest_object_id": null, + "date": "2026-05-16", + "period": "evening", + "title": "Evening Digest", + "summary": null, + "created_at": "2026-05-16T20:00:00Z", + "block_count": 0, + "unanswered_question_count": 0, + "blocks": [] + } + ] + } + """ + + let response = try decoder.decode(FlintDigestListResponse.self, from: Data(json.utf8)) + + #expect(response.count == 1) + #expect(response.digests.first?.period == .evening) + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/HTMLFragmentTextTests.swift b/Packages/SparkKit/Tests/SparkKitTests/HTMLFragmentTextTests.swift new file mode 100644 index 0000000..f568972 --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/HTMLFragmentTextTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import SparkKit + +@Suite("HTML fragment text") +struct HTMLFragmentTextTests { + @Test("strips tags and preserves visible text") + func stripsTagsAndPreservesVisibleText() { + let html = #"80 bpm"# + + #expect(html.sparkPlainTextFromHTMLFragment == "80 bpm") + } + + @Test("decodes entities and escaped tags") + func decodesEntitiesAndEscapedTags() { + let html = #"97<span class="text-sm">%</span> & steady"# + + #expect(html.sparkPlainTextFromHTMLFragment == "97% & steady") + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/IntegrationsDecodingTests.swift b/Packages/SparkKit/Tests/SparkKitTests/IntegrationsDecodingTests.swift new file mode 100644 index 0000000..59dd289 --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/IntegrationsDecodingTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("Integrations decoding") +struct IntegrationsDecodingTests { + @Test("list decodes data envelope with null status") + func listDecodesDataEnvelopeWithNullStatus() throws { + let json = """ + { + "data": [ + { + "id": "fba067e7-675e-4675-8af2-4ae3e6f9f75e", + "instance_type": "metrics", + "name": "Metrics", + "service": "apple_health", + "status": null + } + ] + } + """ + + let response = try JSONDecoder().decode(IntegrationsEndpoint.ListResponse.self, from: Data(json.utf8)) + + #expect(response.data.count == 1) + #expect(response.data[0].id == "fba067e7-675e-4675-8af2-4ae3e6f9f75e") + #expect(response.data[0].status == nil) + #expect(response.data[0].statusValue == "unknown") + } + + @Test("list decodes non-null status") + func listDecodesNonNullStatus() throws { + let json = """ + { + "data": [ + { + "id": "integration_1", + "instance_type": "metrics", + "name": "Metrics", + "service": "apple_health", + "status": "active" + } + ] + } + """ + + let response = try JSONDecoder().decode(IntegrationsEndpoint.ListResponse.self, from: Data(json.utf8)) + + #expect(response.data[0].status == "active") + #expect(response.data[0].statusValue == "active") + } + + @Test("detail maps null integration status to unknown") + func detailMapsNullIntegrationStatusToUnknown() throws { + let json = """ + { + "integration": { + "id": "integration_1", + "instance_type": "metrics", + "name": "Metrics", + "service": "apple_health", + "status": null + }, + "last_sync_at": null, + "coverage_percent": null, + "recent_events": [], + "oauth_start_url": null, + "domain": "health", + "status_message": null + } + """ + + let detail = try JSONDecoder().decode(IntegrationDetail.self, from: Data(json.utf8)) + + #expect(detail.integration.status == nil) + #expect(detail.status == .unknown) + #expect(detail.status.label == "Unknown") + } + + @Test("status mapping covers known statuses and unknown strings") + func statusMappingCoversKnownStatusesAndUnknownStrings() { + #expect(Integration(id: "1", service: "s", name: "n", status: nil).statusValue == "unknown") + #expect(IntegrationDetail(integration: Integration(id: "2", service: "s", name: "n", status: "active")).status == .upToDate) + #expect(IntegrationDetail(integration: Integration(id: "3", service: "s", name: "n", status: "syncing")).status == .syncing) + #expect(IntegrationDetail(integration: Integration(id: "4", service: "s", name: "n", status: "needs_reauth")).status == .needsReauth) + #expect(IntegrationDetail(integration: Integration(id: "5", service: "s", name: "n", status: "broken")).status == .error("broken")) + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/KeychainTokenStoreTests.swift b/Packages/SparkKit/Tests/SparkKitTests/KeychainTokenStoreTests.swift index 2b4fca5..9307bbb 100644 --- a/Packages/SparkKit/Tests/SparkKitTests/KeychainTokenStoreTests.swift +++ b/Packages/SparkKit/Tests/SparkKitTests/KeychainTokenStoreTests.swift @@ -8,8 +8,7 @@ struct KeychainTokenStoreTests { /// the production Keychain item or bleed state between test cases. Using /// `accessGroup: nil` sidesteps the signed-entitlement requirement when /// running under `swift test` without a provisioning profile. - private func makeStore() -> KeychainTokenStore { - let service = "co.cronx.spark.tests.\(UUID().uuidString)" + private func makeStore(service: String = "co.cronx.sparkapp.tests.\(UUID().uuidString)") -> KeychainTokenStore { return KeychainTokenStore(service: service, account: "test", accessGroup: nil) } @@ -42,6 +41,23 @@ struct KeychainTokenStoreTests { await store.clear() } + @Test("separate store instances observe token rotation") + func independentInstancesObserveRotation() async { + let service = "co.cronx.sparkapp.tests.\(UUID().uuidString)" + let first = makeStore(service: service) + let second = makeStore(service: service) + + await first.store(access: "old", refresh: "old-r", expiresIn: 1) + #expect(await second.refreshToken() == "old-r") + + await first.store(access: "new", refresh: "new-r", expiresIn: 7200) + #expect(await second.accessToken() == "new") + #expect(await second.refreshToken() == "new-r") + + await first.clear() + #expect(await second.tokens() == nil) + } + @Test("clear wipes stored tokens") func clear() async { let store = makeStore() diff --git a/Packages/SparkKit/Tests/SparkKitTests/MetricIdentifierTests.swift b/Packages/SparkKit/Tests/SparkKitTests/MetricIdentifierTests.swift new file mode 100644 index 0000000..71e5937 --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/MetricIdentifierTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("Metric identifiers") +struct MetricIdentifierTests { + @Test("builds direct identifier from event service and action") + func buildsDirectIdentifierFromEvent() { + let event = Event( + id: "evt_sleep", + time: nil, + service: "oura", + domain: "health", + action: "sleep_score" + ) + + #expect(MetricIdentifier.from(event: event) == "oura.sleep_score") + } + + @Test("splits exact two-part metric identifiers") + func splitsExactIdentifier() throws { + let parts = try #require(MetricIdentifier.split("oura.sleep_score")) + + #expect(parts.service == "oura") + #expect(parts.action == "sleep_score") + } + + @Test("rejects ambiguous identifiers") + func rejectsAmbiguousIdentifiers() { + #expect(MetricIdentifier.split("sleep_score") == nil) + #expect(MetricIdentifier.split("oura.") == nil) + #expect(MetricIdentifier.split(".sleep_score") == nil) + #expect(MetricIdentifier.split("oura.sleep.score") == nil) + } + + @Test("rejects unit suffixed metric identifiers") + func rejectsUnitSuffixedIdentifiers() { + #expect(!MetricIdentifier.isValid("apple_health.had_stair_speed_up.m/s")) + #expect(!MetricIdentifier.isValid("apple_health.had_physical_effort.kcal/hr·kg")) + } + + @Test("accepts service action metric identifiers") + func acceptsServiceActionIdentifiers() { + #expect(MetricIdentifier.isValid("apple_health.had_stair_speed_up")) + #expect(MetricIdentifier.isValid("apple_health.had_physical_effort")) + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/MetricsEndpointTests.swift b/Packages/SparkKit/Tests/SparkKitTests/MetricsEndpointTests.swift new file mode 100644 index 0000000..b8d4d4e --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/MetricsEndpointTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("Metrics endpoints") +struct MetricsEndpointTests { + @Test("list endpoint targets bare metrics collection") + func listEndpoint() { + let endpoint = MetricsEndpoint.list() + + #expect(endpoint.method == .get) + #expect(endpoint.path == "/metrics") + #expect(endpoint.query.isEmpty) + } + + @Test("detail endpoint carries requested range") + func detailEndpointRange() throws { + let endpoint = MetricsEndpoint.detail(identifier: "oura.sleep_score", range: .sevenDays) + + #expect(endpoint.method == .get) + #expect(endpoint.path == "/metrics/oura.sleep_score") + let range = try #require(endpoint.query.first { $0.name == "range" }) + #expect(range.value == "7d") + } + + @Test("detail endpoint canonicalizes legacy identifiers") + func detailEndpointCanonicalizesLegacyIdentifiers() { + #expect(MetricsEndpoint.detail(identifier: "sleep.score").path == "/metrics/oura.sleep_score") + #expect(MetricsEndpoint.detail(identifier: "health.steps").path == "/metrics/oura.steps") + #expect(MetricsEndpoint.detail(identifier: "money.spend").path == "/metrics/monzo.spend_daily") + } + + @Test("metric decodes mobile metadata") + func metricDecodesMobileMetadata() throws { + let json = """ + [ + { + "id": "met_1", + "identifier": "oura.sleep_score", + "display_name": "Sleep Score", + "service": "oura", + "domain": "health", + "action": "sleep_score", + "unit": "score", + "event_count": 42, + "mean": 86.4, + "last_event_at": "2026-05-03T12:09:00Z" + } + ] + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let metrics = try decoder.decode([Metric].self, from: Data(json.utf8)) + let metric = try #require(metrics.first) + + #expect(metric.identifier == "oura.sleep_score") + #expect(metric.displayName == "Sleep Score") + #expect(metric.domain == "health") + #expect(metric.eventCount == 42) + #expect(metric.lastEventAt != nil) + } + + @Test("metric detail skips null daily values but preserves null-valued anomalies") + func metricDetailDecodesNullDailyValues() throws { + let json = """ + { + "metric": "oura.spo2", + "service": "oura", + "action": "had_spo2", + "display_name": "SpO2", + "domain": "health", + "unit": "%", + "baseline": { + "normal_lower": 96.2, + "normal_upper": 99.7 + }, + "daily_values": [ + { + "date": "2026-03-02", + "value": 96.642, + "is_anomaly": false + }, + { + "date": "2026-03-03", + "value": null, + "is_anomaly": true + }, + { + "date": "2026-03-04", + "value": 97.957, + "is_anomaly": false + } + ] + } + """ + + let detail = try JSONDecoder().decode(MetricDetail.self, from: Data(json.utf8)) + + #expect(detail.id == "oura.spo2") + #expect(detail.series.map(\.value) == [96.642, 97.957]) + #expect(detail.today == 97.957) + #expect(detail.yesterday == 96.642) + #expect(detail.anomalies.count == 1) + #expect(detail.anomalies.first?.id == "2026-03-03") + #expect(detail.anomalies.first?.value == nil) + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/ObjectDetailDecodingTests.swift b/Packages/SparkKit/Tests/SparkKitTests/ObjectDetailDecodingTests.swift new file mode 100644 index 0000000..e2fbab2 --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/ObjectDetailDecodingTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("ObjectDetail decoding") +struct ObjectDetailDecodingTests { + @Test("decodes flat object detail payload") + func decodesFlatObjectDetailPayload() throws { + let json = """ + { + "id": "obj_1", + "concept": "bookmark", + "type": "fetch_webpage", + "title": "AI Is African Intelligence", + "time": "2026-05-03T08:45:11+00:00", + "content": "# Heading\\n\\nFull article body", + "url": "https://www.404media.co/story", + "media_url": "https://cdn.example.com/image.jpeg", + "recent_events": [ + { + "id": "evt_1", + "time": "2026-05-03T08:45:26+00:00", + "service": "fetch", + "domain": "knowledge", + "action": "bookmarked" + } + ] + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: string) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot parse date" + ) + } + + let detail = try decoder.decode(ObjectDetail.self, from: Data(json.utf8)) + #expect(detail.id == "obj_1") + #expect(detail.object.content == "# Heading\n\nFull article body") + #expect(detail.object.url == "https://www.404media.co/story") + #expect(detail.recentEvents.count == 1) + #expect(detail.relatedObjects.isEmpty) + #expect(detail.tags.isEmpty) + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/SearchResponseDecodingTests.swift b/Packages/SparkKit/Tests/SparkKitTests/SearchResponseDecodingTests.swift index b7aaa93..7e1bf0c 100644 --- a/Packages/SparkKit/Tests/SparkKitTests/SearchResponseDecodingTests.swift +++ b/Packages/SparkKit/Tests/SparkKitTests/SearchResponseDecodingTests.swift @@ -35,4 +35,87 @@ struct SearchResponseDecodingTests { Issue.record("Expected an integration hit.") } } + + @Test("decodes grouped backend format") + func decodesGroupedBackendPayload() throws { + let json = """ + { + "mode": "default", + "query": "Test", + "events": [ + { + "id": "evt_1", + "service": "monzo", + "domain": "money", + "action": "purchase", + "target": { "id": "obj_1", "title": "Costa Coffee", "concept": "merchant" } + } + ], + "objects": [ + { + "id": "obj_2", + "concept": "article", + "type": "knowledge", + "title": "Testing in Swift" + } + ], + "integrations": [ + { + "id": "int_1", + "service": "monzo", + "name": "Monzo", + "instance_type": "bank", + "status": "active" + } + ], + "metrics": [ + { + "id": "met_1", + "identifier": "oura.sleep_score", + "display_name": "Sleep Score", + "service": "oura", + "domain": "health", + "action": "sleep_score", + "unit": "points", + "event_count": 30, + "mean": 82.5, + "last_event_at": "2026-05-03T00:00:00Z" + } + ] + } + """ + + let response = try JSONDecoder().decode(SearchResponse.self, from: Data(json.utf8)) + #expect(response.results.count == 4) + + if case .event(let hit) = response.results[0] { + #expect(hit.title == "Costa Coffee") + #expect(hit.domain == "money") + } else { + Issue.record("Expected an event hit at index 0.") + } + + if case .object(let hit) = response.results[1] { + #expect(hit.title == "Testing in Swift") + #expect(hit.concept == "article") + } else { + Issue.record("Expected an object hit at index 1.") + } + + if case .integration(let hit) = response.results[2] { + #expect(hit.title == "Monzo") + #expect(hit.service == "monzo") + } else { + Issue.record("Expected an integration hit at index 2.") + } + + if case .metric(let hit) = response.results[3] { + #expect(hit.identifier == "oura.sleep_score") + #expect(hit.title == "Sleep Score") + #expect(hit.subtitle == "points") + #expect(hit.domain == "health") + } else { + Issue.record("Expected a metric hit at index 3.") + } + } } diff --git a/Packages/SparkKit/Tests/SparkKitTests/SpendWidgetDecodingTests.swift b/Packages/SparkKit/Tests/SparkKitTests/SpendWidgetDecodingTests.swift new file mode 100644 index 0000000..eab9a08 --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/SpendWidgetDecodingTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("Spend widget decoding") +struct SpendWidgetDecodingTests { + @Test("decodes production merchant amount payload") + func decodesProductionMerchantAmountPayload() throws { + let json = """ + { + "currency": "GBP", + "date": "2026-05-06", + "top_merchants": [ + { + "amount": 407023.43, + "name": "2026-05-06" + }, + { + "amount": 5.59, + "name": "Daybridge" + } + ], + "total": 407031.95, + "transaction_count": 65, + "unit": "GBP" + } + """ + + let widget = try JSONDecoder().decode(SpendWidget.self, from: Data(json.utf8)) + + #expect(widget.currency == "GBP") + #expect(widget.total == 407031.95) + #expect(widget.transactionCount == 65) + #expect(widget.topMerchants.count == 2) + #expect(widget.topMerchants[0].name == "2026-05-06") + #expect(widget.topMerchants[0].total == 407023.43) + #expect(widget.topMerchants[0].count == nil) + } + + @Test("still decodes legacy merchant total and count payload") + func decodesLegacyMerchantTotalPayload() throws { + let json = """ + { + "currency": "GBP", + "date": "2026-05-06", + "top_merchants": [ + { + "count": 2, + "name": "Coffee", + "total": 8.4 + } + ], + "total": 8.4, + "transaction_count": 2, + "unit": "GBP" + } + """ + + let widget = try JSONDecoder().decode(SpendWidget.self, from: Data(json.utf8)) + + #expect(widget.topMerchants[0].total == 8.4) + #expect(widget.topMerchants[0].count == 2) + } +} diff --git a/Packages/SparkSync/Sources/SparkSync/BGTaskCoordinator.swift b/Packages/SparkSync/Sources/SparkSync/BGTaskCoordinator.swift index f54d203..382e911 100644 --- a/Packages/SparkSync/Sources/SparkSync/BGTaskCoordinator.swift +++ b/Packages/SparkSync/Sources/SparkSync/BGTaskCoordinator.swift @@ -7,10 +7,10 @@ import WidgetKit /// Manages the two background task identifiers Spark registers with the OS. /// -/// `co.cronx.spark.refresh` — BGAppRefreshTask, fires ~every 2 h. +/// `co.cronx.sparkapp.refresh` — BGAppRefreshTask, fires ~every 2 h. /// Fetches /sync/delta, writes to SwiftData, reloads widget timelines. /// -/// `co.cronx.spark.prefetch` — BGProcessingTask, fires nightly when on +/// `co.cronx.sparkapp.prefetch` — BGProcessingTask, fires nightly when on /// power + Wi-Fi. Runs the optional Spotlight indexing closure provided /// by the app target, then pre-warms image caches. /// @@ -18,10 +18,10 @@ import WidgetKit /// `BGTaskCoordinator.register(...)` inside `SparkAppDelegate.application(_:didFinishLaunchingWithOptions:)` /// or `SparkApp.init()` before the method returns. public enum BGTaskCoordinator { - public static let refreshTaskIdentifier = "co.cronx.spark.refresh" - public static let prefetchTaskIdentifier = "co.cronx.spark.prefetch" + public static let refreshTaskIdentifier = "co.cronx.sparkapp.refresh" + public static let prefetchTaskIdentifier = "co.cronx.sparkapp.prefetch" - private static let logger = Logger(subsystem: "co.cronx.spark", category: "BGTask") + private static let logger = Logger(subsystem: "co.cronx.sparkapp", category: "BGTask") // MARK: - Registration diff --git a/Packages/SparkSync/Sources/SparkSync/DeltaSyncer.swift b/Packages/SparkSync/Sources/SparkSync/DeltaSyncer.swift index 1417f82..8e1da06 100644 --- a/Packages/SparkSync/Sources/SparkSync/DeltaSyncer.swift +++ b/Packages/SparkSync/Sources/SparkSync/DeltaSyncer.swift @@ -8,7 +8,7 @@ import SwiftData /// All SwiftData operations run on the MainActor so the ModelContext is /// never accessed across thread-suspension points. public enum DeltaSyncer { - private static let logger = Logger(subsystem: "co.cronx.spark", category: "DeltaSyncer") + private static let logger = Logger(subsystem: "co.cronx.sparkapp", category: "DeltaSyncer") /// Fetches new events from the server and applies them to the local cache. /// Returns `true` if any records were written, `false` for no-change or error. @@ -68,8 +68,17 @@ public enum DeltaSyncer { existing.value = event.value existing.unit = event.unit existing.url = event.url + existing.displayName = event.displayName + existing.hidden = event.hidden + existing.displayValue = event.displayValue + existing.tagNames = CachedEvent.encodeTagNames(event.tags) + existing.blocksCount = event.blocksCount existing.actorTitle = event.actor?.title + existing.actorType = event.actor?.type + existing.actorMediaUrl = event.actor?.mediaUrl existing.targetTitle = event.target?.title + existing.targetType = event.target?.type + existing.targetMediaUrl = event.target?.mediaUrl existing.lastSyncedAt = syncedAt } else { context.insert(CachedEvent( @@ -81,8 +90,17 @@ public enum DeltaSyncer { value: event.value, unit: event.unit, url: event.url, + displayName: event.displayName, + hidden: event.hidden, + displayValue: event.displayValue, + tagNames: CachedEvent.encodeTagNames(event.tags), + blocksCount: event.blocksCount, actorTitle: event.actor?.title, + actorType: event.actor?.type, + actorMediaUrl: event.actor?.mediaUrl, targetTitle: event.target?.title, + targetType: event.target?.type, + targetMediaUrl: event.target?.mediaUrl, lastSyncedAt: syncedAt )) } diff --git a/Packages/SparkSync/Sources/SparkSync/ReverbClient.swift b/Packages/SparkSync/Sources/SparkSync/ReverbClient.swift index 21115b4..ed99072 100644 --- a/Packages/SparkSync/Sources/SparkSync/ReverbClient.swift +++ b/Packages/SparkSync/Sources/SparkSync/ReverbClient.swift @@ -34,7 +34,7 @@ public actor ReverbClient { private let environment: APIEnvironment private let tokenStore: KeychainTokenStore private let session: URLSession - private let logger = Logger(subsystem: "co.cronx.spark", category: "ReverbClient") + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "ReverbClient") private var socketTask: URLSessionWebSocketTask? private var receiveLoopTask: Task? @@ -64,6 +64,11 @@ public actor ReverbClient { // MARK: - Public API + /// Current connection state — safe to call from any context. + public func connectionStatus() async -> (isConnected: Bool, socketId: String?) { + (isConnected, socketId) + } + /// Register a handler that receives every broadcast event. Thread-safe. public func addHandler(_ handler: @escaping EventHandler) { handlers.append(handler) @@ -95,6 +100,7 @@ public actor ReverbClient { socketTask = session.webSocketTask(with: request) socketTask?.resume() logger.info("Reverb socket opened → \(url.absoluteString, privacy: .public)") + await captureSocketTelemetry(url: url, outcome: .success) startReceiveLoop() startPingLoop() } @@ -124,6 +130,11 @@ public actor ReverbClient { } catch { if Task.isCancelled { return } logger.warning("Reverb receive error: \(error, privacy: .public)") + await captureSocketTelemetry( + url: task.currentRequest?.url ?? environment.reverbWebSocketURL, + outcome: .transportError, + errorDescription: String(describing: error) + ) scheduleReconnect() return } @@ -233,17 +244,81 @@ public actor ReverbClient { request.httpBody = "channel_name=\(channel)&socket_id=\(socketId)".data(using: .utf8) + let startedAt = Date() do { let (data, response) = try await URLSession.shared.data(for: request) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { return nil } + let http = response as? HTTPURLResponse + await captureAuthTelemetry( + request: request, + response: http, + data: data, + startedAt: startedAt, + outcome: http?.statusCode == 200 ? .success : .httpError + ) + guard http?.statusCode == 200 else { return nil } let authResponse = try JSONDecoder().decode(AuthResponse.self, from: data) return authResponse.auth } catch { logger.error("Reverb auth request failed: \(error, privacy: .public)") + await captureAuthTelemetry( + request: request, + response: nil, + data: nil, + startedAt: startedAt, + outcome: .transportError, + errorDescription: String(describing: error) + ) return nil } } + private func captureAuthTelemetry( + request: URLRequest, + response: HTTPURLResponse?, + data: Data?, + startedAt: Date, + outcome: APITelemetryEvent.Outcome, + errorDescription: String? = nil + ) async { + await APITelemetry.shared.capture( + APITelemetryEvent( + operation: "http.client.reverb_auth", + method: request.httpMethod ?? "POST", + url: APITelemetryRedactor.url(request.url ?? environment.reverbHTTPBaseURL), + endpointPath: "/broadcasting/auth", + requiresAuth: true, + requestHeaders: APITelemetryRedactor.headers(request.allHTTPHeaderFields ?? [:]), + requestBody: APITelemetryRedactor.body(request.httpBody, contentType: request.value(forHTTPHeaderField: "Content-Type")), + statusCode: response?.statusCode, + responseHeaders: APITelemetryRedactor.headers(response?.stringHeaderFields ?? [:]), + responseBody: APITelemetryRedactor.body(data, contentType: response?.value(forHTTPHeaderField: "Content-Type")), + responseSizeBytes: data?.count ?? 0, + durationMillis: Date().timeIntervalSince(startedAt) * 1_000, + outcome: outcome, + errorDescription: errorDescription + ) + ) + } + + private func captureSocketTelemetry( + url: URL, + outcome: APITelemetryEvent.Outcome, + errorDescription: String? = nil + ) async { + await APITelemetry.shared.capture( + APITelemetryEvent( + operation: "websocket.reverb", + method: "WEBSOCKET", + url: APITelemetryRedactor.url(url), + endpointPath: "/app/{key}", + requiresAuth: false, + durationMillis: 0, + outcome: outcome, + errorDescription: errorDescription + ) + ) + } + // MARK: - Ping loop private func startPingLoop() { @@ -325,3 +400,12 @@ public actor ReverbClient { let auth: String } } + +private extension HTTPURLResponse { + var stringHeaderFields: [String: String] { + Dictionary(uniqueKeysWithValues: allHeaderFields.compactMap { key, value in + guard let key = key as? String else { return nil } + return (key, String(describing: value)) + }) + } +} diff --git a/Packages/SparkSync/Sources/SparkSync/SilentPushHandler.swift b/Packages/SparkSync/Sources/SparkSync/SilentPushHandler.swift index 6d9873b..34920ed 100644 --- a/Packages/SparkSync/Sources/SparkSync/SilentPushHandler.swift +++ b/Packages/SparkSync/Sources/SparkSync/SilentPushHandler.swift @@ -16,7 +16,7 @@ import UIKit /// /// Wire in `SparkAppDelegate.application(_:didReceiveRemoteNotification:fetchCompletionHandler:)`. public enum SilentPushHandler { - private static let logger = Logger(subsystem: "co.cronx.spark", category: "SilentPush") + private static let logger = Logger(subsystem: "co.cronx.sparkapp", category: "SilentPush") private static let signposter = OSSignposter(logger: logger) /// All mutable handler state lives on the MainActor — both tasks are diff --git a/Packages/SparkUI/Sources/SparkUI/Charts/BalanceAreaChart.swift b/Packages/SparkUI/Sources/SparkUI/Charts/BalanceAreaChart.swift new file mode 100644 index 0000000..064bb34 --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Charts/BalanceAreaChart.swift @@ -0,0 +1,53 @@ +import Charts +import SwiftUI + +public struct BalanceAreaChart: View { + public struct Point: Identifiable { + public let id: UUID + public let date: Date + public let value: Double + + public init(id: UUID = UUID(), date: Date, value: Double) { + self.id = id + self.date = date + self.value = value + } + } + + let data: [Point] + let tint: Color + let showXAxis: Bool + + public init(data: [Point], tint: Color, showXAxis: Bool = false) { + self.data = data + self.tint = tint + self.showXAxis = showXAxis + } + + public var body: some View { + Chart(data) { point in + AreaMark( + x: .value("Date", point.date), + y: .value("Balance", point.value) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle( + LinearGradient( + colors: [tint.opacity(0.35), tint.opacity(0.0)], + startPoint: .top, + endPoint: .bottom + ) + ) + + LineMark( + x: .value("Date", point.date), + y: .value("Balance", point.value) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(tint) + .lineStyle(StrokeStyle(lineWidth: 2, lineJoin: .round)) + } + .chartXAxis(showXAxis ? .automatic : .hidden) + .chartYAxis(.hidden) + } +} diff --git a/Packages/SparkUI/Sources/SparkUI/Components/EntityRefChip.swift b/Packages/SparkUI/Sources/SparkUI/Components/EntityRefChip.swift new file mode 100644 index 0000000..8f40642 --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Components/EntityRefChip.swift @@ -0,0 +1,147 @@ +import SwiftUI +import SparkKit + +/// Inline reference pill — the iOS analogue of the web `` chip +/// card. Domain-tinted glass capsule with a leading glyph, the entity title, +/// and an optional service badge. +public struct EntityRefChip: View { + public let reference: EntityReference + + public init(_ reference: EntityReference) { + self.reference = reference + } + + private var tint: Color { EntityPresentation.tint(for: reference) } + + public var body: some View { + HStack(spacing: SparkSpacing.xs) { + Image(systemName: EntityPresentation.icon(for: reference)) + .font(.system(size: 11, weight: .semibold)) + .opacity(0.8) + Text(reference.title) + .lineLimit(1) + if let service = reference.service, !service.isEmpty { + Text(service) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + .font(SparkTypography.captionStrong) + .foregroundStyle(tint) + .padding(.horizontal, SparkSpacing.md - 2) + .padding(.vertical, SparkSpacing.xs + 1) + .sparkGlass(.capsule, tint: tint.opacity(0.15)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(reference.title)\(reference.service.map { ", \($0)" } ?? "")") + .accessibilityAddTraits(.isButton) + } +} + +/// Wrapping cluster of `EntityRefChip`s with an optional leading label +/// (e.g. "Connecting:"), mirroring the web insight-block reference row. +/// Navigation-free: the host supplies `onTap`. +public struct EntityRefChipRow: View { + public let label: String? + public let references: [EntityReference] + public let onTap: (EntityReference) -> Void + + public init( + label: String? = nil, + references: [EntityReference], + onTap: @escaping (EntityReference) -> Void + ) { + self.label = label + self.references = references + self.onTap = onTap + } + + public var body: some View { + if !references.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + if let label { + Text(label) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + FlowLayout(spacing: SparkSpacing.xs + 2) { + ForEach(references) { reference in + Button { + onTap(reference) + } label: { + EntityRefChip(reference) + } + .buttonStyle(.plain) + .contextMenu { + Button { + onTap(reference) + } label: { + Label("Open", systemImage: "arrow.up.forward.app") + } + } preview: { + EntityPreviewCard(reference: reference) + } + } + } + } + } + } +} + +/// Lightweight peek preview shown on long-press of a reference chip — the +/// iOS analogue of the web hover popover. Built only from the fields the +/// reference carries (no fetch), so it appears instantly. +public struct EntityPreviewCard: View { + public let reference: EntityReference + + public init(reference: EntityReference) { + self.reference = reference + } + + private var tint: Color { EntityPresentation.tint(for: reference) } + + public var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + HStack(spacing: SparkSpacing.sm) { + DomainGlyph(icon: EntityPresentation.icon(for: reference), tint: tint, size: 30) + VStack(alignment: .leading, spacing: SparkSpacing.xxs) { + Text(reference.title) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + Text(reference.type.rawValue.capitalized) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + if reference.service != nil || reference.domain != nil { + HStack(spacing: SparkSpacing.sm) { + if let service = reference.service, !service.isEmpty { + Label(service, systemImage: "app.connected.to.app.below.fill") + } + if let domain = reference.domain, !domain.isEmpty { + Label(domain.capitalized, systemImage: "circle.fill") + .foregroundStyle(tint) + } + } + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + } + } + .padding(SparkSpacing.lg) + .frame(minWidth: 240, alignment: .leading) + .background(Color.sparkElevated) + } +} + +#Preview { + EntityRefChipRow( + label: "Connecting:", + references: [ + EntityReference(type: .event, id: "1", title: "Morning Walk", service: "Strava", domain: "activity"), + EntityReference(type: .event, id: "2", title: "Slept 7h 42m", service: "Oura", domain: "health"), + EntityReference(type: .object, id: "3", title: "Flat White", service: "Monzo", domain: "money"), + ], + onTap: { _ in } + ) + .padding() + .background(Color.sparkSurface) +} diff --git a/Packages/SparkUI/Sources/SparkUI/Components/SparkRichContentText.swift b/Packages/SparkUI/Sources/SparkUI/Components/SparkRichContentText.swift new file mode 100644 index 0000000..534bf66 --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Components/SparkRichContentText.swift @@ -0,0 +1,246 @@ +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +public struct SparkRichContentText: View { + public let text: String + public var font: Font + public var foregroundStyle: Color + public var lineSpacing: CGFloat + public var linkTint: Color + public var recognizesLink: ((URL) -> Bool)? + + public init( + text: String, + font: Font = SparkTypography.body, + foregroundStyle: Color = .primary, + lineSpacing: CGFloat = 6, + linkTint: Color = .sparkAccent, + recognizesLink: ((URL) -> Bool)? = nil + ) { + self.text = text + self.font = font + self.foregroundStyle = foregroundStyle + self.lineSpacing = lineSpacing + self.linkTint = linkTint + self.recognizesLink = recognizesLink + } + + public var body: some View { + Text(styled(Self.rendered(text))) + .font(font) + .foregroundStyle(foregroundStyle) + .lineSpacing(lineSpacing) + .tint(linkTint) + .fixedSize(horizontal: false, vertical: true) + } + + /// Restyle recognised deep links as inline chip-like tokens: tinted and + /// semibold, underline removed. The `.link` attribute is preserved so a + /// tap still flows through the host's `\.openURL` action — that is where + /// in-app navigation is wired, keeping SparkUI free of routing logic. + private func styled(_ attributed: AttributedString) -> AttributedString { + var attributed = attributed + let isRecognised = recognizesLink ?? Self.defaultRecognizer + + for run in attributed.runs where run.link != nil { + guard let url = run.link, isRecognised(url) else { continue } + attributed[run.range].foregroundColor = linkTint + attributed[run.range].font = SparkTypography.bodyStrong + attributed[run.range].underlineStyle = nil + } + + return attributed + } + + private static func defaultRecognizer(_ url: URL) -> Bool { + url.host()?.hasSuffix("spark.cronx.co") ?? false + } + + public static func rendered(_ text: String) -> AttributedString { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + + #if canImport(UIKit) + if looksLikeHTML(trimmed), + let data = trimmed.data(using: .utf8), + let attributed = try? NSAttributedString( + data: data, + options: [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue, + ], + documentAttributes: nil + ) { + return AttributedString(attributed) + } + #endif + + if let attributed = try? AttributedString(markdown: trimmed) { + return attributed + } + + return AttributedString(trimmed) + } + + private static func looksLikeHTML(_ text: String) -> Bool { + text.range(of: #"<[a-zA-Z][\s\S]*>"#, options: .regularExpression) != nil + } +} + +public struct SparkLongFormContentView: View { + public let text: String + public var tint: Color + public var paragraphFont: Font + + private var blocks: [SparkLongFormBlock] { + SparkLongFormBlock.parse(text) + } + + public init( + text: String, + tint: Color = .sparkAccent, + paragraphFont: Font = SparkTypography.longFormBody + ) { + self.text = text + self.tint = tint + self.paragraphFont = paragraphFont + } + + public var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + switch block { + case .heading(let text, let level): + SparkRichContentText( + text: text, + font: level == 1 + ? SparkFonts.display(.title2, weight: .bold) + : SparkFonts.display(.title3, weight: .bold), + foregroundStyle: .primary, + lineSpacing: 2 + ) + .padding(.top, level == 1 ? SparkSpacing.sm : SparkSpacing.xs) + + case .paragraph(let text): + SparkRichContentText( + text: text, + font: paragraphFont, + foregroundStyle: .primary, + lineSpacing: 9 + ) + + case .quote(let text): + HStack(alignment: .top, spacing: SparkSpacing.md) { + Rectangle() + .fill(tint) + .frame(width: 3) + .clipShape(.capsule) + SparkRichContentText( + text: text, + font: SparkTypography.longFormQuote, + foregroundStyle: .secondary, + lineSpacing: 9 + ) + } + + case .bullets(let bullets): + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + ForEach(bullets, id: \.self) { bullet in + HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.sm) { + Text("•") + .font(SparkTypography.bodyStrong) + .foregroundStyle(tint) + SparkRichContentText( + text: bullet, + font: paragraphFont, + foregroundStyle: .primary, + lineSpacing: 7 + ) + } + } + } + } + } + } + .padding(.horizontal, SparkSpacing.xs) + } +} + +public enum SparkLongFormBlock: Sendable, Hashable { + case heading(String, level: Int) + case paragraph(String) + case quote(String) + case bullets([String]) + + public static func parse(_ text: String) -> [SparkLongFormBlock] { + let normalized = text + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard !normalized.isEmpty else { return [] } + + let rawBlocks = normalized.components(separatedBy: "\n\n") + var output: [SparkLongFormBlock] = [] + + for rawBlock in rawBlocks { + let trimmed = rawBlock.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + let lines = trimmed + .components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + guard !lines.isEmpty else { continue } + + if lines.count == 1, let heading = heading(from: lines[0]) { + output.append(.heading(heading.text, level: heading.level)) + continue + } + + if lines.allSatisfy({ $0.hasPrefix(">") }) { + let text = lines + .map { String($0.drop(while: { $0 == ">" || $0 == " " })) } + .joined(separator: "\n") + output.append(.quote(text)) + continue + } + + if lines.allSatisfy(isBulletLine) { + output.append(.bullets(lines.map(stripBulletPrefix))) + continue + } + + output.append(.paragraph(lines.joined(separator: "\n"))) + } + + return output + } + + private static func heading(from line: String) -> (text: String, level: Int)? { + if line.hasPrefix("### ") { + return (String(line.dropFirst(4)), 3) + } + if line.hasPrefix("## ") { + return (String(line.dropFirst(3)), 2) + } + if line.hasPrefix("# ") { + return (String(line.dropFirst(2)), 1) + } + return nil + } + + private static func isBulletLine(_ line: String) -> Bool { + line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("• ") + } + + private static func stripBulletPrefix(_ line: String) -> String { + if isBulletLine(line) { + return String(line.dropFirst(2)) + } + return line + } +} diff --git a/Packages/SparkUI/Sources/SparkUI/Theme/EntityPresentation.swift b/Packages/SparkUI/Sources/SparkUI/Theme/EntityPresentation.swift new file mode 100644 index 0000000..3b0929e --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Theme/EntityPresentation.swift @@ -0,0 +1,65 @@ +import SwiftUI +import SparkKit + +/// Single source of truth for mapping a Spark entity's domain/service/action +/// to its tint colour and SF Symbol. Consolidates logic that was duplicated +/// in `MetricPresentation` and `Color.domainTint(for:)`. +public enum EntityPresentation { + public static func tint(domain: String?) -> Color { + switch (domain ?? "").lowercased() { + case "health": .domainHealth + case "activity": .domainActivity + case "money": .domainMoney + case "media": .domainMedia + case "knowledge": .domainKnowledge + case "anomaly": .domainAnomaly + default: .sparkAccent + } + } + + /// Action-keyword matching takes priority (preserves prior + /// `MetricPresentation.icon` behaviour exactly), then domain, then a + /// generic per-entity-type fallback for references that carry no action. + public static func icon( + domain: String? = nil, + service: String? = nil, + action: String? = nil, + type: String? = nil + ) -> String { + let action = action ?? "" + let domain = (domain ?? "").lowercased() + let service = service ?? "" + + if action.localizedCaseInsensitiveContains("sleep") { return "moon.zzz.fill" } + if action.localizedCaseInsensitiveContains("heart") { return "heart.fill" } + if action.localizedCaseInsensitiveContains("hrv") { return "waveform.path.ecg" } + if action.localizedCaseInsensitiveContains("step") { return "figure.walk" } + if action.localizedCaseInsensitiveContains("calorie") { return "flame.fill" } + if service.localizedCaseInsensitiveContains("monzo") || domain == "money" { return "sterlingsign.circle.fill" } + if domain == "media" { return "iphone" } + if domain == "activity" { return "figure.run" } + if domain == "health" { return "heart.text.square.fill" } + + switch (type ?? "").lowercased() { + case "event": return "bolt.fill" + case "object": return "cube.fill" + case "block": return "square.grid.2x2.fill" + case "place": return "mappin.circle.fill" + case "integration": return "puzzlepiece.extension.fill" + default: return "chart.line.uptrend.xyaxis" + } + } + + public static func icon(for reference: EntityReference) -> String { + icon( + domain: reference.domain, + service: reference.service, + action: nil, + type: reference.type.rawValue + ) + } + + public static func tint(for reference: EntityReference) -> Color { + tint(domain: reference.domain) + } +} diff --git a/Packages/SparkUI/Sources/SparkUI/Theme/SparkAppBackground.swift b/Packages/SparkUI/Sources/SparkUI/Theme/SparkAppBackground.swift new file mode 100644 index 0000000..c7b2d95 --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Theme/SparkAppBackground.swift @@ -0,0 +1,189 @@ +import SparkKit +import SwiftUI + +public enum SparkAppBackgroundMode: String, CaseIterable, Identifiable, Sendable { + case auto + case morning + case day + case evening + case night + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .auto: "Auto" + case .morning: "Morning" + case .day: "Day" + case .evening: "Evening" + case .night: "Night" + } + } +} + +public enum SparkAppBackgroundPhase: String, CaseIterable, Sendable { + case morning + case day + case eveningLight + case eveningDark + case night + + public static func resolve( + mode: SparkAppBackgroundMode, + date: Date = .now, + calendar: Calendar = .current, + colorScheme: ColorScheme + ) -> SparkAppBackgroundPhase { + switch mode { + case .auto: + return autoPhase(date: date, calendar: calendar, colorScheme: colorScheme) + case .morning: + return .morning + case .day: + return .day + case .evening: + return colorScheme == .dark ? .eveningDark : .eveningLight + case .night: + return .night + } + } + + private static func autoPhase( + date: Date, + calendar: Calendar, + colorScheme: ColorScheme + ) -> SparkAppBackgroundPhase { + let hour = calendar.component(.hour, from: date) + + if colorScheme == .dark { + return (6..<22).contains(hour) ? .eveningDark : .night + } + + switch hour { + case 6..<10: + return .morning + case 10..<17: + return .day + default: + return .eveningLight + } + } +} + +public struct SparkAppBackground: View { + public let phase: SparkAppBackgroundPhase + + public init(phase: SparkAppBackgroundPhase) { + self.phase = phase + } + + public var body: some View { + ZStack { + Color.sparkSurface + gradient + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + } + + private var gradient: some View { + LinearGradient( + colors: colors, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + private var colors: [Color] { + switch phase { + case .morning: + [Color.ocean100.opacity(0.24), Color.spark100.opacity(0.18), Color.clear] + case .day: + [Color.domainMoney.opacity(0.16), Color.spark100.opacity(0.16), Color.clear] + case .eveningLight: + [ + Color.ember100.opacity(0.22), + Color.flame100.opacity(0.18), + Color.spark200.opacity(0.12), + Color.clear, + ] + case .eveningDark: + [Color.domainMoney.opacity(0.34), Color.ocean950, Color.black.opacity(0.45)] + case .night: + [ + Color.ocean800.opacity(0.70), + Color.slate700.opacity(0.90), + Color.ocean950, + Color.black.opacity(0.50), + ] + } + } +} + +public struct SparkResolvedAppBackground: View { + public let date: Date + + @Environment(\.colorScheme) private var colorScheme + @AppStorage("spark.background.mode", store: .sparkAppGroup) + private var storedMode = SparkAppBackgroundMode.auto.rawValue + + public init(date: Date = .now) { + self.date = date + } + + public var body: some View { + let mode = SparkAppBackgroundMode(rawValue: storedMode) ?? .auto + let phase = SparkAppBackgroundPhase.resolve( + mode: mode, + date: date, + colorScheme: colorScheme + ) + + SparkAppBackground(phase: phase) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .environment(\.colorScheme, phase == .night ? .dark : colorScheme) + } +} + +public struct SparkResolvedStatusBarBackground: View { + public let date: Date + + public init(date: Date = .now) { + self.date = date + } + + public var body: some View { + GeometryReader { proxy in + VStack(spacing: 0) { + SparkResolvedAppBackground(date: date) + .frame(height: proxy.safeAreaInsets.top) + .ignoresSafeArea(edges: .top) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(edges: .top) + } + .allowsHitTesting(false) + } +} + +public extension View { + func sparkAppBackground(date: Date = .now) -> some View { + modifier(SparkAppBackgroundModifier(date: date)) + } +} + +private struct SparkAppBackgroundModifier: ViewModifier { + let date: Date + + @Environment(\.colorScheme) private var colorScheme + @AppStorage("spark.background.mode", store: .sparkAppGroup) + private var storedMode = SparkAppBackgroundMode.auto.rawValue + + func body(content: Content) -> some View { + content + .scrollContentBackground(.hidden) + .background(SparkResolvedAppBackground(date: date)) + } +} diff --git a/Packages/SparkUI/Sources/SparkUI/Theme/Typography.swift b/Packages/SparkUI/Sources/SparkUI/Theme/Typography.swift index 1517cac..328e890 100644 --- a/Packages/SparkUI/Sources/SparkUI/Theme/Typography.swift +++ b/Packages/SparkUI/Sources/SparkUI/Theme/Typography.swift @@ -22,6 +22,11 @@ public enum SparkTypography { public static let caption = Font.system(.caption) public static let captionStrong = Font.system(.caption).weight(.semibold) + // Long-form reading — New York-style system serif for article/digest prose. + public static let longFormBody = Font.system(.title3, design: .serif) + public static let longFormBodySmall = Font.system(.body, design: .serif) + public static let longFormQuote = Font.system(.title3, design: .serif).italic() + // Technical — PT Mono. Used for timestamps, IDs, all-caps section labels. public static let mono = SparkFonts.mono(.footnote) public static let monoSmall = SparkFonts.mono(.caption2) diff --git a/Packages/SparkUI/Tests/SparkUITests/SparkUITests.swift b/Packages/SparkUI/Tests/SparkUITests/SparkUITests.swift index 45726f4..bac3fa2 100644 --- a/Packages/SparkUI/Tests/SparkUITests/SparkUITests.swift +++ b/Packages/SparkUI/Tests/SparkUITests/SparkUITests.swift @@ -1,4 +1,87 @@ import Testing +import SwiftUI @testable import SparkUI -// Placeholder — add component snapshot tests here +@Suite("Spark app background phase") +struct SparkAppBackgroundPhaseTests { + @Test("auto light mode resolves expected day phases") + func autoLightModePhases() throws { + #expect(phase(hour: 5, colorScheme: .light) == .eveningLight) + #expect(phase(hour: 6, colorScheme: .light) == .morning) + #expect(phase(hour: 9, colorScheme: .light) == .morning) + #expect(phase(hour: 10, colorScheme: .light) == .day) + #expect(phase(hour: 16, colorScheme: .light) == .day) + #expect(phase(hour: 17, colorScheme: .light) == .eveningLight) + #expect(phase(hour: 22, colorScheme: .light) == .eveningLight) + } + + @Test("auto dark mode resolves evening and night phases") + func autoDarkModePhases() throws { + #expect(phase(hour: 5, colorScheme: .dark) == .night) + #expect(phase(hour: 6, colorScheme: .dark) == .eveningDark) + #expect(phase(hour: 21, colorScheme: .dark) == .eveningDark) + #expect(phase(hour: 22, colorScheme: .dark) == .night) + } + + @Test("manual modes resolve independently of auto time buckets") + func manualModePhases() throws { + let date = try #require(Self.date(hour: 12)) + + #expect(SparkAppBackgroundPhase.resolve(mode: .morning, date: date, colorScheme: .dark) == .morning) + #expect(SparkAppBackgroundPhase.resolve(mode: .day, date: date, colorScheme: .dark) == .day) + #expect(SparkAppBackgroundPhase.resolve(mode: .evening, date: date, colorScheme: .light) == .eveningLight) + #expect(SparkAppBackgroundPhase.resolve(mode: .evening, date: date, colorScheme: .dark) == .eveningDark) + #expect(SparkAppBackgroundPhase.resolve(mode: .night, date: date, colorScheme: .light) == .night) + } + + private func phase(hour: Int, colorScheme: ColorScheme) throws -> SparkAppBackgroundPhase { + let date = try #require(Self.date(hour: hour)) + return SparkAppBackgroundPhase.resolve( + mode: .auto, + date: date, + calendar: Self.calendar, + colorScheme: colorScheme + ) + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static func date(hour: Int) -> Date? { + DateComponents(calendar: calendar, timeZone: calendar.timeZone, year: 2026, month: 5, day: 4, hour: hour).date + } +} + +@Suite("Spark long-form content parsing") +struct SparkLongFormContentParsingTests { + @Test("parses headings paragraphs quotes and bullets") + func parsesLongFormBlocks() { + let blocks = SparkLongFormBlock.parse(""" + # Digest + + The day started well. + + > Keep an eye on recovery. + + - Hydrate + - Read later + """) + + #expect(blocks == [ + .heading("Digest", level: 1), + .paragraph("The day started well."), + .quote("Keep an eye on recovery."), + .bullets(["Hydrate", "Read later"]), + ]) + } + + @Test("plain markdown inline text stays a paragraph") + func parsesInlineMarkdownAsParagraph() { + let blocks = SparkLongFormBlock.parse("This has **emphasis** but remains one paragraph.") + + #expect(blocks == [.paragraph("This has **emphasis** but remains one paragraph.")]) + } +} diff --git a/Project.swift b/Project.swift index 89837f0..6dd5248 100644 --- a/Project.swift +++ b/Project.swift @@ -4,11 +4,12 @@ import ProjectDescription let appIdentifierPrefix = "$(AppIdentifierPrefix)" let organizationName = "Cronx" -let bundleIdBase = "co.cronx.spark" -let appGroup = "group.co.cronx.spark" +let developmentTeam = "SHZS45BR7Q" // William Scott +let bundleIdBase = "co.cronx.sparkapp" +let appGroup = "group.co.cronx.sparkapp" let keychainGroup = "\(appIdentifierPrefix)\(bundleIdBase)" let associatedDomain = "applinks:spark.cronx.co" -let iosDeploymentTarget: DeploymentTargets = .iOS("26.0") +let iosDeploymentTarget: DeploymentTargets = .iOS("26.4") let watchDeploymentTarget: DeploymentTargets = .watchOS("26.0") // MARK: - Entitlements builders @@ -58,17 +59,17 @@ func appInfoPlist() -> InfoPlist { "NSLocationWhenInUseUsageDescription": "Spark uses your location to tag check-ins and detect place visits.", "BGTaskSchedulerPermittedIdentifiers": [ - "co.cronx.spark.refresh", - "co.cronx.spark.prefetch", + "co.cronx.sparkapp.refresh", + "co.cronx.sparkapp.prefetch", ], "NSUserActivityTypes": [ - "co.cronx.spark.openToday", - "co.cronx.spark.openEvent", + "co.cronx.sparkapp.openToday", + "co.cronx.sparkapp.openEvent", "com.apple.corespotlight.search-continue", ], "CFBundleURLTypes": [ [ - "CFBundleURLName": "co.cronx.spark.oauth", + "CFBundleURLName": "co.cronx.sparkapp.oauth", "CFBundleURLSchemes": ["spark"], ], ], @@ -169,7 +170,15 @@ let baseSettings: SettingsDictionary = [ "GCC_TREAT_WARNINGS_AS_ERRORS": "YES", "ENABLE_USER_SCRIPT_SANDBOXING": "YES", "CODE_SIGN_STYLE": "Automatic", - "DEVELOPMENT_TEAM": "$(DEVELOPMENT_TEAM)", + "DEVELOPMENT_TEAM": .string(developmentTeam), +] + +let debugSettings: SettingsDictionary = [ + "APS_ENVIRONMENT": "development", +] + +let releaseSettings: SettingsDictionary = [ + "APS_ENVIRONMENT": "production", ] func sharedSettings(bundleId: String) -> Settings { @@ -178,8 +187,8 @@ func sharedSettings(bundleId: String) -> Settings { "PRODUCT_BUNDLE_IDENTIFIER": .string(bundleId), ]), configurations: [ - .debug(name: "Debug"), - .release(name: "Release"), + .debug(name: "Debug", settings: debugSettings), + .release(name: "Release", settings: releaseSettings), ] ) } @@ -217,8 +226,8 @@ let sparkApp: Target = .target( "ASSETCATALOG_COMPILER_APPICON_NAME": "SparkIcon", ]), configurations: [ - .debug(name: "Debug"), - .release(name: "Release"), + .debug(name: "Debug", settings: debugSettings), + .release(name: "Release", settings: releaseSettings), ] ) ) diff --git a/README.md b/README.md index 774b96c..0523f0e 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,9 @@ tuist generate ### Provisioning -Every target shares the App Group `group.co.cronx.spark`, the Keychain access group `$(AppIdentifierPrefix)co.cronx.spark`, and the associated domain `applinks:spark.cronx.co`. If you're running on a personal team: +Every target uses the William Scott development team declared in `Project.swift`, and shares the App Group `group.co.cronx.sparkapp`, the Keychain access group `$(AppIdentifierPrefix)co.cronx.spark`, and the associated domain `applinks:spark.cronx.co`. -1. In Xcode, select each target → Signing & Capabilities → pick your Team. -2. Let Xcode regenerate provisioning profiles. The App Group, Keychain Sharing, Associated Domains, Push Notifications, and HealthKit capabilities are already declared — Xcode will just need to register the group IDs under your team. -3. `DEVELOPMENT_TEAM` is read from your Xcode user settings; no changes to `Project.swift` required. +After `tuist generate`, Xcode should keep the team selection automatically. The App Group, Keychain Sharing, Associated Domains, Push Notifications, and HealthKit capabilities are already declared in the generated targets. ### Environment overrides @@ -50,7 +48,7 @@ cd Packages/SparkKit && swift test xcodebuild \ -workspace Spark.xcworkspace \ -scheme SparkApp \ - -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=26.0' \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' \ -skipPackagePluginValidation \ -skipMacroValidation \ test diff --git a/SparkApp/Sources/App/AppModel.swift b/SparkApp/Sources/App/AppModel.swift index b884ce8..8c5b252 100644 --- a/SparkApp/Sources/App/AppModel.swift +++ b/SparkApp/Sources/App/AppModel.swift @@ -22,6 +22,7 @@ enum AppRoute: Hashable { case metric(identifier: String) case place(id: String) case integration(service: String) + case account(id: String) } @MainActor @@ -55,6 +56,15 @@ final class AppModel { var onboardingComplete: Bool var lastError: String? var pendingRoute: AppRoute? + private(set) var profile: UserProfile? { + didSet { + if let name = profile?.name { + UserDefaults.sparkAppGroup.set(name, forKey: "spark.profile.name") + } + } + } + private var deviceRegistrationTask: Task? + private var deviceRegistrationTokenInFlight: String? init(container: ModelContainer) { self.container = container @@ -66,10 +76,14 @@ final class AppModel { self.apiClient = client self.authService = AuthenticationService(tokenStore: tokenStore, apiClient: client) self.reverb = ReverbClient(tokenStore: tokenStore) - self.onboardingComplete = UserDefaults(suiteName: "group.co.cronx.spark")?.bool(forKey: "onboarding.completed") == true + self.onboardingComplete = UserDefaults(suiteName: "group.co.cronx.sparkapp")?.bool(forKey: "onboarding.completed") == true + if let cachedName = UserDefaults.sparkAppGroup.string(forKey: "spark.profile.name"), !cachedName.isEmpty { + self.profile = UserProfile(id: "", name: cachedName, email: "") + } } func bootstrap() async { + clearLegacyCheckInKeysIfNeeded() if let token = await tokenStore.accessToken() { onboardingComplete = true session = .loggedIn @@ -84,6 +98,16 @@ final class AppModel { } } + private static let legacyCheckInMigrationKey = "spark.checkin.legacyCleared.v1" + + private func clearLegacyCheckInKeysIfNeeded() { + let defaults = UserDefaults.sparkAppGroup + guard !defaults.bool(forKey: Self.legacyCheckInMigrationKey) else { return } + let legacyKeys = defaults.dictionaryRepresentation().keys.filter { $0.hasPrefix("checkin_") } + for key in legacyKeys { defaults.removeObject(forKey: key) } + defaults.set(true, forKey: Self.legacyCheckInMigrationKey) + } + private func wireReverbHandler() async { let client = apiClient let cont = container @@ -117,7 +141,7 @@ final class AppModel { /// Read a route written by an AppIntent (from the extension process) and /// navigate to it. Consumed once to prevent stale navigation on re-launch. private func consumePendingIntentRoute() { - let defaults = UserDefaults(suiteName: "group.co.cronx.spark") + let defaults = UserDefaults(suiteName: "group.co.cronx.sparkapp") guard let raw = defaults?.string(forKey: "spark.pendingRoute") else { return } defaults?.removeObject(forKey: "spark.pendingRoute") let parts = raw.split(separator: ":", maxSplits: 1).map(String.init) @@ -139,8 +163,9 @@ final class AppModel { } private func fetchAndCacheUserId() async { - guard let profile = try? await apiClient.request(MeEndpoint.get()) else { return } - UserDefaults.sparkAppGroup.set(profile.id, forKey: "spark.userId") + guard let fetched = try? await apiClient.request(MeEndpoint.get()) else { return } + profile = fetched + UserDefaults.sparkAppGroup.set(fetched.id, forKey: "spark.userId") } private func configureHealthUploader(accessToken: String) { @@ -150,19 +175,66 @@ final class AppModel { ) } - private func registerDevice() async { - #if canImport(UIKit) - let name = UIDevice.current.name - #else - let name = "Unknown" - #endif - _ = try? await apiClient.request(DevicesEndpoint.register(name: name, platform: "ios")) + func registerDevice() async { + guard session == .loggedIn, await tokenStore.accessToken() != nil else { return } + + while true { + guard let apnsToken = UserDefaults.sparkAppGroup.string(forKey: "spark.apnsToken") else { return } + + if let deviceRegistrationTask { + let tokenInFlight = deviceRegistrationTokenInFlight + await deviceRegistrationTask.value + if tokenInFlight == apnsToken { + return + } + continue + } + + let task = Task { @MainActor [apiClient] in + #if canImport(UIKit) + let name = UIDevice.current.name + let osVersion = UIDevice.current.systemVersion + #else + let name = "Unknown" + let osVersion = "Unknown" + #endif + + let info = Bundle.main.infoDictionary + let appVersion = info?["CFBundleShortVersionString"] as? String ?? "0.0.0" + let bundleId = Bundle.main.bundleIdentifier ?? "co.cronx.sparkapp" + #if DEBUG + let appEnvironment = "sandbox" + #else + let appEnvironment = "production" + #endif + + if let registered = try? await apiClient.request(DevicesEndpoint.register( + name: name, platform: "ios", + apnsToken: apnsToken, appEnvironment: appEnvironment, + appVersion: appVersion, bundleId: bundleId, osVersion: osVersion + )) { + UserDefaults.sparkAppGroup.set(registered.id, forKey: "spark.apnsDeviceId") + } + } + deviceRegistrationTokenInFlight = apnsToken + deviceRegistrationTask = task + await task.value + deviceRegistrationTask = nil + deviceRegistrationTokenInFlight = nil + return + } + } + + func sendTestPush() async throws { + _ = try await apiClient.request(DevicesEndpoint.sendTestPush()) } func signIn(anchor: ASPresentationAnchorHandle) async { do { try await authService.signIn(presentationAnchor: anchor.value) session = .loggedIn + await fetchAndCacheUserId() + await registerDevice() lastError = nil } catch AuthenticationError.cancelled { lastError = nil @@ -176,8 +248,15 @@ final class AppModel { } func signOut() async { + if let deviceId = UserDefaults.sparkAppGroup.string(forKey: "spark.apnsDeviceId") { + _ = try? await apiClient.request(DevicesEndpoint.revoke(id: deviceId)) + UserDefaults.sparkAppGroup.removeObject(forKey: "spark.apnsDeviceId") + } await authService.signOut() await etagCache.clearAll() + profile = nil + UserDefaults.sparkAppGroup.removeObject(forKey: "spark.userId") + await reverbDisconnect() session = .loggedOut } } diff --git a/SparkApp/Sources/App/DetailRouteNavigation.swift b/SparkApp/Sources/App/DetailRouteNavigation.swift new file mode 100644 index 0000000..4f4fcd2 --- /dev/null +++ b/SparkApp/Sources/App/DetailRouteNavigation.swift @@ -0,0 +1,98 @@ +import SparkKit +import SparkUI +import SwiftUI + +/// Reference chip cluster for detail screens. Uses value-based +/// `NavigationLink`, so it pushes onto whatever stack presented the detail +/// view (matching every other in-detail navigation) instead of routing +/// globally and jumping tabs. Long-press shows the same peek as the Flint row. +struct EntityReferenceLinkRow: View { + let label: String? + let references: [EntityReference] + + var body: some View { + if !references.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + if let label { + SparkDetailSectionHeader(label) + } + FlowLayout(spacing: SparkSpacing.xs + 2) { + ForEach(references) { reference in + if let route = reference.detailRoute { + NavigationLink(value: route) { + EntityRefChip(reference) + } + .buttonStyle(.plain) + .contextMenu { + NavigationLink(value: route) { + Label("Open", systemImage: "arrow.up.forward.app") + } + } preview: { + EntityPreviewCard(reference: reference) + } + } else { + EntityRefChip(reference) + } + } + } + } + } + } +} + +extension EntityReference { + /// The in-stack destination this reference navigates to, if its type is + /// one the app has a detail screen for. + var detailRoute: DetailRoute? { + switch type { + case .event: .event(id: id) + case .object: .object(id: id) + case .block: .block(id: id) + case .metric: .metric(identifier: id) + case .place: .place(id: id) + case .integration: .integration(service: id) + case .unknown: nil + } + } +} + +extension DeepLink { + /// Maps a parsed universal link to an in-stack `DetailRoute`. Returns nil + /// for non-entity links (today/day/auth) which are handled elsewhere. + var detailRoute: DetailRoute? { + switch self { + case .event(let id): .event(id: id) + case .object(let id): .object(id: id) + case .block(let id): .block(id: id) + case .metric(let identifier): .metric(identifier: identifier) + case .place(let id): .place(id: id) + case .integration(let service): .integration(service: service) + case .today, .day, .authCallback: nil + } + } +} + +extension View { + /// Standard `DetailRoute` → detail-view destinations. Mirrors the switch + /// in `DayPagerView`; applied wherever a tab owns its own stack. + func sparkDetailDestinations() -> some View { + navigationDestination(for: DetailRoute.self) { route in + switch route { + case .event(let id): + EventDetailView(eventId: id) + case .object(let id): + ObjectDetailView(objectId: id) + case .block(let id): + BlockDetailView(blockId: id) + case .metric(let identifier): + MetricDetailView(identifier: identifier) + case .place(let id): + PlaceDetailView(placeId: id) + case .integration(let service): + IntegrationDetailView(integrationId: service) + case .account(let id): + AccountDetailView(accountId: id) + } + } + } +} diff --git a/SparkApp/Sources/App/LiveActivityManager.swift b/SparkApp/Sources/App/LiveActivityManager.swift index ca908f7..64ba670 100644 --- a/SparkApp/Sources/App/LiveActivityManager.swift +++ b/SparkApp/Sources/App/LiveActivityManager.swift @@ -14,7 +14,7 @@ final class LiveActivityManager { private var dailyActivity: Activity? private var tokenTasks: [String: Task] = [:] - private nonisolated let logger = Logger(subsystem: "co.cronx.spark", category: "LiveActivity") + private nonisolated let logger = Logger(subsystem: "co.cronx.sparkapp", category: "LiveActivity") // MARK: - Sleep LA diff --git a/SparkApp/Sources/App/MainTabView.swift b/SparkApp/Sources/App/MainTabView.swift index 798bd76..b344be2 100644 --- a/SparkApp/Sources/App/MainTabView.swift +++ b/SparkApp/Sources/App/MainTabView.swift @@ -5,9 +5,37 @@ import SwiftUI struct MainTabView: View { @Environment(AppModel.self) private var model @State private var selection: AppTab = .day + @State private var tabAccessoryCoordinator = TabAccessoryCoordinator() var body: some View { @Bindable var model = model + let activeAccessory = tabAccessoryCoordinator.accessory?.owner == selection + ? tabAccessoryCoordinator.accessory + : nil + + ZStack { + if selection == .day { + SparkResolvedAppBackground() + } + + tabs + .tabBarMinimizeBehavior(.onScrollDown) + .tabViewBottomAccessory(isEnabled: activeAccessory != nil) { + if let activeAccessory { + TabAccessoryView(accessory: activeAccessory) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .environment(\.tabAccessoryCoordinator, tabAccessoryCoordinator) + .onChange(of: model.pendingRoute) { _, new in + guard new != nil else { return } + selection = .day + } + } + + private var tabs: some View { TabView(selection: $selection) { Tab("Day", systemImage: "sun.max.fill", value: AppTab.day) { DayPagerView() @@ -25,10 +53,6 @@ struct MainTabView: View { SearchView() } } - .onChange(of: model.pendingRoute) { _, new in - guard new != nil else { return } - selection = .day - } } } diff --git a/SparkApp/Sources/App/RootView.swift b/SparkApp/Sources/App/RootView.swift index 292cb66..750f79f 100644 --- a/SparkApp/Sources/App/RootView.swift +++ b/SparkApp/Sources/App/RootView.swift @@ -11,7 +11,6 @@ struct RootView: View { switch model.session { case .unknown: ProgressView() - .task { await model.bootstrap() } case .loggedOut: OnboardingFlow(isComplete: $model.onboardingComplete) case .loggedIn: @@ -22,7 +21,18 @@ struct RootView: View { } } } + .task { await model.bootstrap() } .onOpenURL(perform: handle(url:)) + .environment(\.openURL, OpenURLAction { url in + // In-app deep links embedded in rendered prose (e.g. Flint digest + // markdown). Anything not a navigable Spark route falls through to + // the system (real external links still open in Safari). + guard let link = DeepLink.parse(url), link.detailRoute != nil else { + return .systemAction + } + handle(url: url) + return .handled + }) } private func handle(url: URL) { diff --git a/SparkApp/Sources/App/SentryAPITelemetrySink.swift b/SparkApp/Sources/App/SentryAPITelemetrySink.swift new file mode 100644 index 0000000..f9dfef2 --- /dev/null +++ b/SparkApp/Sources/App/SentryAPITelemetrySink.swift @@ -0,0 +1,156 @@ +import Foundation +import Sentry +import SparkKit + +final class SentryAPITelemetrySink: APITelemetrySink, @unchecked Sendable { + private let maxBodyCharacters = 100_000 + + func capture(_ event: APITelemetryEvent) async { + addBreadcrumb(for: event) + captureTransaction(for: event) + + guard event.outcome != .success, event.outcome != .notModified else { return } + guard !isExpectedMetricMiss(event) else { return } + guard !isCancelledRequest(event) else { return } + + let message = "API \(event.outcome.sentryName): \(event.method) \(event.url.host() ?? "")\(event.url.path)" + SentrySDK.capture(message: message) { scope in + scope.setTag(value: event.outcome.sentryName, key: "api.outcome") + scope.setTag(value: event.method, key: "http.method") + scope.setTag(value: event.url.host() ?? "unknown", key: "http.host") + if let statusCode = event.statusCode { + scope.setTag(value: String(statusCode), key: "http.status_code") + } + scope.setContext(value: self.context(for: event), key: "api") + } + } + + private func addBreadcrumb(for event: APITelemetryEvent) { + let level: SentryLevel = switch event.outcome { + case .success, .notModified: .info + case .unauthorized, .httpError, .transportError, .decodingError, .noData: .error + } + + let crumb = Breadcrumb(level: level, category: "api") + crumb.type = "http" + crumb.message = "\(event.method) \(event.url.path) \(event.statusCode.map(String.init) ?? event.outcome.sentryName)" + crumb.data = breadcrumbData(for: event) + SentrySDK.addBreadcrumb(crumb) + } + + private func captureTransaction(for event: APITelemetryEvent) { + let name = "\(event.method) \(event.endpointPath ?? event.url.path)" + let transaction = SentrySDK.startTransaction(name: name, operation: event.operation) + transaction.startTimestamp = Date(timeIntervalSinceNow: -(event.durationMillis / 1_000)) + transaction.setTag(value: event.outcome.sentryName, key: "api.outcome") + transaction.setTag(value: event.method, key: "http.method") + transaction.setTag(value: event.url.host() ?? "unknown", key: "http.host") + transaction.setData(value: event.url.path, key: "http.path") + transaction.setData(value: event.url.query ?? "", key: "http.query") + transaction.setData(value: event.statusCode as Any, key: "http.status_code") + transaction.setData(value: event.responseSizeBytes, key: "http.response_content_length") + transaction.setData(value: event.attempt, key: "spark.api.attempt") + transaction.setData(value: event.isRefreshRequest, key: "spark.api.is_refresh_request") + transaction.setMeasurement(name: "http.client.duration", value: NSNumber(value: event.durationMillis)) + if let decodeDurationMillis = event.decodeDurationMillis { + transaction.setMeasurement(name: "spark.api.decode_duration", value: NSNumber(value: decodeDurationMillis)) + } + if let metrics = event.metrics { + transaction.setData(value: metrics.requestBodyBytesSent, key: "http.request_body_bytes_sent") + transaction.setData(value: metrics.responseBodyBytesReceived, key: "http.response_body_bytes_received") + transaction.setData(value: metrics.transactionCount, key: "http.transaction_count") + transaction.setData(value: metrics.redirects, key: "http.redirect_count") + transaction.setMeasurement(name: "http.fetch", value: number(metrics.fetchStartMillis)) + transaction.setMeasurement(name: "http.dns", value: number(metrics.domainLookupMillis)) + transaction.setMeasurement(name: "http.connect", value: number(metrics.connectMillis)) + transaction.setMeasurement(name: "http.tls", value: number(metrics.secureConnectionMillis)) + transaction.setMeasurement(name: "http.request", value: number(metrics.requestMillis)) + transaction.setMeasurement(name: "http.response", value: number(metrics.responseMillis)) + } + transaction.finish() + } + + private func breadcrumbData(for event: APITelemetryEvent) -> [String: Any] { + [ + "id": event.id.uuidString, + "method": event.method, + "url": event.url.absoluteString, + "status_code": event.statusCode as Any, + "outcome": event.outcome.sentryName, + "duration_ms": event.durationMillis, + "response_size_bytes": event.responseSizeBytes, + "attempt": event.attempt, + "is_refresh_request": event.isRefreshRequest, + ] + } + + private func context(for event: APITelemetryEvent) -> [String: Any] { + var context: [String: Any] = breadcrumbData(for: event) + context["endpoint_path"] = event.endpointPath as Any + context["requires_auth"] = event.requiresAuth + context["request_headers"] = event.requestHeaders + context["response_headers"] = event.responseHeaders + context["request_body"] = bodyString(event.requestBody) + context["response_body"] = bodyString(event.responseBody) + context["decode_duration_ms"] = event.decodeDurationMillis as Any + context["error"] = event.errorDescription as Any + + if let metrics = event.metrics { + context["metrics"] = [ + "transaction_count": metrics.transactionCount, + "redirects": metrics.redirects, + "request_body_bytes_sent": metrics.requestBodyBytesSent, + "response_body_bytes_received": metrics.responseBodyBytesReceived, + "fetch_ms": metrics.fetchStartMillis as Any, + "dns_ms": metrics.domainLookupMillis as Any, + "connect_ms": metrics.connectMillis as Any, + "tls_ms": metrics.secureConnectionMillis as Any, + "request_ms": metrics.requestMillis as Any, + "response_ms": metrics.responseMillis as Any, + ] + } + + return context + } + + private func isCancelledRequest(_ event: APITelemetryEvent) -> Bool { + guard event.outcome == .transportError else { return false } + return event.errorDescription?.contains("Code=-999") == true + || event.errorDescription?.contains("cancelled") == true + } + + private func isExpectedMetricMiss(_ event: APITelemetryEvent) -> Bool { + guard event.outcome == .httpError, event.statusCode == 404 else { return false } + return (event.endpointPath ?? event.url.path).hasPrefix("/metrics/") + || event.url.path.hasPrefix("/api/v1/mobile/metrics/") + } + + private func bodyString(_ data: Data?) -> String? { + guard let data, !data.isEmpty else { return data == nil ? nil : "" } + + let raw = String(data: data, encoding: .utf8) ?? data.base64EncodedString() + if raw.count <= maxBodyCharacters { + return raw + } + let index = raw.index(raw.startIndex, offsetBy: maxBodyCharacters) + return String(raw[.." + } + + private func number(_ value: Double?) -> NSNumber { + NSNumber(value: value ?? 0) + } +} + +private extension APITelemetryEvent.Outcome { + var sentryName: String { + switch self { + case .success: "success" + case .notModified: "not_modified" + case .unauthorized: "unauthorized" + case .httpError: "http_error" + case .transportError: "transport_error" + case .decodingError: "decoding_error" + case .noData: "no_data" + } + } +} diff --git a/SparkApp/Sources/App/TabAccessoryCoordinator.swift b/SparkApp/Sources/App/TabAccessoryCoordinator.swift new file mode 100644 index 0000000..f96527a --- /dev/null +++ b/SparkApp/Sources/App/TabAccessoryCoordinator.swift @@ -0,0 +1,133 @@ +import Observation +import SparkUI +import SwiftUI + +@MainActor +@Observable +final class TabAccessoryCoordinator { + var accessory: TabAccessory? + + func set(_ accessory: TabAccessory) { + self.accessory = accessory + } + + func clear(owner: AppTab) { + guard accessory?.owner == owner else { return } + accessory = nil + } +} + +struct TabAccessory { + let owner: AppTab + let title: String + let items: [TabAccessoryItem] + let selectedID: String + let select: @MainActor (String) -> Void +} + +struct TabAccessoryItem: Identifiable, Hashable { + let id: String + let title: String + let systemImage: String? + + init(id: String, title: String, systemImage: String? = nil) { + self.id = id + self.title = title + self.systemImage = systemImage + } +} + +private struct TabAccessoryCoordinatorKey: EnvironmentKey { + static let defaultValue: TabAccessoryCoordinator? = nil +} + +extension EnvironmentValues { + var tabAccessoryCoordinator: TabAccessoryCoordinator? { + get { self[TabAccessoryCoordinatorKey.self] } + set { self[TabAccessoryCoordinatorKey.self] = newValue } + } +} + +struct TabAccessoryView: View { + let accessory: TabAccessory + + @Environment(\.tabViewBottomAccessoryPlacement) private var placement + + var body: some View { + switch placement { + case .inline: + inlinePicker + case .expanded, .none: + expandedPicker + @unknown default: + expandedPicker + } + } + + private var selection: Binding { + Binding( + get: { accessory.selectedID }, + set: { accessory.select($0) } + ) + } + + private var expandedPicker: some View { + Picker(accessory.title, selection: selection) { + ForEach(accessory.items) { item in + if let systemImage = item.systemImage { + Label(item.title, systemImage: systemImage).tag(item.id) + } else { + Text(item.title).tag(item.id) + } + } + } + .pickerStyle(.segmented) + .padding(SparkSpacing.sm) + } + + @ViewBuilder + private var inlinePicker: some View { + if accessory.items.count <= 3 { + inlineSegmentedPicker + } else { + inlineMenuPicker + } + } + + private var inlineSegmentedPicker: some View { + Picker(accessory.title, selection: selection) { + ForEach(accessory.items) { item in + Text(item.title).tag(item.id) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .frame(minWidth: 260) + .accessibilityLabel(accessory.title) + } + + private var inlineMenuPicker: some View { + Menu { + Picker(accessory.title, selection: selection) { + ForEach(accessory.items) { item in + if let systemImage = item.systemImage { + Label(item.title, systemImage: systemImage).tag(item.id) + } else { + Text(item.title).tag(item.id) + } + } + } + } label: { + Label(selectedTitle, systemImage: "chevron.up.chevron.down") + .font(SparkTypography.captionStrong) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .accessibilityLabel(accessory.title) + .accessibilityValue(selectedTitle) + } + + private var selectedTitle: String { + accessory.items.first { $0.id == accessory.selectedID }?.title ?? accessory.title + } +} diff --git a/SparkApp/Sources/Auth/LoginView.swift b/SparkApp/Sources/Auth/LoginView.swift index ef28973..4abd55f 100644 --- a/SparkApp/Sources/Auth/LoginView.swift +++ b/SparkApp/Sources/Auth/LoginView.swift @@ -10,11 +10,9 @@ struct LoginView: View { Image(systemName: "sparkles") .font(.system(size: 72, weight: .regular, design: .rounded)) .foregroundStyle(Color.sparkAccent) - Text("Spark") - .font(SparkTypography.displayLarge) - Text("Your day, unified.") - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) + SparkSystemScreenHeader(title: "Spark", subtitle: "Your day, unified.") + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) Spacer() PillButton("Sign in with Spark", systemImage: "arrow.right.circle.fill") { Task { diff --git a/SparkApp/Sources/CheckIn/CheckInHistoryView.swift b/SparkApp/Sources/CheckIn/CheckInHistoryView.swift new file mode 100644 index 0000000..a772ef2 --- /dev/null +++ b/SparkApp/Sources/CheckIn/CheckInHistoryView.swift @@ -0,0 +1,149 @@ +import SparkKit +import SparkUI +import SwiftData +import SwiftUI + +struct CheckInHistoryView: View { + @Environment(\.dismiss) private var dismiss + @State private var historyVM: CheckInHistoryViewModel + + init(apiClient: APIClient, container: ModelContainer, todayViewModel: TodayViewModel) { + _historyVM = State(initialValue: CheckInHistoryViewModel(apiClient: apiClient, container: container)) + } + + var body: some View { + NavigationStack { + ZStack { + SparkResolvedAppBackground().ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + streakHeader + daysList + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.xl) + } + .scrollContentBackground(.hidden) + } + .navigationTitle("Check-in History") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { dismiss() } label: { Image(systemName: "xmark") } + .accessibilityLabel("Close") + } + } + .task { await historyVM.load() } + } + } + + // MARK: - Streak header + + private var streakHeader: some View { + GlassCard { + HStack { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Text("\(historyVM.streakCount)") + .font(.custom(SparkFonts.displayPostScriptName, size: 40, relativeTo: .largeTitle)) + .foregroundStyle(Color.sparkAccent) + Text("day streak") + .font(SparkTypography.body) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "flame.fill") + .font(.system(size: 36)) + .foregroundStyle(historyVM.streakCount > 0 ? Color.sparkAccent : Color.secondary.opacity(0.4)) + } + } + } + + // MARK: - Days list + + @ViewBuilder + private var daysList: some View { + if case .loading = historyVM.state, historyVM.days.isEmpty { + ForEach(0..<5, id: \.self) { _ in + LoadingShimmerCard() + } + } else if case .error(let msg) = historyVM.state, historyVM.days.isEmpty { + EmptyState( + systemImage: "exclamationmark.triangle", + title: "Couldn't load history", + message: msg, + actionTitle: "Retry" + ) { Task { await historyVM.load() } } + } else { + LazyVStack(spacing: SparkSpacing.sm) { + ForEach(historyVM.days, id: \.date) { day in + CheckInHistoryDayRow(day: day) + } + } + } + } +} + +// MARK: - Day row + +private struct CheckInHistoryDayRow: View { + let day: CheckInHistoryDay + + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEE d MMM" + return f + }() + + private var dateLabel: String { + let parser = DateFormatter() + parser.dateFormat = "yyyy-MM-dd" + if let date = parser.date(from: day.date) { + return Self.formatter.string(from: date) + } + return day.date + } + + var body: some View { + GlassCard { + HStack { + Text(dateLabel) + .font(SparkTypography.bodySmall) + .foregroundStyle(.primary) + Spacer() + HStack(spacing: SparkSpacing.sm) { + PeriodChip(label: "AM", period: day.morning) + PeriodChip(label: "PM", period: day.afternoon) + } + } + } + } +} + +private struct PeriodChip: View { + let label: String + let period: CheckInHistoryPeriod + + var body: some View { + HStack(spacing: 3) { + Text(label) + .font(SparkTypography.monoSmall) + if period.completed, let combined = period.combined { + Text("\(combined)") + .font(SparkTypography.monoSmall) + .bold() + } + } + .foregroundStyle(period.completed ? Color.sparkAccent : .secondary) + .padding(.horizontal, SparkSpacing.sm) + .padding(.vertical, 3) + .background(period.completed ? Color.sparkAccent.opacity(0.12) : Color.primary.opacity(0.05)) + .clipShape(.capsule) + .overlay { + if !period.completed { + Capsule().strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1) + } + } + .accessibilityLabel(period.completed ? "\(label) logged, \(period.combined ?? 0) out of 10" : "\(label) not logged") + } +} diff --git a/SparkApp/Sources/CheckIn/CheckInHistoryViewModel.swift b/SparkApp/Sources/CheckIn/CheckInHistoryViewModel.swift new file mode 100644 index 0000000..9dcedb4 --- /dev/null +++ b/SparkApp/Sources/CheckIn/CheckInHistoryViewModel.swift @@ -0,0 +1,141 @@ +import Foundation +import Observation +import SparkKit +import SwiftData + +enum CheckInHistoryState: Equatable { + case idle + case loading + case error(String) +} + +@MainActor +@Observable +final class CheckInHistoryViewModel { + private(set) var days: [CheckInHistoryDay] = [] + private(set) var state: CheckInHistoryState = .idle + private(set) var streakCount: Int = 0 + + private let apiClient: APIClient + private let container: ModelContainer + + init(apiClient: APIClient, container: ModelContainer) { + self.apiClient = apiClient + self.container = container + } + + func load() async { + state = .loading + loadFromCache() + await fetchFromAPI() + } + + private func loadFromCache() { + let context = ModelContext(container) + let thirtyDaysAgo = Calendar.current.date(byAdding: .day, value: -29, to: .now) ?? .now + let fromKey = Self.isoDate(thirtyDaysAgo) + let toKey = Self.isoDate(.now) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.date >= fromKey && $0.date <= toKey }, + sortBy: [SortDescriptor(\.date)] + ) + let rows = (try? context.fetch(descriptor)) ?? [] + days = buildHistoryDays(rows: rows, fromKey: fromKey, toKey: toKey) + computeStreak() + state = .idle + } + + private func fetchFromAPI() async { + let thirtyDaysAgo = Calendar.current.date(byAdding: .day, value: -29, to: .now) ?? .now + let fromKey = Self.isoDate(thirtyDaysAgo) + let toKey = Self.isoDate(.now) + do { + let response = try await apiClient.request( + CheckInsEndpoint.history(from: fromKey, to: toKey) + ) + let context = ModelContext(container) + for day in response.days { + upsertPeriod(day.morning, date: day.date, period: .morning, in: context) + upsertPeriod(day.afternoon, date: day.date, period: .afternoon, in: context) + } + try? context.save() + loadFromCache() + } catch APIError.notModified { + state = .idle + } catch is CancellationError { + state = .idle + } catch { + state = days.isEmpty ? .error("Couldn't load history") : .idle + } + } + + private func upsertPeriod(_ period: CheckInHistoryPeriod, date: String, period checkInPeriod: CheckInPeriod, in context: ModelContext) { + CachedCheckIn.upsert( + date: date, + period: checkInPeriod, + completed: period.completed, + physical: period.physical, + mental: period.mental, + notes: period.notes, + eventId: period.eventId, + in: context + ) + } + + private func buildHistoryDays(rows: [CachedCheckIn], fromKey: String, toKey: String) -> [CheckInHistoryDay] { + var grouped: [String: [CachedCheckIn]] = [:] + for row in rows { + grouped[row.date, default: []].append(row) + } + + let calendar = Calendar.current + let thirtyDaysAgo = calendar.date(byAdding: .day, value: -29, to: calendar.startOfDay(for: .now)) ?? .now + var result: [CheckInHistoryDay] = [] + + for offset in 0..<30 { + guard let day = calendar.date(byAdding: .day, value: offset, to: thirtyDaysAgo) else { continue } + let key = Self.isoDate(day) + let dayRows = grouped[key] ?? [] + let morningRow = dayRows.first { $0.period == "morning" } + let afternoonRow = dayRows.first { $0.period == "afternoon" } + result.append(CheckInHistoryDay( + date: key, + morning: periodFromRow(morningRow), + afternoon: periodFromRow(afternoonRow) + )) + } + return result.reversed() + } + + private func periodFromRow(_ row: CachedCheckIn?) -> CheckInHistoryPeriod { + guard let row, row.completed else { + return CheckInHistoryPeriod(completed: false) + } + return CheckInHistoryPeriod( + completed: true, + physical: row.physical, + mental: row.mental, + combined: row.physical.flatMap { phy in row.mental.map { phy + $0 } }, + notes: row.notes, + eventId: row.eventId + ) + } + + private func computeStreak() { + var streak = 0 + for day in days { + if day.morning.completed || day.afternoon.completed { + streak += 1 + } else { + break + } + } + streakCount = streak + } + + private static func isoDate(_ date: Date) -> String { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f.string(from: date) + } +} diff --git a/SparkApp/Sources/CheckIn/CheckInModalView.swift b/SparkApp/Sources/CheckIn/CheckInModalView.swift index 3d0dc0b..9482343 100644 --- a/SparkApp/Sources/CheckIn/CheckInModalView.swift +++ b/SparkApp/Sources/CheckIn/CheckInModalView.swift @@ -1,60 +1,69 @@ +import CoreLocation import SparkKit import SparkUI import SwiftUI struct CheckInModalView: View { - @Environment(AppModel.self) private var appModel - @Environment(\.dismiss) private var dismiss - - let slot: String + let viewModel: TodayViewModel let date: Date + let initialPeriod: CheckInPeriod - @State private var selectedMood: String? - @State private var selectedTags: Set = [] - @State private var note: String = "" - @State private var isLogging = false - @State private var logError: String? + @Environment(\.dismiss) private var dismiss - private let moods: [(String, Color)] = [ - ("exhausted", Color.sparkError), - ("tired", Color.sparkWarning), - ("ok", Color(red: 0.6, green: 0.6, blue: 0.65)), - ("rested", Color.sparkSuccess), - ("great", Color.sparkAccent), - ] + @State private var period: CheckInPeriod + @State private var physical: Int? = nil + @State private var mental: Int? = nil + @State private var notes: String = "" + @State private var locationState: LocationState = .idle + @State private var isSubmitting = false + @State private var submitError: String? = nil - private let defaultTags = ["restless", "dreams", "headache", "energised", "stressed", "calm"] + init(viewModel: TodayViewModel, date: Date, initialPeriod: CheckInPeriod) { + self.viewModel = viewModel + self.date = date + self.initialPeriod = initialPeriod + _period = State(initialValue: initialPeriod) + } + + private var otherPeriodAlsoPending: Bool { + switch initialPeriod { + case .morning: + if case .pending = viewModel.checkInDayStatus.afternoon { return true } + case .afternoon: + if case .pending = viewModel.checkInDayStatus.morning { return true } + } + return false + } var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: SparkSpacing.xl) { - moodSection - tagsSection - noteSection - if let err = logError { + if otherPeriodAlsoPending { + periodPicker + } + physicalSection + mentalSection + notesSection + locationSection + if let err = submitError { Text(err) .font(SparkTypography.bodySmall) .foregroundStyle(Color.sparkError) } + logButton } .padding(.horizontal, SparkSpacing.lg) .padding(.vertical, SparkSpacing.xl) } .scrollContentBackground(.hidden) - .background(Color.sparkSurface.ignoresSafeArea()) - .navigationTitle("\(slot.capitalized) check-in") + .background(SparkResolvedAppBackground().ignoresSafeArea()) + .navigationTitle("\(period.rawValue.capitalized) check-in") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { dismiss() } - } ToolbarItem(placement: .topBarTrailing) { - Button("Log it") { - Task { await logCheckIn() } - } - .disabled(selectedMood == nil || isLogging) - .bold() + Button { dismiss() } label: { Image(systemName: "xmark") } + .accessibilityLabel("Close") } } } @@ -62,151 +71,295 @@ struct CheckInModalView: View { // MARK: - Sections - private var moodSection: some View { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - SectionLabel("MOOD") - HStack(spacing: SparkSpacing.sm) { - ForEach(moods, id: \.0) { mood, color in - MoodChip( - label: mood, - color: color, - isSelected: selectedMood == mood - ) { - selectedMood = selectedMood == mood ? nil : mood - } + private var periodPicker: some View { + HStack(spacing: SparkSpacing.sm) { + ForEach(CheckInPeriod.allCases, id: \.self) { p in + Button { + period = p + } label: { + Text(p.rawValue.capitalized) + .font(SparkTypography.monoSmall) + .foregroundStyle(period == p ? Color.sparkAccent : .primary) + .padding(.horizontal, SparkSpacing.md) + .padding(.vertical, SparkSpacing.sm) + .background( + period == p + ? Color.sparkAccent.opacity(0.15) + : Color.primary.opacity(0.06) + ) + .clipShape(.capsule) + .overlay { + if period == p { + Capsule().strokeBorder(Color.sparkAccent.opacity(0.5), lineWidth: 1) + } + } } + .buttonStyle(.plain) + .accessibilityLabel("\(p.rawValue.capitalized)\(period == p ? ", selected" : "")") + .accessibilityAddTraits(period == p ? .isSelected : []) } } } - private var tagsSection: some View { + private var physicalSection: some View { VStack(alignment: .leading, spacing: SparkSpacing.md) { - SectionLabel("CONTEXT") - FlowLayout(spacing: SparkSpacing.sm) { - ForEach(defaultTags, id: \.self) { tag in - SelectableTagChip(tag: tag, isSelected: selectedTags.contains(tag)) { - if selectedTags.contains(tag) { - selectedTags.remove(tag) - } else { - selectedTags.insert(tag) - } - } - } - } + SectionLabel("HOW'S YOUR BODY?") + EmojiRatingRow( + selected: $physical, + emojis: ["💀", "😴", "🚶‍♂️", "🏃‍♂️", "💪"], + labels: ["Dead", "Exhausted", "Walking", "Running", "Strong"] + ) + } + } + + private var mentalSection: some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + SectionLabel("HOW'S YOUR MIND?") + EmojiRatingRow( + selected: $mental, + emojis: ["😭", "🥹", "😕", "😊", "😄"], + labels: ["Awful", "Sad", "Meh", "Happy", "Great"] + ) } } - private var noteSection: some View { + private var notesSection: some View { VStack(alignment: .leading, spacing: SparkSpacing.md) { HStack { SectionLabel("NOTE") Spacer() - Text("\(note.count) / 500") + Text("\(notes.count) / 1000") .font(SparkTypography.monoSmall) .foregroundStyle(.secondary) } TextEditor(text: Binding( - get: { note }, - set: { note = String($0.prefix(500)) } + get: { notes }, + set: { notes = String($0.prefix(1000)) } )) .font(SparkTypography.body) - .frame(minHeight: 100, maxHeight: 200) + .frame(minHeight: 80, maxHeight: 160) .scrollContentBackground(.hidden) .padding(SparkSpacing.md) .sparkGlass(.roundedRect(SparkRadii.md)) } } + private var logButton: some View { + Button { + Task { await logCheckIn() } + } label: { + HStack(spacing: SparkSpacing.sm) { + if isSubmitting { + ProgressView() + .scaleEffect(0.8) + .tint(.white) + } + Text("Log it") + .font(SparkTypography.body) + .bold() + } + .frame(maxWidth: .infinity) + .padding(.vertical, SparkSpacing.md) + .background(physical != nil && mental != nil ? Color.sparkAccent : Color.secondary.opacity(0.25)) + .foregroundStyle(physical != nil && mental != nil ? Color.white : Color.secondary) + .clipShape(.rect(cornerRadius: SparkRadii.md)) + } + .disabled(physical == nil || mental == nil || isSubmitting) + .animation(.easeInOut(duration: 0.15), value: physical == nil || mental == nil) + } + + private var locationSection: some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + SectionLabel("LOCATION") + LocationChip(state: locationState) { + Task { await fetchLocation() } + } onClear: { + locationState = .idle + } + } + } + // MARK: - Actions private func logCheckIn() async { - guard let mood = selectedMood else { return } - isLogging = true - defer { isLogging = false } - - let entry = CheckIn( - slot: slot, - mood: mood, - tags: Array(selectedTags), - note: note.isEmpty ? nil : note, - loggedAt: .now - ) + guard let phy = physical, let men = mental else { return } + isSubmitting = true + defer { isSubmitting = false } - // Persist locally first (optimistic) - persistLocally(entry) + let dateKey = Self.isoDate(date) + let (lat, lng, addr): (Double?, Double?, String?) = { + if case let .resolved(address, lat, lng) = locationState { + return (lat, lng, address) + } + return (nil, nil, nil) + }() - // POST to backend (best-effort) - _ = try? await appModel.apiClient.request(CheckInsEndpoint.create(entry)) + let request = CheckInRequest( + period: period, + physical: phy, + mental: men, + date: dateKey, + latitude: lat, + longitude: lng, + address: addr, + notes: notes.isEmpty ? nil : notes + ) - dismiss() + do { + try await viewModel.submitCheckIn(request: request) + dismiss() + } catch { + submitError = (error as? LocalizedError)?.errorDescription ?? "Something went wrong. Please try again." + } } - private func persistLocally(_ entry: CheckIn) { - let defaults = UserDefaults(suiteName: "group.co.cronx.spark") - let dateKey = Self.dateKey(date) - let storageKey = "checkin_\(dateKey)_\(slot)" - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - if let data = try? encoder.encode(entry) { - defaults?.set(data, forKey: storageKey) + private func fetchLocation() async { + let status = CLLocationManager().authorizationStatus + switch status { + case .denied, .restricted: + locationState = .denied + return + default: + break + } + locationState = .fetching + do { + for try await update in CLLocationUpdate.liveUpdates() { + guard let location = update.location, + location.horizontalAccuracy >= 0, + location.horizontalAccuracy < 200 else { continue } + locationState = .resolved( + address: nil, + lat: location.coordinate.latitude, + lng: location.coordinate.longitude + ) + break + } + } catch { + let newStatus = CLLocationManager().authorizationStatus + if newStatus == .denied || newStatus == .restricted { + locationState = .denied + } else { + locationState = .failed("Couldn't get location") + } } } - private static func dateKey(_ date: Date) -> String { + private static func isoDate(_ date: Date) -> String { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd" return f.string(from: date) } } -// MARK: - Components +// MARK: - Location state -private struct MoodChip: View { - let label: String - let color: Color - let isSelected: Bool - let onTap: () -> Void +private enum LocationState { + case idle + case fetching + case resolved(address: String?, lat: Double, lng: Double) + case failed(String) + case denied +} + +// MARK: - Emoji rating row + +private struct EmojiRatingRow: View { + @Binding var selected: Int? + let emojis: [String] + let labels: [String] var body: some View { - Button(action: onTap) { - Text(label) - .font(SparkTypography.monoSmall) - .foregroundStyle(isSelected ? .white : .primary) - .padding(.horizontal, SparkSpacing.md) - .padding(.vertical, SparkSpacing.sm) - .background(isSelected ? color : color.opacity(0.12)) - .clipShape(.capsule) + HStack(spacing: SparkSpacing.lg) { + ForEach(Array(emojis.enumerated()), id: \.offset) { index, emoji in + let value = index + 1 + Button { + selected = selected == value ? nil : value + } label: { + Text(emoji) + .font(.system(size: 28)) + .opacity(selected == value ? 1 : 0.35) + .scaleEffect(selected == value ? 1.15 : 1.0) + .animation(.spring(response: 0.2), value: selected) + .padding(SparkSpacing.xs) + .background( + selected == value + ? Color.sparkAccent.opacity(0.12) + : Color.clear + ) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(labels[index]), \(value) of 5") + .accessibilityAddTraits(selected == value ? .isSelected : []) + } } - .buttonStyle(.plain) - .accessibilityLabel("Mood: \(label)\(isSelected ? ", selected" : "")") } } -private struct SelectableTagChip: View { - let tag: String - let isSelected: Bool +// MARK: - Location chip + +private struct LocationChip: View { + let state: LocationState let onTap: () -> Void + let onClear: () -> Void var body: some View { - Button(action: onTap) { - Text("#\(tag)") - .font(SparkTypography.monoSmall) - .foregroundStyle(isSelected ? Color.sparkAccent : .primary) - .padding(.horizontal, SparkSpacing.md - 2) - .padding(.vertical, SparkSpacing.xs + 1) - .background( - isSelected - ? Color.sparkAccent.opacity(0.15) - : Color.primary.opacity(0.06) - ) - .clipShape(.capsule) - .overlay { - if isSelected { - Capsule().strokeBorder(Color.sparkAccent.opacity(0.5), lineWidth: 1) - } + switch state { + case .idle: + Button(action: onTap) { + Label("Add location", systemImage: "location") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .padding(.horizontal, SparkSpacing.md) + .padding(.vertical, SparkSpacing.sm) + .background(Color.primary.opacity(0.06)) + .clipShape(.capsule) + .overlay(Capsule().strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1, antialiased: true)) + } + .buttonStyle(.plain) + + case .fetching: + HStack(spacing: SparkSpacing.sm) { + ProgressView().scaleEffect(0.7) + Text("Getting location…") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + + case let .resolved(address, _, _): + HStack(spacing: SparkSpacing.xs) { + Image(systemName: "location.fill") + .font(.caption2) + .foregroundStyle(Color.sparkAccent) + Text(address ?? "Current location") + .font(SparkTypography.monoSmall) + .lineLimit(1) + Button(action: onClear) { + Image(systemName: "xmark") + .font(.caption2) + .foregroundStyle(.secondary) } + .buttonStyle(.plain) + .accessibilityLabel("Remove location") + } + .padding(.horizontal, SparkSpacing.md) + .padding(.vertical, SparkSpacing.sm) + .background(Color.sparkAccent.opacity(0.08)) + .clipShape(.capsule) + + case let .failed(message): + Button(action: onTap) { + Text(message + " — retry") + .font(SparkTypography.monoSmall) + .foregroundStyle(Color.sparkWarning) + } + .buttonStyle(.plain) + + case .denied: + Text("Location access denied") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) } - .buttonStyle(.plain) - .accessibilityLabel("Tag \(tag)\(isSelected ? ", selected" : "")") } } diff --git a/SparkApp/Sources/Detail/BlockDetailView.swift b/SparkApp/Sources/Detail/BlockDetailView.swift index 6dd4e39..6446d8d 100644 --- a/SparkApp/Sources/Detail/BlockDetailView.swift +++ b/SparkApp/Sources/Detail/BlockDetailView.swift @@ -35,6 +35,11 @@ struct BlockDetailView: View { @Environment(AppModel.self) private var appModel @State private var viewModel: BlockDetailViewModel? + @ViewBuilder + private func referencesSection(for block: Block) -> some View { + EntityReferenceLinkRow(label: "References", references: block.references ?? []) + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: SparkSpacing.lg) { @@ -53,11 +58,19 @@ struct BlockDetailView: View { LoadingShimmerCard() } } - .padding(SparkSpacing.lg) + .padding(.horizontal, SparkSpacing.lg) + .padding(.top, SparkSpacing.xxl) + .padding(.bottom, SparkSpacing.xl) } - .background(Color.sparkSurface.ignoresSafeArea()) + .sparkAppBackground() .navigationTitle("Block") .navigationBarTitleDisplayMode(.inline) + .sparkSubViewToolbar( + shareItems: blockShareItems, + rawTitle: "Raw block", + rawPayload: blockRawPayload, + refresh: { await viewModel?.load() } + ) .task(id: blockId) { if viewModel == nil { viewModel = BlockDetailViewModel(blockId: blockId, apiClient: appModel.apiClient) @@ -66,98 +79,90 @@ struct BlockDetailView: View { } } + private var blockShareItems: [Any] { + guard case .loaded(let detail) = viewModel?.state else { + return ["Spark Block: \(blockId)"] + } + return ["Spark Block: \(detail.block.title)"] + } + + private var blockRawPayload: String? { + guard case .loaded(let detail) = viewModel?.state else { return nil } + return SparkPrettyJSON.string(for: detail) + ?? SparkPrettyJSON.fallback(entity: "block", id: detail.block.id, title: detail.block.title) + } + @ViewBuilder private func content(for detail: BlockDetail) -> some View { - heroCard(for: detail.block) - - if isValueBlock(detail.block), let value = detail.block.value { - valueCard(value: value, unit: detail.block.unit) - } + heroSection(for: detail) if let body = detail.block.content, !body.isEmpty { - GlassCard { - Text(LocalizedStringKey(body)) - .font(SparkTypography.body) - .accessibilityLabel(body) + GlassCard(radius: SparkRadii.lg, padding: SparkSpacing.lg) { + SparkRichContentText(text: body, font: SparkTypography.body, foregroundStyle: .primary) } } + referencesSection(for: detail.block) + if let summary = detail.aiSummary, !summary.isEmpty { - GlassCard { - HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.sm) { - Image(systemName: "sparkles") - .font(.caption) - .foregroundStyle(Color.sparkAccent) - Text(summary) - .font(SparkTypography.bodySmall) - .italic() - .foregroundStyle(.secondary) - } - } + SparkDetailInsightCard(label: "Insight", text: summary) } if let parent = detail.event { VStack(alignment: .leading, spacing: SparkSpacing.sm) { - SectionLabel("From event") - GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { - HStack { - Text(parent.action.capitalized) - .font(SparkTypography.bodySmall) - Spacer(minLength: 0) - if let time = parent.time { - Text(Self.shortTimeFormatter.string(from: time)) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - } - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) - } + SparkDetailSectionHeader("From event") + NavigationLink { + EventDetailView(eventId: parent.id) + } label: { + SparkDetailLinkedRow( + title: eventTitle(for: parent), + subtitle: parent.time.map { SparkDetailFormatters.compactDateTime.string(from: $0) }, + trailing: parent.displayValue?.sparkPlainTextFromHTMLFragment ?? parent.value?.sparkPlainTextFromHTMLFragment, + tint: Color.domainTint(for: parent.domain) + ) } + .buttonStyle(.plain) } } } - private func heroCard(for block: Block) -> some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - SectionLabel(block.blockType.replacingOccurrences(of: "_", with: " ")) - Text(block.title) - .font(SparkFonts.display(.title2, weight: .bold)) - .accessibilityAddTraits(.isHeader) - if let time = block.time { - Text(Self.shortTimeFormatter.string(from: time)) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - } - } - } + private func heroSection(for detail: BlockDetail) -> some View { + SparkDetailHero( + eyebrow: blockEyebrow(for: detail.block), + status: detail.event?.action.humanisedAction, + title: detail.block.title, + subtitle: detail.event?.time.map { "From event on \(SparkDetailFormatters.compactDateTime.string(from: $0))" }, + value: blockDisplayValue(for: detail.block), + valueTint: .sparkAccent + ) } - private func valueCard(value: String, unit: String?) -> some View { - GlassCard { - HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.sm) { - Text(value) - .font(SparkFonts.display(.largeTitle, weight: .bold)) - .foregroundStyle(Color.sparkAccent) - if let unit { - Text(unit) - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel("\(value)\(unit.map { " \($0)" } ?? "")") + private func blockEyebrow(for block: Block) -> String { + var parts = [block.blockType.replacingOccurrences(of: "_", with: " ").uppercased()] + if let time = block.time { + parts.append(SparkDetailFormatters.shortDate.string(from: time)) + parts.append(SparkDetailFormatters.shortTime.string(from: time)) } + return parts.joined(separator: " — ") } - private func isValueBlock(_ block: Block) -> Bool { - block.blockType.lowercased().contains("value") && block.value != nil + private func blockDisplayValue(for block: Block) -> String? { + guard let value = block.value?.sparkPlainTextFromHTMLFragment, !value.isEmpty else { return nil } + guard let unit = block.unit, !unit.isEmpty else { return value } + if value.localizedCaseInsensitiveContains(unit) { + return value + } + return "\(value) \(unit)" } - private static let shortTimeFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "d MMM, HH:mm" - return f - }() + private func eventTitle(for event: Event) -> String { + let action = event.action.sparkActionTitle + guard event.displayWithObject, + let target = event.target?.title.trimmingCharacters(in: .whitespacesAndNewlines), + !target.isEmpty + else { + return action + } + return "\(action) \(target)" + } } diff --git a/SparkApp/Sources/Detail/EventDetailView.swift b/SparkApp/Sources/Detail/EventDetailView.swift index 8044ee5..339c23a 100644 --- a/SparkApp/Sources/Detail/EventDetailView.swift +++ b/SparkApp/Sources/Detail/EventDetailView.swift @@ -1,14 +1,28 @@ +import MapKit import SparkKit import SparkUI import SwiftUI -/// Inspector-style event detail. Mirrors the design's data-led variant — -/// hero card with value + title, then a key/value ledger, glass cards for -/// Actor / Target, linked blocks, and tags. struct EventDetailView: View { let eventId: String @Environment(AppModel.self) private var appModel @State private var viewModel: EventDetailViewModel? + @State private var showNoteEditor = false + + private func aggregatedReferences(for detail: EventDetail) -> [EntityReference] { + var seen = Set() + return detail.blocks + .compactMap(\.references) + .flatMap { $0 } + .filter { seen.insert("\($0.type.rawValue):\($0.id)").inserted } + } + + @ViewBuilder + private func referencesSection(for detail: EventDetail) -> some View { + EntityReferenceLinkRow(label: "Connecting", references: aggregatedReferences(for: detail)) + } + @State private var noteDraft = "" + @State private var noteError: String? var body: some View { ScrollView { @@ -29,11 +43,17 @@ struct EventDetailView: View { } } .padding(.horizontal, SparkSpacing.lg) - .padding(.vertical, SparkSpacing.lg) + .padding(.top, SparkSpacing.xxl) + .padding(.bottom, SparkSpacing.xl) } - .background(Color.sparkSurface.ignoresSafeArea()) - .navigationTitle("Event") + .sparkAppBackground() .navigationBarTitleDisplayMode(.inline) + .sparkSubViewToolbar( + shareItems: eventShareItems, + rawTitle: "Raw event", + rawPayload: eventRawPayload, + refresh: { await viewModel?.retry() } + ) .task(id: eventId) { if viewModel == nil { viewModel = EventDetailViewModel(eventId: eventId, apiClient: appModel.apiClient) @@ -44,217 +64,393 @@ struct EventDetailView: View { @ViewBuilder private func content(for detail: EventDetail) -> some View { - heroCard(for: detail) - inspectorRows(for: detail) + heroSection(for: detail) - if let actor = detail.actor { - actorTargetCard(label: "Actor", entity: actor) + if let summary = detail.aiSummary, !summary.isEmpty { + aiCalloutCard(summary) } - if let target = detail.target { - actorTargetCard(label: "Target", entity: target) + + if !detail.tags.isEmpty { + TagChipRow(detail.tags.names) } - if let summary = detail.aiSummary, !summary.isEmpty { - aiSummaryCard(summary) + metricBaselineStatusRow() + + if let loc = detail.location { + eventMapCard(loc) } + linkedObjectsSection(for: detail) + + referencesSection(for: detail) + if !detail.blocks.isEmpty { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - SectionLabel("Linked blocks (\(detail.blocks.count))") - ForEach(detail.blocks) { block in - blockRow(block) - } - } + blocksGrid(detail.blocks) } - if !detail.tags.isEmpty { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - SectionLabel("Tags") - TagChipRow(detail.tags) - } + if !detail.related.isEmpty { + relatedSection(detail.related) } - if !detail.related.isEmpty { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - SectionLabel("Recurring at this place") - ForEach(detail.related) { rel in - relatedRow(rel) - } - } + noteSection(for: detail) + } + + // MARK: - Cinematic hero + + private func heroSection(for detail: EventDetail) -> some View { + SparkDetailHero( + eyebrow: eyebrow(for: detail.event), + status: nil, + title: heroTitle(for: detail), + subtitle: heroSubtitle(for: detail), + value: displayValue(for: detail.event), + valueTint: Color.domainTint(for: detail.event.domain), + valueAlignment: .trailing + ) + } + + private func eyebrow(for event: Event) -> String { + var parts: [String] = [event.service.uppercased()] + if let time = event.time { + parts.append(SparkDetailFormatters.shortDate.string(from: time)) + parts.append(SparkDetailFormatters.shortTime.string(from: time)) } + return parts.joined(separator: " — ") } - // MARK: - Hero + private func heroTitle(for detail: EventDetail) -> String { + eventTitle(for: detail.event) + } - private func heroCard(for detail: EventDetail) -> some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - HStack(spacing: SparkSpacing.sm) { - Circle() - .fill(Color.domainTint(for: detail.event.domain)) - .frame(width: 6, height: 6) - Text(heroBadge(for: detail.event)) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - Spacer(minLength: SparkSpacing.sm) - if let time = detail.event.time { - Text(Self.shortTimeFormatter.string(from: time)) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - } - } + private func heroSubtitle(for detail: EventDetail) -> String? { + if let tldr = detail.event.tldr, !tldr.isEmpty { + return tldr + } + return nil + } - HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.md) { - if let value = detail.event.value { - Text(value) - .font(SparkFonts.display(.title, weight: .bold)) - .foregroundStyle(Color.domainTint(for: detail.event.domain)) - .accessibilityLabel("Value \(value)") - } - if let target = detail.target { - Text(target.title) - .font(SparkTypography.bodyStrong) + // MARK: - Linked objects + + @ViewBuilder + private func linkedObjectsSection(for detail: EventDetail) -> some View { + let links = linkedObjects(for: detail) + if !links.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SparkDetailSectionHeader("Objects", trailing: "\(links.count) linked") + ForEach(links) { link in + NavigationLink { + ObjectDetailView(objectId: link.id) + } label: { + SparkDetailLinkedRow( + title: link.title, + subtitle: link.subtitle, + trailing: link.role + ) } + .buttonStyle(.plain) } } } } - private func heroBadge(for event: Event) -> String { - [event.action, event.domain, event.service] - .map { $0.uppercased() } - .joined(separator: " · ") + private func linkedObjects(for detail: EventDetail) -> [LinkedEventObject] { + [ + linkedObject(from: detail.actor, role: "Actor"), + linkedObject(from: detail.target, role: "Target") + ].compactMap { $0 } } - // MARK: - Inspector ledger - - private func inspectorRows(for detail: EventDetail) -> some View { - GlassCard(radius: SparkRadii.md, padding: 0) { - VStack(spacing: 0) { - InspectorRow("Action") { Text(detail.event.action) } - InspectorRow("Domain") { Text(detail.event.domain) } - InspectorRow("Service") { Text(detail.event.service) } - if let time = detail.event.time { - InspectorRow("When", isMono: true) { - Text(Self.fullTimeFormatter.string(from: time)) - } - } - if let url = detail.event.url, let parsed = URL(string: url) { - InspectorRow("URL", isMono: true) { - Link(parsed.host ?? url, destination: parsed) - } - } + private func linkedObject(from object: EventDetail.ActorTarget?, role: String) -> LinkedEventObject? { + guard let object, let id = object.id, !id.isEmpty else { return nil } + let subtitle = [ + object.concept?.replacingOccurrences(of: "_", with: " "), + object.type?.replacingOccurrences(of: "_", with: " ") + ] + .compactMap { value -> String? in + guard let value, !value.isEmpty else { return nil } + return value } - } + .joined(separator: " — ") + return LinkedEventObject( + id: id, + role: role, + title: object.title, + subtitle: subtitle.isEmpty ? object.subtitle : subtitle + ) } - // MARK: - Actor / Target + // MARK: - AI summary callout - private func actorTargetCard(label: String, entity: EventDetail.ActorTarget) -> some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.xs) { - SectionLabel(label) - Text(entity.title) - .font(SparkTypography.bodyStrong) - if let subtitle = entity.subtitle { - Text(subtitle) - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - } + private func aiCalloutCard(_ summary: String) -> some View { + SparkDetailInsightCard(label: "Insight", text: summary, tint: Color.domainTint(for: "anomaly")) + } + + // MARK: - Metric baseline + + @ViewBuilder + private func metricBaselineStatusRow() -> some View { + if let status = viewModel?.metricBaselineStatus { + NavigationLink { + MetricDetailView(identifier: status.metricIdentifier) + } label: { + metricBaselineStatusCard(status) } + .buttonStyle(.plain) } } - private func aiSummaryCard(_ summary: String) -> some View { - GlassCard { - HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.sm) { - Image(systemName: "sparkles") - .font(.caption) - .foregroundStyle(Color.sparkAccent) - Text(summary) - .font(SparkTypography.bodySmall) - .italic() + private func metricBaselineStatusCard(_ status: MetricBaselineStatus) -> some View { + let tint = metricBaselineTint(for: status.state) + return GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md, tint: tint?.opacity(0.08)) { + HStack(alignment: .center, spacing: SparkSpacing.md) { + Text(status.title) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + .lineLimit(2) + + Spacer(minLength: SparkSpacing.sm) + + Text(status.trailing) + .font(SparkTypography.bodyStrong) + .foregroundStyle(tint ?? .secondary) + .lineLimit(1) + .minimumScaleFactor(0.72) + + Image(systemName: "chevron.right") + .font(.caption2) .foregroundStyle(.secondary) } } .accessibilityElement(children: .combine) - .accessibilityLabel("AI summary. \(summary)") + .accessibilityLabel("\(status.title), \(status.trailing)") } - // MARK: - Blocks / related + private func metricBaselineTint(for state: MetricBaselineStatus.State) -> Color? { + switch state { + case .normal: nil + case .high: .sparkError + case .low: .sparkInfo + } + } - private func blockRow(_ block: Block) -> some View { - GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { - HStack(spacing: SparkSpacing.md) { - Text(block.blockType.replacingOccurrences(of: "_", with: " ")) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.primary.opacity(0.06), in: .rect(cornerRadius: 4)) - Text(block.title) - .font(SparkTypography.bodySmall) - .lineLimit(1) - Spacer(minLength: 0) - if let value = block.value { - Text(value) - .font(SparkTypography.bodyStrong) - .foregroundStyle(Color.sparkAccent) + // MARK: - Map + + private func eventMapCard(_ loc: EventDetail.Location) -> some View { + let coordinate = CLLocationCoordinate2D(latitude: loc.lat, longitude: loc.lng) + let region = MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005) + ) + return VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SparkDetailSectionHeader("Location") + + Map(initialPosition: .region(region)) { + Marker("", coordinate: coordinate) + .tint(Color.sparkAccent) + } + .frame(height: 180) + .clipShape(RoundedRectangle(cornerRadius: SparkRadii.lg)) + .overlay { + RoundedRectangle(cornerRadius: SparkRadii.lg) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + .allowsHitTesting(false) + } + } + + // MARK: - Blocks grid + + private func blocksGrid(_ blocks: [Block]) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SparkDetailSectionHeader("Blocks", trailing: "\(blocks.count) blocks") + LazyVGrid( + columns: [GridItem(.flexible(), spacing: SparkSpacing.sm), GridItem(.flexible(), spacing: SparkSpacing.sm)], + spacing: SparkSpacing.sm + ) { + ForEach(blocks) { block in + NavigationLink { + BlockDetailView(blockId: block.id) + } label: { + blockTile(block) + } + .buttonStyle(.plain) } - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) } } + } + + private func blockTile(_ block: Block) -> some View { + SparkDetailValueTile( + label: block.blockType.replacingOccurrences(of: "_", with: " "), + value: block.value?.sparkPlainTextFromHTMLFragment ?? block.title, + subtitle: block.value == nil ? nil : block.title, + tint: Color.sparkAccent + ) .accessibilityElement(children: .combine) .accessibilityLabel("\(block.title), \(block.blockType.replacingOccurrences(of: "_", with: " "))") } - private func relatedRow(_ rel: EventDetail.RelatedEvent) -> some View { - GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { - HStack(spacing: SparkSpacing.md) { - VStack(alignment: .leading, spacing: 2) { - Text(rel.title) + // MARK: - Related events + + private func relatedSection(_ related: [EventDetail.RelatedEvent]) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SparkDetailSectionHeader("Related") + ForEach(related) { rel in + SparkDetailLinkedRow(title: rel.title, subtitle: rel.meta, trailing: nil) + } + } + } + + // MARK: - Notes + + private func noteSection(for detail: EventDetail) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack { + SectionLabel("Notes") + Spacer(minLength: 0) + Button { + noteDraft = detail.note ?? "" + noteError = nil + showNoteEditor = true + } label: { + Label(detail.note?.isEmpty == false ? "Edit" : "Add", systemImage: "square.and.pencil") + .font(SparkTypography.captionStrong) + } + .buttonStyle(.plain) + .foregroundStyle(Color.sparkAccent) + } + + if let note = detail.note, !note.isEmpty { + GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { + Text(note) + .font(SparkTypography.body) + .italic() + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { + Text("No note yet.") .font(SparkTypography.bodySmall) - if let meta = rel.meta { - Text(meta) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) + .foregroundStyle(.secondary) + } + } + } + .sheet(isPresented: $showNoteEditor) { + NavigationStack { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + TextEditor(text: $noteDraft) + .font(SparkTypography.body) + .frame(minHeight: 220) + .padding(SparkSpacing.sm) + .sparkGlass(.roundedRect(SparkRadii.md)) + + if let noteError { + Text(noteError) + .font(SparkTypography.caption) + .foregroundStyle(Color.sparkError) + } + + Spacer(minLength: 0) + } + .padding(SparkSpacing.lg) + .background(Color.sparkSurface.ignoresSafeArea()) + .navigationTitle(detail.note?.isEmpty == false ? "Edit note" : "Add note") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showNoteEditor = false + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("Close") + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + Task { await saveNoteDraft() } + } } } - Spacer(minLength: 0) - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) } } } - private static let shortTimeFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "HH:mm" - return f - }() - - private static let fullTimeFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZZZ" - return f - }() -} + private func saveNoteDraft() async { + do { + try await viewModel?.saveNote(noteDraft) + showNoteEditor = false + } catch { + SparkObservability.captureHandled(error) + noteError = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } + + // MARK: - Raw metadata + + private var eventRawPayload: String? { + guard case .loaded(let detail) = viewModel?.state else { return nil } + if let metadata = detail.metadata, + let json = SparkPrettyJSON.string(for: metadata) { + return json + } + return SparkPrettyJSON.string(for: detail) + ?? SparkPrettyJSON.fallback( + entity: "event", + id: detail.event.id, + title: eventTitle(for: detail.event) + ) + } + + private var eventShareItems: [Any] { + guard case .loaded(let detail) = viewModel?.state else { + return ["Spark Event: \(eventId)"] + } + if let url = detail.event.url.flatMap(URL.init) { + return [url] + } + return [eventTitle(for: detail.event)] + } -extension Color { - /// Map a domain string ("money", "health", …) to its canonical tint. - /// Falls back to the brand accent for unknown values. - static func domainTint(for domain: String) -> Color { - switch domain.lowercased() { - case "health": .domainHealth - case "activity": .domainActivity - case "money": .domainMoney - case "media": .domainMedia - case "knowledge": .domainKnowledge - case "anomaly": .domainAnomaly - default: .sparkAccent + private func formattedHeroValue(_ v: String, unit: String?) -> String { + let plainValue = v.sparkPlainTextFromHTMLFragment + guard let u = unit else { return plainValue } + if plainValue.localizedCaseInsensitiveContains(u) { + return plainValue } + let currencyCodes = ["GBP", "USD", "EUR", "JPY"] + if currencyCodes.contains(u.uppercased()), let amount = Double(plainValue.replacingOccurrences(of: ",", with: "")) { + let fmt = NumberFormatter() + fmt.numberStyle = .currency + fmt.currencyCode = u + fmt.maximumFractionDigits = 2 + return fmt.string(from: NSNumber(value: amount)) ?? "\(plainValue) \(u)" + } + return "\(plainValue) \(u)" } + + private func displayValue(for event: Event) -> String? { + if let displayValue = event.displayValue?.sparkPlainTextFromHTMLFragment, !displayValue.isEmpty { + return displayValue + } + return event.value.map { formattedHeroValue($0, unit: event.unit) }?.sparkPlainTextFromHTMLFragment + } + + private func eventTitle(for event: Event) -> String { + let action = event.action.sparkActionTitle + guard event.displayWithObject, + let target = event.target?.title.trimmingCharacters(in: .whitespacesAndNewlines), + !target.isEmpty + else { + return action + } + return "\(action) \(target)" + } +} + +private struct LinkedEventObject: Identifiable { + let id: String + let role: String + let title: String + let subtitle: String? } diff --git a/SparkApp/Sources/Detail/EventDetailViewModel.swift b/SparkApp/Sources/Detail/EventDetailViewModel.swift index cf3284c..9777d6c 100644 --- a/SparkApp/Sources/Detail/EventDetailViewModel.swift +++ b/SparkApp/Sources/Detail/EventDetailViewModel.swift @@ -13,6 +13,7 @@ enum DetailLoadState: Sendable { final class EventDetailViewModel { let eventId: String private(set) var state: DetailLoadState = .loading + private(set) var metricBaselineStatus: MetricBaselineStatus? private let apiClient: APIClient @@ -23,9 +24,11 @@ final class EventDetailViewModel { func load() async { state = .loading + metricBaselineStatus = nil do { let detail = try await apiClient.request(EventsEndpoint.detail(id: eventId)) state = .loaded(detail) + await loadMetricBaselineStatus(for: detail) } catch APIError.notModified { // Already loaded — keep current state. return @@ -39,4 +42,33 @@ final class EventDetailViewModel { func retry() async { await load() } + + func saveNote(_ note: String) async throws { + let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines) + let updated = try await apiClient.request( + EventsEndpoint.updateNote(id: eventId, note: trimmed.isEmpty ? nil : trimmed) + ) + state = .loaded(updated) + await loadMetricBaselineStatus(for: updated) + } + + private func loadMetricBaselineStatus(for detail: EventDetail) async { + let identifier = MetricIdentifier.from(event: detail.event) + guard MetricIdentifier.split(identifier) != nil else { return } + do { + let metric = try await apiClient.request(MetricsEndpoint.detail(identifier: identifier)) + metricBaselineStatus = MetricBaselineStatus.make( + event: detail.event, + metric: metric, + metricIdentifier: identifier + ) + } catch APIError.notModified { + return + } catch APIError.httpStatus(404, _, _) { + metricBaselineStatus = nil + } catch { + SparkObservability.captureHandled(error) + metricBaselineStatus = nil + } + } } diff --git a/SparkApp/Sources/Detail/MetricBaselineStatus.swift b/SparkApp/Sources/Detail/MetricBaselineStatus.swift new file mode 100644 index 0000000..f633395 --- /dev/null +++ b/SparkApp/Sources/Detail/MetricBaselineStatus.swift @@ -0,0 +1,125 @@ +import Foundation +import SparkKit + +struct MetricBaselineStatus: Equatable, Sendable { + enum State: Equatable, Sendable { + case normal + case high + case low + } + + let metricIdentifier: String + let state: State + let title: String + let trailing: String + + static func make( + event: Event, + metric: MetricDetail, + metricIdentifier: String? = nil, + calendar: Calendar = .current + ) -> MetricBaselineStatus? { + guard let baseline = metric.baseline, + baseline.low <= baseline.high, + baseline.low.isFinite, + baseline.high.isFinite, + let value = value(for: event, in: metric, calendar: calendar) + else { + return nil + } + + let normalLow = max(0, baseline.low) + + if value >= normalLow && value <= baseline.high { + return MetricBaselineStatus( + metricIdentifier: metricIdentifier ?? metric.id, + state: .normal, + title: "Normal", + trailing: formatRange(low: normalLow, high: baseline.high, unit: event.unit ?? metric.unit) + ) + } + + if value > baseline.high { + guard baseline.high != 0 else { return nil } + return MetricBaselineStatus( + metricIdentifier: metricIdentifier ?? metric.id, + state: .high, + title: "Outside Normal Range", + trailing: formatPercent((value - baseline.high) / abs(baseline.high)) + ) + } + + guard normalLow != 0 else { return nil } + return MetricBaselineStatus( + metricIdentifier: metricIdentifier ?? metric.id, + state: .low, + title: "Outside Normal Range", + trailing: formatPercent(-((normalLow - value) / abs(normalLow))) + ) + } + + private static func value(for event: Event, in metric: MetricDetail, calendar: Calendar) -> Double? { + if let eventDate = event.time, + let point = metric.series.first(where: { calendar.isDate($0.date, inSameDayAs: eventDate) }) { + return point.value + } + + guard let rawValue = event.value?.sparkPlainTextFromHTMLFragment else { + return nil + } + return parseNumber(rawValue) + } + + private static func parseNumber(_ rawValue: String) -> Double? { + let allowed = CharacterSet(charactersIn: "-0123456789.") + let filtered = rawValue + .replacingOccurrences(of: ",", with: "") + .unicodeScalars + .filter { allowed.contains($0) } + .map(String.init) + .joined() + return Double(filtered) + } + + private static func formatRange(low: Double, high: Double, unit: String?) -> String { + "\(formatValue(low, unit: unit))-\(formatValue(high, unit: unit))" + } + + private static func formatValue(_ value: Double, unit: String?) -> String { + if let unit, ["GBP", "USD", "EUR", "JPY"].contains(unit.uppercased()) { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = unit.uppercased() + formatter.maximumFractionDigits = 2 + formatter.minimumFractionDigits = value.rounded() == value ? 0 : 2 + return formatter.string(from: NSNumber(value: value)) ?? compactNumber(value) + } + + let number = compactNumber(value) + guard let unit, !unit.isEmpty else { + return number + } + + if unit == "%" || unit.lowercased() == "percent" { + return "\(number)%" + } + return "\(number) \(unit)" + } + + private static func compactNumber(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + formatter.minimumFractionDigits = 0 + return formatter.string(from: NSNumber(value: value)) ?? String(value) + } + + private static func formatPercent(_ ratio: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + formatter.maximumFractionDigits = 0 + formatter.minimumFractionDigits = 0 + formatter.positivePrefix = "+" + return formatter.string(from: NSNumber(value: ratio)) ?? "\(Int((ratio * 100).rounded()))%" + } +} diff --git a/SparkApp/Sources/Detail/MetricDetailView.swift b/SparkApp/Sources/Detail/MetricDetailView.swift index cd27634..307385c 100644 --- a/SparkApp/Sources/Detail/MetricDetailView.swift +++ b/SparkApp/Sources/Detail/MetricDetailView.swift @@ -8,6 +8,7 @@ final class MetricDetailViewModel { let identifier: String var range: MetricsEndpoint.Range private(set) var state: DetailLoadState = .loading + private(set) var recentEvents: [Event] = [] private let apiClient: APIClient @@ -19,13 +20,24 @@ final class MetricDetailViewModel { func load() async { state = .loading + recentEvents = [] + let canonicalIdentifier = MetricsEndpoint.canonicalIdentifier(identifier) + guard MetricIdentifier.split(canonicalIdentifier) != nil else { + state = .error("Metric unavailable.") + return + } do { let detail = try await apiClient.request( - MetricsEndpoint.detail(identifier: identifier, range: range) + MetricsEndpoint.detail(identifier: canonicalIdentifier, range: range) ) state = .loaded(detail) + recentEvents = await fetchRecentEvents(for: detail) } catch APIError.notModified { return + } catch where error.isAPICancellation { + return + } catch APIError.httpStatus(404, _, _) { + state = .error("Metric unavailable.") } catch { SparkObservability.captureHandled(error) let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) @@ -38,6 +50,122 @@ final class MetricDetailViewModel { range = newRange await load() } + + private func fetchRecentEvents(for detail: MetricDetail) async -> [Event] { + guard let parts = MetricIdentifier.split(detail.id) else { return [] } + + var cursor: String? + var matches: [Event] = [] + var pagesFetched = 0 + + repeat { + do { + let page = try await apiClient.request( + FeedEndpoint.feed( + cursor: cursor, + limit: 100, + domain: detail.domain + ) + ) + matches.append(contentsOf: page.data.filter { event in + event.service == parts.service && event.action == parts.action + }) + cursor = page.hasMore ? page.nextCursor : nil + pagesFetched += 1 + } catch APIError.notModified { + return matches + } catch where error.isAPICancellation { + return matches + } catch { + SparkObservability.captureHandled(error) + return matches + } + } while cursor != nil && matches.count < 10 && pagesFetched < 3 + + return Array(matches.prefix(10)) + } +} + +struct MetricAnomalyRowModel: Equatable, Sendable, Identifiable { + enum State: Equatable, Sendable { + case high + case low + case unknown + } + + let id: String + let title: String + let subtitle: String + let trailing: String? + let state: State + let eventId: String? + + static func make( + anomaly: MetricDetail.AnomalyPoint, + detail: MetricDetail, + recentEvents: [Event], + calendar: Calendar = .current + ) -> MetricAnomalyRowModel { + let value = detail.valueForAnomaly(anomaly) + let state = state(value: value, baseline: detail.baseline) + let eventId = matchingEvent(for: anomaly, in: recentEvents, calendar: calendar)?.id + + return MetricAnomalyRowModel( + id: anomaly.id, + title: title(for: state), + subtitle: formatDate(anomaly.date), + trailing: value.map { format(value: $0, unit: detail.unit) }, + state: state, + eventId: eventId + ) + } + + private static func state(value: Double?, baseline: MetricDetail.Baseline?) -> State { + guard let value, let baseline else { return .unknown } + let low = max(0, baseline.low) + if value > baseline.high { return .high } + if value < low { return .low } + return .unknown + } + + private static func title(for state: State) -> String { + switch state { + case .high: "Above Normal Range" + case .low: "Below Normal Range" + case .unknown: "Outside Normal Range" + } + } + + private static func matchingEvent( + for anomaly: MetricDetail.AnomalyPoint, + in events: [Event], + calendar: Calendar + ) -> Event? { + events.first { event in + guard let time = event.time else { return false } + return calendar.isDate(time, inSameDayAs: anomaly.date) + } + } + + private static func format(value: Double, unit: String?) -> String { + let formatted = formatNumber(value) + guard let unit, !unit.isEmpty else { return formatted } + return "\(formatted) \(unit)" + } + + private static func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "d MMM" + return formatter.string(from: date) + } + + private static func formatNumber(_ value: Double) -> String { + let absValue = abs(value) + if absValue >= 100 || absValue == floor(absValue) { + return String(format: "%.0f", value) + } + return String(format: "%.1f", value) + } } struct MetricDetailView: View { @@ -65,9 +193,15 @@ struct MetricDetailView: View { } .padding(SparkSpacing.lg) } - .background(Color.sparkSurface.ignoresSafeArea()) + .sparkAppBackground() .navigationTitle("Metric") .navigationBarTitleDisplayMode(.inline) + .sparkSubViewToolbar( + shareItems: metricShareItems, + rawTitle: "Raw metric", + rawPayload: metricRawPayload, + refresh: { await viewModel?.load() } + ) .task(id: identifier) { if viewModel == nil { viewModel = MetricDetailViewModel( @@ -79,6 +213,19 @@ struct MetricDetailView: View { } } + private var metricShareItems: [Any] { + guard case .loaded(let detail) = viewModel?.state else { + return ["Spark Metric: \(identifier)"] + } + return ["Spark Metric: \(detail.title)"] + } + + private var metricRawPayload: String? { + guard case .loaded(let detail) = viewModel?.state else { return nil } + return SparkPrettyJSON.string(for: detail) + ?? SparkPrettyJSON.fallback(entity: "metric", id: detail.id, title: detail.title) + } + @ViewBuilder private func content(for detail: MetricDetail) -> some View { heroSection(detail) @@ -89,6 +236,7 @@ struct MetricDetailView: View { compareSection(compares) } anomalyList(detail) + recentEventsSection(detail) } // MARK: - Hero @@ -234,23 +382,89 @@ struct MetricDetailView: View { VStack(alignment: .leading, spacing: SparkSpacing.sm) { SectionLabel("Recent anomalies") ForEach(detail.anomalies) { anomaly in - GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { - HStack(spacing: SparkSpacing.md) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(Color.sparkWarning) - VStack(alignment: .leading, spacing: 2) { - Text(anomaly.note ?? "Anomaly") - .font(SparkTypography.bodySmall) - Text(Self.dateFormatter.string(from: anomaly.date)) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - } - Spacer(minLength: 0) - Text(anomaly.severity.uppercased()) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) + let row = MetricAnomalyRowModel.make( + anomaly: anomaly, + detail: detail, + recentEvents: viewModel?.recentEvents ?? [] + ) + if let eventId = row.eventId { + NavigationLink { + EventDetailView(eventId: eventId) + } label: { + anomalyRow(row) } + .buttonStyle(.plain) + } else { + anomalyRow(row) + } + } + } + } + } + + private func anomalyRow(_ row: MetricAnomalyRowModel) -> some View { + let tint = anomalyTint(for: row.state) + return GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md, tint: tint?.opacity(0.08)) { + HStack(alignment: .center, spacing: SparkSpacing.md) { + VStack(alignment: .leading, spacing: 2) { + Text(row.title) + .font(SparkTypography.bodySmall) + .fontWeight(.semibold) + .foregroundStyle(.primary) + Text(row.subtitle) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + + if let trailing = row.trailing { + Text(trailing) + .font(SparkTypography.bodyStrong) + .foregroundStyle(tint ?? Color.domainTint(for: "anomaly")) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + + if row.eventId != nil { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel([row.title, row.subtitle, row.trailing].compactMap { $0 }.joined(separator: ", ")) + } + + private func anomalyTint(for state: MetricAnomalyRowModel.State) -> Color? { + switch state { + case .high: .sparkError + case .low: .sparkInfo + case .unknown: nil + } + } + + // MARK: - Recent events + + @ViewBuilder + private func recentEventsSection(_ detail: MetricDetail) -> some View { + let events = viewModel?.recentEvents ?? [] + if !events.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SparkDetailSectionHeader("Recent Events", trailing: "\(events.count)") + ForEach(events) { event in + NavigationLink { + EventDetailView(eventId: event.id) + } label: { + SparkDetailLinkedRow( + title: eventTitle(for: event), + subtitle: eventSubtitle(for: event), + trailing: displayValue(for: event), + tint: Color.domainTint(for: detail.domain) + ) } + .buttonStyle(.plain) } } } @@ -258,6 +472,41 @@ struct MetricDetailView: View { // MARK: - Formatting + private func eventTitle(for event: Event) -> String { + let action = event.action.sparkActionTitle + guard event.displayWithObject, + let target = event.target?.title.trimmingCharacters(in: .whitespacesAndNewlines), + !target.isEmpty + else { + return action + } + return "\(action) \(target)" + } + + private func eventSubtitle(for event: Event) -> String? { + var parts: [String] = [event.service.uppercased()] + if let time = event.time { + parts.append(SparkDetailFormatters.compactDateTime.string(from: time)) + } + return parts.joined(separator: " - ") + } + + private func displayValue(for event: Event) -> String? { + if let displayValue = event.displayValue?.sparkPlainTextFromHTMLFragment, !displayValue.isEmpty { + return displayValue + } + return event.value.map { formattedEventValue($0, unit: event.unit) }?.sparkPlainTextFromHTMLFragment + } + + private func formattedEventValue(_ value: String, unit: String?) -> String { + let plainValue = value.sparkPlainTextFromHTMLFragment + guard let unit else { return plainValue } + if plainValue.localizedCaseInsensitiveContains(unit) { + return plainValue + } + return "\(plainValue) \(unit)" + } + private func format(value: Double, unit: String?) -> String { let formatted = formatNumber(value) guard let unit, !unit.isEmpty else { return formatted } @@ -272,7 +521,7 @@ struct MetricDetailView: View { return String(format: "%.1f", value) } - private static let dateFormatter: DateFormatter = { + static let dateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "d MMM" return f diff --git a/SparkApp/Sources/Detail/ObjectDetailView.swift b/SparkApp/Sources/Detail/ObjectDetailView.swift index 72ed768..e356684 100644 --- a/SparkApp/Sources/Detail/ObjectDetailView.swift +++ b/SparkApp/Sources/Detail/ObjectDetailView.swift @@ -53,11 +53,19 @@ struct ObjectDetailView: View { LoadingShimmerCard() } } - .padding(SparkSpacing.lg) + .padding(.horizontal, SparkSpacing.lg) + .padding(.top, SparkSpacing.xxl) + .padding(.bottom, SparkSpacing.xl) } - .background(Color.sparkSurface.ignoresSafeArea()) + .sparkAppBackground() .navigationTitle("Object") .navigationBarTitleDisplayMode(.inline) + .sparkSubViewToolbar( + shareItems: objectShareItems, + rawTitle: "Raw object", + rawPayload: objectRawPayload, + refresh: { await viewModel?.load() } + ) .task(id: objectId) { if viewModel == nil { viewModel = ObjectDetailViewModel(objectId: objectId, apiClient: appModel.apiClient) @@ -66,39 +74,28 @@ struct ObjectDetailView: View { } } + private var objectShareItems: [Any] { + guard case .loaded(let detail) = viewModel?.state else { + return ["Spark Object: \(objectId)"] + } + if let url = detail.object.url.flatMap(URL.init) { + return [url] + } + return ["Spark Object: \(detail.object.title)"] + } + + private var objectRawPayload: String? { + guard case .loaded(let detail) = viewModel?.state else { return nil } + return SparkPrettyJSON.string(for: detail) + ?? SparkPrettyJSON.fallback(entity: "object", id: detail.object.id, title: detail.object.title) + } + @ViewBuilder private func content(for detail: ObjectDetail) -> some View { - heroCard(for: detail) + heroSection(for: detail) if let summary = detail.aiSummary, !summary.isEmpty { - GlassCard { - HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.sm) { - Image(systemName: "sparkles") - .font(.caption) - .foregroundStyle(Color.sparkAccent) - Text(summary) - .font(SparkTypography.bodySmall) - .italic() - .foregroundStyle(.secondary) - } - } - } - - GlassCard(radius: SparkRadii.md, padding: 0) { - VStack(spacing: 0) { - InspectorRow("Concept") { Text(detail.object.concept) } - InspectorRow("Type") { Text(detail.object.type) } - if let url = detail.object.url, let parsed = URL(string: url) { - InspectorRow("URL", isMono: true) { - Link(parsed.host ?? url, destination: parsed) - } - } - if let time = detail.object.time { - InspectorRow("Created", isMono: true) { - Text(Self.fullTimeFormatter.string(from: time)) - } - } - } + SparkDetailInsightCard(label: "Insight", text: summary) } if !detail.tags.isEmpty { @@ -110,7 +107,7 @@ struct ObjectDetailView: View { if !detail.relatedObjects.isEmpty { VStack(alignment: .leading, spacing: SparkSpacing.sm) { - SectionLabel("Related") + SparkDetailSectionHeader("Related", trailing: "\(detail.relatedObjects.count) objects") ForEach(detail.relatedObjects) { rel in relatedObjectRow(rel) } @@ -119,85 +116,76 @@ struct ObjectDetailView: View { if !detail.recentEvents.isEmpty { VStack(alignment: .leading, spacing: SparkSpacing.sm) { - SectionLabel("Recent events") + SparkDetailSectionHeader("Recent events", trailing: "\(detail.recentEvents.count) events") ForEach(detail.recentEvents) { event in - eventRowSummary(event) + NavigationLink { + EventDetailView(eventId: event.id) + } label: { + eventRowSummary(event) + } + .buttonStyle(.plain) } } } } - private func heroCard(for detail: ObjectDetail) -> some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - HStack(spacing: SparkSpacing.sm) { - DomainGlyph(icon: "shippingbox", tint: .sparkAccent, size: 28) - Text(detail.object.concept.uppercased()) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - } - Text(detail.object.title) - .font(SparkFonts.display(.title2, weight: .bold)) - .accessibilityAddTraits(.isHeader) - if let content = detail.object.content { - Text(content) - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - } - } + private func heroSection(for detail: ObjectDetail) -> some View { + SparkDetailHero( + eyebrow: objectEyebrow(for: detail.object), + status: detail.object.type.humanisedAction, + title: detail.object.title, + subtitle: objectSubtitle(for: detail.object), + value: nil + ) + } + + private func objectEyebrow(for object: EventObject) -> String { + var parts = [ + object.concept.uppercased(), + object.type.replacingOccurrences(of: "_", with: " ").uppercased() + ] + if let time = object.time { + parts.append(SparkDetailFormatters.shortDate.string(from: time)) + parts.append(SparkDetailFormatters.shortTime.string(from: time)) } + return parts.joined(separator: " — ") } - private func relatedObjectRow(_ rel: ObjectDetail.Related) -> some View { - GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { - HStack { - Text(rel.title) - .font(SparkTypography.bodySmall) - Spacer(minLength: 0) - Text(rel.relationship ?? rel.concept) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) - } + private func objectSubtitle(for object: EventObject) -> String? { + if let content = object.content, !content.isEmpty { + return content + } + if let url = object.url, let parsed = URL(string: url) { + return parsed.host ?? url } + return nil + } + + private func relatedObjectRow(_ rel: ObjectDetail.Related) -> some View { + SparkDetailLinkedRow( + title: rel.title, + subtitle: rel.concept, + trailing: rel.relationship + ) } private func eventRowSummary(_ event: Event) -> some View { - GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(event.action) - .font(SparkTypography.bodySmall) - if let time = event.time { - Text(Self.shortTimeFormatter.string(from: time)) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - } - } - Spacer(minLength: 0) - if let value = event.value { - Text(value) - .font(SparkTypography.bodyStrong) - .foregroundStyle(Color.domainTint(for: event.domain)) - } - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) - } - } + SparkDetailLinkedRow( + title: eventTitle(for: event), + subtitle: event.time.map { SparkDetailFormatters.compactDateTime.string(from: $0) }, + trailing: event.displayValue?.sparkPlainTextFromHTMLFragment ?? event.value?.sparkPlainTextFromHTMLFragment, + tint: Color.domainTint(for: event.domain) + ) } - private static let shortTimeFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "d MMM, HH:mm" - return f - }() - - private static let fullTimeFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd HH:mm:ss" - return f - }() + private func eventTitle(for event: Event) -> String { + let action = event.action.sparkActionTitle + guard event.displayWithObject, + let target = event.target?.title.trimmingCharacters(in: .whitespacesAndNewlines), + !target.isEmpty + else { + return action + } + return "\(action) \(target)" + } } diff --git a/SparkApp/Sources/Detail/PlaceDetailView.swift b/SparkApp/Sources/Detail/PlaceDetailView.swift index 0f05b10..ad34df2 100644 --- a/SparkApp/Sources/Detail/PlaceDetailView.swift +++ b/SparkApp/Sources/Detail/PlaceDetailView.swift @@ -57,9 +57,15 @@ struct PlaceDetailView: View { } .padding(SparkSpacing.lg) } - .background(Color.sparkSurface.ignoresSafeArea()) + .sparkAppBackground() .navigationTitle("Place") .navigationBarTitleDisplayMode(.inline) + .sparkSubViewToolbar( + shareItems: placeShareItems, + rawTitle: "Raw place", + rawPayload: placeRawPayload, + refresh: { await viewModel?.load() } + ) .task(id: placeId) { if viewModel == nil { viewModel = PlaceDetailViewModel(placeId: placeId, apiClient: appModel.apiClient) @@ -68,6 +74,19 @@ struct PlaceDetailView: View { } } + private var placeShareItems: [Any] { + guard case .loaded(let detail) = viewModel?.state else { + return ["Spark Place: \(placeId)"] + } + return ["Spark Place: \(detail.place.title)"] + } + + private var placeRawPayload: String? { + guard case .loaded(let detail) = viewModel?.state else { return nil } + return SparkPrettyJSON.string(for: detail) + ?? SparkPrettyJSON.fallback(entity: "place", id: detail.place.id, title: detail.place.title) + } + @ViewBuilder private func content(for detail: PlaceDetail) -> some View { heroCard(for: detail) diff --git a/SparkApp/Sources/Detail/SparkDetailViewSystem.swift b/SparkApp/Sources/Detail/SparkDetailViewSystem.swift new file mode 100644 index 0000000..81aa49a --- /dev/null +++ b/SparkApp/Sources/Detail/SparkDetailViewSystem.swift @@ -0,0 +1,222 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct SparkDetailHero: View { + let eyebrow: String + let status: String? + let title: String + let subtitle: String? + let value: String? + var valueTint: Color = .sparkAccent + var valueAlignment: HorizontalAlignment = .leading + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + Text(eyebrow) + .font(SparkTypography.mono) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .lineLimit(2) + + if let status, !status.isEmpty { + Text(status) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Text(title) + .font(SparkFonts.display(.largeTitle, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(3) + .minimumScaleFactor(0.72) + .accessibilityAddTraits(.isHeader) + + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(SparkTypography.title) + .foregroundStyle(.secondary) + .lineLimit(3) + } + + if let value, !value.isEmpty { + Text(value) + .font(SparkFonts.display(.largeTitle, weight: .bold)) + .foregroundStyle(valueTint) + .lineLimit(1) + .minimumScaleFactor(0.52) + .frame(maxWidth: .infinity, alignment: valueAlignment == .trailing ? .trailing : .leading) + .multilineTextAlignment(valueAlignment == .trailing ? .trailing : .leading) + .padding(.top, SparkSpacing.xs) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} + +struct SparkDetailSectionHeader: View { + let title: String + let trailing: String? + + init(_ title: String, trailing: String? = nil) { + self.title = title + self.trailing = trailing + } + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(SparkFonts.display(.title2, weight: .bold)) + .foregroundStyle(.primary) + + Spacer(minLength: SparkSpacing.sm) + + if let trailing, !trailing.isEmpty { + Text(trailing) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + } + } +} + +struct SparkDetailInsightCard: View { + var label = "Insight" + let text: String + var tint: Color = .sparkWarning + + var body: some View { + GlassCard(radius: SparkRadii.lg, padding: SparkSpacing.md, tint: tint.opacity(0.06)) { + HStack(alignment: .top, spacing: SparkSpacing.sm) { + Circle() + .fill(tint) + .frame(width: 12, height: 12) + .padding(.top, 5) + .background { + Circle() + .fill(tint.opacity(0.2)) + .frame(width: 26, height: 26) + } + + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(SparkTypography.mono) + .fontWeight(.semibold) + .foregroundStyle(tint) + .textCase(.uppercase) + + Text(text) + .font(SparkTypography.body) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(label): \(text)") + } +} + +struct SparkDetailValueTile: View { + let label: String + let value: String + var subtitle: String? + var tint: Color = .sparkAccent + + var body: some View { + GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Text(label) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .lineLimit(1) + + Text(value) + .font(SparkFonts.display(.title2, weight: .bold)) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.6) + + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + .accessibilityElement(children: .combine) + } +} + +struct SparkDetailLinkedRow: View { + let title: String + let subtitle: String? + let trailing: String? + var tint: Color = .sparkAccent + + var body: some View { + GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { + HStack(alignment: .center, spacing: SparkSpacing.md) { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + .lineLimit(2) + + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: 0) + + if let trailing, !trailing.isEmpty { + Text(trailing) + .font(SparkTypography.bodyStrong) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + } +} + +enum SparkDetailFormatters { + static let shortDate: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "d MMM yyyy" + return f + }() + + static let shortTime: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f + }() + + static let compactDateTime: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "d MMM, HH:mm" + return f + }() +} + +extension Color { + static func domainTint(for domain: String) -> Color { + EntityPresentation.tint(domain: domain) + } +} diff --git a/SparkApp/Sources/Explore/AccountDetailView.swift b/SparkApp/Sources/Explore/AccountDetailView.swift new file mode 100644 index 0000000..828e761 --- /dev/null +++ b/SparkApp/Sources/Explore/AccountDetailView.swift @@ -0,0 +1,411 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct AccountDetailView: View { + let accountId: String + + @Environment(AppModel.self) private var appModel + @State private var viewModel: AccountDetailViewModel? + @State private var showAddBalance = false + @State private var showEditAccount = false + @State private var showArchiveConfirm = false + @State private var selectedRange: HistoryRange = .threeMonths + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + if let vm = viewModel { + switch vm.loadState { + case .loading: + shimmerPlaceholder + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load account", + message: msg, + actionTitle: "Retry" + ) { Task { await vm.load() } } + case .loaded: + if let account = vm.account { + balanceHero(account: account, vm: vm) + actionsRow(account: account) + detailsCard(account: account) + balanceHistorySection(vm: vm) + } + } + } else { + shimmerPlaceholder + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.top, SparkSpacing.md) + .padding(.bottom, SparkSpacing.xl) + } + .sparkAppBackground() + .sparkMainNavigationTitle(viewModel?.account?.title ?? "Account") + .navigationBarTitleDisplayMode(.inline) + .sparkMainAppToolbar(isVisible: false) + .task { + if viewModel == nil { + viewModel = AccountDetailViewModel(accountId: accountId, apiClient: appModel.apiClient) + } + await viewModel?.load() + } + .sheet(isPresented: $showAddBalance) { + if let vm = viewModel { + AddBalanceSheet(accountId: accountId, currency: vm.account?.currency ?? "GBP") { entry in + vm.balanceAdded(entry) + } + } + } + .sheet(isPresented: $showEditAccount) { + if let vm = viewModel, let account = vm.account { + EditAccountSheet(account: account) { updated in + vm.accountUpdated(updated) + } + } + } + .confirmationDialog( + "Archive Account", + isPresented: $showArchiveConfirm, + titleVisibility: .visible + ) { + Button("Archive", role: .destructive) { + Task { + do { + try await viewModel?.archive() + } catch { + // error surfaced via state + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will mark the account as archived and record a final £0 balance. It won't be deleted.") + } + } + + // MARK: - Sections + + private func balanceHero(account: MoneyAccount, vm: AccountDetailViewModel) -> some View { + GlassCard(radius: 22, padding: SparkSpacing.xl, tint: balanceTint(account: account)) { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + HStack(spacing: SparkSpacing.sm) { + Image(systemName: accountIcon(kind: account.kind)) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.domainMoney) + let providerSuffix = account.provider.map { " — \($0.capitalized)" } ?? "" + Text(accountTypeLabel(account.accountType) + providerSuffix) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.secondary) + } + + if account.isNegativeBalance { + Text("OUTSTANDING BALANCE") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + + if let balance = account.latestBalance { + Text(formatAmount(balance.balance, currency: account.currency, isNegative: account.isNegativeBalance)) + .font(SparkFonts.display(.largeTitle, weight: .bold)) + .foregroundStyle(balanceColor(balance: balance.balance, isNegative: account.isNegativeBalance)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } else { + Text("No balance recorded") + .font(SparkFonts.display(.largeTitle, weight: .bold)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + + if !vm.balances.isEmpty { + rangeChips + + let points = chartData(vm: vm) + if points.count >= 2 { + BalanceAreaChart( + data: points, + tint: account.isNegativeBalance ? Color.sparkError : Color.sparkSuccess + ) + .frame(height: 80) + } + } + + if let balance = account.latestBalance { + Text("Updated \(balance.time.relativeFormatted)") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + } + } + + private var rangeChips: some View { + HStack(spacing: SparkSpacing.xs) { + ForEach(HistoryRange.allCases) { range in + Button { + selectedRange = range + } label: { + Text(range.rawValue) + .font(SparkTypography.monoSmall) + .fontWeight(.semibold) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background { + if selectedRange == range { + RoundedRectangle(cornerRadius: SparkRadii.sm) + .fill(Color.domainMoney) + } else { + RoundedRectangle(cornerRadius: SparkRadii.sm) + .fill(.primary.opacity(0.06)) + } + } + .foregroundStyle(selectedRange == range ? .black : .secondary) + } + .buttonStyle(.plain) + } + } + } + + private func chartData(vm: AccountDetailViewModel) -> [BalanceAreaChart.Point] { + let cutoff: Date? = selectedRange.days.map { + Calendar.current.date(byAdding: .day, value: -$0, to: .now)! + } + return vm.balances + .filter { entry in cutoff.map { entry.time >= $0 } ?? true } + .sorted { $0.time < $1.time } + .map { BalanceAreaChart.Point(date: $0.time, value: $0.balance) } + } + + private func actionsRow(account: MoneyAccount) -> some View { + HStack(spacing: SparkSpacing.sm) { + PillButton("Add Balance", systemImage: "plus.circle.fill", tint: Color.domainMoney) { + showAddBalance = true + } + + if account.kind == "manual_account" { + PillButton("Edit", systemImage: "pencil", tint: .secondary) { + showEditAccount = true + } + PillButton("Archive", systemImage: "archivebox", tint: .orange) { + showArchiveConfirm = true + } + } + } + } + + private func detailsCard(account: MoneyAccount) -> some View { + GlassCard { + VStack(alignment: .leading, spacing: 0) { + GlassCardHeader(icon: "info.circle.fill", tint: Color.domainMoney, title: "Details") + .padding(.bottom, SparkSpacing.md) + + InspectorRow("Type") { + Text(accountTypeLabel(account.accountType)) + } + InspectorRow("Currency") { + Text(account.currency) + } + if let provider = account.provider { + InspectorRow("Provider") { + Text(provider) + } + } + if let accountNumber = account.accountNumber { + InspectorRow("Account No.") { + Text(accountNumber) + } + } + if let sortCode = account.sortCode { + InspectorRow("Sort Code") { + Text(sortCode) + } + } + if let rate = account.interestRate { + InspectorRow("Interest") { + Text("\(String(format: "%.2f", rate))%") + } + } + if let startDate = account.startDate { + InspectorRow("Opened") { + Text(startDate) + } + } + } + } + } + + private func balanceHistorySection(vm: AccountDetailViewModel) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + Text("Balance History") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + if vm.balances.isEmpty { + GlassCard { + EmptyState( + systemImage: "chart.line.uptrend.xyaxis", + title: "No balance history", + message: "Add a balance update to start tracking." + ) + } + } else { + VStack(spacing: SparkSpacing.xs) { + ForEach(vm.balances) { entry in + BalanceHistoryRow( + entry: entry, + currency: vm.account?.currency ?? "GBP", + isNegative: vm.account?.isNegativeBalance ?? false + ) + } + } + + if vm.hasMore { + Button { + Task { await vm.loadMoreBalances() } + } label: { + HStack { + if vm.isLoadingMore { + ProgressView() + .scaleEffect(0.8) + } + Text(vm.isLoadingMore ? "Loading…" : "Load more") + .font(SparkTypography.bodySmall) + } + .frame(maxWidth: .infinity) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.lg)) + } + .buttonStyle(.plain) + .disabled(vm.isLoadingMore) + } + } + } + } + + private var shimmerPlaceholder: some View { + VStack(spacing: SparkSpacing.sm) { + LoadingShimmerCard().frame(height: 200) + LoadingShimmerCard().frame(height: 56) + LoadingShimmerCard().frame(height: 180) + } + } + + // MARK: - Helpers + + private func balanceTint(account: MoneyAccount) -> Color { + if let provider = account.provider { + return issuerAccentColor(provider: provider).opacity(0.10) + } + guard let balance = account.latestBalance?.balance else { return .clear } + if account.isNegativeBalance { + return Color.sparkError.opacity(0.08) + } + return balance >= 0 ? Color.sparkSuccess.opacity(0.08) : Color.sparkError.opacity(0.08) + } + + private func issuerAccentColor(provider: String) -> Color { + switch provider.lowercased() { + case "monzo": Color(red: 0.909, green: 0.467, blue: 0.369) + case "starling": Color(red: 0.431, green: 0.400, blue: 0.780) + case "amex": Color(red: 0.239, green: 0.435, blue: 0.690) + case "halifax": Color(red: 0.310, green: 0.482, blue: 0.710) + default: Color.domainMoney + } + } + + private func balanceColor(balance: Double, isNegative: Bool) -> Color { + isNegative ? Color.sparkError : (balance >= 0 ? Color.sparkSuccess : Color.sparkError) + } + + private func formatAmount(_ value: Double, currency: String, isNegative: Bool) -> String { + let symbol: String = switch currency { + case "GBP": "£" + case "EUR": "€" + case "USD": "$" + default: currency + " " + } + return "\(symbol)\(String(format: "%.2f", abs(value)))" + } + + private func accountTypeLabel(_ type: String?) -> String { + switch type { + case "current_account": "Current Account" + case "savings_account": "Savings Account" + case "mortgage": "Mortgage" + case "investment_account": "Investment Account" + case "credit_card": "Credit Card" + case "loan": "Loan" + case "pension": "Pension" + default: type?.capitalized ?? "Account" + } + } + + private func accountIcon(kind: String) -> String { + switch kind { + case "credit_card": "creditcard.fill" + case "monzo_account", "bank_account": "building.columns.fill" + case "monzo_pot": "bitcoinsign.square.fill" + default: "sterlingsign.circle.fill" + } + } +} + +private struct BalanceHistoryRow: View { + let entry: BalanceEntry + let currency: String + let isNegative: Bool + + var body: some View { + HStack(spacing: SparkSpacing.md) { + VStack(alignment: .leading, spacing: SparkSpacing.xxs) { + Text(entry.time.formatted(date: .abbreviated, time: .omitted)) + .font(SparkTypography.bodySmall) + .foregroundStyle(.primary) + if let notes = entry.notes, !notes.isEmpty { + Text(notes) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: SparkSpacing.sm) + + Text(formattedBalance) + .font(SparkFonts.display(.callout, weight: .semibold)) + .foregroundStyle(balanceColor) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + + private var formattedBalance: String { + let symbol: String = switch currency { + case "GBP": "£" + case "EUR": "€" + case "USD": "$" + default: currency + " " + } + return "\(symbol)\(String(format: "%.2f", abs(entry.balance)))" + } + + private var balanceColor: Color { + isNegative ? Color.sparkError : (entry.balance >= 0 ? Color.sparkSuccess : Color.sparkError) + } +} + +private extension Date { + var relativeFormatted: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: self, relativeTo: .now) + } +} diff --git a/SparkApp/Sources/Explore/AccountDetailViewModel.swift b/SparkApp/Sources/Explore/AccountDetailViewModel.swift new file mode 100644 index 0000000..bf151a8 --- /dev/null +++ b/SparkApp/Sources/Explore/AccountDetailViewModel.swift @@ -0,0 +1,96 @@ +import Foundation +import Observation +import OSLog +import SparkKit + +@Observable +@MainActor +final class AccountDetailViewModel { + enum LoadState { case loading, loaded, error(String) } + + private(set) var account: MoneyAccount? + private(set) var balances: [BalanceEntry] = [] + private(set) var nextCursor: String? + private(set) var hasMore = false + private(set) var loadState: LoadState = .loading + private(set) var isLoadingMore = false + private(set) var isArchiving = false + + let accountId: String + private let apiClient: APIClient + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "AccountDetail") + + init(accountId: String, apiClient: APIClient) { + self.accountId = accountId + self.apiClient = apiClient + } + + func load() async { + loadState = .loading + async let accountResult = apiClient.request(MoneyEndpoint.account(id: accountId)) + async let balancesResult = apiClient.request(MoneyEndpoint.balances(accountId: accountId)) + + do { + let (accountData, balancesData) = try await (accountResult, balancesResult) + account = accountData.data + balances = balancesData.data + nextCursor = balancesData.nextCursor + hasMore = balancesData.hasMore + loadState = .loaded + } catch where error.isAPICancellation { + loadState = .loading + } catch { + SparkObservability.captureHandled(error) + logger.error("AccountDetail load failed: \(String(describing: error))") + loadState = .error((error as? LocalizedError)?.errorDescription ?? "Couldn't load account.") + } + } + + func loadMoreBalances() async { + guard hasMore, let cursor = nextCursor, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + do { + let page = try await apiClient.request(MoneyEndpoint.balances(accountId: accountId, cursor: cursor)) + balances.append(contentsOf: page.data) + nextCursor = page.nextCursor + hasMore = page.hasMore + } catch { + logger.error("AccountDetail load more failed: \(String(describing: error))") + } + } + + func archive() async throws { + isArchiving = true + defer { isArchiving = false } + _ = try await apiClient.request(MoneyEndpoint.deleteAccount(id: accountId)) + } + + func balanceAdded(_ entry: BalanceEntry) { + balances.insert(entry, at: 0) + if var updated = account { + updated = MoneyAccount( + id: updated.id, + title: updated.title, + kind: updated.kind, + accountType: updated.accountType, + currency: updated.currency, + isNegativeBalance: updated.isNegativeBalance, + provider: updated.provider, + accountNumber: updated.accountNumber, + sortCode: updated.sortCode, + interestRate: updated.interestRate, + startDate: updated.startDate, + integrationId: updated.integrationId, + latestBalance: entry, + updatedAt: updated.updatedAt + ) + account = updated + } + } + + func accountUpdated(_ updated: MoneyAccount) { + account = updated + } +} diff --git a/SparkApp/Sources/Explore/AddBalanceSheet.swift b/SparkApp/Sources/Explore/AddBalanceSheet.swift new file mode 100644 index 0000000..d3fae47 --- /dev/null +++ b/SparkApp/Sources/Explore/AddBalanceSheet.swift @@ -0,0 +1,116 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct AddBalanceSheet: View { + let accountId: String + let currency: String + let onSuccess: (BalanceEntry) -> Void + + @Environment(AppModel.self) private var appModel + @Environment(\.dismiss) private var dismiss + + @State private var balanceText = "" + @State private var date = Date.now + @State private var notes = "" + @State private var isSubmitting = false + @State private var errorMessage: String? + + private var isValid: Bool { !balanceText.isEmpty && Double(balanceText) != nil } + + var body: some View { + SparkSheetScaffold("Add Balance") { + VStack(alignment: .leading, spacing: SparkSpacing.xl) { + // Balance field + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("BALANCE (\(currency))") + TextField("0.00", text: $balanceText) + .keyboardType(.decimalPad) + .font(SparkTypography.body) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + + // Date field + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("DATE") + DatePicker("Balance date", selection: $date, displayedComponents: .date) + .labelsHidden() + .datePickerStyle(.compact) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + + // Notes field + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack { + SectionLabel("NOTES") + Spacer() + Text("\(notes.count)/500") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + TextEditor(text: Binding( + get: { notes }, + set: { notes = String($0.prefix(500)) } + )) + .font(SparkTypography.body) + .frame(minHeight: 72, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + + if let error = errorMessage { + Text(error) + .font(SparkTypography.caption) + .foregroundStyle(Color.sparkError) + } + + Button { + Task { await submit() } + } label: { + HStack { + if isSubmitting { + ProgressView().scaleEffect(0.85) + } + Text(isSubmitting ? "Saving…" : "Save Balance") + .font(SparkTypography.bodyStrong) + } + .frame(maxWidth: .infinity) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md), tint: Color.domainMoney.opacity(0.25)) + } + .buttonStyle(.plain) + .disabled(!isValid || isSubmitting) + .opacity(isValid ? 1 : 0.5) + } + } + } + + private func submit() async { + guard let balance = Double(balanceText) else { return } + isSubmitting = true + errorMessage = nil + + let dateString = date.formatted(.iso8601.year().month().day()) + let request = AddBalanceRequest( + balance: balance, + date: dateString, + notes: notes.isEmpty ? nil : notes + ) + + do { + let response = try await appModel.apiClient.request( + MoneyEndpoint.addBalance(accountId: accountId, request) + ) + onSuccess(response.data) + dismiss() + } catch { + SparkObservability.captureHandled(error) + errorMessage = (error as? LocalizedError)?.errorDescription ?? "Couldn't save balance." + } + + isSubmitting = false + } +} diff --git a/SparkApp/Sources/Explore/CreateAccountSheet.swift b/SparkApp/Sources/Explore/CreateAccountSheet.swift new file mode 100644 index 0000000..1c52ec1 --- /dev/null +++ b/SparkApp/Sources/Explore/CreateAccountSheet.swift @@ -0,0 +1,232 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct CreateAccountSheet: View { + let onSuccess: (MoneyAccount) -> Void + + @Environment(AppModel.self) private var appModel + @Environment(\.dismiss) private var dismiss + + @State private var name = "" + @State private var accountType = "current_account" + @State private var currency = "GBP" + @State private var provider = "" + @State private var accountNumber = "" + @State private var sortCode = "" + @State private var interestRateText = "" + @State private var startDate = Date.now + @State private var showStartDate = false + @State private var isNegativeBalance = false + @State private var isSubmitting = false + @State private var errorMessage: String? + + private var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty } + + private var negativeForced: Bool { + ["credit_card", "loan", "mortgage"].contains(accountType) + } + + private var showAccountNumber: Bool { + ["current_account", "savings_account", "mortgage"].contains(accountType) + } + + private var showInterestRate: Bool { + ["savings_account", "loan", "mortgage", "pension"].contains(accountType) + } + + var body: some View { + SparkSheetScaffold("New Account") { + VStack(alignment: .leading, spacing: SparkSpacing.xl) { + // Name + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("ACCOUNT NAME") + TextField("e.g. Monzo Current", text: $name) + .font(SparkTypography.body) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + + // Account Type + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("TYPE") + Picker("Account Type", selection: $accountType) { + ForEach(AccountTypeOption.allCases, id: \.value) { opt in + Text(opt.label).tag(opt.value) + } + } + .pickerStyle(.menu) + .padding(SparkSpacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + .onChange(of: accountType) { _, newType in + if ["credit_card", "loan", "mortgage"].contains(newType) { + isNegativeBalance = true + } + } + + // Currency + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("CURRENCY") + Picker("Currency", selection: $currency) { + Text("GBP (£)").tag("GBP") + Text("USD ($)").tag("USD") + Text("EUR (€)").tag("EUR") + } + .pickerStyle(.segmented) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + + // Provider + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("PROVIDER (OPTIONAL)") + TextField("e.g. Monzo, Barclays", text: $provider) + .font(SparkTypography.body) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + + // Account Number + Sort Code (conditional) + if showAccountNumber { + HStack(alignment: .top, spacing: SparkSpacing.md) { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("ACCOUNT NO.") + TextField("Optional", text: $accountNumber) + .font(SparkTypography.body) + .keyboardType(.numberPad) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("SORT CODE") + TextField("00-00-00", text: $sortCode) + .font(SparkTypography.body) + .keyboardType(.numberPad) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + } + } + + // Interest Rate (conditional) + if showInterestRate { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("INTEREST RATE (%)") + TextField("0.00", text: $interestRateText) + .keyboardType(.decimalPad) + .font(SparkTypography.body) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + } + + // Start Date + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + Toggle(isOn: $showStartDate) { + SectionLabel("START DATE") + } + .toggleStyle(.switch) + if showStartDate { + DatePicker("Start date", selection: $startDate, displayedComponents: .date) + .labelsHidden() + .datePickerStyle(.compact) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + } + + // Negative Balance toggle (only when not forced) + if !negativeForced { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + Toggle("Debt account (higher balance = worse)", isOn: $isNegativeBalance) + .font(SparkTypography.bodySmall) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + } + + if let error = errorMessage { + Text(error) + .font(SparkTypography.caption) + .foregroundStyle(Color.sparkError) + } + + Button { + Task { await submit() } + } label: { + HStack { + if isSubmitting { ProgressView().scaleEffect(0.85) } + Text(isSubmitting ? "Creating…" : "Create Account") + .font(SparkTypography.bodyStrong) + } + .frame(maxWidth: .infinity) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md), tint: Color.domainMoney.opacity(0.25)) + } + .buttonStyle(.plain) + .disabled(!isValid || isSubmitting) + .opacity(isValid ? 1 : 0.5) + } + } + } + + private func submit() async { + isSubmitting = true + errorMessage = nil + + let request = CreateAccountRequest( + name: name.trimmingCharacters(in: .whitespaces), + accountType: accountType, + currency: currency, + provider: provider.isEmpty ? nil : provider, + accountNumber: accountNumber.isEmpty ? nil : accountNumber, + sortCode: sortCode.isEmpty ? nil : sortCode, + interestRate: Double(interestRateText), + startDate: showStartDate ? startDate.formatted(.iso8601.year().month().day()) : nil, + isNegativeBalance: isNegativeBalance + ) + + do { + let response = try await appModel.apiClient.request(MoneyEndpoint.createAccount(request)) + onSuccess(response.data) + dismiss() + } catch { + SparkObservability.captureHandled(error) + errorMessage = (error as? LocalizedError)?.errorDescription ?? "Couldn't create account." + } + + isSubmitting = false + } +} + +private enum AccountTypeOption: CaseIterable { + case currentAccount, savingsAccount, creditCard, mortgage, loan, investmentAccount, pension, other + + var value: String { + switch self { + case .currentAccount: "current_account" + case .savingsAccount: "savings_account" + case .creditCard: "credit_card" + case .mortgage: "mortgage" + case .loan: "loan" + case .investmentAccount: "investment_account" + case .pension: "pension" + case .other: "other" + } + } + + var label: String { + switch self { + case .currentAccount: "Current Account" + case .savingsAccount: "Savings Account" + case .creditCard: "Credit Card" + case .mortgage: "Mortgage" + case .loan: "Loan" + case .investmentAccount: "Investment Account" + case .pension: "Pension" + case .other: "Other" + } + } +} diff --git a/SparkApp/Sources/Explore/EditAccountSheet.swift b/SparkApp/Sources/Explore/EditAccountSheet.swift new file mode 100644 index 0000000..623abb2 --- /dev/null +++ b/SparkApp/Sources/Explore/EditAccountSheet.swift @@ -0,0 +1,251 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct EditAccountSheet: View { + let account: MoneyAccount + let onSuccess: (MoneyAccount) -> Void + + @Environment(AppModel.self) private var appModel + @Environment(\.dismiss) private var dismiss + + @State private var name: String + @State private var accountType: String + @State private var currency: String + @State private var provider: String + @State private var accountNumber: String + @State private var sortCode: String + @State private var interestRateText: String + @State private var startDate: Date + @State private var showStartDate: Bool + @State private var isNegativeBalance: Bool + @State private var isSubmitting = false + @State private var errorMessage: String? + + init(account: MoneyAccount, onSuccess: @escaping (MoneyAccount) -> Void) { + self.account = account + self.onSuccess = onSuccess + _name = State(initialValue: account.title) + _accountType = State(initialValue: account.accountType ?? "other") + _currency = State(initialValue: account.currency) + _provider = State(initialValue: account.provider ?? "") + _accountNumber = State(initialValue: account.accountNumber ?? "") + _sortCode = State(initialValue: account.sortCode ?? "") + _interestRateText = State(initialValue: account.interestRate.map { String($0) } ?? "") + let parsedDate = account.startDate.flatMap { DateFormatter.iso8601Short.date(from: $0) } ?? Date.now + _startDate = State(initialValue: parsedDate) + _showStartDate = State(initialValue: account.startDate != nil) + _isNegativeBalance = State(initialValue: account.isNegativeBalance) + } + + private var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty } + + private var negativeForced: Bool { + ["credit_card", "loan", "mortgage"].contains(accountType) + } + + private var showAccountNumber: Bool { + ["current_account", "savings_account", "mortgage"].contains(accountType) + } + + private var showInterestRate: Bool { + ["savings_account", "loan", "mortgage", "pension"].contains(accountType) + } + + var body: some View { + SparkSheetScaffold("Edit Account") { + VStack(alignment: .leading, spacing: SparkSpacing.xl) { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("ACCOUNT NAME") + TextField("Account name", text: $name) + .font(SparkTypography.body) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("TYPE") + Picker("Account Type", selection: $accountType) { + ForEach(AccountTypeOption.allCases, id: \.value) { opt in + Text(opt.label).tag(opt.value) + } + } + .pickerStyle(.menu) + .padding(SparkSpacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + .onChange(of: accountType) { _, newType in + if ["credit_card", "loan", "mortgage"].contains(newType) { + isNegativeBalance = true + } + } + + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("CURRENCY") + Picker("Currency", selection: $currency) { + Text("GBP (£)").tag("GBP") + Text("USD ($)").tag("USD") + Text("EUR (€)").tag("EUR") + } + .pickerStyle(.segmented) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("PROVIDER (OPTIONAL)") + TextField("e.g. Monzo, Barclays", text: $provider) + .font(SparkTypography.body) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + + if showAccountNumber { + HStack(alignment: .top, spacing: SparkSpacing.md) { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("ACCOUNT NO.") + TextField("Optional", text: $accountNumber) + .font(SparkTypography.body) + .keyboardType(.numberPad) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("SORT CODE") + TextField("00-00-00", text: $sortCode) + .font(SparkTypography.body) + .keyboardType(.numberPad) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + } + } + + if showInterestRate { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("INTEREST RATE (%)") + TextField("0.00", text: $interestRateText) + .keyboardType(.decimalPad) + .font(SparkTypography.body) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + } + + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + Toggle(isOn: $showStartDate) { + SectionLabel("START DATE") + } + .toggleStyle(.switch) + if showStartDate { + DatePicker("Start date", selection: $startDate, displayedComponents: .date) + .labelsHidden() + .datePickerStyle(.compact) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + } + + if !negativeForced { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + Toggle("Debt account (higher balance = worse)", isOn: $isNegativeBalance) + .font(SparkTypography.bodySmall) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + } + + if let error = errorMessage { + Text(error) + .font(SparkTypography.caption) + .foregroundStyle(Color.sparkError) + } + + Button { + Task { await submit() } + } label: { + HStack { + if isSubmitting { ProgressView().scaleEffect(0.85) } + Text(isSubmitting ? "Saving…" : "Save Changes") + .font(SparkTypography.bodyStrong) + } + .frame(maxWidth: .infinity) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md), tint: Color.domainMoney.opacity(0.25)) + } + .buttonStyle(.plain) + .disabled(!isValid || isSubmitting) + .opacity(isValid ? 1 : 0.5) + } + } + } + + private func submit() async { + isSubmitting = true + errorMessage = nil + + let request = UpdateAccountRequest( + name: name.trimmingCharacters(in: .whitespaces), + accountType: accountType, + currency: currency, + provider: provider.isEmpty ? nil : provider, + accountNumber: accountNumber.isEmpty ? nil : accountNumber, + sortCode: sortCode.isEmpty ? nil : sortCode, + interestRate: Double(interestRateText), + startDate: showStartDate ? startDate.formatted(.iso8601.year().month().day()) : nil, + isNegativeBalance: isNegativeBalance + ) + + do { + let response = try await appModel.apiClient.request( + MoneyEndpoint.updateAccount(id: account.id, request) + ) + onSuccess(response.data) + dismiss() + } catch { + SparkObservability.captureHandled(error) + errorMessage = (error as? LocalizedError)?.errorDescription ?? "Couldn't save changes." + } + + isSubmitting = false + } +} + +private extension DateFormatter { + static let iso8601Short: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f + }() +} + +private enum AccountTypeOption: CaseIterable { + case currentAccount, savingsAccount, creditCard, mortgage, loan, investmentAccount, pension, other + + var value: String { + switch self { + case .currentAccount: "current_account" + case .savingsAccount: "savings_account" + case .creditCard: "credit_card" + case .mortgage: "mortgage" + case .loan: "loan" + case .investmentAccount: "investment_account" + case .pension: "pension" + case .other: "other" + } + } + + var label: String { + switch self { + case .currentAccount: "Current Account" + case .savingsAccount: "Savings Account" + case .creditCard: "Credit Card" + case .mortgage: "Mortgage" + case .loan: "Loan" + case .investmentAccount: "Investment Account" + case .pension: "Pension" + case .other: "Other" + } + } +} diff --git a/SparkApp/Sources/Explore/ExploreView.swift b/SparkApp/Sources/Explore/ExploreView.swift index b33df8a..853c4bc 100644 --- a/SparkApp/Sources/Explore/ExploreView.swift +++ b/SparkApp/Sources/Explore/ExploreView.swift @@ -2,15 +2,21 @@ import SparkUI import SwiftUI struct ExploreView: View { + @Environment(\.tabAccessoryCoordinator) private var tabAccessoryCoordinator @State private var section: ExploreSection = .map var body: some View { - ZStack(alignment: .top) { - currentSectionView - .frame(maxWidth: .infinity, maxHeight: .infinity) - - sectionPicker - } + currentSectionView + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + registerSectionAccessory() + } + .onChange(of: section) { _, _ in + registerSectionAccessory() + } + .onDisappear { + tabAccessoryCoordinator?.clear(owner: .explore) + } } @ViewBuilder @@ -27,29 +33,34 @@ struct ExploreView: View { } } - private var sectionPicker: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: SparkSpacing.sm) { - ForEach(ExploreSection.allCases, id: \.self) { sec in - Button { - section = sec - } label: { - ExploreSectionChip(sec, isSelected: section == sec) - } - .buttonStyle(.plain) - } + private func registerSectionAccessory() { + tabAccessoryCoordinator?.set(TabAccessory( + owner: .explore, + title: "Section", + items: ExploreSection.allCases.map { + TabAccessoryItem(id: $0.id, title: $0.label, systemImage: $0.icon) + }, + selectedID: section.id, + select: { id in + guard let selected = ExploreSection.allCases.first(where: { $0.id == id }) else { return } + section = selected } - .padding(.horizontal, SparkSpacing.lg) - } - .safeAreaPadding(.top) - .padding(.vertical, SparkSpacing.sm) - .background(.ultraThinMaterial) + )) } } -enum ExploreSection: CaseIterable { +enum ExploreSection: CaseIterable, Equatable { case map, health, metrics, money + var id: String { + switch self { + case .map: "map" + case .health: "health" + case .metrics: "metrics" + case .money: "money" + } + } + var label: String { switch self { case .map: "Map" @@ -61,41 +72,10 @@ enum ExploreSection: CaseIterable { var icon: String { switch self { - case .map: "map" + case .map: "mappin" case .health: "heart.fill" - case .metrics: "chart.line.uptrend.xyaxis" - case .money: "sterlingsign.circle.fill" - } - } - - var tint: Color { - switch self { - case .map: .sparkOcean - case .health: .sparkSuccess - case .metrics: .sparkAccent - case .money: .domainMoney - } - } -} - -private struct ExploreSectionChip: View { - let section: ExploreSection - let isSelected: Bool - - init(_ section: ExploreSection, isSelected: Bool) { - self.section = section - self.isSelected = isSelected - } - - var body: some View { - HStack(spacing: SparkSpacing.xs) { - Image(systemName: section.icon) - Text(section.label) + case .metrics: "bolt.fill" + case .money: "sterlingsign" } - .font(SparkTypography.captionStrong) - .padding(.horizontal, SparkSpacing.md) - .padding(.vertical, SparkSpacing.sm) - .foregroundStyle(isSelected ? Color.white : section.tint) - .sparkGlass(.capsule, tint: isSelected ? section.tint : section.tint.opacity(0.15)) } } diff --git a/SparkApp/Sources/Explore/HealthExploreView.swift b/SparkApp/Sources/Explore/HealthExploreView.swift index f1429f5..20f9edd 100644 --- a/SparkApp/Sources/Explore/HealthExploreView.swift +++ b/SparkApp/Sources/Explore/HealthExploreView.swift @@ -5,33 +5,39 @@ import SwiftUI struct HealthExploreView: View { @Environment(AppModel.self) private var appModel + @Environment(\.colorScheme) private var colorScheme @State private var viewModel: HealthExploreViewModel? @State private var path: [DetailRoute] = [] + private static let categories: [HealthMetricCategory] = [ + .init(title: "Sleep Score", icon: "moon.zzz.fill", tint: .sparkOcean, identifier: "oura.sleep_score"), + .init(title: "Heart Rate", icon: "heart.fill", tint: .domainHealth, identifier: "oura.heart_rate"), + .init(title: "HRV", icon: "waveform.path.ecg", tint: .domainHealth, identifier: "oura.hrv"), + .init(title: "Steps", icon: "figure.walk", tint: .domainActivity, identifier: "oura.steps"), + .init(title: "Calories", icon: "flame.fill", tint: .domainActivity, identifier: "oura.calories"), + ] + + private var heroCategory: HealthMetricCategory { Self.categories[0] } + private var rowCategories: [HealthMetricCategory] { Array(Self.categories.dropFirst()) } + var body: some View { NavigationStack(path: $path) { ScrollView { - VStack(spacing: SparkSpacing.lg) { - let hasData = viewModel?.snapshots.isEmpty == false - switch viewModel?.loadState { - case .none, .idle: - shimmerGroup - case .loading where !hasData: - shimmerGroup - default: - if let vm = viewModel { - sleepRecoveryCard(vm: vm) - activityCard(vm: vm) - heartCard(vm: vm) - } - } + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + pageHeader + .padding(.horizontal, SparkSpacing.lg) + + heroHealthCard + .padding(.horizontal, SparkSpacing.lg) + + metricRows + .padding(.horizontal, SparkSpacing.lg) } - .padding(.horizontal, SparkSpacing.lg) - .padding(.vertical, SparkSpacing.xl) + .padding(.top, SparkSpacing.md) + .padding(.bottom, SparkSpacing.xl) } - .background(Color.sparkSurface.ignoresSafeArea()) - .navigationTitle("Health") - .navigationBarTitleDisplayMode(.large) + .sparkAppBackground() + .sparkMainNavigationTitle("Health") .navigationDestination(for: DetailRoute.self) { route in switch route { case .metric(let identifier): @@ -45,6 +51,7 @@ struct HealthExploreView: View { .refreshable { await viewModel?.refresh() } + .sparkMainAppToolbar() } .task { if viewModel == nil { @@ -54,158 +61,277 @@ struct HealthExploreView: View { } } - // MARK: - Card groups + private var pageHeader: some View { + SparkMainPageHeader(title: "Health", subtitle: headerSubtitle) + } - private func sleepRecoveryCard(vm: HealthExploreViewModel) -> some View { - GlassCard { + @ViewBuilder + private var heroHealthCard: some View { + let category = heroCategory + GlassCard(radius: 22, padding: SparkSpacing.xl) { VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader(icon: "moon.zzz.fill", tint: .sparkOcean, title: "Sleep & Recovery") - LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: SparkSpacing.sm) { - tileOrShimmer(identifier: "oura.sleep_score", tint: .sparkOcean, vm: vm) - tileOrShimmer(identifier: "oura.hrv", tint: .sparkOcean, vm: vm) + HStack(alignment: .top, spacing: SparkSpacing.sm) { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + HStack(spacing: SparkSpacing.sm) { + Image(systemName: category.icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(category.tint) + Text(category.title) + .font(SparkTypography.bodyStrong) + .foregroundStyle(headerTextColor) + } + + if let detail = viewModel?.snapshots[category.identifier] { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + if let today = detail.today { + Text(formatValue(today, unit: detail.unit)) + .font(SparkFonts.display(.largeTitle, weight: .bold)) + .foregroundStyle(category.tint) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + if let delta = delta(for: detail) { + deltaChip(delta, suffix: "vs 30-day avg") + } + } + } else { + LoadingShimmerCard() + .frame(width: 140, height: 74) + } + } + + Spacer(minLength: SparkSpacing.md) + + Image(systemName: "heart.text.square.fill") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(category.tint) + .frame(width: 48, height: 48) + .background { + Circle().fill(category.tint.opacity(0.12)) + } } - } - } - } - private func activityCard(vm: HealthExploreViewModel) -> some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader(icon: "figure.walk", tint: .domainActivity, title: "Activity") - LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: SparkSpacing.sm) { - tileOrShimmer(identifier: "oura.steps", tint: .domainActivity, vm: vm) - tileOrShimmer(identifier: "oura.calories", tint: .domainActivity, vm: vm) + if let detail = viewModel?.snapshots[category.identifier] { + SparklineMiniChart(series: Array(detail.series.suffix(14)), tint: category.tint) + .frame(maxWidth: .infinity) + .frame(height: 96) + } else { + LoadingShimmerCard() + .frame(height: 96) } } } + .contentShape(RoundedRectangle(cornerRadius: 22)) + .onTapGesture { + path.append(.metric(identifier: category.identifier)) + } } - private func heartCard(vm: HealthExploreViewModel) -> some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader(icon: "heart.fill", tint: .domainHealth, title: "Heart") - tileOrShimmer(identifier: "oura.heart_rate", tint: .domainHealth, vm: vm) + private var metricRows: some View { + VStack(spacing: SparkSpacing.sm) { + ForEach(rowCategories) { category in + Button { + path.append(.metric(identifier: category.identifier)) + } label: { + HealthMetricRow( + category: category, + detail: viewModel?.snapshots[category.identifier], + isLoading: isLoadingMetrics + ) + } + .buttonStyle(.plain) } } } @ViewBuilder - private func tileOrShimmer(identifier: String, tint: Color, vm: HealthExploreViewModel) -> some View { - if let detail = vm.snapshots[identifier] { - Button { - path.append(.metric(identifier: identifier)) - } label: { - MetricTileCard(detail: detail, tint: tint) + private func deltaChip(_ d: (value: Double, isPositive: Bool), suffix: String? = nil) -> some View { + HStack(spacing: 3) { + Image(systemName: d.isPositive ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + Text(deltaLabel(d.value)) + .font(SparkTypography.monoSmall) + if let suffix { + Text(suffix) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) } - .buttonStyle(.plain) - } else if case .loading = vm.loadState { - LoadingShimmerCard() } - // If loaded and nil → metric not connected; show nothing. + .foregroundStyle(d.isPositive ? Color.sparkSuccess : Color.sparkWarning) } - // MARK: - Shimmers + private func delta(for detail: MetricDetail) -> (value: Double, isPositive: Bool)? { + guard let today = detail.today, let avg = detail.average30d else { return nil } + return (today - avg, today >= avg) + } - private var shimmerGroup: some View { - VStack(spacing: SparkSpacing.lg) { - ForEach(0..<3, id: \.self) { _ in - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - LoadingShimmerCard().frame(height: 16).frame(maxWidth: 120) - LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: SparkSpacing.sm) { - LoadingShimmerCard().frame(height: 110) - LoadingShimmerCard().frame(height: 110) - } - } - } - } + private func formatValue(_ v: Double, unit: String?) -> String { + switch unit { + case "score", "bpm", "percent", "ms": + return String(Int(v)) + default: + if v >= 1000 { return String(format: "%.1fk", v / 1000) } + return v.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(v)) : String(format: "%.1f", v) } } -} -// MARK: - Metric Tile Card + private func deltaLabel(_ diff: Double) -> String { + let sign = diff >= 0 ? "+" : "" + if abs(diff) >= 1000 { return "\(sign)\(String(format: "%.1fk", diff / 1000))" } + return "\(sign)\(diff.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(diff)) : String(format: "%.1f", diff))" + } -private struct MetricTileCard: View { - let detail: MetricDetail - let tint: Color + private var headerTextColor: Color { + colorScheme == .dark ? Color.spark100 : Color.sparkTextPrimary + } - private var recentSeries: [MetricDetail.Point] { - Array(detail.series.suffix(7)) + private var headerSubtitle: String { + switch viewModel?.loadState { + case .loaded: + let count = viewModel?.snapshots.count ?? 0 + return "\(count) connected metric\(count == 1 ? "" : "s")" + case .error: + return "Health data unavailable" + case .loading: + return "Loading health signals" + case .idle, .none: + return "Health signals from your connected sources" + } } - private var delta: (value: Double, isPositive: Bool)? { - guard let today = detail.today, let avg = detail.average30d else { return nil } - return (today - avg, today >= avg) + private var isLoadingMetrics: Bool { + guard let viewModel else { return true } + if case .loading = viewModel.loadState { return true } + return false + } +} + +private struct HealthMetricRow: View { + let category: HealthMetricCategory + let detail: MetricDetail? + let isLoading: Bool + + private var recentSeries: [MetricDetail.Point] { + Array((detail?.series ?? []).suffix(14)) } var body: some View { - VStack(alignment: .leading, spacing: SparkSpacing.xs) { - HStack { - Text(detail.title) - .font(SparkTypography.captionStrong) - .foregroundStyle(.secondary) - .lineLimit(1) - Spacer(minLength: 0) - if let unit = detail.unit { - Text(unit) - .font(SparkTypography.monoSmall) - .foregroundStyle(.tertiary) - } - } + ZStack(alignment: .leading) { + if !recentSeries.isEmpty { + SparklineMiniChart(series: recentSeries, tint: category.tint) + .opacity(0.28) + .frame(maxWidth: .infinity) + .frame(height: 92) + .offset(x: 70, y: 18) + .clipShape(RoundedRectangle(cornerRadius: 20)) - if let today = detail.today { - Text(formatValue(today, unit: detail.unit)) - .font(SparkFonts.display(.title2, weight: .bold)) - .foregroundStyle(tint) - .lineLimit(1) - .minimumScaleFactor(0.7) - } else { - Text("—") - .font(SparkFonts.display(.title2, weight: .bold)) - .foregroundStyle(.tertiary) + LinearGradient( + stops: [ + .init(color: Color.sparkElevated.opacity(0.95), location: 0), + .init(color: Color.sparkElevated.opacity(0.72), location: 0.40), + .init(color: Color.sparkElevated.opacity(0), location: 0.75), + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 92) + .clipShape(RoundedRectangle(cornerRadius: 20)) } - if let d = delta { - HStack(spacing: 3) { - Image(systemName: d.isPositive ? "arrow.up.right" : "arrow.down.right") - .font(.caption2) - Text(deltaLabel(d.value)) - .font(SparkTypography.monoSmall) + HStack(spacing: SparkSpacing.md) { + Image(systemName: category.icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background( + RoundedRectangle(cornerRadius: SparkRadii.sm) + .fill(category.tint) + ) + + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Text(category.title) + .font(SparkTypography.bodySmall) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .lineLimit(1) + + if let today = detail?.today { + HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.xs) { + Text(formatValue(today, unit: detail?.unit)) + .font(SparkFonts.display(.title, weight: .bold)) + .foregroundStyle(category.tint) + .lineLimit(1) + .minimumScaleFactor(0.75) + if let unit = unitLabel(detail?.unit) { + Text(unit) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + } + + if let delta = delta(for: detail) { + HStack(spacing: 3) { + Image(systemName: delta.isPositive ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + Text(deltaLabel(delta.value)) + .font(SparkTypography.monoSmall) + } + .foregroundStyle(delta.isPositive ? Color.sparkSuccess : Color.sparkWarning) + } + } else if isLoading { + Text("--") + .font(SparkTypography.monoBody) + .foregroundStyle(.secondary) + } } - .foregroundStyle(d.isPositive ? Color.sparkSuccess : Color.sparkWarning) - } - if !recentSeries.isEmpty { - SparklineMiniChart(series: recentSeries, tint: tint) - .frame(height: 32) - .padding(.top, SparkSpacing.xxs) + Spacer(minLength: 0) } + .padding(.horizontal, SparkSpacing.lg) } - .padding(SparkSpacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - .sparkGlass(.roundedRect(SparkRadii.md)) + .frame(height: 104) + .sparkGlass(.roundedRect(20)) + } + + private func delta(for detail: MetricDetail?) -> (value: Double, isPositive: Bool)? { + guard let detail, let today = detail.today, let avg = detail.average30d else { return nil } + return (today - avg, today >= avg) } private func formatValue(_ v: Double, unit: String?) -> String { switch unit { - case "score", "bpm", "percent": + case "score", "bpm", "percent", "ms": return String(Int(v)) - case "ms": - return "\(Int(v))" default: if v >= 1000 { return String(format: "%.1fk", v / 1000) } return v.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(v)) : String(format: "%.1f", v) } } + private func unitLabel(_ unit: String?) -> String? { + switch unit { + case "score", "steps", "percent", nil: + return nil + default: + return unit + } + } + private func deltaLabel(_ diff: Double) -> String { let sign = diff >= 0 ? "+" : "" if abs(diff) >= 1000 { return "\(sign)\(String(format: "%.1fk", diff / 1000))" } - return "\(sign)\(diff.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(diff)) : String(format: "%.1f", diff)) vs avg" + return "\(sign)\(diff.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(diff)) : String(format: "%.1f", diff))" } } -// MARK: - Sparkline mini chart +private struct HealthMetricCategory: Identifiable { + let title: String + let icon: String + let tint: Color + let identifier: String + + var id: String { identifier } +} private struct SparklineMiniChart: View { let series: [MetricDetail.Point] @@ -219,7 +345,7 @@ private struct SparklineMiniChart: View { ) .foregroundStyle( LinearGradient( - colors: [tint.opacity(0.3), tint.opacity(0.0)], + colors: [tint.opacity(0.4), tint.opacity(0)], startPoint: .top, endPoint: .bottom ) ) diff --git a/SparkApp/Sources/Explore/HealthExploreViewModel.swift b/SparkApp/Sources/Explore/HealthExploreViewModel.swift index 435a6ec..273fc3f 100644 --- a/SparkApp/Sources/Explore/HealthExploreViewModel.swift +++ b/SparkApp/Sources/Explore/HealthExploreViewModel.swift @@ -20,7 +20,7 @@ final class HealthExploreViewModel { private(set) var loadState: LoadState = .idle private let apiClient: APIClient - private let logger = Logger(subsystem: "co.cronx.spark", category: "HealthExplore") + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "HealthExplore") init(apiClient: APIClient) { self.apiClient = apiClient diff --git a/SparkApp/Sources/Explore/MetricsExploreView.swift b/SparkApp/Sources/Explore/MetricsExploreView.swift index 16e9a5d..48eb026 100644 --- a/SparkApp/Sources/Explore/MetricsExploreView.swift +++ b/SparkApp/Sources/Explore/MetricsExploreView.swift @@ -5,52 +5,79 @@ import SwiftUI struct MetricsExploreView: View { @Environment(AppModel.self) private var appModel + @Environment(\.colorScheme) private var colorScheme @State private var viewModel: MetricsExploreViewModel? - @State private var filterDomain: MetricDomain? = nil + @State private var filterDomain: String? = nil + @State private var sortMode: MetricSortMode = .anomalies + @State private var searchText = "" + @State private var heroRange: HeroMetricRange = .week @State private var path: [DetailRoute] = [] - private static let categories: [MetricCategory] = [ - .init(domain: .health, title: "Sleep Score", icon: "moon.zzz.fill", tint: .sparkOcean, identifier: "oura.sleep_score"), - .init(domain: .health, title: "Heart Rate", icon: "heart.fill", tint: .domainHealth, identifier: "oura.heart_rate"), - .init(domain: .activity, title: "Steps", icon: "figure.walk", tint: .domainActivity, identifier: "oura.steps"), - .init(domain: .activity, title: "Calories", icon: "flame.fill", tint: .domainActivity, identifier: "oura.calories"), - .init(domain: .money, title: "Daily Spend", icon: "sterlingsign.circle.fill", tint: .domainMoney, identifier: "monzo.spend_daily"), - .init(domain: .media, title: "Screen Time", icon: "iphone", tint: .domainMedia, identifier: "screen_time.daily"), - ] + private var visibleMetrics: [MetricPresentation] { + let metrics = (viewModel?.metrics ?? []).map { metric in + MetricPresentation( + metric: metric, + detail: viewModel?.snapshots[metric.identifier] + ) + } + let domainFiltered = metrics.filter { metric in + guard let filterDomain else { return true } + return metric.domain == filterDomain + } + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let searched = query.isEmpty ? domainFiltered : domainFiltered.filter { $0.matches(query: query) } + return searched.sorted { sortMode.areInIncreasingOrder($0, $1) } + } - private var allIdentifiers: [String] { Self.categories.map(\.identifier) } + private var heroMetric: MetricPresentation? { + visibleMetrics.first + } - private var visibleCategories: [MetricCategory] { - guard let filter = filterDomain else { return Self.categories } - return Self.categories.filter { $0.domain == filter } + private var rowMetrics: [MetricPresentation] { + Array(visibleMetrics.dropFirst()) + } + + private var availableDomains: [String] { + Array(Set((viewModel?.metrics ?? []).map { MetricPresentation.domain(for: $0) })).sorted { + displayLabel(forDomain: $0) < displayLabel(forDomain: $1) + } + } + + private var heatmapRows: [DomainHeatmapRow] { + let raw = HeatmapPlaceholder.generate() + return [ + .init(id: "sleep", label: "Sleep", values: raw["sleep"] ?? [], tint: .domainHealth), + .init(id: "motion", label: "Motion", values: raw["activity"] ?? [], tint: .domainActivity), + .init(id: "spend", label: "Spend", values: raw["spend"] ?? [], tint: .domainMoney), + .init(id: "mood", label: "Mood", values: raw["mood"] ?? [], tint: .sparkSuccess), + ] } var body: some View { NavigationStack(path: $path) { ScrollView { - VStack(spacing: SparkSpacing.lg) { - domainFilter + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + pageHeader .padding(.horizontal, SparkSpacing.lg) - ForEach(visibleCategories, id: \.identifier) { category in - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - GlassCardHeader( - icon: category.icon, - tint: category.tint, - title: category.title - ) - tileContent(for: category) - } - } + sortControls .padding(.horizontal, SparkSpacing.lg) + + domainFilter + .padding(.horizontal, SparkSpacing.lg) + + content + + if heroMetric != nil { + historySection + .padding(.horizontal, SparkSpacing.lg) } } - .padding(.vertical, SparkSpacing.xl) + .padding(.top, SparkSpacing.md) + .padding(.bottom, SparkSpacing.xl) } - .background(Color.sparkSurface.ignoresSafeArea()) - .navigationTitle("Metrics") - .navigationBarTitleDisplayMode(.large) + .sparkAppBackground() + .sparkMainNavigationTitle("Metrics") .navigationDestination(for: DetailRoute.self) { route in switch route { case .metric(let identifier): @@ -59,130 +86,429 @@ struct MetricsExploreView: View { EmptyView() } } - .refreshable { - await viewModel?.refresh(identifiers: allIdentifiers) - } + .refreshable { await viewModel?.refresh() } + .sparkMainAppToolbar() } + .searchable( + text: $searchText, + placement: .automatic, + prompt: "Search service or action" + ) .task { if viewModel == nil { viewModel = MetricsExploreViewModel(apiClient: appModel.apiClient) } - await viewModel?.load(identifiers: allIdentifiers) + await viewModel?.load() } } + // MARK: - Page header + + private var pageHeader: some View { + SparkMainPageHeader(title: "Metrics", subtitle: headerSubtitle) + } + + // MARK: - Domain filter + + @ViewBuilder private var domainFilter: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: SparkSpacing.sm) { - Button { filterDomain = nil } label: { - TagChip("All", isGhost: filterDomain != nil) - } - .buttonStyle(.plain) - ForEach(MetricDomain.allCases, id: \.self) { domain in - Button { filterDomain = domain } label: { - TagChip(domain.label, isGhost: filterDomain != domain) + if !availableDomains.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: SparkSpacing.sm) { + Button { filterDomain = nil } label: { + MetricsFilterChip("All", isSelected: filterDomain == nil) } .buttonStyle(.plain) + ForEach(availableDomains, id: \.self) { domain in + Button { filterDomain = domain } label: { + MetricsFilterChip( + displayLabel(forDomain: domain), + isSelected: filterDomain == domain + ) + } + .buttonStyle(.plain) + } } } } } - @ViewBuilder - private func tileContent(for category: MetricCategory) -> some View { - if let detail = viewModel?.snapshots[category.identifier] { - Button { - path.append(.metric(identifier: category.identifier)) + private var sortControls: some View { + HStack(spacing: SparkSpacing.sm) { + Menu { + ForEach(MetricSortMode.allCases, id: \.self) { mode in + Button { + sortMode = mode + } label: { + Label(mode.title, systemImage: mode.systemImage) + } + } } label: { - MetricsTileCard(detail: detail, tint: category.tint) + HStack(spacing: SparkSpacing.xs) { + Image(systemName: sortMode.systemImage) + Text(sortMode.title) + Image(systemName: "chevron.down") + .font(.caption2) + } + .font(SparkTypography.captionStrong) + .foregroundStyle(Color.sparkTextPrimary) + .padding(.horizontal, SparkSpacing.md) + .padding(.vertical, SparkSpacing.xs + 2) + .background { + Capsule().fill(Color.primary.opacity(0.04)) + } + .overlay { + Capsule().strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } } .buttonStyle(.plain) - } else if viewModel?.loadState == .loading || viewModel == nil { - LoadingShimmerCard() - } else { + + Spacer(minLength: 0) + } + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + switch viewModel?.loadState { + case .idle, .loading, .none: + loadingContent + case .error(let message): EmptyState( - systemImage: category.icon, - title: "No data yet", - message: "Metrics will appear here once your integration syncs." - ) + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load metrics", + message: message, + actionTitle: "Retry" + ) { Task { await viewModel?.refresh() } } + .padding(.horizontal, SparkSpacing.lg) + case .loaded: + if viewModel?.metrics.isEmpty == true { + EmptyState( + systemImage: "chart.line.uptrend.xyaxis", + title: "No active metrics", + message: "Connected metrics will appear here once Spark receives events for them." + ) + .padding(.horizontal, SparkSpacing.lg) + } else if visibleMetrics.isEmpty { + EmptyState( + systemImage: "magnifyingglass", + title: "No matching metrics", + message: searchText.isEmpty ? "Try another domain." : "Try a different service, action, or metric name." + ) + .padding(.horizontal, SparkSpacing.lg) + } else { + heroChartCard + .padding(.horizontal, SparkSpacing.lg) + + metricsStack + .padding(.horizontal, SparkSpacing.lg) + } } } -} -// MARK: - Supporting types + private var loadingContent: some View { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + LoadingShimmerCard() + .frame(height: 210) + LoadingShimmerCard() + .frame(height: 104) + LoadingShimmerCard() + .frame(height: 104) + } + .padding(.horizontal, SparkSpacing.lg) + } -private enum MetricDomain: CaseIterable { - case health, activity, money, media + // MARK: - Hero chart card - var label: String { - switch self { - case .health: "Health" - case .activity: "Activity" - case .money: "Money" - case .media: "Media" + @ViewBuilder + private var heroChartCard: some View { + if let metric = heroMetric { + GlassCard(radius: 22, padding: SparkSpacing.xl) { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + HStack(alignment: .top, spacing: SparkSpacing.sm) { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + HStack(spacing: SparkSpacing.sm) { + Image(systemName: metric.icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(metric.tint) + Text(metric.title) + .font(SparkTypography.bodyStrong) + .foregroundStyle(headerTextColor) + } + + if let detail = metric.detail { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + if let today = detail.today { + Text(formatValue(today, unit: detail.unit)) + .font(SparkFonts.display(.largeTitle, weight: .bold)) + .foregroundStyle(metric.tint) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + if let delta = delta(for: detail) { + deltaChip(delta, suffix: "vs 30-day avg") + } + } + } else { + LoadingShimmerCard() + .frame(width: 140, height: 74) + } + } + + Spacer(minLength: SparkSpacing.md) + + rangePicker + } + + if let detail = metric.detail { + SparklineMiniChart(series: series(for: detail), tint: metric.tint) + .frame(maxWidth: .infinity) + .frame(height: 96) + } else { + LoadingShimmerCard() + .frame(height: 96) + } + } + } + .contentShape(RoundedRectangle(cornerRadius: 22)) + .onTapGesture { + path.append(.metric(identifier: metric.identifier)) + } } } -} -private struct MetricCategory { - let domain: MetricDomain - let title: String - let icon: String - let tint: Color - let identifier: String -} + // MARK: - Metric rows (full-bleed sparkline) + + private var metricsStack: some View { + VStack(spacing: SparkSpacing.sm) { + ForEach(rowMetrics, id: \.identifier) { metric in + Button { + path.append(.metric(identifier: metric.identifier)) + } label: { + FullBleedMetricRow( + metric: metric, + isLoading: viewModel?.loadState == .loading || viewModel == nil + ) + } + .buttonStyle(.plain) + } + } + } -// MARK: - Compact metric tile for Metrics Explore + // MARK: - History / heatmap -private struct MetricsTileCard: View { - let detail: MetricDetail - let tint: Color + private var historySection: some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + Text("Last 45 days") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .textCase(.uppercase) - private var recentSeries: [MetricDetail.Point] { Array(detail.series.suffix(7)) } + GlassCard(radius: SparkRadii.lg, padding: SparkSpacing.lg) { + Heatmap45(rows: heatmapRows) + } + } + } - private var delta: (value: Double, isPositive: Bool)? { + // MARK: - Helpers + + private func delta(for detail: MetricDetail) -> (value: Double, isPositive: Bool)? { guard let today = detail.today, let avg = detail.average30d else { return nil } return (today - avg, today >= avg) } - var body: some View { - HStack(alignment: .top, spacing: SparkSpacing.lg) { - VStack(alignment: .leading, spacing: SparkSpacing.xs) { - if let today = detail.today { - Text(formatValue(today, unit: detail.unit)) - .font(SparkFonts.display(.title, weight: .bold)) - .foregroundStyle(tint) - } else { - Text("—") - .font(SparkFonts.display(.title, weight: .bold)) - .foregroundStyle(.tertiary) - } + @ViewBuilder + private func deltaChip(_ d: (value: Double, isPositive: Bool), suffix: String? = nil) -> some View { + HStack(spacing: 3) { + Image(systemName: d.isPositive ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + Text(deltaLabel(d.value)) + .font(SparkTypography.monoSmall) + if let suffix { + Text(suffix) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + } + } + .foregroundStyle(d.isPositive ? Color.sparkSuccess : Color.sparkWarning) + } - if let d = delta { - HStack(spacing: 3) { - Image(systemName: d.isPositive ? "arrow.up.right" : "arrow.down.right") - .font(.caption2) - Text(deltaLabel(d.value)) - .font(SparkTypography.monoSmall) - } - .foregroundStyle(d.isPositive ? Color.sparkSuccess : Color.sparkWarning) + private func formatValue(_ v: Double, unit: String?) -> String { + switch unit { + case "score", "bpm", "percent": return String(Int(v)) + case "ms": return "\(Int(v))" + case "GBP", "USD", "EUR": return String(format: "£%.2f", v) + default: + if v >= 1000 { return String(format: "%.1fk", v / 1000) } + return v.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(v)) : String(format: "%.1f", v) + } + } + + private func deltaLabel(_ diff: Double) -> String { + let sign = diff >= 0 ? "+" : "" + if abs(diff) >= 1000 { return "\(sign)\(String(format: "%.1fk", diff / 1000))" } + return "\(sign)\(diff.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(diff)) : String(format: "%.1f", diff))" + } + + private var headerTextColor: Color { + colorScheme == .dark ? Color.spark100 : Color.sparkTextPrimary + } + + private var headerSubtitle: String { + switch viewModel?.metadataState { + case .loaded(let summary): + let sourceText = "\(summary.activeSourceCount) active sources" + guard let lastSyncAt = summary.lastSyncAt else { return sourceText } + return "\(sourceText) - last sync \(relativeSyncText(for: lastSyncAt)) ago" + case .unavailable: + return "Sources unavailable" + case .idle, .none: + return "Loading sources" + } + } + + private var rangePicker: some View { + HStack(spacing: SparkSpacing.xs) { + ForEach(HeroMetricRange.allCases, id: \.self) { range in + Button { + heroRange = range + } label: { + Text(range.label) + .font(SparkTypography.monoSmall) + .fontWeight(.semibold) + .frame(width: 34, height: 34) + .foregroundStyle(heroRange == range ? Color.sparkTextPrimary : Color.secondary) + .background { + Circle() + .fill(heroRange == range ? Color.spark100 : Color.primary.opacity(0.05)) + } } + .buttonStyle(.plain) } + } + } - Spacer(minLength: 0) + private func series(for detail: MetricDetail) -> [MetricDetail.Point] { + switch heroRange { + case .day: + return Array(detail.series.suffix(2)) + case .week: + return Array(detail.series.suffix(7)) + case .month: + return Array(detail.series.suffix(30)) + } + } + + private func relativeSyncText(for date: Date) -> String { + let seconds = max(0, Int(Date().timeIntervalSince(date))) + if seconds < 60 { return "\(max(1, seconds))s" } + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h" } + return "\(hours / 24)d" + } + + private func displayLabel(forDomain domain: String) -> String { + domain.replacingOccurrences(of: "_", with: " ").sparkActionTitle + } +} + +// MARK: - Full-bleed metric row + +private struct FullBleedMetricRow: View { + let metric: MetricPresentation + let isLoading: Bool + private var recentSeries: [MetricDetail.Point] { + Array((metric.detail?.series ?? []).suffix(14)) + } + + var body: some View { + ZStack(alignment: .leading) { if !recentSeries.isEmpty { - SparklineMiniChart(series: recentSeries, tint: tint) - .frame(width: 100, height: 50) + SparklineMiniChart(series: recentSeries, tint: metric.tint) + .opacity(0.28) + .frame(maxWidth: .infinity) + .frame(height: 92) + .offset(x: 70, y: 18) + .clipShape(RoundedRectangle(cornerRadius: 20)) + + LinearGradient( + stops: [ + .init(color: Color.sparkElevated.opacity(0.95), location: 0), + .init(color: Color.sparkElevated.opacity(0.72), location: 0.40), + .init(color: Color.sparkElevated.opacity(0), location: 0.75), + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 92) + .clipShape(RoundedRectangle(cornerRadius: 20)) } + + HStack(spacing: SparkSpacing.md) { + Image(systemName: metric.icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background( + RoundedRectangle(cornerRadius: SparkRadii.sm) + .fill(metric.tint) + ) + + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Text(metric.title) + .font(SparkTypography.bodySmall) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .lineLimit(1) + if let today = metric.detail?.today { + HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.xs) { + Text(formatValue(today, unit: metric.detail?.unit)) + .font(SparkFonts.display(.title, weight: .bold)) + .foregroundStyle(metric.tint) + if let unit = unitLabel(metric.detail?.unit) { + Text(unit) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + } + + if let delta = delta(for: metric.detail) { + HStack(spacing: 3) { + Image(systemName: delta.isPositive ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + Text(deltaLabel(delta.value)) + .font(SparkTypography.monoSmall) + } + .foregroundStyle(delta.isPositive ? Color.sparkSuccess : Color.sparkWarning) + } + } else if isLoading { + Text("—") + .font(SparkTypography.monoBody) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, SparkSpacing.lg) } - .padding(.top, SparkSpacing.xs) + .frame(height: 104) + .sparkGlass(.roundedRect(20)) + } + + private func delta(for detail: MetricDetail?) -> (value: Double, isPositive: Bool)? { + guard let detail, let today = detail.today, let avg = detail.average30d else { return nil } + return (today - avg, today >= avg) } private func formatValue(_ v: Double, unit: String?) -> String { switch unit { case "score", "bpm", "percent": return String(Int(v)) - case "ms": return "\(Int(v))" + case "steps": return String(Int(v)) + case "kcal": return String(Int(v)) case "GBP", "USD", "EUR": return String(format: "£%.2f", v) default: if v >= 1000 { return String(format: "%.1fk", v / 1000) } @@ -190,6 +516,17 @@ private struct MetricsTileCard: View { } } + private func unitLabel(_ unit: String?) -> String? { + switch unit { + case "score", "steps", "percent", nil: + return nil + case "GBP": + return nil + default: + return unit + } + } + private func deltaLabel(_ diff: Double) -> String { let sign = diff >= 0 ? "+" : "" if abs(diff) >= 1000 { return "\(sign)\(String(format: "%.1fk", diff / 1000))" } @@ -197,7 +534,148 @@ private struct MetricsTileCard: View { } } -// MARK: - Sparkline mini chart (shared with HealthExploreView) +// MARK: - Supporting types + +private enum MetricSortMode: String, CaseIterable { + case anomalies + case recent + case name + case service + + var title: String { + switch self { + case .anomalies: "Anomalies" + case .recent: "Most Recent" + case .name: "Name" + case .service: "Service" + } + } + + var systemImage: String { + switch self { + case .anomalies: "exclamationmark.triangle.fill" + case .recent: "clock.fill" + case .name: "textformat" + case .service: "server.rack" + } + } + + func areInIncreasingOrder(_ lhs: MetricPresentation, _ rhs: MetricPresentation) -> Bool { + switch self { + case .anomalies: + return compare(lhs, rhs, dates: [\.latestAnomalyAt, \.lastEventAt]) + case .recent: + return compare(lhs, rhs, dates: [\.lastEventAt]) + case .name: + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + case .service: + let service = lhs.service.localizedCaseInsensitiveCompare(rhs.service) + if service != .orderedSame { return service == .orderedAscending } + let action = lhs.action.localizedCaseInsensitiveCompare(rhs.action) + if action != .orderedSame { return action == .orderedAscending } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } + + private func compare( + _ lhs: MetricPresentation, + _ rhs: MetricPresentation, + dates: [KeyPath] + ) -> Bool { + for keyPath in dates { + let left = lhs[keyPath: keyPath] + let right = rhs[keyPath: keyPath] + switch (left, right) { + case let (left?, right?) where left != right: + return left > right + case (_?, nil): + return true + case (nil, _?): + return false + default: + break + } + } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } +} + +private enum HeroMetricRange: CaseIterable { + case day, week, month + + var label: String { + switch self { + case .day: "D" + case .week: "W" + case .month: "M" + } + } +} + +private struct MetricPresentation { + let metric: Metric + let detail: MetricDetail? + + var identifier: String { metric.identifier } + var title: String { metric.displayName } + var service: String { metric.service } + var action: String { metric.action } + var domain: String { Self.domain(for: metric) } + var lastEventAt: Date? { metric.lastEventAt } + var latestAnomalyAt: Date? { detail?.anomalies.map(\.date).max() } + + var icon: String { + EntityPresentation.icon(domain: domain, service: service, action: action) + } + + var tint: Color { + EntityPresentation.tint(domain: domain) + } + + func matches(query: String) -> Bool { + let fields = [title, identifier, service, action, domain] + return fields.contains { field in + field.localizedCaseInsensitiveContains(query) + } + } + + static func domain(for metric: Metric) -> String { + guard let domain = metric.domain?.trimmingCharacters(in: .whitespacesAndNewlines), + !domain.isEmpty + else { + return metric.service + } + return domain + } +} + +private struct MetricsFilterChip: View { + let title: String + let isSelected: Bool + + init(_ title: String, isSelected: Bool) { + self.title = title + self.isSelected = isSelected + } + + var body: some View { + Text(title) + .font(SparkTypography.captionStrong) + .padding(.horizontal, SparkSpacing.md) + .padding(.vertical, SparkSpacing.xs + 2) + .foregroundStyle(isSelected ? Color.sparkTextPrimary : Color.secondary) + .background { + Capsule() + .fill(isSelected ? Color.spark100 : Color.primary.opacity(0.04)) + } + .overlay { + Capsule() + .strokeBorder(Color.primary.opacity(isSelected ? 0 : 0.08), lineWidth: 1) + } + } +} + +// MARK: - Sparkline mini chart private struct SparklineMiniChart: View { let series: [MetricDetail.Point] @@ -211,7 +689,7 @@ private struct SparklineMiniChart: View { ) .foregroundStyle( LinearGradient( - colors: [tint.opacity(0.3), tint.opacity(0)], + colors: [tint.opacity(0.4), tint.opacity(0)], startPoint: .top, endPoint: .bottom ) ) diff --git a/SparkApp/Sources/Explore/MetricsExploreViewModel.swift b/SparkApp/Sources/Explore/MetricsExploreViewModel.swift index 03981d8..057292e 100644 --- a/SparkApp/Sources/Explore/MetricsExploreViewModel.swift +++ b/SparkApp/Sources/Explore/MetricsExploreViewModel.swift @@ -7,37 +7,65 @@ import SparkKit @MainActor final class MetricsExploreViewModel { enum LoadState { case idle, loading, loaded, error(String) } + enum MetadataState { case idle, loaded(MetricsMetadataSummary), unavailable } private(set) var snapshots: [String: MetricDetail] = [:] + private(set) var metrics: [Metric] = [] private(set) var loadState: LoadState = .idle + private(set) var metadataState: MetadataState = .idle private let apiClient: APIClient - private let logger = Logger(subsystem: "co.cronx.spark", category: "MetricsExplore") + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "MetricsExplore") init(apiClient: APIClient) { self.apiClient = apiClient } - func load(identifiers: [String]) async { + func load() async { guard case .idle = loadState else { return } loadState = .loading - await fetchAll(identifiers: identifiers) + await fetchAll() } - func refresh(identifiers: [String]) async { + func refresh() async { snapshots = [:] + metrics = [] loadState = .idle - await fetchAll(identifiers: identifiers) + metadataState = .idle + await fetchAll() } - private func fetchAll(identifiers: [String]) async { + private func fetchAll() async { + loadState = .loading + do { + let metrics = try await apiClient.request(MetricsEndpoint.list()) + self.metrics = metrics.filter { $0.eventCount > 0 } + metadataState = .loaded(MetricsMetadataSummary(metrics: self.metrics)) + } catch where error.isAPICancellation { + loadState = .idle + return + } catch { + logger.error("Metrics list failed: \(String(describing: error), privacy: .public)") + metrics = [] + metadataState = .unavailable + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + loadState = .error(message) + return + } + + snapshots = await fetchDetails(identifiers: metrics.map(\.identifier)) + loadState = .loaded + } + + private func fetchDetails(identifiers: [String]) async -> [String: MetricDetail] { + var details: [String: MetricDetail] = [:] await withTaskGroup(of: (String, MetricDetail?).self) { group in let client = apiClient for id in identifiers { group.addTask { do { let detail = try await client.request( - MetricsEndpoint.detail(identifier: id, range: .sevenDays) + MetricsEndpoint.detail(identifier: id, range: .thirtyDays) ) return (id, detail) } catch { @@ -46,9 +74,19 @@ final class MetricsExploreViewModel { } } for await (id, detail) in group { - if let detail { snapshots[id] = detail } + if let detail { details[id] = detail } } } - loadState = .loaded + return details + } +} + +struct MetricsMetadataSummary: Equatable, Sendable { + let activeSourceCount: Int + let lastSyncAt: Date? + + init(metrics: [Metric]) { + activeSourceCount = metrics.filter { $0.eventCount > 0 }.count + lastSyncAt = metrics.compactMap(\.lastEventAt).max() } } diff --git a/SparkApp/Sources/Explore/MoneyExploreView.swift b/SparkApp/Sources/Explore/MoneyExploreView.swift index 374856a..a961382 100644 --- a/SparkApp/Sources/Explore/MoneyExploreView.swift +++ b/SparkApp/Sources/Explore/MoneyExploreView.swift @@ -1,50 +1,62 @@ +import Charts import SparkKit import SparkUI import SwiftUI +enum HistoryRange: String, CaseIterable, Identifiable { + case oneMonth = "1M" + case threeMonths = "3M" + case sixMonths = "6M" + case oneYear = "1Y" + case all = "ALL" + + var id: String { rawValue } + + var days: Int? { + switch self { + case .oneMonth: 30 + case .threeMonths: 90 + case .sixMonths: 180 + case .oneYear: 365 + case .all: nil + } + } + + var rangeLabel: String { + switch self { + case .oneMonth: "vs 1M" + case .threeMonths: "vs 3M" + case .sixMonths: "vs 6M" + case .oneYear: "vs 1Y" + case .all: "all time" + } + } +} + struct MoneyExploreView: View { @Environment(AppModel.self) private var appModel @State private var viewModel: MoneyExploreViewModel? @State private var path: [DetailRoute] = [] + @State private var showCreateAccount = false + @State private var selectedRange: HistoryRange = .oneMonth var body: some View { NavigationStack(path: $path) { ScrollView { - VStack(spacing: SparkSpacing.lg) { - if let vm = viewModel { - switch vm.loadState { - case .idle: - shimmerPlaceholder - case .loading where vm.spend == nil: - shimmerPlaceholder - case .error(let msg) where vm.spend == nil: - EmptyState( - systemImage: "exclamationmark.triangle.fill", - title: "Couldn't load money data", - message: msg, - actionTitle: "Retry" - ) { Task { await vm.refresh() } } - default: - spendingOverviewCard(vm: vm) - if let spend = vm.spend, !spend.topMerchants.isEmpty { - topMerchantsCard(merchants: spend.topMerchants, currency: spend.currency) - } - transactionsCard(vm: vm) - } - } else { - shimmerPlaceholder - } + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + content } - .padding(.horizontal, SparkSpacing.lg) - .padding(.vertical, SparkSpacing.xl) + .padding(.top, SparkSpacing.md) + .padding(.bottom, SparkSpacing.xl) } - .background(Color.sparkSurface.ignoresSafeArea()) - .navigationTitle("Money") - .navigationBarTitleDisplayMode(.large) + .sparkAppBackground() + .sparkMainNavigationTitle("Money") .navigationDestination(for: DetailRoute.self) { route in switch route { case .event(let id): EventDetailView(eventId: id) + case .account(let id): + AccountDetailView(accountId: id) default: EmptyView() } @@ -52,6 +64,12 @@ struct MoneyExploreView: View { .refreshable { await viewModel?.refresh() } + .sparkMainAppToolbar() + .sheet(isPresented: $showCreateAccount) { + CreateAccountSheet { account in + viewModel?.accountCreated(account) + } + } } .task { if viewModel == nil { @@ -61,115 +79,397 @@ struct MoneyExploreView: View { } } - // MARK: - Spending overview + @ViewBuilder + private var content: some View { + if let vm = viewModel { + switch vm.loadState { + case .idle, .loading: + shimmerPlaceholder + .padding(.horizontal, SparkSpacing.lg) + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load accounts", + message: msg, + actionTitle: "Retry" + ) { Task { await vm.refresh() } } + .padding(.horizontal, SparkSpacing.lg) + case .loaded: + netWorthHero(vm: vm) + .padding(.horizontal, SparkSpacing.lg) - private func spendingOverviewCard(vm: MoneyExploreViewModel) -> some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "sterlingsign.circle.fill", - tint: .domainMoney, - title: "Spending Overview" - ) - if let spend = vm.spend { - HStack(spacing: SparkSpacing.sm) { - SpendingPeriodCell( - period: "Today", - amount: formatAmount(spend.total, currency: spend.currency) - ) - SpendingPeriodCell( - period: "Transactions", - amount: "\(spend.transactionCount)" - ) - } - } else { - HStack(spacing: SparkSpacing.sm) { - SpendingPeriodCell(period: "Today", amount: "—") - SpendingPeriodCell(period: "Transactions", amount: "—") - } + if !vm.accounts.isEmpty { + compositionCard(vm: vm) + .padding(.horizontal, SparkSpacing.lg) } + + accountsSection(vm: vm) + .padding(.horizontal, SparkSpacing.lg) } + } else { + shimmerPlaceholder + .padding(.horizontal, SparkSpacing.lg) } } - // MARK: - Top merchants + // MARK: - Net Worth Hero - private func topMerchantsCard(merchants: [SpendWidget.Merchant], currency: String) -> some View { - GlassCard { + private func netWorthHero(vm: MoneyExploreViewModel) -> some View { + GlassCard(radius: 22, padding: SparkSpacing.xl, tint: Color.domainMoney.opacity(0.06)) { VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader(icon: "cart.fill", tint: .domainMoney, title: "Top Merchants") - VStack(spacing: 0) { - ForEach(merchants, id: \.id) { merchant in - HStack(spacing: SparkSpacing.md) { - VStack(alignment: .leading, spacing: 2) { - Text(merchant.name) - .font(SparkTypography.body) - Text("\(merchant.count) transaction\(merchant.count == 1 ? "" : "s")") - .font(SparkTypography.caption) - .foregroundStyle(.secondary) + Text("NET WORTH") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + let netWorth = vm.netWorth + let isPositive = netWorth >= 0 + let netWorthColor: Color = isPositive ? .sparkSuccess : .sparkError + + HStack(alignment: .firstTextBaseline, spacing: 1) { + Text(formatInteger(netWorth)) + .font(.system(size: 52, weight: .bold, design: .default)) + .foregroundStyle(netWorthColor) + .lineLimit(1) + .minimumScaleFactor(0.55) + Text(formatDecimal(netWorth)) + .font(.system(size: 24, weight: .semibold, design: .default)) + .foregroundStyle(netWorthColor.opacity(0.7)) + } + + let history = filteredHistory(vm: vm) + let delta = netWorthDelta(history: history) + let percent = netWorthDeltaPercent(history: history) + + if delta != 0 { + HStack(spacing: SparkSpacing.xs) { + Image(systemName: delta > 0 ? "arrow.up" : "arrow.down") + .font(.system(size: 13, weight: .semibold)) + Text("\(formatAmount(abs(delta))) \(String(format: "%.1f%%", abs(percent))) \(selectedRange.rangeLabel)") + .font(SparkTypography.bodySmall) + .fontWeight(.semibold) + } + .foregroundStyle(delta > 0 ? Color.sparkSuccess : Color.sparkError) + } + + rangeChips + + chartBody(history: history, tint: netWorthColor) + .frame(height: 100) + } + } + } + + private var rangeChips: some View { + HStack(spacing: SparkSpacing.xs) { + ForEach(HistoryRange.allCases) { range in + Button { + selectedRange = range + } label: { + Text(range.rawValue) + .font(SparkTypography.monoSmall) + .fontWeight(.semibold) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background { + if selectedRange == range { + RoundedRectangle(cornerRadius: SparkRadii.sm) + .fill(Color.domainMoney) + } else { + RoundedRectangle(cornerRadius: SparkRadii.sm) + .fill(.primary.opacity(0.06)) } - Spacer(minLength: SparkSpacing.sm) - Text(formatAmount(merchant.total, currency: currency)) - .font(SparkTypography.bodyStrong) - .foregroundStyle(Color.domainMoney) } - .padding(.vertical, SparkSpacing.sm) - if merchant.id != merchants.last?.id { - Divider().opacity(0.5) + .foregroundStyle(selectedRange == range ? .black : .secondary) + } + .buttonStyle(.plain) + } + } + } + + @ViewBuilder + private func chartBody(history: [BalanceAreaChart.Point], tint: Color) -> some View { + if case .loading = viewModel?.historyState { + LoadingShimmerCard() + } else if history.count >= 2 { + BalanceAreaChart(data: history, tint: tint) + } else { + RoundedRectangle(cornerRadius: SparkRadii.sm) + .fill(.primary.opacity(0.04)) + .overlay { + Text("Not enough history") + .font(SparkTypography.monoSmall) + .foregroundStyle(.tertiary) + } + } + } + + // MARK: - Composition Card + + @ViewBuilder + private func compositionCard(vm: MoneyExploreViewModel) -> some View { + let segments = compositionSegments(vm.accounts) + if !segments.isEmpty { + GlassCard { + VStack(alignment: .leading, spacing: 0) { + GlassCardHeader(icon: "chart.pie.fill", tint: Color.domainMoney, title: "Where It Lives") + .padding(.bottom, SparkSpacing.md) + + HStack(alignment: .center, spacing: SparkSpacing.lg) { + Chart(segments) { segment in + SectorMark( + angle: .value("Amount", segment.value), + innerRadius: .ratio(0.60), + angularInset: 1.5 + ) + .foregroundStyle(segment.color) + .cornerRadius(3) + } + .frame(width: 120, height: 120) + + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + ForEach(segments) { segment in + HStack(spacing: SparkSpacing.xs) { + Circle() + .fill(segment.color) + .frame(width: 8, height: 8) + Text(segment.type) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 4) + Text(formatAmount(segment.value)) + .font(SparkTypography.monoSmall) + .fontWeight(.semibold) + .foregroundStyle(.primary) + } + } } + .frame(maxWidth: .infinity, alignment: .leading) } } } } } - // MARK: - Transactions + private struct CompositionSegment: Identifiable { + let id: String + let type: String + let value: Double + let color: Color + } - private func transactionsCard(vm: MoneyExploreViewModel) -> some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "list.bullet.rectangle", - tint: .domainMoney, - title: "Recent Transactions" - ) - if vm.transactions.isEmpty { + private func compositionSegments(_ accounts: [MoneyAccount]) -> [CompositionSegment] { + let grouped = Dictionary(grouping: accounts) { $0.accountType ?? "other" } + return grouped.compactMap { type, accs in + let total = accs.compactMap { acc -> Double? in + guard let bal = acc.latestBalance?.balance, bal > 0 else { return nil } + return acc.isNegativeBalance ? nil : bal + }.reduce(0, +) + guard total > 0 else { return nil } + return CompositionSegment( + id: type, + type: accountTypeLabel(type), + value: total, + color: segmentColor(for: type) + ) + } + .sorted { $0.value > $1.value } + } + + private func segmentColor(for type: String) -> Color { + switch type { + case "savings_account": Color.sparkSuccess + case "investment_account", "pension": Color.ocean300 + case "current_account": Color.domainMoney + case "credit_card", "mortgage", "loan": Color.sparkError + default: Color.secondary.opacity(0.4) + } + } + + // MARK: - Accounts Section + + private func accountsSection(vm: MoneyExploreViewModel) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + HStack { + Text("Accounts") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Button { + showCreateAccount = true + } label: { + Image(systemName: "plus.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(Color.domainMoney) + } + .buttonStyle(.plain) + } + + if vm.accounts.isEmpty { + GlassCard { EmptyState( systemImage: "creditcard", - title: "No transactions yet", - message: "Connect a bank integration to see your transactions here." - ) - } else { - LazyVStack(spacing: 0) { - ForEach(vm.transactions) { event in - Button { - path.append(.event(id: event.id)) - } label: { - TransactionRow(event: event) - } - .buttonStyle(.plain) - if event.id != vm.transactions.last?.id { - Divider().opacity(0.5) - } - } + title: "No accounts yet", + message: "Tap + to add your first account.", + actionTitle: "Add Account" + ) { showCreateAccount = true } + } + } else { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + let groups = groupedAccounts(vm.accounts) + ForEach(groups, id: \.type) { group in + accountGroup(group: group) } } } } } - // MARK: - Shimmer placeholder + private func accountGroup(group: AccountGroup) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + AccountGroupHeader( + label: group.type, + count: group.accounts.count, + total: group.total, + currency: group.currency, + isDebt: group.isDebt + ) - private var shimmerPlaceholder: some View { - VStack(spacing: SparkSpacing.lg) { - LoadingShimmerCard().frame(height: 120) - LoadingShimmerCard().frame(height: 180) - LoadingShimmerCard().frame(height: 200) + ForEach(group.accounts) { account in + Button { + path.append(.account(id: account.id)) + } label: { + MoneyAccountRow(account: account) + } + .buttonStyle(.plain) + } + } + } + + private func groupedAccounts(_ accounts: [MoneyAccount]) -> [AccountGroup] { + let assetTypes = ["current_account", "savings_account", "investment_account", "pension"] + let debtTypes = ["credit_card", "mortgage", "loan"] + let order = assetTypes + debtTypes + ["other"] + + let grouped = Dictionary(grouping: accounts) { $0.accountType ?? "other" } + return order.compactMap { type in + guard let accs = grouped[type], !accs.isEmpty else { return nil } + let sorted = accs.sorted { $0.title < $1.title } + let isDebt = debtTypes.contains(type) + let balances = sorted.compactMap { $0.latestBalance?.balance } + let total: Double? = balances.isEmpty ? nil : balances.reduce(0, +) + return AccountGroup( + type: accountTypeLabel(type), + accounts: sorted, + total: total, + currency: sorted.first?.currency ?? "GBP", + isDebt: isDebt + ) } } // MARK: - Helpers + private func filteredHistory(vm: MoneyExploreViewModel) -> [BalanceAreaChart.Point] { + let cutoff: Date? = selectedRange.days.map { + Calendar.current.date(byAdding: .day, value: -$0, to: .now)! + } + return vm.netWorthHistory + .filter { point in cutoff.map { point.date >= $0 } ?? true } + .map { BalanceAreaChart.Point(date: $0.date, value: $0.total) } + } + + private func netWorthDelta(history: [BalanceAreaChart.Point]) -> Double { + guard let first = history.first?.value, let last = history.last?.value else { return 0 } + return last - first + } + + private func netWorthDeltaPercent(history: [BalanceAreaChart.Point]) -> Double { + guard let first = history.first?.value, first != 0 else { return 0 } + return (netWorthDelta(history: history) / abs(first)) * 100 + } + + private func formatInteger(_ value: Double) -> String { + let f = NumberFormatter() + f.numberStyle = .decimal + f.maximumFractionDigits = 0 + return "£" + (f.string(from: NSNumber(value: abs(value))) ?? "0") + } + + private func formatDecimal(_ value: Double) -> String { + let cents = Int((abs(value) * 100).rounded()) % 100 + return String(format: ".%02d", cents) + } + + private func formatAmount(_ value: Double) -> String { + "£\(String(format: "%.2f", abs(value)))" + } + + private func accountTypeLabel(_ type: String) -> String { + switch type { + case "current_account": "Current Accounts" + case "savings_account": "Savings" + case "mortgage": "Mortgages" + case "investment_account": "Investments" + case "credit_card": "Credit Cards" + case "loan": "Loans" + case "pension": "Pensions" + default: "Other" + } + } + + private var shimmerPlaceholder: some View { + VStack(spacing: SparkSpacing.sm) { + LoadingShimmerCard().frame(height: 240) + LoadingShimmerCard().frame(height: 160) + LoadingShimmerCard().frame(height: 72) + LoadingShimmerCard().frame(height: 72) + LoadingShimmerCard().frame(height: 72) + } + } +} + +// MARK: - Supporting Types + +private struct AccountGroup { + let type: String + let accounts: [MoneyAccount] + let total: Double? + let currency: String + let isDebt: Bool +} + +// MARK: - AccountGroupHeader + +private struct AccountGroupHeader: View { + let label: String + let count: Int + let total: Double? + let currency: String + let isDebt: Bool + + var body: some View { + HStack(spacing: SparkSpacing.sm) { + RoundedRectangle(cornerRadius: 2) + .fill(isDebt ? Color.sparkError : Color.sparkSuccess) + .frame(width: 7, height: 7) + Text(label) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + if let total { + Text(formatAmount(total, currency: currency)) + .font(SparkTypography.monoSmall) + .fontWeight(.semibold) + .foregroundStyle(isDebt ? Color.sparkError : Color.sparkSuccess) + } + } + .padding(.top, SparkSpacing.xs) + } + private func formatAmount(_ value: Double, currency: String) -> String { let symbol: String = switch currency { case "GBP": "£" @@ -177,87 +477,139 @@ struct MoneyExploreView: View { case "USD": "$" default: currency + " " } - return "\(symbol)\(String(format: "%.2f", value))" + return "\(symbol)\(String(format: "%.2f", abs(value)))" } } -// MARK: - Transaction row +// MARK: - BankTile -private struct TransactionRow: View { - let event: Event +private struct BankTile: View { + let provider: String? + let title: String + let size: CGFloat - private var merchant: String { - event.target?.title ?? event.actor?.title ?? event.service.capitalized + init(provider: String?, title: String, size: CGFloat = 42) { + self.provider = provider + self.title = title + self.size = size } - private var amount: String { - guard let value = event.value else { return "" } - let unit = event.unit ?? "" - let symbol: String = switch unit { - case "GBP": "£" - case "EUR": "€" - case "USD": "$" - default: unit.isEmpty ? "" : unit + " " + var body: some View { + let (from, to) = issuerTintColors(provider: provider) + ZStack { + RoundedRectangle(cornerRadius: size * 0.24) + .fill(LinearGradient(colors: [from, to], startPoint: .topLeading, endPoint: .bottomTrailing)) + Text(initials) + .font(.system(size: size * 0.33, weight: .semibold)) + .foregroundStyle(.white) } - return "\(symbol)\(value)" + .frame(width: size, height: size) } + private var initials: String { + let source = provider ?? title + let words = source.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + if words.count >= 2 { + return (String(words[0].prefix(1)) + String(words[1].prefix(1))).uppercased() + } + return String(source.prefix(2)).uppercased() + } +} + +// MARK: - MoneyAccountRow + +private struct MoneyAccountRow: View { + let account: MoneyAccount + var body: some View { HStack(spacing: SparkSpacing.md) { - ZStack { - Circle() - .fill(Color.domainMoney.opacity(0.12)) - .frame(width: 36, height: 36) - Image(systemName: "sterlingsign") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.domainMoney) - } + BankTile(provider: account.provider, title: account.title) - VStack(alignment: .leading, spacing: 2) { - Text(merchant) - .font(SparkTypography.body) - .lineLimit(1) - if let time = event.time { - Text(time.formatted(date: .abbreviated, time: .omitted)) - .font(SparkTypography.caption) + VStack(alignment: .leading, spacing: SparkSpacing.xxs) { + HStack(spacing: SparkSpacing.xs) { + Text(account.title) + .font(SparkTypography.bodySmall) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(1) + if let type = account.accountType { + Text(accountTypeChip(type)) + .font(SparkTypography.caption) + .foregroundStyle(Color.domainMoney) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.domainMoney.opacity(0.12), in: Capsule()) + } + } + if let provider = account.provider { + Text(provider.capitalized) + .font(SparkTypography.monoSmall) .foregroundStyle(.secondary) } } Spacer(minLength: SparkSpacing.sm) - if !amount.isEmpty { - Text(amount) - .font(SparkTypography.bodyStrong) - .foregroundStyle(.primary) + VStack(alignment: .trailing, spacing: SparkSpacing.xxs) { + if let balance = account.latestBalance { + Text(formattedBalance(balance.balance, currency: account.currency)) + .font(SparkFonts.display(.callout, weight: .bold)) + .foregroundStyle(balanceColor(balance: balance.balance, isNegative: account.isNegativeBalance)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } else { + Text("—") + .font(SparkTypography.bodySmall) + .foregroundStyle(.tertiary) + } } Image(systemName: "chevron.right") .font(.caption2) .foregroundStyle(.tertiary) } - .padding(.vertical, SparkSpacing.sm) + .padding(.horizontal, SparkSpacing.lg) + .frame(height: 72) .contentShape(Rectangle()) + .sparkGlass(.roundedRect(20)) } -} - -// MARK: - Spending period cell -private struct SpendingPeriodCell: View { - let period: String - let amount: String + private func accountTypeChip(_ type: String) -> String { + switch type { + case "current_account": "Current" + case "savings_account": "Savings" + case "mortgage": "Mortgage" + case "investment_account": "Investment" + case "credit_card": "Credit" + case "loan": "Loan" + case "pension": "Pension" + default: type.capitalized + } + } - var body: some View { - VStack(alignment: .leading, spacing: SparkSpacing.xxs) { - Text(amount) - .font(SparkTypography.titleStrong) - .foregroundStyle(.primary) - Text(period) - .font(SparkTypography.caption) - .foregroundStyle(.secondary) + private func formattedBalance(_ value: Double, currency: String) -> String { + let symbol: String = switch currency { + case "GBP": "£" + case "EUR": "€" + case "USD": "$" + default: currency + " " } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(SparkSpacing.md) - .sparkGlass(.roundedRect(SparkRadii.sm)) + return "\(symbol)\(String(format: "%.2f", abs(value)))" + } + + private func balanceColor(balance: Double, isNegative: Bool) -> Color { + isNegative ? Color.sparkError : (balance >= 0 ? Color.sparkSuccess : Color.sparkError) + } +} + +// MARK: - Issuer Tint Helper + +private func issuerTintColors(provider: String?) -> (Color, Color) { + switch provider?.lowercased() { + case "monzo": return (Color(red: 0.953, green: 0.612, blue: 0.518), Color(red: 0.831, green: 0.369, blue: 0.271)) + case "starling": return (Color(red: 0.565, green: 0.537, blue: 0.855), Color(red: 0.310, green: 0.278, blue: 0.647)) + case "amex": return (Color(red: 0.435, green: 0.584, blue: 0.780), Color(red: 0.176, green: 0.341, blue: 0.565)) + case "halifax": return (Color(red: 0.482, green: 0.612, blue: 0.800), Color(red: 0.204, green: 0.369, blue: 0.580)) + default: return (Color.domainMoney.opacity(0.7), Color.domainMoney) } } diff --git a/SparkApp/Sources/Explore/MoneyExploreViewModel.swift b/SparkApp/Sources/Explore/MoneyExploreViewModel.swift index 301d1a7..74d351f 100644 --- a/SparkApp/Sources/Explore/MoneyExploreViewModel.swift +++ b/SparkApp/Sources/Explore/MoneyExploreViewModel.swift @@ -3,48 +3,112 @@ import Observation import OSLog import SparkKit +struct NetWorthPoint: Identifiable { + let id = UUID() + let date: Date + let total: Double +} + @Observable @MainActor final class MoneyExploreViewModel { enum LoadState { case idle, loading, loaded, error(String) } - private(set) var spend: SpendWidget? - private(set) var transactions: [Event] = [] + private(set) var accounts: [MoneyAccount] = [] + private(set) var netWorthHistory: [NetWorthPoint] = [] private(set) var loadState: LoadState = .idle + private(set) var historyState: LoadState = .idle private let apiClient: APIClient - private let logger = Logger(subsystem: "co.cronx.spark", category: "MoneyExplore") + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "MoneyExplore") init(apiClient: APIClient) { self.apiClient = apiClient } + var netWorth: Double { + accounts.reduce(0.0) { sum, account in + let balance = account.latestBalance?.balance ?? 0 + return sum + (account.isNegativeBalance ? -abs(balance) : balance) + } + } + func load() async { guard case .idle = loadState else { return } loadState = .loading - await fetchAll() + do { + let data = try await apiClient.request(MoneyEndpoint.accounts()) + accounts = data.data + loadState = .loaded + await buildNetWorthHistory() + } catch where error.isAPICancellation { + loadState = .idle + } catch { + SparkObservability.captureHandled(error) + logger.error("Money explore load failed: \(String(describing: error))") + loadState = .error((error as? LocalizedError)?.errorDescription ?? "Couldn't load accounts.") + } } func refresh() async { - spend = nil - transactions = [] + accounts = [] + netWorthHistory = [] loadState = .idle - await fetchAll() + historyState = .idle + await load() } - private func fetchAll() async { - async let spendResult = apiClient.request(WidgetsEndpoint.spend()) - async let feedResult = apiClient.request(FeedEndpoint.feed(limit: 30, domain: "money")) + func accountCreated(_ account: MoneyAccount) { + accounts.append(account) + } - do { - let (spendData, feedData) = try await (spendResult, feedResult) - spend = spendData - transactions = feedData.data - loadState = .loaded - } catch { - SparkObservability.captureHandled(error) - logger.error("Money explore failed: \(String(describing: error))") - loadState = .error((error as? LocalizedError)?.errorDescription ?? "Couldn't load money data.") + private func buildNetWorthHistory() async { + guard !accounts.isEmpty else { return } + historyState = .loading + + var allBalances: [String: [BalanceEntry]] = [:] + let snapAccounts = accounts + + await withTaskGroup(of: (String, [BalanceEntry]).self) { group in + for account in snapAccounts { + group.addTask { [apiClient] in + do { + let page = try await apiClient.request(MoneyEndpoint.balances(accountId: account.id)) + return (account.id, page.data) + } catch { + return (account.id, []) + } + } + } + for await (id, entries) in group { + allBalances[id] = entries + } + } + + let cal = Calendar.current + var dateMap: [Date: [String: Double]] = [:] + + for account in snapAccounts { + let entries = allBalances[account.id] ?? [] + for entry in entries { + let day = cal.startOfDay(for: entry.time) + let adjusted = account.isNegativeBalance ? -abs(entry.balance) : entry.balance + if dateMap[day] == nil { dateMap[day] = [:] } + dateMap[day]![account.id] = adjusted + } + } + + let sortedDays = dateMap.keys.sorted() + var running: [String: Double] = [:] + + netWorthHistory = sortedDays.compactMap { day in + if let updates = dateMap[day] { + for (id, balance) in updates { running[id] = balance } + } + guard !running.isEmpty else { return nil } + return NetWorthPoint(date: day, total: running.values.reduce(0, +)) } + + historyState = .loaded } } diff --git a/SparkApp/Sources/Flint/FlintGenerationService.swift b/SparkApp/Sources/Flint/FlintGenerationService.swift new file mode 100644 index 0000000..58425c0 --- /dev/null +++ b/SparkApp/Sources/Flint/FlintGenerationService.swift @@ -0,0 +1,203 @@ +import Foundation +import SparkKit + +#if canImport(FoundationModels) +import FoundationModels + +@Generable +private struct GeneratedFlintDailyNote { + var title: String + var summary: String + var highlights: [String] + var watchouts: [String] + var suggestedActions: [String] + + var note: FlintDailyNote { + FlintDailyNote( + title: title, + summary: summary, + highlights: highlights, + watchouts: watchouts, + suggestedActions: suggestedActions + ) + } +} + +@Generable +private struct GeneratedTodaySummaryLine { + var text: String +} +#endif + +enum FlintGenerationAvailability: Equatable, Sendable { + case available + case deviceNotEligible + case appleIntelligenceNotEnabled + case modelNotReady + case unavailable +} + +struct FlintGenerationResult: Sendable, Equatable { + let note: FlintDailyNote + let availability: FlintGenerationAvailability + let usedAppleIntelligence: Bool +} + +enum FlintGenerationService { + static func generateNote(from facts: FlintBriefingFacts) async throws -> FlintGenerationResult { + #if canImport(FoundationModels) + let model = SystemLanguageModel.default + + switch model.availability { + case .available: + let session = LanguageModelSession( + instructions: """ + You are Flint, Spark's concise daily briefing assistant. + Use only the supplied Spark briefing facts. + Do not invent missing data. + Avoid medical or financial advice; phrase suggestions as lightweight observations. + Keep the note calm, specific, and useful. + """ + ) + let response = try await session.respond( + generating: GeneratedFlintDailyNote.self, + options: GenerationOptions(maximumResponseTokens: 500) + ) { + """ + Create a daily highlight note from these facts. + Return: + - a short title + - a two sentence summary + - up to four highlights + - up to three watchouts + - up to three suggested actions + + Spark briefing facts: + \(facts.promptText) + """ + } + return FlintGenerationResult( + note: response.content.note, + availability: .available, + usedAppleIntelligence: true + ) + case .unavailable(let reason): + return FlintGenerationResult( + note: facts.fallbackNote, + availability: availability(from: reason), + usedAppleIntelligence: false + ) + @unknown default: + return FlintGenerationResult( + note: facts.fallbackNote, + availability: .unavailable, + usedAppleIntelligence: false + ) + } + #else + return FlintGenerationResult( + note: facts.fallbackNote, + availability: .unavailable, + usedAppleIntelligence: false + ) + #endif + } + + static func generateTodaySummaryLine( + from facts: FlintBriefingFacts, + context: FlintBriefingFacts.SummaryLineContext + ) async throws -> FlintGenerationResult { + let fallback = FlintDailyNote( + title: "", + summary: facts.fallbackSummaryLine(context: context) ?? "", + highlights: [], + watchouts: [], + suggestedActions: [] + ) + + #if canImport(FoundationModels) + let model = SystemLanguageModel.default + + switch model.availability { + case .available: + let session = LanguageModelSession( + instructions: """ + You write the subtitle under Spark's Day page heading. + Use only the supplied Spark briefing facts. + Help the user understand the main pattern at a glance. + Choose the single most useful pattern for the user to notice. + Prefer anomalies or unusual changes, then activity, sleep, or spend highlights, then a neutral recap. + Do not invent missing data. + For today, describe the day so far. + For past dates, describe the day in review. + Do not say "today" for past dates. + Avoid raw metric names, IDs, source names, and jargon. + Avoid medical or financial advice. + Avoid percentage changes; they are not meaningful in this header. + Write one warm, plain-English sentence. + Keep it ideally 80-140 characters and never more than 180 characters. + """ + ) + let response = try await session.respond( + generating: GeneratedTodaySummaryLine.self, + options: GenerationOptions(maximumResponseTokens: 80) + ) { + """ + Write one sentence for the top of the day page. + Tone: calm, specific, concise, and human. + Context: \(context == .daySoFar ? "today's day so far" : "the day in review"). + + Spark briefing facts: + \(facts.promptText) + """ + } + return FlintGenerationResult( + note: FlintDailyNote( + title: "", + summary: response.content.text, + highlights: [], + watchouts: [], + suggestedActions: [] + ), + availability: .available, + usedAppleIntelligence: true + ) + case .unavailable(let reason): + return FlintGenerationResult( + note: fallback, + availability: availability(from: reason), + usedAppleIntelligence: false + ) + @unknown default: + return FlintGenerationResult( + note: fallback, + availability: .unavailable, + usedAppleIntelligence: false + ) + } + #else + return FlintGenerationResult( + note: fallback, + availability: .unavailable, + usedAppleIntelligence: false + ) + #endif + } + + #if canImport(FoundationModels) + private static func availability( + from reason: SystemLanguageModel.Availability.UnavailableReason + ) -> FlintGenerationAvailability { + switch reason { + case .deviceNotEligible: + return .deviceNotEligible + case .appleIntelligenceNotEnabled: + return .appleIntelligenceNotEnabled + case .modelNotReady: + return .modelNotReady + @unknown default: + return .unavailable + } + } + #endif +} diff --git a/SparkApp/Sources/Flint/FlintView.swift b/SparkApp/Sources/Flint/FlintView.swift index d8dc9ad..13b0a3f 100644 --- a/SparkApp/Sources/Flint/FlintView.swift +++ b/SparkApp/Sources/Flint/FlintView.swift @@ -1,57 +1,535 @@ +import SparkKit import SparkUI import SwiftUI struct FlintView: View { + @Environment(AppModel.self) private var appModel + @Environment(\.tabAccessoryCoordinator) private var tabAccessoryCoordinator + @State private var viewModel: FlintViewModel? + @State private var path: [DetailRoute] = [] + var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: SparkSpacing.lg) { - GlassCard(tint: .sparkAccent.opacity(0.08)) { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "sparkles", - tint: .sparkAccent, - title: "Daily Briefing" - ) - StatusPill(.ok, message: "Ready when your data syncs", trailing: "Phase 3") - EmptyState( - systemImage: "text.bubble", - title: "Your briefing will appear here", - message: "Flint reads your day — sleep, activity, calendar, spend — and surfaces what matters most." - ) - } + NavigationStack(path: $path) { + digestScrollView + .sparkMainNavigationTitle("Flint") + .sparkAppBackground() + .sparkMainAppToolbar() + .sparkDetailDestinations() + .environment(\.openURL, OpenURLAction { url in + if let route = DeepLink.parse(url)?.detailRoute { + push(route) + return .handled + } + return .systemAction + }) + .onAppear { + registerPeriodAccessory() + } + .onChange(of: viewModel?.selectedPeriod) { _, _ in + registerPeriodAccessory() + } + .onChange(of: viewModel?.availablePeriodSelections.map(\.id) ?? []) { _, _ in + registerPeriodAccessory() + } + .onDisappear { + tabAccessoryCoordinator?.clear(owner: .flint) + } + } + .task { + if viewModel == nil { + viewModel = FlintViewModel(apiClient: appModel.apiClient) + } + await viewModel?.load() + registerPeriodAccessory() + } + } + + private var digestScrollView: some View { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + if let viewModel { + content(for: viewModel) + } else { + loadingContent + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.top, SparkSpacing.md) + .padding(.bottom, SparkSpacing.xxl * 2) + } + .refreshable { await viewModel?.refresh() } + } + + private func registerPeriodAccessory() { + guard let viewModel else { return } + + tabAccessoryCoordinator?.set(TabAccessory( + owner: .flint, + title: "Digest period", + items: viewModel.availablePeriodSelections.map { + TabAccessoryItem(id: $0.id, title: $0.title) + }, + selectedID: viewModel.selectedPeriod.id, + select: { id in + guard let period = FlintViewModel.PeriodSelection(rawValue: id) else { return } + Task { await viewModel.selectPeriod(period) } + } + )) + } + + @ViewBuilder + private func content(for viewModel: FlintViewModel) -> some View { + switch viewModel.state { + case .idle, .loading: + loadingContent + case .loaded: + VStack(alignment: .leading, spacing: SparkSpacing.xl) { + if let digest = viewModel.digests.first { + FlintDigestHeader(digest: digest) + } + + ForEach(viewModel.digests) { digest in + FlintDigestSection(digest: digest, viewModel: viewModel, onOpen: push) + } + } + case .empty(let message): + EmptyState( + systemImage: "sparkles", + title: "No digest yet", + message: message + ) + case .error(let message): + VStack(spacing: SparkSpacing.md) { + EmptyState( + systemImage: "wifi.exclamationmark", + title: "Couldn't load Flint", + message: message + ) + PillButton("Retry", systemImage: "arrow.clockwise", tint: .sparkAccent) { + Task { await viewModel.refresh() } + } + } + } + } + + private func push(_ route: DetailRoute) { + if path.last == route { return } + path.append(route) + } + + private var loadingContent: some View { + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + LoadingShimmer(cornerRadius: SparkRadii.sm) + .frame(height: 18) + .frame(maxWidth: 220) + LoadingShimmer(cornerRadius: SparkRadii.sm) + .frame(height: 84) + LoadingShimmer(cornerRadius: SparkRadii.sm) + .frame(height: 18) + .frame(maxWidth: 280) + } + .accessibilityLabel("Loading Flint digest") + } + } + +} + +private struct FlintDigestSection: View { + let digest: FlintDigest + let viewModel: FlintViewModel + let onOpen: (DetailRoute) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + if let summary = digest.summary, !summary.isEmpty { + SparkLongFormContentView(text: summary, tint: .sparkAccent) + } + + if digest.blocks.isEmpty { + Text("This digest has no blocks yet.") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + ForEach(digest.blocks) { block in + FlintBlockRow(block: block, viewModel: viewModel, onOpen: onOpen) } + } + } + } + } +} + +private struct FlintDigestHeader: View { + let digest: FlintDigest + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + Text(digest.displayTitle) + .font(SparkTypography.heroXL) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityAddTraits(.isHeader) + + HStack(spacing: SparkSpacing.sm) { + Label(createdAtText, systemImage: "clock") + if let count = digest.unansweredQuestionCount, count > 0 { + Text("\(count) unanswered") + .foregroundStyle(Color.sparkWarning) + } + } + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + } + } + + private var createdAtText: String { + guard let createdAt = digest.createdAt else { return digest.date } + return createdAt.formatted( + Date.FormatStyle() + .weekday(.abbreviated) + .day() + .month(.abbreviated) + .hour() + .minute() + ) + } +} - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "bubble.left.and.bubble.right.fill", - tint: .sparkAccent, - title: "Ask Flint" - ) - HStack(spacing: SparkSpacing.md) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - Text("Ask anything about your day…") - .font(SparkTypography.body) - .foregroundStyle(.secondary) - Spacer() - } - .padding(SparkSpacing.md) - .sparkGlass(.roundedRect(SparkRadii.md)) - .opacity(0.5) - - Text("Conversational AI advisor — coming in Phase 3.") - .font(SparkTypography.bodySmall) +private extension FlintDigest { + var displayTitle: String { + guard let period else { return title } + + let generatedPrefix = "\(period.displayName) Digest" + guard title.hasPrefix(generatedPrefix) else { return title } + + let suffix = title.dropFirst(generatedPrefix.count) + let separators = [" — ", " – ", " - "] + if separators.contains(where: { suffix.hasPrefix($0) }) { + return generatedPrefix + } + + return title + } +} + +private struct FlintBlockRow: View { + let block: FlintDigestBlock + let viewModel: FlintViewModel + let onOpen: (DetailRoute) -> Void + @State private var isEditorialExpanded = false + + @ViewBuilder + private var referenceRow: some View { + if let references = block.references, !references.isEmpty { + EntityRefChipRow(label: "Connecting:", references: references) { reference in + if let route = reference.detailRoute { + onOpen(route) + } + } + } + } + + var body: some View { + if block.blockType == "flint_editorial_note" { + editorialDisclosure + } else { + standardRow + } + } + + private var standardRow: some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + HStack(alignment: .top, spacing: SparkSpacing.md) { + DomainGlyph(icon: icon, tint: tint, size: 26) + + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.sm) { + Text(block.title) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: SparkSpacing.sm) + if let badge { + Text(badge) + .font(SparkTypography.monoSmall) .foregroundStyle(.secondary) } } + + if let topic = block.topic, !topic.isEmpty { + Text(topic.capitalized) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + } + } + } + + if block.isQuestion { + FlintQuestionContent(block: block, viewModel: viewModel) + } else if let content = block.content, !content.isEmpty { + SparkRichContentText(text: content, font: SparkTypography.bodySmall, foregroundStyle: .secondary) + } + + referenceRow + } + .padding(SparkSpacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .sparkGlass(.roundedRect(SparkRadii.md), tint: tint.opacity(0.08)) + } + + private var editorialDisclosure: some View { + DisclosureGroup(isExpanded: $isEditorialExpanded) { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + if let content = block.content, !content.isEmpty { + SparkRichContentText(text: content, font: SparkTypography.bodySmall, foregroundStyle: .secondary) + } + referenceRow + } + .padding(.top, SparkSpacing.md) + } label: { + HStack(alignment: .center, spacing: SparkSpacing.md) { + DomainGlyph(icon: icon, tint: tint, size: 24) + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Text(block.title) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + Text("Editorial Note") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + Spacer(minLength: SparkSpacing.sm) + } + } + .padding(SparkSpacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .sparkGlass(.roundedRect(SparkRadii.md), tint: tint.opacity(0.08)) + } + + private var icon: String { + switch block.blockType { + case "flint_user_question": "questionmark.circle.fill" + case "flint_editorial_note": "pencil.and.scribble" + case "flint_health_insight", "flint_coaching_check_in": "heart.fill" + case "flint_money_insight": "sterlingsign.circle.fill" + case "flint_media_insight": "play.circle.fill" + case "flint_knowledge_insight", "flint_articles_waiting": "book.fill" + case "flint_online_insight": "network" + case "flint_cross_domain_insight", "flint_correlation": "arrow.left.arrow.right" + case "flint_pattern_detected": "chart.line.uptrend.xyaxis" + case "flint_prioritized_action": "flag.fill" + case "flint_urgent_alert": "bell.badge.fill" + case "flint_digest": "doc.text.fill" + case "flint_news_briefing": "newspaper.fill" + case "flint_coaching_insight": "brain.head.profile" + default: "sparkles" + } + } + + private var tint: Color { + switch block.blockType { + case "flint_user_question", "flint_prioritized_action": .sparkAccent + case "flint_urgent_alert": .sparkError + case "flint_health_insight", "flint_coaching_check_in", "flint_coaching_insight": .sparkSuccess + case "flint_money_insight": .sparkWarning + case "flint_media_insight": .sparkInfo + case "flint_knowledge_insight", "flint_news_briefing", "flint_articles_waiting": .sparkOcean + default: .sparkAccent + } + } + + private var badge: String? { + if let priority = block.priority { + return "\(priority.displayName) priority" + } + return blockTypeTitle(block.blockType) + } + + private func blockTypeTitle(_ raw: String) -> String? { + let trimmed = raw.replacingOccurrences(of: "flint_", with: "") + .replacingOccurrences(of: "_", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed.capitalized + } +} + +private struct FlintQuestionContent: View { + let block: FlintDigestBlock + let viewModel: FlintViewModel + + @State private var selectedAnswer = "" + @State private var freeformAnswer = "" + @State private var answerNote = "" + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + Text(block.question ?? block.title) + .font(SparkTypography.body) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + + if block.answered { + answeredView + } else { + answerForm + } + + if let error = viewModel.answerErrorByBlockID[block.id] { + Text(error) + .font(SparkTypography.caption) + .foregroundStyle(Color.sparkError) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var answeredView: some View { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Label(block.answer ?? "Answered", systemImage: "checkmark.circle.fill") + .font(SparkTypography.bodySmall) + .foregroundStyle(Color.sparkSuccess) + + if let note = block.answerNote, !note.isEmpty { + Text(note) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + + if let answeredAt = block.answeredAt { + Text("Answered \(answeredAt.formatted(date: .abbreviated, time: .shortened))") + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + } + } + } + + private var answerForm: some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + if let options = block.answerOptions, !options.isEmpty { + FlowLayout(spacing: SparkSpacing.sm) { + ForEach(options, id: \.self) { option in + Button { + selectedAnswer = option + } label: { + Text(option) + .font(SparkTypography.captionStrong) + .foregroundStyle(selectedAnswer == option ? Color.white : Color.primary) + .padding(.horizontal, SparkSpacing.md) + .padding(.vertical, SparkSpacing.sm) + .sparkGlass( + .capsule, + tint: selectedAnswer == option ? Color.sparkAccent : Color.sparkAccent.opacity(0.1) + ) + } + .buttonStyle(.plain) + } + } + } else { + TextField("Answer", text: $freeformAnswer, axis: .vertical) + .font(SparkTypography.bodySmall) + .lineLimit(1...4) + .padding(SparkSpacing.md) + .textFieldInputBackground() + } + + TextField("Add a note", text: $answerNote, axis: .vertical) + .font(SparkTypography.bodySmall) + .lineLimit(1...3) + .padding(SparkSpacing.md) + .textFieldInputBackground() + + Button { + Task { + await viewModel.answerQuestion( + block: block, + answer: submittedAnswer, + note: answerNote + ) + } + } label: { + HStack(spacing: SparkSpacing.sm) { + if viewModel.answeringBlockIDs.contains(block.id) { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "paperplane.fill") + } + Text("Submit") + .font(SparkTypography.bodyStrong) } .padding(.horizontal, SparkSpacing.lg) - .padding(.vertical, SparkSpacing.xl) + .padding(.vertical, SparkSpacing.sm) + .foregroundStyle(Color.white) + .sparkGlass(.capsule, tint: Color.sparkAccent) + } + .buttonStyle(.plain) + .disabled(viewModel.answeringBlockIDs.contains(block.id) || submittedAnswer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + private var submittedAnswer: String { + if let options = block.answerOptions, !options.isEmpty { + return selectedAnswer + } + return freeformAnswer + } +} + +private extension View { + func textFieldInputBackground() -> some View { + background { + RoundedRectangle(cornerRadius: SparkRadii.sm) + .fill(.thinMaterial) + .overlay { + RoundedRectangle(cornerRadius: SparkRadii.sm) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + } + } +} + +private struct FlowLayout: Layout { + var spacing: CGFloat + + init(spacing: CGFloat) { + self.spacing = spacing + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.width ?? 0 + var position = CGPoint.zero + var lineHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if position.x > 0, position.x + size.width > width { + position.x = 0 + position.y += lineHeight + spacing + lineHeight = 0 } - .navigationTitle("Flint") - .navigationBarTitleDisplayMode(.large) + position.x += size.width + spacing + lineHeight = max(lineHeight, size.height) + } + + return CGSize(width: width, height: position.y + lineHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var position = CGPoint(x: bounds.minX, y: bounds.minY) + var lineHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if position.x > bounds.minX, position.x + size.width > bounds.maxX { + position.x = bounds.minX + position.y += lineHeight + spacing + lineHeight = 0 + } + + subview.place(at: position, proposal: ProposedViewSize(size)) + position.x += size.width + spacing + lineHeight = max(lineHeight, size.height) } } } diff --git a/SparkApp/Sources/Flint/FlintViewModel.swift b/SparkApp/Sources/Flint/FlintViewModel.swift new file mode 100644 index 0000000..78a2e27 --- /dev/null +++ b/SparkApp/Sources/Flint/FlintViewModel.swift @@ -0,0 +1,302 @@ +import Foundation +import Observation +import OSLog +import SparkKit + +@MainActor +@Observable +final class FlintViewModel { + enum LoadState: Equatable { + case idle + case loading + case loaded + case empty(String) + case error(String) + } + + enum PeriodSelection: String, Identifiable { + case latest + case morning + case afternoon + case evening + + var id: String { rawValue } + + var title: String { + switch self { + case .latest: "Latest" + case .morning: "Morning" + case .afternoon: "Afternoon" + case .evening: "Evening" + } + } + + var apiPeriod: FlintDigestPeriod? { + switch self { + case .morning: .morning + case .afternoon: .afternoon + case .evening: .evening + case .latest: nil + } + } + + init?(period: FlintDigestPeriod) { + switch period { + case .morning: + self = .morning + case .afternoon: + self = .afternoon + case .evening: + self = .evening + } + } + } + + private(set) var state: LoadState = .idle + private(set) var digests: [FlintDigest] = [] + private(set) var availablePeriodSelections: [PeriodSelection] = [.latest] + private(set) var answeringBlockIDs: Set = [] + private(set) var answerErrorByBlockID: [String: String] = [:] + var selectedPeriod: PeriodSelection = .latest + + private let date: Date + private let apiClient: APIClient + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "Flint") + + init(date: Date = .now, apiClient: APIClient) { + self.date = date + self.apiClient = apiClient + } + + var unansweredQuestionCount: Int { + digests.reduce(0) { total, digest in + total + digest.blocks.filter { $0.isQuestion && !$0.answered }.count + } + } + + func load() async { + guard state == .idle else { return } + await refresh() + } + + func refresh() async { + state = .loading + answerErrorByBlockID.removeAll() + + do { + let loaded = try await fetchDigests() + applyLoadedDigests(loaded) + } catch APIError.notModified { + state = digests.isEmpty ? .empty(emptyMessage) : .loaded + } catch where error.isAPICancellation { + if digests.isEmpty { + state = .idle + } + } catch where error.isNotFound { + digests = [] + availablePeriodSelections = [.latest] + selectedPeriod = .latest + state = .empty(emptyMessage) + } catch { + SparkObservability.captureHandled(error) + logger.error("Flint digest load failed: \(String(describing: error))") + if digests.isEmpty { + state = .error(userFacingError(error)) + } else { + state = .loaded + } + } + } + + func selectPeriod(_ period: PeriodSelection) async { + guard selectedPeriod != period else { return } + guard availablePeriodSelections.contains(period) else { return } + selectedPeriod = period + await refresh() + } + + func answerQuestion(block: FlintDigestBlock, answer: String, note: String? = nil) async { + let trimmedAnswer = answer.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedNote = note?.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedAnswer.isEmpty else { + answerErrorByBlockID[block.id] = "Enter an answer before submitting." + return + } + + answeringBlockIDs.insert(block.id) + answerErrorByBlockID[block.id] = nil + + do { + _ = try await apiClient.request(FlintEndpoint.answerQuestion( + blockID: block.id, + FlintQuestionAnswerRequest( + answer: trimmedAnswer, + answerNote: trimmedNote?.isEmpty == false ? trimmedNote : nil + ) + )) + answeringBlockIDs.remove(block.id) + await refresh() + } catch where error.isAPICancellation { + answeringBlockIDs.remove(block.id) + } catch { + answeringBlockIDs.remove(block.id) + SparkObservability.captureHandled(error) + logger.error("Flint question answer failed: \(String(describing: error))") + answerErrorByBlockID[block.id] = userFacingAnswerError(error) + } + } + + private func fetchDigests() async throws -> [FlintDigest] { + let dateKey = Self.isoKey(for: date) + let response = try await apiClient.request(FlintEndpoint.digests(date: dateKey, all: true)) + return response.digests + } + + private func applyLoadedDigests(_ loaded: [FlintDigest]) { + let ordered = loaded.map(Self.orderedDigest) + availablePeriodSelections = Self.availableSelections(for: ordered) + + if !availablePeriodSelections.contains(selectedPeriod) { + selectedPeriod = .latest + } + + digests = Self.visibleDigests(from: ordered, selectedPeriod: selectedPeriod) + state = digests.isEmpty ? .empty(emptyMessage) : .loaded + } + + private var emptyMessage: String { + switch selectedPeriod { + case .latest: + "No Flint digest has been created for today yet." + case .morning, .afternoon, .evening: + "No \(selectedPeriod.title.lowercased()) digest has been created for today yet." + } + } + + private func userFacingError(_ error: Error) -> String { + (error as? LocalizedError)?.errorDescription ?? "Couldn't load Flint." + } + + private func userFacingAnswerError(_ error: Error) -> String { + if case APIError.httpStatus(403, _, _) = error { + return "This session cannot submit Flint answers." + } + if case APIError.httpStatus(422, _, _) = error { + return "Flint could not save that answer." + } + return (error as? LocalizedError)?.errorDescription ?? "Couldn't submit your answer." + } + + static func isoKey(for date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = .current + return formatter.string(from: date) + } + + private static func availableSelections(for digests: [FlintDigest]) -> [PeriodSelection] { + let availablePeriods = Set(digests.compactMap(\.period)) + let periodSelections = FlintDigestPeriod.allCases.compactMap { period -> PeriodSelection? in + guard availablePeriods.contains(period) else { return nil } + return PeriodSelection(period: period) + } + + return [.latest] + periodSelections + } + + private static func visibleDigests( + from digests: [FlintDigest], + selectedPeriod: PeriodSelection + ) -> [FlintDigest] { + switch selectedPeriod { + case .latest: + guard let latest = latestDigest(from: digests) else { return [] } + return [latest] + case .morning, .afternoon, .evening: + guard let period = selectedPeriod.apiPeriod else { return [] } + return digests.filter { $0.period == period } + } + } + + private static func latestDigest(from digests: [FlintDigest]) -> FlintDigest? { + digests.max { lhs, rhs in + switch (lhs.createdAt, rhs.createdAt) { + case let (lhsDate?, rhsDate?): + return lhsDate < rhsDate + case (nil, _?): + return true + case (_?, nil): + return false + case (nil, nil): + return false + } + } + } + + private static func orderedDigest(_ digest: FlintDigest) -> FlintDigest { + let orderedBlocks = digest.blocks + .enumerated() + .sorted { lhs, rhs in + let lhsRank = blockRank(lhs.element) + let rhsRank = blockRank(rhs.element) + if lhsRank != rhsRank { + return lhsRank < rhsRank + } + return lhs.offset < rhs.offset + } + .map(\.element) + + return FlintDigest( + eventID: digest.eventID, + digestObjectID: digest.digestObjectID, + date: digest.date, + period: digest.period, + title: digest.title, + summary: digest.summary, + createdAt: digest.createdAt, + blockCount: digest.blockCount, + unansweredQuestionCount: digest.unansweredQuestionCount, + blocks: orderedBlocks + ) + } + + private static func blockRank(_ block: FlintDigestBlock) -> Int { + if block.isQuestion { + return 0 + } + if block.blockType == "flint_editorial_note" { + return 50 + } + + switch block.blockType { + case "flint_urgent_alert", "flint_prioritized_action": + return 10 + case "flint_health_insight", + "flint_money_insight", + "flint_media_insight", + "flint_knowledge_insight", + "flint_online_insight", + "flint_cross_domain_insight", + "flint_pattern_detected", + "flint_correlation", + "flint_coaching_insight": + return 20 + case "flint_digest", "flint_news_briefing", "flint_articles_waiting": + return 30 + case "flint_coaching_check_in": + return 40 + default: + return 30 + } + } +} + +private extension Error { + var isNotFound: Bool { + if let apiError = self as? APIError, + case APIError.httpStatus(404, _, _) = apiError { + return true + } + return false + } +} diff --git a/SparkApp/Sources/Integrations/IntegrationDetailView.swift b/SparkApp/Sources/Integrations/IntegrationDetailView.swift index d32aff5..e64a9a4 100644 --- a/SparkApp/Sources/Integrations/IntegrationDetailView.swift +++ b/SparkApp/Sources/Integrations/IntegrationDetailView.swift @@ -27,9 +27,15 @@ struct IntegrationDetailView: View { } .padding(SparkSpacing.lg) } - .background(Color.sparkSurface.ignoresSafeArea()) + .sparkAppBackground() .navigationTitle(viewModel?.state.loadedTitle ?? "Integration") .navigationBarTitleDisplayMode(.inline) + .sparkSubViewToolbar( + shareItems: integrationShareItems, + rawTitle: "Raw integration", + rawPayload: integrationRawPayload, + refresh: { await viewModel?.load() } + ) .task(id: integrationId) { if viewModel == nil { viewModel = IntegrationDetailViewModel( @@ -41,6 +47,23 @@ struct IntegrationDetailView: View { } } + private var integrationShareItems: [Any] { + guard case .loaded(let detail) = viewModel?.state else { + return ["Spark Integration: \(integrationId)"] + } + return ["Spark Integration: \(detail.integration.name)"] + } + + private var integrationRawPayload: String? { + guard case .loaded(let detail) = viewModel?.state else { return nil } + return SparkPrettyJSON.string(for: detail) + ?? SparkPrettyJSON.fallback( + entity: "integration", + id: detail.integration.id, + title: detail.integration.name + ) + } + @ViewBuilder private func content(for detail: IntegrationDetail) -> some View { heroCard(for: detail) @@ -155,7 +178,7 @@ struct IntegrationDetailView: View { private func pillTone(for status: IntegrationStatus) -> StatusPill.Tone { switch status { case .upToDate: .ok - case .syncing: .neutral + case .syncing, .unknown: .neutral case .needsReauth, .error: .warning } } diff --git a/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift b/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift index 97f2f1b..60aeb2a 100644 --- a/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift +++ b/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift @@ -19,7 +19,7 @@ final class IntegrationDetailViewModel { private let apiClient: APIClient private let reauthService = IntegrationReauthService() - private let logger = Logger(subsystem: "co.cronx.spark", category: "IntegrationDetail") + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "IntegrationDetail") init(integrationId: String, apiClient: APIClient) { self.integrationId = integrationId diff --git a/SparkApp/Sources/Integrations/IntegrationsListView.swift b/SparkApp/Sources/Integrations/IntegrationsListView.swift index d106b64..93ff4b6 100644 --- a/SparkApp/Sources/Integrations/IntegrationsListView.swift +++ b/SparkApp/Sources/Integrations/IntegrationsListView.swift @@ -18,6 +18,15 @@ struct IntegrationsListView: View { ) } else { Form { + Section { + SparkSystemScreenHeader( + title: "Integrations", + subtitle: "Connection health and sync controls for Spark sources." + ) + .padding(.vertical, SparkSpacing.sm) + } + .listRowBackground(Color.clear) + ForEach(viewModel?.grouped(list) ?? [], id: \.0) { group in Section(group.0) { ForEach(group.1) { integration in @@ -77,14 +86,15 @@ private struct IntegrationRow: View { Circle() .fill(statusColor) .frame(width: 8, height: 8) - .accessibilityLabel(integration.status) + .accessibilityLabel(integration.statusValue) } private var statusColor: Color { - switch integration.status.lowercased() { + switch integration.statusValue.lowercased() { case "up_to_date", "ok", "active": .sparkSuccess case "syncing", "running": .sparkInfo case "needs_reauth", "reauth", "expired": .sparkWarning + case "unknown": .secondary default: .sparkError } } diff --git a/SparkApp/Sources/Integrations/IntegrationsListViewModel.swift b/SparkApp/Sources/Integrations/IntegrationsListViewModel.swift index 59158f7..3d75b3a 100644 --- a/SparkApp/Sources/Integrations/IntegrationsListViewModel.swift +++ b/SparkApp/Sources/Integrations/IntegrationsListViewModel.swift @@ -15,7 +15,7 @@ final class IntegrationsListViewModel { private(set) var state: LoadState = .loading private let apiClient: APIClient - private let logger = Logger(subsystem: "co.cronx.spark", category: "Integrations") + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "Integrations") init(apiClient: APIClient) { self.apiClient = apiClient @@ -24,8 +24,8 @@ final class IntegrationsListViewModel { func load() async { state = .loading do { - let list = try await apiClient.request(IntegrationsEndpoint.list()) - state = .loaded(list) + let response = try await apiClient.request(IntegrationsEndpoint.list()) + state = .loaded(response.data) } catch APIError.notModified { return } catch { diff --git a/SparkApp/Sources/Knowledge/KnowledgeItemDetailView.swift b/SparkApp/Sources/Knowledge/KnowledgeItemDetailView.swift index 3c2e1b7..a65a440 100644 --- a/SparkApp/Sources/Knowledge/KnowledgeItemDetailView.swift +++ b/SparkApp/Sources/Knowledge/KnowledgeItemDetailView.swift @@ -6,77 +6,155 @@ struct KnowledgeItemDetailView: View { let event: Event @Environment(AppModel.self) private var appModel @Environment(\.openURL) private var openURL - @State private var detailState: DetailLoadState = .loading + @State private var detailState: KnowledgeDetailState = .loading + @State private var reprocessError: String? - private var imageUrl: URL? { - guard let raw = event.target?.mediaUrl else { return nil } - return URL(string: raw) - } - - private var title: String { event.target?.title ?? event.action } - private var source: String { event.actor?.title ?? event.service } + private var title: String { event.target?.title ?? event.displayName ?? event.action } var body: some View { ScrollView { - VStack(alignment: .leading, spacing: SparkSpacing.lg) { - heroImage + VStack(alignment: .leading, spacing: 0) { + switch detailState { + case .loaded(let payload): + hero(for: payload) + .padding(.bottom, SparkSpacing.lg) + default: + hero(for: nil) + .padding(.bottom, SparkSpacing.lg) + } + VStack(alignment: .leading, spacing: SparkSpacing.lg) { - headerSection switch detailState { case .loading: + headerSection(payload: nil) LoadingShimmerCard() LoadingShimmerCard() - case .loaded(let detail): - contentCards(for: detail) + case .loaded(let payload): + headerSection(payload: payload) + if !payload.eventDetail.tags.isEmpty { + TagChipRow(payload.eventDetail.tags.names) + } + contentCards(for: payload) case .error: + headerSection(payload: nil) EmptyState( systemImage: "exclamationmark.triangle", title: "Couldn't load content", message: "The full article analysis isn't available right now." ) } + readOriginalButton } .padding(.horizontal, SparkSpacing.lg) .padding(.bottom, SparkSpacing.xl) } } - .background(Color.sparkSurface.ignoresSafeArea()) + .ignoresSafeArea(edges: .top) + .sparkAppBackground() .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.hidden, for: .navigationBar) + .sparkSubViewToolbar( + shareItems: knowledgeShareItems, + rawTitle: "Raw knowledge item", + rawPayload: knowledgeRawPayload, + refresh: { await loadDetail() }, + reprocess: { await reprocessKnowledgeEvent() } + ) + .alert("Couldn't reprocess", isPresented: reprocessErrorBinding) { + Button("OK", role: .cancel) { + reprocessError = nil + } + } message: { + Text(reprocessError ?? "Try again later.") + } .task(id: event.id) { await loadDetail() } } - // MARK: - Hero image + private var knowledgeShareItems: [Any] { + if let url = event.url.flatMap(URL.init) { + return [url] + } + return ["Spark Knowledge: \(title)"] + } - @ViewBuilder - private var heroImage: some View { - if let url = imageUrl { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image.resizable().scaledToFill() - default: - Color.sparkElevated + private var knowledgeRawPayload: String? { + guard case .loaded(let payload) = detailState else { return nil } + return SparkPrettyJSON.string(for: payload.eventDetail) + ?? SparkPrettyJSON.fallback(entity: "knowledge_item", id: event.id, title: title) + } + + private var reprocessErrorBinding: Binding { + Binding( + get: { reprocessError != nil }, + set: { if !$0 { reprocessError = nil } } + ) + } + + // MARK: - Colour block hero + + private func hero(for payload: KnowledgeDetailPayload?) -> some View { + GeometryReader { proxy in + let topInset = proxy.safeAreaInsets.top + + ZStack(alignment: .bottomLeading) { + if let url = payload?.mainImageURL ?? mainImageURL(event: event) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + fallbackHeroBackground + } + } + } else { + fallbackHeroBackground } + + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + Image(systemName: "books.vertical.fill") + .font(.system(size: 40, weight: .light)) + .foregroundStyle(.white.opacity(0.75)) + + Text(sourceLabel(payload: payload)) + .font(SparkTypography.monoSmall) + .foregroundStyle(.white.opacity(0.9)) + .textCase(.uppercase) + } + .padding(SparkSpacing.lg) } - .frame(maxWidth: .infinity) - .frame(height: 220) + .frame(height: 240 + topInset) .clipped() + .offset(y: -topInset) } + .frame(height: 240) + .ignoresSafeArea(edges: .top) + } + + private var fallbackHeroBackground: some View { + Rectangle() + .fill(Color.domainKnowledge) + .overlay { + Image(systemName: "books.vertical.fill") + .font(.system(size: 92, weight: .light)) + .foregroundStyle(.white.opacity(0.32)) + } } // MARK: - Header - private var headerSection: some View { + private func headerSection(payload: KnowledgeDetailPayload?) -> some View { VStack(alignment: .leading, spacing: SparkSpacing.sm) { HStack(spacing: SparkSpacing.xs) { - Text(source) + Text(sourceLabel(payload: payload)) .font(SparkTypography.captionStrong) .foregroundStyle(.secondary) if let time = event.time { - Text("·") + Text(" — ") .foregroundStyle(.secondary) Text(time.formatted(date: .abbreviated, time: .omitted)) .font(SparkTypography.caption) @@ -91,35 +169,64 @@ struct KnowledgeItemDetailView: View { // MARK: - Content cards @ViewBuilder - private func contentCards(for detail: EventDetail) -> some View { + private func contentCards(for payload: KnowledgeDetailPayload) -> some View { + let detail = payload.eventDetail let blocks = detail.blocks - let service = event.service + let service = detail.event.service + let summaryBlock = summaryBlock(service: service, blocks: blocks) + let articleBody = articleBodyContent(payload: payload, service: service, blocks: blocks) - if let tldrText = blockContent(service: service, kind: "tldr", blocks: blocks) ?? event.tldr { - GlassCard(tint: Color.domainKnowledge.opacity(0.08)) { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - GlassCardHeader(icon: "text.quote", tint: .domainKnowledge, title: "TL;DR") - Text(tldrText) - .font(SparkTypography.body) - .italic() - .foregroundStyle(.primary) - } + if let summary = summaryText(payload: payload, summaryBlock: summaryBlock, articleBody: articleBody) { + summaryCallout(summary) + } + + if let articleBody { + SparkLongFormContentView(text: articleBody.text, tint: .domainKnowledge) + } + + ForEach(remainingBlocks(blocks, summaryBlock: summaryBlock, articleBody: articleBody)) { block in + blockCard(block) + } + } + + // MARK: - Summary callout + + private func summaryCallout(_ text: String) -> some View { + GlassCard(tint: Color.domainKnowledge.opacity(0.08)) { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + GlassCardHeader(icon: "doc.text", tint: .domainKnowledge, title: "Summary") + SparkRichContentText(text: text, font: SparkTypography.body, foregroundStyle: .primary) } } + } - if let summary = blockContent(service: service, kind: "summary_paragraph", blocks: blocks) { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - GlassCardHeader(icon: "doc.text", tint: .domainKnowledge, title: "Summary") - Text(summary) - .font(SparkTypography.body) - .foregroundStyle(.primary) + // MARK: - Read Original + + @ViewBuilder + private var readOriginalButton: some View { + if let urlString = event.url, let url = URL(string: urlString) { + Button { + openURL(url) + } label: { + HStack(spacing: SparkSpacing.sm) { + Image(systemName: "safari") + Text("Open Original ↗") + .font(SparkTypography.bodyStrong) } + .frame(maxWidth: .infinity) + .padding(.vertical, SparkSpacing.md) } + .sparkGlass(.capsule, tint: Color.domainKnowledge.opacity(0.12)) + .foregroundStyle(Color.domainKnowledge) } + } + + // MARK: - Helpers - if let takeaways = blockContent(service: service, kind: "key_takeaways", blocks: blocks) { - let bullets = takeaways.components(separatedBy: "\n").filter { !$0.isEmpty } + @ViewBuilder + private func blockCard(_ block: Block) -> some View { + if isKeyTakeawaysBlock(block), let content = nonEmpty(block.content) { + let bullets = takeawayBullets(from: content) if !bullets.isEmpty { GlassCard { VStack(alignment: .leading, spacing: SparkSpacing.sm) { @@ -127,59 +234,183 @@ struct KnowledgeItemDetailView: View { VStack(alignment: .leading, spacing: SparkSpacing.xs) { ForEach(bullets, id: \.self) { bullet in HStack(alignment: .top, spacing: SparkSpacing.sm) { - Text("·") - .font(SparkTypography.bodyStrong) + Image(systemName: "checkmark") + .font(.caption2) .foregroundStyle(Color.domainKnowledge) - Text(bullet) - .font(SparkTypography.body) - .fixedSize(horizontal: false, vertical: true) + .padding(.top, 3) + SparkRichContentText(text: bullet, font: SparkTypography.body, foregroundStyle: .primary) } } } } } } + } else { + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + GlassCardHeader( + icon: "square.stack.3d.up", + tint: .domainKnowledge, + title: displayTitle(for: block), + trailing: displayType(for: block) + ) + if let content = nonEmpty(block.content) { + SparkRichContentText(text: content, font: SparkTypography.body, foregroundStyle: .primary) + } + if let value = nonEmpty(block.value) { + Text([value, block.unit].compactMap(nonEmpty).joined(separator: " ")) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + } + } + } } + } + + private func summaryText( + payload: KnowledgeDetailPayload, + summaryBlock: Block?, + articleBody: KnowledgeArticleBodyContent? + ) -> String? { + let candidates = [ + summaryBlock.flatMap { nonEmpty($0.content) }, + nonEmpty(payload.objectDetail?.aiSummary), + nonEmpty(payload.eventDetail.aiSummary), + nonEmpty(event.tldr), + articleBody.flatMap { firstArticleParagraph($0.text) }, + ].compactMap { $0 } + + return candidates.first { !looksTruncated($0) } ?? candidates.first + } - if !detail.tags.isEmpty { - TagChipRow(detail.tags) + private func summaryBlock(service: String, blocks: [Block]) -> Block? { + blocks + .filter { block in + !isRawBlock(block) + && nonEmpty(block.content) != nil + && isSummaryBlock(block, service: service) + } + .sorted { lhs, rhs in + summaryRank(lhs) > summaryRank(rhs) + } + .first + } + + private func articleBodyContent(payload: KnowledgeDetailPayload, service: String, blocks: [Block]) -> KnowledgeArticleBodyContent? { + if let block = blocks.first(where: { block in + !isRawBlock(block) + && nonEmpty(block.content) != nil + && (blockType(block, matches: "\(service)_content") + || blockType(block, matches: "content") + || block.blockType.lowercased().hasSuffix("_content")) + }), let text = nonEmpty(block.content) { + return KnowledgeArticleBodyContent(text: text, blockID: block.id) } + + if let text = nonEmpty(payload.objectDetail?.object.content) { + return KnowledgeArticleBodyContent(text: text, blockID: nil) + } + + if let text = nonEmpty(payload.eventDetail.target?.content) { + return KnowledgeArticleBodyContent(text: text, blockID: nil) + } + + return nil } - // MARK: - Read Original + private func remainingBlocks(_ blocks: [Block], summaryBlock: Block?, articleBody: KnowledgeArticleBodyContent?) -> [Block] { + blocks.filter { block in + if isRawBlock(block) { return false } + if block.id == summaryBlock?.id { return false } + if block.id == articleBody?.blockID { return false } + return nonEmpty(block.content) != nil || nonEmpty(block.value) != nil + } + } - @ViewBuilder - private var readOriginalButton: some View { - if let urlString = event.url, let url = URL(string: urlString) { - Button { - openURL(url) - } label: { - HStack(spacing: SparkSpacing.sm) { - Image(systemName: "safari") - Text("Read Original") - .font(SparkTypography.bodyStrong) - } - .frame(maxWidth: .infinity) - .padding(.vertical, SparkSpacing.md) - } - .sparkGlass(.capsule, tint: Color.domainKnowledge.opacity(0.15)) - .foregroundStyle(Color.domainKnowledge) + private func isRawBlock(_ block: Block) -> Bool { + block.blockType.localizedCaseInsensitiveContains("raw") + } + + private func isKeyTakeawaysBlock(_ block: Block) -> Bool { + let type = block.blockType.lowercased() + return type == "key_takeaways" || type.hasSuffix("_key_takeaways") + } + + private func blockType(_ block: Block, matches expected: String) -> Bool { + block.blockType.caseInsensitiveCompare(expected) == .orderedSame + } + + private func isSummaryBlock(_ block: Block, service: String) -> Bool { + let type = block.blockType.lowercased() + return blockType(block, matches: "\(service)_summary_paragraph") + || blockType(block, matches: "summary_paragraph") + || blockType(block, matches: "paragraph_summary") + || type.hasSuffix("_summary_paragraph") + || type.contains("summary") + } + + private func summaryRank(_ block: Block) -> Int { + let content = nonEmpty(block.content) ?? "" + let type = block.blockType.lowercased() + let title = block.title.lowercased() + var score = min(content.count, 2_000) + + if looksTruncated(content) { + score -= 1_000 + } + if type.contains("short") || title.contains("short") || type.contains("tldr") || title.contains("tldr") { + score -= 500 } + if type.hasSuffix("_summary_paragraph") || type == "summary_paragraph" { + score += 100 + } + return score } - // MARK: - Helpers + private func displayTitle(for block: Block) -> String { + nonEmpty(block.title) ?? displayType(for: block) + } - private func blockContent(service: String, kind: String, blocks: [Block]) -> String? { - let prefixed = "\(service)_\(kind)" - return blocks.first { $0.blockType == prefixed }?.content - ?? blocks.first { $0.blockType == kind }?.content + private func displayType(for block: Block) -> String { + block.blockType + .replacingOccurrences(of: "_", with: " ") + .capitalized + } + + private func nonEmpty(_ text: String?) -> String? { + guard let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private func looksTruncated(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasSuffix("...") + || trimmed.hasSuffix("…") + } + + private func firstArticleParagraph(_ text: String) -> String? { + SparkLongFormBlock.parse(text).compactMap { block in + if case .paragraph(let paragraph) = block, !looksTruncated(paragraph) { + return nonEmpty(paragraph) + } + return nil + }.first } private func loadDetail() async { detailState = .loading do { let detail = try await appModel.apiClient.request(EventsEndpoint.detail(id: event.id)) - detailState = .loaded(detail) + let objectID = detail.target?.id ?? event.target?.id + let objectDetail: ObjectDetail? + if let objectID { + objectDetail = try? await appModel.apiClient.request(ObjectsEndpoint.detail(id: objectID)) + } else { + objectDetail = nil + } + detailState = .loaded(KnowledgeDetailPayload(eventDetail: detail, objectDetail: objectDetail)) } catch APIError.notModified { return } catch { @@ -187,4 +418,142 @@ struct KnowledgeItemDetailView: View { detailState = .error(String(describing: error)) } } + + private func reprocessKnowledgeEvent() async { + do { + _ = try await appModel.apiClient.request(EventsEndpoint.reprocessKnowledgeEvent(id: event.id)) + await loadDetail() + } catch { + SparkObservability.captureHandled(error) + reprocessError = (error as? LocalizedError)?.errorDescription ?? "The item couldn't be reprocessed." + } + } + + private func mainImageURL(event: Event) -> URL? { + event.target?.mediaUrl.flatMap(URL.init(string:)) + } + + private func sourceLabel(payload: KnowledgeDetailPayload?) -> String { + if let host = sourceHost(payload: payload) { + return host + } + return payload?.eventDetail.actor?.title + ?? event.actor?.title + ?? event.service.capitalized + } + + private func sourceHost(payload: KnowledgeDetailPayload?) -> String? { + let raw = payload?.objectDetail?.object.url + ?? payload?.eventDetail.event.url + ?? event.url + guard let raw, + let host = URL(string: raw)?.host + else { return nil } + return host + .replacingOccurrences(of: "www.", with: "") + .uppercased() + } + + private func takeawayBullets(from content: String) -> [String] { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + + for candidate in takeawayArrayCandidates(from: trimmed) { + if let decoded = decodeTakeawayArray(candidate) { + return decoded + } + } + + return trimmed + .components(separatedBy: .newlines) + .flatMap(splitInlineQuotedTakeaways) + .map(stripBulletPrefix) + .compactMap(nonEmpty) + } + + private func takeawayArrayCandidates(from text: String) -> [String] { + var candidates = [text] + + let unescaped = text + .replacingOccurrences(of: #"\""#, with: #"""#) + .replacingOccurrences(of: #"\/"#, with: "/") + if unescaped != text { + candidates.append(unescaped) + } + + let baseCandidates = candidates + for candidate in baseCandidates { + if let start = candidate.firstIndex(of: "["), + let end = candidate.lastIndex(of: "]"), + start < end { + let slice = String(candidate[start...end]) + if !candidates.contains(slice) { + candidates.append(slice) + } + } + } + + return candidates + } + + private func decodeTakeawayArray(_ text: String) -> [String]? { + guard text.hasPrefix("["), text.hasSuffix("]"), let data = text.data(using: .utf8) else { + return nil + } + + guard let decoded = try? JSONDecoder().decode([String].self, from: data) else { + return nil + } + + let bullets = decoded + .map(stripBulletPrefix) + .compactMap(nonEmpty) + return bullets.isEmpty ? nil : bullets + } + + private func splitInlineQuotedTakeaways(_ line: String) -> [String] { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("[\""), trimmed.hasSuffix("\"]") else { + return [trimmed] + } + + let body = trimmed + .dropFirst(2) + .dropLast(2) + .replacingOccurrences(of: #"\/"#, with: "/") + return body + .components(separatedBy: "\",\"") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + } + + private func stripBulletPrefix(_ line: String) -> String { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + for prefix in ["- ", "* ", "• "] { + if trimmed.hasPrefix(prefix) { + return String(trimmed.dropFirst(prefix.count)) + } + } + return trimmed + } +} + +private enum KnowledgeDetailState { + case loading + case loaded(KnowledgeDetailPayload) + case error(String) +} + +private struct KnowledgeDetailPayload { + let eventDetail: EventDetail + let objectDetail: ObjectDetail? + + var mainImageURL: URL? { + eventDetail.target?.mediaUrl.flatMap(URL.init(string:)) + ?? objectDetail?.object.mediaUrl.flatMap(URL.init(string:)) + ?? eventDetail.event.target?.mediaUrl.flatMap(URL.init(string:)) + } +} + +private struct KnowledgeArticleBodyContent { + let text: String + let blockID: String? } diff --git a/SparkApp/Sources/Knowledge/KnowledgeView.swift b/SparkApp/Sources/Knowledge/KnowledgeView.swift index 1cc6329..02e7dd0 100644 --- a/SparkApp/Sources/Knowledge/KnowledgeView.swift +++ b/SparkApp/Sources/Knowledge/KnowledgeView.swift @@ -4,23 +4,29 @@ import SwiftUI struct KnowledgeView: View { @Environment(AppModel.self) private var appModel + @Environment(\.tabAccessoryCoordinator) private var tabAccessoryCoordinator @State private var viewModel: KnowledgeViewModel? @State private var path: [Event] = [] var body: some View { NavigationStack(path: $path) { content - .navigationTitle("Knowledge") - .navigationBarTitleDisplayMode(.large) + .sparkMainNavigationTitle("Knowledge") .navigationDestination(for: Event.self) { event in KnowledgeItemDetailView(event: event) } + .sparkMainAppToolbar() + .onAppear { updateFilterAccessory() } + .onChange(of: viewModel?.filter) { _, _ in updateFilterAccessory() } + .onChange(of: path.count) { _, _ in updateFilterAccessory() } + .onDisappear { tabAccessoryCoordinator?.clear(owner: .knowledge) } } .task { if viewModel == nil { viewModel = KnowledgeViewModel(apiClient: appModel.apiClient) } await viewModel?.initialLoad() + updateFilterAccessory() } } @@ -36,7 +42,7 @@ struct KnowledgeView: View { private func mainContent(viewModel: KnowledgeViewModel) -> some View { ScrollView { VStack(spacing: SparkSpacing.lg) { - filterRow(viewModel: viewModel) + pageHeader(viewModel: viewModel) .padding(.horizontal, SparkSpacing.lg) let items = viewModel.filteredItems @@ -86,24 +92,65 @@ struct KnowledgeView: View { } } } - .padding(.vertical, SparkSpacing.xl) + .padding(.top, SparkSpacing.md) + .padding(.bottom, SparkSpacing.xl) } .refreshable { await viewModel.refresh() } - .background(Color.sparkSurface.ignoresSafeArea()) + .sparkAppBackground() } - private func filterRow(viewModel: KnowledgeViewModel) -> some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: SparkSpacing.sm) { - ForEach(KnowledgeViewModel.Filter.allCases) { f in - Button { - viewModel.filter = f - } label: { - TagChip(f.rawValue, isGhost: viewModel.filter != f) - } - .buttonStyle(.plain) + private func updateFilterAccessory() { + guard path.isEmpty else { + tabAccessoryCoordinator?.clear(owner: .knowledge) + return + } + + registerFilterAccessory() + } + + private func registerFilterAccessory() { + guard let viewModel else { return } + + tabAccessoryCoordinator?.set( + TabAccessory( + owner: .knowledge, + title: "Knowledge filter", + items: KnowledgeViewModel.Filter.allCases.map { + TabAccessoryItem(id: $0.id, title: $0.rawValue, systemImage: filterIcon($0)) + }, + selectedID: viewModel.filter.id, + select: { id in + guard let filter = KnowledgeViewModel.Filter(rawValue: id) else { return } + viewModel.filter = filter } - } + ) + ) + } + + private func filterIcon(_ filter: KnowledgeViewModel.Filter) -> String { + switch filter { + case .reading: "newspaper.fill" + case .personal: "person.crop.circle.fill" + case .all: "square.grid.2x2.fill" + } + } + + private func pageHeader(viewModel: KnowledgeViewModel) -> some View { + SparkMainPageHeader(title: "Knowledge", subtitle: headerSubtitle(viewModel: viewModel)) + } + + private func headerSubtitle(viewModel: KnowledgeViewModel) -> String { + switch viewModel.loadState { + case .idle: + return "Loading your reading" + case .loading where viewModel.allItems.isEmpty: + return "Loading your reading" + case .error where viewModel.allItems.isEmpty: + return "Knowledge unavailable" + default: + let count = viewModel.filteredItems.count + let noun = count == 1 ? "item" : "items" + return "\(count) \(noun) in \(viewModel.filter.rawValue)" } } @@ -124,6 +171,7 @@ struct KnowledgeView: View { } .padding(SparkSpacing.lg) } + .sparkAppBackground() } } @@ -131,6 +179,9 @@ struct KnowledgeView: View { private struct KnowledgeItemCard: View { let event: Event + @Environment(\.colorScheme) private var colorScheme + + private let cardRadius: CGFloat = 20 private var imageUrl: URL? { guard let raw = event.target?.mediaUrl else { return nil } @@ -138,7 +189,7 @@ private struct KnowledgeItemCard: View { } private var title: String { - event.target?.title ?? event.action.replacingOccurrences(of: "_", with: " ").capitalized + event.target?.title ?? event.displayName ?? event.action.replacingOccurrences(of: "_", with: " ").capitalized } private var source: String { @@ -149,12 +200,43 @@ private struct KnowledgeItemCard: View { switch event.service { case "newsletter": "Newsletter" case "fetch": "Web Digest" + case "outline": "Outline" + case "calendar": "Calendar" default: event.service.capitalized } } + private var serviceIcon: String { + switch event.service { + case "newsletter": "newspaper.fill" + case "fetch": "safari.fill" + case "outline": "list.bullet.rectangle.fill" + case "calendar": "calendar" + default: "books.vertical.fill" + } + } + + private var accent: Color { + let palette: [Color] = [ + .spark400, + .spark500, + .ocean300, + .ember300, + .sparkSuccess, + .sparkWarning, + ] + return palette[stablePaletteIndex % palette.count] + } + + private var stablePaletteIndex: Int { + let seed = event.id + title + event.service + return seed.unicodeScalars.reduce(0) { partial, scalar in + (partial &* 31 &+ Int(scalar.value)) & 0x7fffffff + } + } + var body: some View { - GlassCard(padding: 0) { + GlassCard(radius: cardRadius, padding: 0) { VStack(alignment: .leading, spacing: 0) { Group { if let url = imageUrl { @@ -193,9 +275,7 @@ private struct KnowledgeItemCard: View { .foregroundStyle(.primary) if let tldr = event.tldr { - Text(tldr) - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) + SparkRichContentText(text: tldr, font: SparkTypography.bodySmall, foregroundStyle: .secondary) .italic() .lineLimit(2) } @@ -203,11 +283,16 @@ private struct KnowledgeItemCard: View { HStack { Text(serviceLabel) .font(SparkTypography.monoSmall) - .foregroundStyle(Color.domainKnowledge) + .foregroundStyle(accent) .padding(.horizontal, SparkSpacing.sm) .padding(.vertical, 3) - .background(Color.domainKnowledge.opacity(0.12)) + .background(accent.opacity(colorScheme == .dark ? 0.20 : 0.12)) .clipShape(.capsule) + if let count = event.blocksCount, count > 0 { + Text("\(count) blocks") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } Spacer(minLength: 0) Image(systemName: "chevron.right") .font(.caption2) @@ -217,14 +302,24 @@ private struct KnowledgeItemCard: View { .padding(SparkSpacing.lg) } } + .clipShape(RoundedRectangle(cornerRadius: cardRadius, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: cardRadius, style: .continuous)) } private var imagePlaceholder: some View { - Color.sparkElevated - .overlay( - Image(systemName: "doc.richtext") - .font(.title) - .foregroundStyle(.tertiary) - ) + Rectangle() + .fill(accent.opacity(colorScheme == .dark ? 0.62 : 0.82)) + .overlay(alignment: .center) { + Image(systemName: "books.vertical.fill") + .font(.system(size: 88, weight: .light)) + .foregroundStyle(.white.opacity(0.26)) + .offset(x: 58, y: 8) + } + .overlay(alignment: .bottomLeading) { + Image(systemName: serviceIcon) + .font(.system(size: 34, weight: .light)) + .foregroundStyle(.white.opacity(0.78)) + .padding(SparkSpacing.lg) + } } } diff --git a/SparkApp/Sources/Knowledge/KnowledgeViewModel.swift b/SparkApp/Sources/Knowledge/KnowledgeViewModel.swift index 838a29a..df5824a 100644 --- a/SparkApp/Sources/Knowledge/KnowledgeViewModel.swift +++ b/SparkApp/Sources/Knowledge/KnowledgeViewModel.swift @@ -7,9 +7,9 @@ import SparkKit @MainActor final class KnowledgeViewModel { enum Filter: String, CaseIterable, Identifiable { + case reading = "Reading" + case personal = "Personal" case all = "All" - case newsletters = "Newsletters" - case webDigests = "Web Digests" var id: String { rawValue } } @@ -17,7 +17,7 @@ final class KnowledgeViewModel { case idle, loading, loaded, error(String) } - var filter: Filter = .all + var filter: Filter = .reading private(set) var allItems: [Event] = [] private(set) var loadState: LoadState = .idle private var cursor: String? @@ -25,14 +25,14 @@ final class KnowledgeViewModel { var filteredItems: [Event] { switch filter { - case .all: allItems - case .newsletters: allItems.filter { $0.service == "newsletter" } - case .webDigests: allItems.filter { $0.service == "fetch" } + case .reading: return allItems.filter { $0.service == "fetch" || $0.service == "newsletter" } + case .personal: return allItems.filter { $0.service == "outline" || $0.service == "calendar" } + case .all: return allItems } } private let apiClient: APIClient - private let logger = Logger(subsystem: "co.cronx.spark", category: "Knowledge") + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "Knowledge") init(apiClient: APIClient) { self.apiClient = apiClient diff --git a/SparkApp/Sources/Knowledge/RichContentText.swift b/SparkApp/Sources/Knowledge/RichContentText.swift new file mode 100644 index 0000000..278f64d --- /dev/null +++ b/SparkApp/Sources/Knowledge/RichContentText.swift @@ -0,0 +1,4 @@ +import SparkUI +import SwiftUI + +typealias RichContentText = SparkRichContentText diff --git a/SparkApp/Sources/Map/MapBottomSheet.swift b/SparkApp/Sources/Map/MapBottomSheet.swift index b770e97..54d8f42 100644 --- a/SparkApp/Sources/Map/MapBottomSheet.swift +++ b/SparkApp/Sources/Map/MapBottomSheet.swift @@ -8,6 +8,7 @@ import SwiftUI struct MapBottomSheet: View { let points: [MapDataPoint] let onSelect: (MapDataPoint) -> Void + @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { @@ -34,6 +35,16 @@ struct MapBottomSheet: View { } .navigationTitle("In view") .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("Close") + } + } } } } diff --git a/SparkApp/Sources/Map/MapView.swift b/SparkApp/Sources/Map/MapView.swift index b4e334f..9073d1f 100644 --- a/SparkApp/Sources/Map/MapView.swift +++ b/SparkApp/Sources/Map/MapView.swift @@ -31,11 +31,14 @@ struct MapView: View { MetricDetailView(identifier: identifier) case .integration(let service): IntegrationDetailView(integrationId: service) + case .account(let id): + AccountDetailView(accountId: id) } } - .navigationTitle("Map") - .navigationBarTitleDisplayMode(.inline) - .toolbar(isEmbedded ? .hidden : .visible, for: .navigationBar) + .sparkMainNavigationTitle("Map") + .toolbar(.visible, for: .navigationBar) + .toolbarBackground(.hidden, for: .navigationBar) + .sparkMainAppToolbar() } .task { if viewModel == nil { @@ -48,7 +51,7 @@ struct MapView: View { @ViewBuilder private var content: some View { if let viewModel { - MapViewContent(viewModel: viewModel, cameraPosition: $cameraPosition) { point in + MapViewContent(viewModel: viewModel, cameraPosition: $cameraPosition, isEmbedded: isEmbedded) { point in handleSelection(point) } } else { @@ -75,6 +78,7 @@ struct MapView: View { private struct MapViewContent: View { @Bindable var viewModel: MapViewModel @Binding var cameraPosition: MapCameraPosition + let isEmbedded: Bool let onSelectPoint: (MapDataPoint) -> Void @State private var sheetDetent: PresentationDetent = .height(160) @@ -101,18 +105,82 @@ private struct MapViewContent: View { anchorDay: viewModel.anchorDay ) .padding(.horizontal, SparkSpacing.lg) - .padding(.bottom, SparkSpacing.xxl + SparkSpacing.xxxl) + .padding(.bottom, timelineBottomPadding) + } + .overlay { + GeometryReader { proxy in + if isEmbedded { + EmbeddedMapSummary(points: viewModel.visiblePoints, onSelect: onSelectPoint) + .frame(width: proxy.size.width * 2 / 3, alignment: .leading) + .padding(.leading, SparkSpacing.lg) + .padding(.top, SparkSpacing.xl) + } + } } .onMapCameraChange(frequency: .onEnd) { context in viewModel.regionDidChange(context.region) } - .sheet(isPresented: .constant(true)) { - MapBottomSheet(points: viewModel.visiblePoints, onSelect: onSelectPoint) - .presentationDetents([.height(160), .medium, .large], selection: $sheetDetent) - .presentationBackgroundInteraction(.enabled(upThrough: .medium)) - .presentationDragIndicator(.visible) - .interactiveDismissDisabled() + .sheet(isPresented: .constant(!isEmbedded)) { + if !isEmbedded { + MapBottomSheet(points: viewModel.visiblePoints, onSelect: onSelectPoint) + .presentationDetents([.height(160), .medium, .large], selection: $sheetDetent) + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + .presentationDragIndicator(.visible) + .interactiveDismissDisabled() + } + } + } + + private var timelineBottomPadding: CGFloat { + let base = SparkSpacing.xxl + SparkSpacing.xxxl + return isEmbedded ? base + 24 : base + } +} + +private struct EmbeddedMapSummary: View { + let points: [MapDataPoint] + let onSelect: (MapDataPoint) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack { + Text("In view") + .font(SparkTypography.bodyStrong) + Spacer(minLength: 0) + Text("\(points.count)") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + + if points.isEmpty { + Text("Pan the map to find visits and events.") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } else { + ForEach(points.prefix(3)) { point in + Button { + onSelect(point) + } label: { + HStack(spacing: SparkSpacing.sm) { + Image(systemName: point.kind == .transaction ? "creditcard.fill" : "mappin.and.ellipse") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 18) + Text(point.title) + .font(SparkTypography.bodySmall) + .lineLimit(1) + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .buttonStyle(.plain) + } + } } + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.lg)) } } diff --git a/SparkApp/Sources/Map/MapViewModel.swift b/SparkApp/Sources/Map/MapViewModel.swift index 12ed4f5..c19b801 100644 --- a/SparkApp/Sources/Map/MapViewModel.swift +++ b/SparkApp/Sources/Map/MapViewModel.swift @@ -10,7 +10,7 @@ import SparkKit @Observable final class MapViewModel { private let apiClient: APIClient - private let logger = Logger(subsystem: "co.cronx.spark", category: "MapViewModel") + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "MapViewModel") // 0...1 fraction of the day — the timeline scrubber binds to this. var dayFraction: Double = 1.0 @@ -74,7 +74,7 @@ final class MapViewModel { let response = try await apiClient.request( MapEndpoint.points(bbox: bbox, date: anchorDay) ) - points = response + points = response.allPoints lastError = nil } catch is CancellationError { return diff --git a/SparkApp/Sources/Notifications/NotificationsInboxView.swift b/SparkApp/Sources/Notifications/NotificationsInboxView.swift index 29d5a8a..57e6e58 100644 --- a/SparkApp/Sources/Notifications/NotificationsInboxView.swift +++ b/SparkApp/Sources/Notifications/NotificationsInboxView.swift @@ -5,6 +5,7 @@ import SwiftUI struct NotificationsInboxView: View { @Environment(AppModel.self) private var appModel + @Environment(\.dismiss) private var dismiss @State private var viewModel: NotificationsInboxViewModel? @State private var path: [DetailRoute] = [] @@ -27,17 +28,27 @@ struct NotificationsInboxView: View { PlaceDetailView(placeId: id) case .integration(let service): IntegrationDetailView(integrationId: service) + case .account(let id): + AccountDetailView(accountId: id) } } .toolbar { if let viewModel, !viewModel.items.isEmpty { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .primaryAction) { Button("Mark all read") { Task { await viewModel.markAllRead() } } .font(SparkTypography.bodySmall) } } + ToolbarItem(placement: .topBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("Close") + } } } .task { @@ -60,13 +71,28 @@ struct NotificationsInboxView: View { switch viewModel.state { case .loaded: if viewModel.items.isEmpty { - EmptyState( - systemImage: "bell.slash", - title: "All caught up", - message: "Anomalies, digests, and integration alerts will land here." - ) + VStack(spacing: SparkSpacing.lg) { + SparkSystemScreenHeader( + title: "Inbox", + subtitle: "Anomalies, digests, and integration alerts." + ) + EmptyState( + systemImage: "bell.slash", + title: "All caught up", + message: "Anomalies, digests, and integration alerts will land here." + ) + } + .padding(SparkSpacing.lg) } else { List { + Section { + SparkSystemScreenHeader( + title: "Inbox", + subtitle: "\(viewModel.items.count) notification\(viewModel.items.count == 1 ? "" : "s")" + ) + } + .listRowBackground(Color.clear) + ForEach(viewModel.items) { item in NotificationRow(item: item) .contentShape(Rectangle()) diff --git a/SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift b/SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift index 04657b4..15c3d20 100644 --- a/SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift +++ b/SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift @@ -21,7 +21,7 @@ final class NotificationsInboxViewModel { private let apiClient: APIClient private let container: ModelContainer - private let logger = Logger(subsystem: "co.cronx.spark", category: "Notifications") + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "Notifications") init(apiClient: APIClient, container: ModelContainer) { self.apiClient = apiClient diff --git a/SparkApp/Sources/Onboarding/OnboardingFlow.swift b/SparkApp/Sources/Onboarding/OnboardingFlow.swift index aa01d97..04b7a7e 100644 --- a/SparkApp/Sources/Onboarding/OnboardingFlow.swift +++ b/SparkApp/Sources/Onboarding/OnboardingFlow.swift @@ -55,17 +55,17 @@ struct OnboardingFlow: View { private func push(_ step: Step) { path.append(step) - UserDefaults(suiteName: "group.co.cronx.spark")?.set(step.rawValue, forKey: "onboarding.lastStep") + UserDefaults(suiteName: "group.co.cronx.sparkapp")?.set(step.rawValue, forKey: "onboarding.lastStep") } private func finish() { - UserDefaults(suiteName: "group.co.cronx.spark")?.set(true, forKey: "onboarding.completed") + UserDefaults(suiteName: "group.co.cronx.sparkapp")?.set(true, forKey: "onboarding.completed") isComplete = true } private func restoreProgress() { guard model.session == .loggedIn else { return } - let savedRaw = UserDefaults(suiteName: "group.co.cronx.spark")?.string(forKey: "onboarding.lastStep") + let savedRaw = UserDefaults(suiteName: "group.co.cronx.sparkapp")?.string(forKey: "onboarding.lastStep") let saved = savedRaw.flatMap(Step.init(rawValue:)) // If we just completed sign-in the saved step is .signIn (or nil for a // fresh install). In both cases the session is now loggedIn, so skip diff --git a/SparkApp/Sources/Onboarding/Steps/DoneStep.swift b/SparkApp/Sources/Onboarding/Steps/DoneStep.swift index 4db7413..dd52cca 100644 --- a/SparkApp/Sources/Onboarding/Steps/DoneStep.swift +++ b/SparkApp/Sources/Onboarding/Steps/DoneStep.swift @@ -5,36 +5,19 @@ struct DoneStep: View { let onFinish: () -> Void var body: some View { - ScrollView { - VStack(spacing: SparkSpacing.xl) { - Spacer().frame(height: SparkSpacing.xxl) - - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 72, weight: .light)) - .foregroundStyle(Color.sparkSuccess) - - VStack(spacing: SparkSpacing.sm) { - Text("You're all set.") - .font(SparkFonts.display(.largeTitle, weight: .bold)) - Text("Spark will start building your daily intelligence as your data syncs.") - .font(SparkTypography.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - - Spacer() - - PillButton("Open Today", systemImage: "sun.max.fill") { - let defaults = UserDefaults(suiteName: "group.co.cronx.spark") - defaults?.set(true, forKey: "onboarding.completed") - onFinish() - } - .padding(.bottom, SparkSpacing.xxl) + SparkOnboardingScaffold( + icon: "checkmark.circle.fill", + title: "You're all set.", + bodyText: "Spark will start building your daily intelligence as your data syncs." + ) { + EmptyView() + } actions: { + PillButton("Open Today", systemImage: "sun.max.fill") { + let defaults = UserDefaults(suiteName: "group.co.cronx.sparkapp") + defaults?.set(true, forKey: "onboarding.completed") + onFinish() } - .padding(.horizontal, SparkSpacing.xl) } - .scrollContentBackground(.hidden) - .background(Color.sparkSurface.ignoresSafeArea()) .navigationBarHidden(true) } } diff --git a/SparkApp/Sources/Onboarding/Steps/HealthKitWaveStep.swift b/SparkApp/Sources/Onboarding/Steps/HealthKitWaveStep.swift index f6f6aa2..d4a7382 100644 --- a/SparkApp/Sources/Onboarding/Steps/HealthKitWaveStep.swift +++ b/SparkApp/Sources/Onboarding/Steps/HealthKitWaveStep.swift @@ -50,66 +50,41 @@ struct HealthKitWaveStep: View { private var mgr: HealthKitPermissionManager { appModel.healthPermissions } var body: some View { - ScrollView { - VStack(spacing: SparkSpacing.xl) { - Spacer().frame(height: SparkSpacing.xl) - - Image(systemName: wave.icon) - .font(.system(size: 48, weight: .light)) - .foregroundStyle(Color.sparkAccent) - - VStack(spacing: SparkSpacing.sm) { - Text(wave.title) - .font(SparkFonts.display(.title, weight: .bold)) - Text(wave.why) - .font(SparkTypography.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - ForEach(wave.types, id: \.self) { type in - HStack(spacing: SparkSpacing.sm) { - Image(systemName: "checkmark") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(Color.sparkAccent) - Text(type) - .font(SparkTypography.body) - } + SparkOnboardingScaffold(icon: wave.icon, title: wave.title, bodyText: wave.why) { + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + ForEach(wave.types, id: \.self) { type in + HStack(spacing: SparkSpacing.sm) { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.sparkAccent) + Text(type) + .font(SparkTypography.body) } } } - - Spacer() - - VStack(spacing: SparkSpacing.md) { - if currentState == .granted { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.sparkSuccess) - Text("Access granted") - .font(SparkTypography.body) - } - PillButton("Continue", systemImage: "arrow.right.circle.fill", action: proceed) - } else { - PillButton("Allow \(wave.title)", systemImage: "heart.fill") { - Task { - await requestAuthorisation() - proceed() - } - } - Button("Skip for now") { proceed() } - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) + } + } actions: { + if currentState == .granted { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.sparkSuccess) + Text("Access granted") + .font(SparkTypography.body) + } + PillButton("Continue", systemImage: "arrow.right.circle.fill", action: proceed) + } else { + PillButton("Allow \(wave.title)", systemImage: "heart.fill") { + Task { + await requestAuthorisation() + proceed() } } - .padding(.bottom, SparkSpacing.xxl) + Button("Skip for now") { proceed() } + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) } - .padding(.horizontal, SparkSpacing.xl) } - .scrollContentBackground(.hidden) - .background(Color.sparkSurface.ignoresSafeArea()) } private var currentState: HealthKitPermissionManager.AuthState { diff --git a/SparkApp/Sources/Onboarding/Steps/HeroStep.swift b/SparkApp/Sources/Onboarding/Steps/HeroStep.swift index ac747d5..fe28dad 100644 --- a/SparkApp/Sources/Onboarding/Steps/HeroStep.swift +++ b/SparkApp/Sources/Onboarding/Steps/HeroStep.swift @@ -18,50 +18,30 @@ struct HeroStep: View { ] var body: some View { - ScrollView { - VStack(spacing: SparkSpacing.xl) { - Spacer().frame(height: SparkSpacing.xxl) - - VStack(spacing: SparkSpacing.md) { - Image(systemName: "sparkles") - .font(.system(size: 56, weight: .light)) - .foregroundStyle(Color.sparkAccent) - - Text("Welcome to Spark.") - .font(SparkFonts.display(.largeTitle, weight: .bold)) - .multilineTextAlignment(.center) - } - - VStack(spacing: SparkSpacing.md) { - ForEach(features) { feature in - HStack(spacing: SparkSpacing.md) { - Image(systemName: feature.icon) - .font(.system(size: 22)) - .foregroundStyle(Color.sparkAccent) - .frame(width: 36) - VStack(alignment: .leading, spacing: 2) { - Text(feature.title) - .font(SparkTypography.bodyStrong) - Text(feature.subtitle) - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - } - Spacer(minLength: 0) + SparkOnboardingScaffold(icon: "sparkles", title: "Welcome to Spark.") { + VStack(spacing: SparkSpacing.md) { + ForEach(features) { feature in + HStack(spacing: SparkSpacing.md) { + Image(systemName: feature.icon) + .font(.system(size: 22)) + .foregroundStyle(Color.sparkAccent) + .frame(width: 36) + VStack(alignment: .leading, spacing: 2) { + Text(feature.title) + .font(SparkTypography.bodyStrong) + Text(feature.subtitle) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) } - .padding(.horizontal, SparkSpacing.lg) + Spacer(minLength: 0) } + .padding(.horizontal, SparkSpacing.lg) } - .padding(.vertical, SparkSpacing.lg) - - Spacer() - - PillButton("Get started", systemImage: "arrow.right.circle.fill", action: proceed) - .padding(.bottom, SparkSpacing.xxl) } - .padding(.horizontal, SparkSpacing.xl) + .padding(.vertical, SparkSpacing.lg) + } actions: { + PillButton("Get started", systemImage: "arrow.right.circle.fill", action: proceed) } - .scrollContentBackground(.hidden) - .background(Color.sparkSurface.ignoresSafeArea()) .navigationBarHidden(true) } } diff --git a/SparkApp/Sources/Onboarding/Steps/LocationStep.swift b/SparkApp/Sources/Onboarding/Steps/LocationStep.swift index 476204f..d1079b1 100644 --- a/SparkApp/Sources/Onboarding/Steps/LocationStep.swift +++ b/SparkApp/Sources/Onboarding/Steps/LocationStep.swift @@ -9,49 +9,30 @@ struct LocationStep: View { @State private var status: CLAuthorizationStatus = .notDetermined var body: some View { - ScrollView { - VStack(spacing: SparkSpacing.xl) { - Spacer().frame(height: SparkSpacing.xl) - - Image(systemName: "location.fill") - .font(.system(size: 48, weight: .light)) - .foregroundStyle(Color.sparkAccent) - - VStack(spacing: SparkSpacing.sm) { - Text("Know your places") - .font(SparkFonts.display(.title, weight: .bold)) - Text("Spark uses your location to tag check-ins and detect visits to places that matter to you.") + SparkOnboardingScaffold( + icon: "location.fill", + title: "Know your places", + bodyText: "Spark uses your location to tag check-ins and detect visits to places that matter to you." + ) { + EmptyView() + } actions: { + if status == .authorizedWhenInUse || status == .authorizedAlways { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.sparkSuccess) + Text("Location access granted") .font(SparkTypography.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) } - - Spacer() - - VStack(spacing: SparkSpacing.md) { - if status == .authorizedWhenInUse || status == .authorizedAlways { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.sparkSuccess) - Text("Location access granted") - .font(SparkTypography.body) - } - PillButton("Continue", systemImage: "arrow.right.circle.fill", action: proceed) - } else { - PillButton("Allow location", systemImage: "location.fill") { - manager.requestWhenInUseAuthorization() - } - Button("Skip for now") { proceed() } - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - } + PillButton("Continue", systemImage: "arrow.right.circle.fill", action: proceed) + } else { + PillButton("Allow location", systemImage: "location.fill") { + manager.requestWhenInUseAuthorization() } - .padding(.bottom, SparkSpacing.xxl) + Button("Skip for now") { proceed() } + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) } - .padding(.horizontal, SparkSpacing.xl) } - .scrollContentBackground(.hidden) - .background(Color.sparkSurface.ignoresSafeArea()) .onAppear { status = manager.authorizationStatus } .onChange(of: manager.authorizationStatus) { _, new in status = new diff --git a/SparkApp/Sources/Onboarding/Steps/NotificationsStep.swift b/SparkApp/Sources/Onboarding/Steps/NotificationsStep.swift index 613e5b5..dfee772 100644 --- a/SparkApp/Sources/Onboarding/Steps/NotificationsStep.swift +++ b/SparkApp/Sources/Onboarding/Steps/NotificationsStep.swift @@ -2,6 +2,10 @@ import SparkUI import SwiftUI import UserNotifications +#if canImport(UIKit) +import UIKit +#endif + struct NotificationsStep: View { let proceed: () -> Void @@ -9,51 +13,32 @@ struct NotificationsStep: View { @State private var isRequesting = false var body: some View { - ScrollView { - VStack(spacing: SparkSpacing.xl) { - Spacer().frame(height: SparkSpacing.xl) - - Image(systemName: "bell.fill") - .font(.system(size: 48, weight: .light)) - .foregroundStyle(Color.sparkAccent) - - VStack(spacing: SparkSpacing.sm) { - Text("Stay in the loop") - .font(SparkFonts.display(.title, weight: .bold)) - Text("Spark can notify you when baselines shift, your digest is ready, or an integration needs attention.") + SparkOnboardingScaffold( + icon: "bell.fill", + title: "Stay in the loop", + bodyText: "Spark can notify you when baselines shift, your digest is ready, or an integration needs attention." + ) { + EmptyView() + } actions: { + if authStatus == .authorized { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.sparkSuccess) + Text("Notifications enabled") .font(SparkTypography.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) } - - Spacer() - - VStack(spacing: SparkSpacing.md) { - if authStatus == .authorized { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.sparkSuccess) - Text("Notifications enabled") - .font(SparkTypography.body) - } - PillButton("Continue", systemImage: "arrow.right.circle.fill", action: proceed) - } else { - PillButton("Allow notifications", systemImage: "bell.fill") { - Task { await requestPermission() } - } - .disabled(isRequesting) - - Button("Skip for now") { proceed() } - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - } + PillButton("Continue", systemImage: "arrow.right.circle.fill", action: proceed) + } else { + PillButton("Allow notifications", systemImage: "bell.fill") { + Task { await requestPermission() } } - .padding(.bottom, SparkSpacing.xxl) + .disabled(isRequesting) + + Button("Skip for now") { proceed() } + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) } - .padding(.horizontal, SparkSpacing.xl) } - .scrollContentBackground(.hidden) - .background(Color.sparkSurface.ignoresSafeArea()) .task { await refreshStatus() } } @@ -64,7 +49,14 @@ struct NotificationsStep: View { options: [.alert, .badge, .sound] )) ?? false authStatus = granted ? .authorized : .denied - if granted { proceed() } + if granted { + #if canImport(UIKit) + await MainActor.run { + UIApplication.shared.registerForRemoteNotifications() + } + #endif + proceed() + } } private func refreshStatus() async { diff --git a/SparkApp/Sources/Onboarding/Steps/SignInStep.swift b/SparkApp/Sources/Onboarding/Steps/SignInStep.swift index 975f41c..7da047b 100644 --- a/SparkApp/Sources/Onboarding/Steps/SignInStep.swift +++ b/SparkApp/Sources/Onboarding/Steps/SignInStep.swift @@ -19,55 +19,41 @@ struct SignInStep: View { ] var body: some View { - ScrollView { - VStack(spacing: SparkSpacing.xl) { - Spacer().frame(height: SparkSpacing.xl) - - Text("Sign in") - .font(SparkFonts.display(.largeTitle, weight: .bold)) - - VStack(spacing: SparkSpacing.md) { - ForEach(rows) { row in - HStack(alignment: .top, spacing: SparkSpacing.md) { - Text(row.number) - .font(SparkTypography.monoSmall) - .foregroundStyle(Color.sparkAccent) - .frame(width: 28, alignment: .leading) - VStack(alignment: .leading, spacing: 2) { - Text(row.title) - .font(SparkTypography.bodyStrong) - Text(row.detail) - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - } - Spacer(minLength: 0) + SparkOnboardingScaffold(icon: "sparkles", title: "Sign in") { + VStack(spacing: SparkSpacing.md) { + ForEach(rows) { row in + HStack(alignment: .top, spacing: SparkSpacing.md) { + Text(row.number) + .font(SparkTypography.monoSmall) + .foregroundStyle(Color.sparkAccent) + .frame(width: 28, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + Text(row.title) + .font(SparkTypography.bodyStrong) + Text(row.detail) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) } + Spacer(minLength: 0) } } - .padding(.horizontal, SparkSpacing.lg) - - Spacer() - - if let err = model.lastError { - Text(err) - .font(SparkTypography.caption) - .foregroundStyle(Color.sparkError) - .multilineTextAlignment(.center) - .padding(.horizontal, SparkSpacing.xl) - } + } + .padding(.horizontal, SparkSpacing.lg) + } actions: { + if let err = model.lastError { + Text(err) + .font(SparkTypography.caption) + .foregroundStyle(Color.sparkError) + .multilineTextAlignment(.center) + } - PillButton("Continue with Spark", systemImage: "arrow.right.circle.fill") { - Task { - guard let anchor = ASPresentationAnchorHandle.current() else { return } - await model.signIn(anchor: anchor) - // proceed() is called by OnboardingFlow via onChange(of: model.session) - } + PillButton("Continue with Spark", systemImage: "arrow.right.circle.fill") { + Task { + guard let anchor = ASPresentationAnchorHandle.current() else { return } + await model.signIn(anchor: anchor) + // proceed() is called by OnboardingFlow via onChange(of: model.session) } - .padding(.bottom, SparkSpacing.xxl) } - .padding(.horizontal, SparkSpacing.xl) } - .scrollContentBackground(.hidden) - .background(Color.sparkSurface.ignoresSafeArea()) } } diff --git a/SparkApp/Sources/Search/SearchView.swift b/SparkApp/Sources/Search/SearchView.swift index d45fab2..6443411 100644 --- a/SparkApp/Sources/Search/SearchView.swift +++ b/SparkApp/Sources/Search/SearchView.swift @@ -16,7 +16,9 @@ struct SearchView: View { var body: some View { NavigationStack(path: $path) { content + .sparkAppBackground() .navigationTitle("Search") + .navigationBarTitleDisplayMode(.inline) .navigationDestination(for: DetailRoute.self) { route in switch route { case .event(let id): @@ -31,8 +33,12 @@ struct SearchView: View { PlaceDetailView(placeId: id) case .integration(let service): IntegrationDetailView(integrationId: service) + case .account(let id): + AccountDetailView(accountId: id) } } + .sparkMainNavigationTitle("Search") + .sparkMainAppToolbar() } .searchable( text: queryBinding, @@ -57,10 +63,17 @@ struct SearchView: View { @ViewBuilder private var content: some View { VStack(spacing: 0) { + SparkMainPageHeader( + title: "Search", + subtitle: "Find events, entities, metrics, integrations, and tags" + ) + .padding(.horizontal, SparkSpacing.lg) + .padding(.top, SparkSpacing.md) + .padding(.bottom, SparkSpacing.md) + modePills .padding(.horizontal, SparkSpacing.lg) .padding(.bottom, SparkSpacing.sm) - Divider() results } } @@ -73,7 +86,7 @@ struct SearchView: View { Button { viewModel?.setMode(mode) } label: { - TagChip(pillLabel(for: mode), isGhost: !isActive) + SearchFilterChip(pillLabel(for: mode), isSelected: isActive) } .buttonStyle(.plain) } @@ -103,22 +116,34 @@ struct SearchView: View { message: "Try a shorter search or switch mode." ) case .results: - List { - ForEach(viewModel.grouped, id: \.0) { group in - Section(group.0) { - ForEach(group.1) { result in - Button { - saveRecent(viewModel.query) - handleTap(result) - } label: { - SearchResultRow(result: result) + ScrollView { + LazyVStack(alignment: .leading, spacing: SparkSpacing.lg) { + ForEach(viewModel.grouped, id: \.0) { group in + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + Text(group.0) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.horizontal, SparkSpacing.xs) + + VStack(spacing: SparkSpacing.sm) { + ForEach(group.1) { result in + Button { + saveRecent(viewModel.query) + handleTap(result) + } label: { + SearchResultRow(result: result) + } + .buttonStyle(.plain) + } } - .buttonStyle(.plain) } } } + .padding(.horizontal, SparkSpacing.lg) + .padding(.top, SparkSpacing.lg) + .padding(.bottom, SparkSpacing.xxxl) } - .listStyle(.plain) case .error(let msg): EmptyState( systemImage: "exclamationmark.triangle.fill", @@ -270,7 +295,8 @@ private struct SearchResultRow: View { .font(.caption2) .foregroundStyle(.secondary) } - .padding(.vertical, SparkSpacing.xs) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.lg), tint: Color.sparkElevated.opacity(0.18)) .contentShape(Rectangle()) } @@ -298,3 +324,37 @@ private struct SearchResultRow: View { } } } + +private struct SearchFilterChip: View { + let label: String + let isSelected: Bool + + init(_ label: String, isSelected: Bool) { + self.label = label + self.isSelected = isSelected + } + + var body: some View { + Text(label) + .font(SparkTypography.captionStrong) + .lineLimit(1) + .padding(.horizontal, SparkSpacing.md) + .padding(.vertical, SparkSpacing.sm) + .foregroundStyle(isSelected ? Color.sparkTextPrimary : Color.secondary) + .background { + if isSelected { + Capsule().fill(Color.sparkAccent) + } else { + Capsule().fill(Color.sparkElevated.opacity(0.16)) + } + } + .overlay { + Capsule() + .strokeBorder( + isSelected ? Color.clear : Color.primary.opacity(0.12), + lineWidth: 1 + ) + } + .sparkGlass(.capsule, tint: isSelected ? Color.sparkAccent.opacity(0.18) : Color.clear) + } +} diff --git a/SparkApp/Sources/Search/SearchViewModel.swift b/SparkApp/Sources/Search/SearchViewModel.swift index 3f86e86..e3277c9 100644 --- a/SparkApp/Sources/Search/SearchViewModel.swift +++ b/SparkApp/Sources/Search/SearchViewModel.swift @@ -21,7 +21,7 @@ final class SearchViewModel { private(set) var state: State = .idle private let apiClient: APIClient - private let logger = Logger(subsystem: "co.cronx.spark", category: "Search") + private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "Search") private var pendingQuery: Task? init(apiClient: APIClient) { diff --git a/SparkApp/Sources/Settings/ApiTokensView.swift b/SparkApp/Sources/Settings/ApiTokensView.swift index df428f7..4a3d124 100644 --- a/SparkApp/Sources/Settings/ApiTokensView.swift +++ b/SparkApp/Sources/Settings/ApiTokensView.swift @@ -66,8 +66,19 @@ struct ApiTokensView: View { message: "Create an API token to access Spark from external tools." ) } else { - List(tokens) { token in - TokenRow(token: token) + List { + Section { + SparkSystemScreenHeader( + title: "API Tokens", + subtitle: "Create and manage tokens for external Spark tools." + ) + .padding(.vertical, SparkSpacing.sm) + } + .listRowBackground(Color.clear) + + ForEach(tokens) { token in + TokenRow(token: token) + } } } } @@ -100,6 +111,7 @@ private struct TokenRow: View { private struct CreateTokenSheet: View { @Bindable var viewModel: ApiTokensViewModel let onDone: () -> Void + @Environment(\.dismiss) private var dismiss private let availableAbilities = ["mcp:read", "mcp:write", "webhooks:read", "data:export"] @@ -132,10 +144,16 @@ private struct CreateTokenSheet: View { .navigationTitle("New Token") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { onDone() } - } ToolbarItem(placement: .topBarTrailing) { + Button { + onDone() + dismiss() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("Close") + } + ToolbarItem(placement: .confirmationAction) { Button("Create") { Task { await viewModel.create() diff --git a/SparkApp/Sources/Settings/DebugView.swift b/SparkApp/Sources/Settings/DebugView.swift index 842dab4..19b92a0 100644 --- a/SparkApp/Sources/Settings/DebugView.swift +++ b/SparkApp/Sources/Settings/DebugView.swift @@ -1,19 +1,66 @@ #if DEBUG import SparkKit +import SparkSync import SparkUI +import SwiftData import SwiftUI +import UserNotifications +import WidgetKit struct DebugView: View { @Environment(AppModel.self) private var appModel + @State private var cacheResetConfirm = false @State private var statusMessage: String? + // Push notifications + @State private var isReregistering = false + @State private var isSendingTestPush = false + + // WebSocket + @State private var wsConnected: Bool? + @State private var wsSocketId: String? + @State private var isReconnecting = false + + // Environment + @State private var selectedEnv: EnvironmentOption = .current + @State private var envChanged = false + + // Sync cursors + @State private var syncCursors: [SyncCursor] = [] + + // Notification permission + @State private var notifStatus: UNAuthorizationStatus = .notDetermined + var body: some View { List { - Section("Cache") { - Button("Reset SwiftData cache") { - cacheResetConfirm = true + cacheSection + pushSection + websocketSection + environmentSection + syncCursorSection + notificationPermissionSection + widgetSection + loggingSection + + if let msg = statusMessage { + Section { + Text(msg) + .font(SparkTypography.bodySmall) + .foregroundStyle(statusMessageColor(for: msg)) } + } + } + .navigationTitle("Debug") + .navigationBarTitleDisplayMode(.inline) + .task { await loadAll() } + } + + // MARK: - Sections + + private var cacheSection: some View { + Section("Cache") { + Button("Reset SwiftData cache") { cacheResetConfirm = true } .foregroundStyle(Color.sparkError) .confirmationDialog( "Reset cache?", @@ -25,40 +72,282 @@ struct DebugView: View { } message: { Text("All locally cached data will be deleted. It will re-sync on next launch.") } + } + } + + private var pushSection: some View { + Section("Push Notifications") { + let token = UserDefaults.sparkAppGroup.string(forKey: "spark.apnsToken") + if let token { + Button { + UIPasteboard.general.string = token + statusMessage = "APNs token copied." + } label: { + VStack(alignment: .leading, spacing: 4) { + Text("APNs Token") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + Text(token) + .font(SparkTypography.monoSmall) + .foregroundStyle(.primary) + .lineLimit(2) + .truncationMode(.middle) + } + } + .buttonStyle(.plain) + } else { + Text("No APNs token registered yet.") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) } - Section("Onboarding") { - Button("Force re-onboard") { - let defaults = UserDefaults(suiteName: "group.co.cronx.spark") - defaults?.set(false, forKey: "onboarding.completed") - defaults?.removeObject(forKey: "onboarding.lastStep") - statusMessage = "Onboarding reset — restart app." + Button(isReregistering ? "Re-registering…" : "Re-register device") { + isReregistering = true + Task { + await appModel.registerDevice() + isReregistering = false + statusMessage = "Device re-registered." } - .foregroundStyle(Color.sparkWarning) } + .disabled(isReregistering || token == nil) - Section("Logging") { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - Text("OSLog is not queryable in-app without entitlements.") - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - Text("Open Console.app on Mac and filter by subsystem: co.cronx.spark") - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) + Button(isSendingTestPush ? "Sending…" : "Send test push") { + isSendingTestPush = true + Task { + do { + try await appModel.sendTestPush() + statusMessage = "Test push sent." + } catch { + statusMessage = "Test push failed: \(debugErrorMessage(error))" + } + isSendingTestPush = false } - .padding(.vertical, 4) } + .disabled(isSendingTestPush || token == nil) + } + } - if let msg = statusMessage { - Section { - Text(msg) - .font(SparkTypography.bodySmall) - .foregroundStyle(Color.sparkSuccess) + private var websocketSection: some View { + Section("WebSocket (Reverb)") { + HStack { + Circle() + .fill(wsStatusColor) + .frame(width: 8, height: 8) + Text(wsStatusLabel) + .font(SparkTypography.body) + Spacer() + Button("Refresh") { Task { await refreshWS() } } + .font(SparkTypography.bodySmall) + .foregroundStyle(Color.sparkAccent) + } + + if let socketId = wsSocketId { + Button { + UIPasteboard.general.string = socketId + statusMessage = "Socket ID copied." + } label: { + VStack(alignment: .leading, spacing: 4) { + Text("Socket ID") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + Text(socketId) + .font(SparkTypography.monoSmall) + .foregroundStyle(.primary) + } + } + .buttonStyle(.plain) + } + + Button(isReconnecting ? "Reconnecting…" : "Reconnect") { + isReconnecting = true + Task { + await appModel.reverb.disconnect() + await appModel.reverbConnect() + await refreshWS() + isReconnecting = false + statusMessage = "WebSocket reconnected." } } + .disabled(isReconnecting) } - .navigationTitle("Debug") - .navigationBarTitleDisplayMode(.inline) + } + + private var environmentSection: some View { + Section("Environment") { + Picker("Environment", selection: $selectedEnv) { + ForEach(EnvironmentOption.allCases) { env in + Text(env.displayName).tag(env) + } + } + .onChange(of: selectedEnv) { _, new in + new.apply() + envChanged = new != .current + statusMessage = "Environment set to \(new.displayName). Restart to apply." + } + + if envChanged { + Text("Restart required for environment change to take effect.") + .font(SparkTypography.bodySmall) + .foregroundStyle(Color.sparkWarning) + } + } + } + + private var syncCursorSection: some View { + Section("Sync Cursors") { + if syncCursors.isEmpty { + Text("No sync cursors stored.") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } else { + ForEach(syncCursors, id: \.resource) { cursor in + VStack(alignment: .leading, spacing: 4) { + Text(cursor.resource) + .font(SparkTypography.bodySmall) + if let value = cursor.cursor { + Text(value) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + if let date = cursor.lastSyncedAt { + Text(date.formatted(.relative(presentation: .named))) + .font(SparkTypography.monoSmall) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 2) + } + } + + Button("Clear all cursors", role: .destructive) { + clearSyncCursors() + } + .disabled(syncCursors.isEmpty) + } + } + + private var notificationPermissionSection: some View { + Section("Notification Permission") { + HStack { + Circle() + .fill(notifStatusColor) + .frame(width: 8, height: 8) + Text(notifStatusLabel) + .font(SparkTypography.body) + Spacer() + Button("Refresh") { Task { await refreshNotifStatus() } } + .font(SparkTypography.bodySmall) + .foregroundStyle(Color.sparkAccent) + } + + if notifStatus == .denied { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .foregroundStyle(Color.sparkAccent) + } + } + } + + private var widgetSection: some View { + Section("Widgets") { + Button("Reload all widget timelines") { + WidgetCenter.shared.reloadAllTimelines() + statusMessage = "Widget timelines reloaded." + } + } + } + + private var loggingSection: some View { + Section("Logging") { + Button("Force re-onboard") { + UserDefaults.sparkAppGroup.set(false, forKey: "onboarding.completed") + UserDefaults.sparkAppGroup.removeObject(forKey: "onboarding.lastStep") + statusMessage = "Onboarding reset — restart app." + } + .foregroundStyle(Color.sparkWarning) + + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + Text("OSLog is not queryable in-app without entitlements.") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + Text("Open Console.app on Mac and filter by subsystem: co.cronx.sparkapp") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + + // MARK: - Helpers + + private var wsStatusColor: Color { + guard let connected = wsConnected else { return .gray } + return connected ? Color.sparkSuccess : Color.sparkError + } + + private var wsStatusLabel: String { + guard let connected = wsConnected else { return "Unknown" } + return connected ? "Connected" : "Disconnected" + } + + private var notifStatusColor: Color { + switch notifStatus { + case .authorized, .provisional, .ephemeral: return Color.sparkSuccess + case .denied: return Color.sparkError + default: return .gray + } + } + + private var notifStatusLabel: String { + switch notifStatus { + case .authorized: return "Authorized" + case .denied: return "Denied" + case .notDetermined: return "Not determined" + case .provisional: return "Provisional" + case .ephemeral: return "Ephemeral" + @unknown default: return "Unknown" + } + } + + // MARK: - Actions + + private func loadAll() async { + await withTaskGroup(of: Void.self) { group in + group.addTask { await refreshWS() } + group.addTask { await refreshNotifStatus() } + group.addTask { await MainActor.run { loadSyncCursors() } } + } + } + + private func refreshWS() async { + let status = await appModel.reverb.connectionStatus() + wsConnected = status.isConnected + wsSocketId = status.socketId + } + + private func refreshNotifStatus() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + notifStatus = settings.authorizationStatus + } + + private func loadSyncCursors() { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.resource)]) + syncCursors = (try? appModel.container.mainContext.fetch(descriptor)) ?? [] + } + + private func clearSyncCursors() { + let context = appModel.container.mainContext + for cursor in syncCursors { + context.delete(cursor) + } + try? context.save() + syncCursors = [] + statusMessage = "Sync cursors cleared. Next sync will be a full fetch." } private func resetCache() { @@ -74,13 +363,58 @@ struct DebugView: View { try context.delete(model: CachedAnomaly.self) try context.delete(model: CachedDaySummary.self) try context.delete(model: CachedNotification.self) - try context.save() + try? context.save() await appModel.etagCache.clearAll() statusMessage = "Cache cleared." } catch { - statusMessage = "Error: \(error.localizedDescription)" + statusMessage = "Error: \(debugErrorMessage(error))" } } } + + private func statusMessageColor(for message: String) -> Color { + if message.localizedCaseInsensitiveContains("failed") + || message.localizedCaseInsensitiveContains("error") { + return Color.sparkError + } + return Color.sparkSuccess + } + + private func debugErrorMessage(_ error: Error) -> String { + (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } +} + +// MARK: - Environment option + +private enum EnvironmentOption: String, CaseIterable, Identifiable { + case production, staging + + var id: String { rawValue } + + var displayName: String { rawValue.capitalized } + + static var current: Self { + let name = UserDefaults.sparkAppGroup.string(forKey: "spark.env.name") ?? "production" + return Self(rawValue: name) ?? .production + } + + func apply() { + let defaults = UserDefaults.sparkAppGroup + switch self { + case .production: + defaults.removeObject(forKey: "spark.env.baseURL") + defaults.removeObject(forKey: "spark.env.oauthURL") + defaults.removeObject(forKey: "spark.env.name") + defaults.removeObject(forKey: "spark.env.reverbHost") + defaults.removeObject(forKey: "spark.env.reverbAppKey") + defaults.removeObject(forKey: "spark.env.reverbPort") + defaults.removeObject(forKey: "spark.env.reverbUseTLS") + case .staging: + defaults.set("https://staging.spark.cronx.co/api/v1/mobile", forKey: "spark.env.baseURL") + defaults.set("https://staging.spark.cronx.co/oauth/authorize", forKey: "spark.env.oauthURL") + defaults.set("staging", forKey: "spark.env.name") + } + } } #endif diff --git a/SparkApp/Sources/Settings/DevicesView.swift b/SparkApp/Sources/Settings/DevicesView.swift index 66f3b10..8e1b0a9 100644 --- a/SparkApp/Sources/Settings/DevicesView.swift +++ b/SparkApp/Sources/Settings/DevicesView.swift @@ -14,6 +14,15 @@ struct DevicesView: View { EmptyState(systemImage: "iphone", title: "No devices", message: "Devices appear here after sign-in.") } else { List { + Section { + SparkSystemScreenHeader( + title: "Devices", + subtitle: "Signed-in devices connected to your Spark account." + ) + .padding(.vertical, SparkSpacing.sm) + } + .listRowBackground(Color.clear) + ForEach(devices) { device in DeviceRow(device: device) { Task { await viewModel?.revoke(device) } diff --git a/SparkApp/Sources/Settings/HealthKitScopesView.swift b/SparkApp/Sources/Settings/HealthKitScopesView.swift index bd3cf39..651d238 100644 --- a/SparkApp/Sources/Settings/HealthKitScopesView.swift +++ b/SparkApp/Sources/Settings/HealthKitScopesView.swift @@ -1,10 +1,14 @@ import SparkHealth +import SparkKit import SparkUI import SwiftUI struct HealthKitScopesView: View { @Environment(AppModel.self) private var appModel + @AppStorage("health.upload.enabled", store: .sparkAppGroup) + private var healthUploadEnabled = false + private var mgr: HealthKitPermissionManager { appModel.healthPermissions } var body: some View { @@ -37,6 +41,18 @@ struct HealthKitScopesView: View { action: { Task { await mgr.requestAdvanced() } } ) + Section { + Toggle(isOn: $healthUploadEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Sync Health Data") + .font(SparkTypography.body) + Text("Upload activity and health metrics to Spark") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + } + } + Section { Link(destination: URL(string: "x-apple-health://")!) { Label("Manage in Health.app", systemImage: "heart.fill") diff --git a/SparkApp/Sources/Settings/NotificationsPreferencesView.swift b/SparkApp/Sources/Settings/NotificationsPreferencesView.swift index 87b3297..b2dc964 100644 --- a/SparkApp/Sources/Settings/NotificationsPreferencesView.swift +++ b/SparkApp/Sources/Settings/NotificationsPreferencesView.swift @@ -35,6 +35,15 @@ struct NotificationsPreferencesView: View { private func prefsForm(_ prefs: NotificationPreferences) -> some View { @Bindable var vm = viewModel! return Form { + Section { + SparkSystemScreenHeader( + title: "Notifications", + subtitle: "Choose what Spark can interrupt you for and when." + ) + .padding(.vertical, SparkSpacing.sm) + } + .listRowBackground(Color.clear) + Section("Categories") { ForEach(NotificationPreferences.Category.allCases, id: \.self) { category in categoryRow(category, prefs: prefs) diff --git a/SparkApp/Sources/Settings/ProfileView.swift b/SparkApp/Sources/Settings/ProfileView.swift index f06653f..a267be4 100644 --- a/SparkApp/Sources/Settings/ProfileView.swift +++ b/SparkApp/Sources/Settings/ProfileView.swift @@ -4,6 +4,8 @@ import SwiftUI struct ProfileView: View { @Environment(AppModel.self) private var appModel + @AppStorage("spark.background.mode", store: .sparkAppGroup) + private var backgroundMode = SparkAppBackgroundMode.auto.rawValue @State private var viewModel: ProfileViewModel? var body: some View { @@ -36,6 +38,11 @@ struct ProfileView: View { private func profileContent(_ profile: UserProfile) -> some View { ScrollView { VStack(spacing: SparkSpacing.lg) { + SparkSystemScreenHeader( + title: "Profile", + subtitle: "Your Spark account and appearance preferences." + ) + GlassCard { VStack(spacing: SparkSpacing.md) { AsyncImage(url: profile.avatarURL) { image in @@ -67,10 +74,37 @@ struct ProfileView: View { } .frame(maxWidth: .infinity) } + + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + GlassCardHeader( + icon: "paintpalette.fill", + tint: .sparkAccent, + title: "Appearance" + ) + + Picker("Background", selection: backgroundModeBinding) { + ForEach(SparkAppBackgroundMode.allCases) { mode in + Text(mode.displayName).tag(mode.rawValue) + } + } + .pickerStyle(.segmented) + } + } } .padding(.horizontal, SparkSpacing.lg) .padding(.vertical, SparkSpacing.xl) } .scrollContentBackground(.hidden) } + + private var backgroundModeBinding: Binding { + Binding( + get: { + SparkAppBackgroundMode(rawValue: backgroundMode)?.rawValue + ?? SparkAppBackgroundMode.auto.rawValue + }, + set: { backgroundMode = $0 } + ) + } } diff --git a/SparkApp/Sources/Settings/SettingsRootView.swift b/SparkApp/Sources/Settings/SettingsRootView.swift index 74dc6f4..7099b28 100644 --- a/SparkApp/Sources/Settings/SettingsRootView.swift +++ b/SparkApp/Sources/Settings/SettingsRootView.swift @@ -4,10 +4,20 @@ import SwiftUI struct SettingsRootView: View { @Environment(AppModel.self) private var appModel + @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Form { + Section { + SparkSystemScreenHeader( + title: "Settings", + subtitle: "Manage your account, preferences, connections, and app diagnostics." + ) + .padding(.vertical, SparkSpacing.sm) + } + .listRowBackground(Color.clear) + Section("Account") { NavigationLink { ProfileView() @@ -75,6 +85,16 @@ struct SettingsRootView: View { } } .navigationTitle("Settings") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("Close") + } + } } } } diff --git a/SparkApp/Sources/Shared/SparkAppViewSystem.swift b/SparkApp/Sources/Shared/SparkAppViewSystem.swift new file mode 100644 index 0000000..faf5b9f --- /dev/null +++ b/SparkApp/Sources/Shared/SparkAppViewSystem.swift @@ -0,0 +1,402 @@ +import SparkKit +import SparkUI +import SwiftData +import SwiftUI +import UIKit + +struct SparkMainPageHeader: View { + let title: String + var subtitle: String? + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Text(title) + .font(SparkTypography.heroXL) + .foregroundStyle(colorScheme == .dark ? Color.spark100 : Color.sparkTextPrimary) + .accessibilityAddTraits(.isHeader) + + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct SparkSystemScreenHeader: View { + let title: String + var subtitle: String? + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Text(title) + .font(SparkFonts.display(.title2, weight: .bold)) + .foregroundStyle(.primary) + .accessibilityAddTraits(.isHeader) + + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct SparkMainPageScaffold: View { + var horizontalPadding: CGFloat + var topPadding: CGFloat + var bottomPadding: CGFloat + var refresh: (() async -> Void)? + @ViewBuilder let content: Content + + init( + horizontalPadding: CGFloat = SparkSpacing.lg, + topPadding: CGFloat = SparkSpacing.xl, + bottomPadding: CGFloat = SparkSpacing.xl, + refresh: (() async -> Void)? = nil, + @ViewBuilder content: () -> Content + ) { + self.horizontalPadding = horizontalPadding + self.topPadding = topPadding + self.bottomPadding = bottomPadding + self.refresh = refresh + self.content = content() + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + content + } + .padding(.horizontal, horizontalPadding) + .padding(.top, topPadding) + .padding(.bottom, bottomPadding) + } + .scrollContentBackground(.hidden) + .sparkAppBackground() + .refreshable { + await refresh?() + } + } +} + +extension View { + func sparkMainNavigationTitle(_ title: String) -> some View { + navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("") + .accessibilityHidden(true) + } + } + .accessibilityLabel(title) + } +} + +struct SparkSheetScaffold: View { + let title: String + var showsDismissButton = true + @ViewBuilder let content: Content + + @Environment(\.dismiss) private var dismiss + + init( + _ title: String, + showsDismissButton: Bool = true, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.showsDismissButton = showsDismissButton + self.content = content() + } + + var body: some View { + NavigationStack { + ScrollView { + content + .padding(SparkSpacing.lg) + } + .scrollContentBackground(.hidden) + .background(SparkResolvedAppBackground().ignoresSafeArea()) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if showsDismissButton { + ToolbarItem(placement: .topBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("Close") + } + } + } + } + } +} + +struct SparkRawPayloadView: View { + let text: String + + var body: some View { + Text(text) + .font(SparkTypography.monoSmall) + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } +} + +struct SparkOnboardingScaffold: View { + let icon: String + let title: String + let bodyText: String? + @ViewBuilder let content: Content + @ViewBuilder let actions: Actions + + init( + icon: String, + title: String, + bodyText: String? = nil, + @ViewBuilder content: () -> Content = { EmptyView() }, + @ViewBuilder actions: () -> Actions + ) { + self.icon = icon + self.title = title + self.bodyText = bodyText + self.content = content() + self.actions = actions() + } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: SparkSpacing.xl) { + VStack(spacing: SparkSpacing.md) { + Image(systemName: icon) + .font(.system(size: 52, weight: .light)) + .foregroundStyle(Color.sparkAccent) + + VStack(spacing: SparkSpacing.sm) { + Text(title) + .font(SparkFonts.display(.largeTitle, weight: .bold)) + .multilineTextAlignment(.center) + .accessibilityAddTraits(.isHeader) + + if let bodyText, !bodyText.isEmpty { + Text(bodyText) + .font(SparkTypography.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + } + + content + } + .padding(.horizontal, SparkSpacing.xl) + .padding(.top, SparkSpacing.xxl) + .padding(.bottom, SparkSpacing.xl) + } + .scrollContentBackground(.hidden) + + VStack(spacing: SparkSpacing.md) { + actions + } + .padding(.horizontal, SparkSpacing.xl) + .padding(.top, SparkSpacing.md) + .padding(.bottom, SparkSpacing.xxl) + .background(.ultraThinMaterial) + } + .background(SparkResolvedAppBackground().ignoresSafeArea()) + .navigationBarBackButtonHidden() + } +} + +struct SparkShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +struct SparkMainAppToolbarModifier: ViewModifier { + let isVisible: Bool + + @State private var showSettings = false + @State private var showNotifications = false + + @Query(filter: #Predicate { !$0.isRead }) + private var unreadNotifications: [CachedNotification] + @Query private var allIntegrations: [CachedIntegration] + + private var hasUnhealthyIntegration: Bool { + let healthy: Set = ["up_to_date", "ok", "active", "syncing", "running"] + return allIntegrations.contains { !healthy.contains($0.status) } + } + + func body(content: Content) -> some View { + content + .toolbar { + if isVisible { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + showSettings = true + } label: { + Image(systemName: "gearshape") + .symbolRenderingMode(.monochrome) + .foregroundStyle(hasUnhealthyIntegration ? Color.sparkError : Color.primary) + } + .accessibilityLabel("Settings") + + Button { + showNotifications = true + } label: { + notificationIcon + } + .accessibilityLabel( + unreadNotifications.isEmpty + ? "Notifications" + : "Notifications, \(unreadNotifications.count) unread" + ) + } + } + } + .sheet(isPresented: $showSettings) { + SettingsRootView() + } + .sheet(isPresented: $showNotifications) { + NotificationsInboxView() + } + } + + @ViewBuilder + private var notificationIcon: some View { + let icon = Image(systemName: "bell") + .symbolRenderingMode(.monochrome) + .foregroundStyle(unreadNotifications.isEmpty ? Color.primary : Color.sparkAccent) + + if unreadNotifications.isEmpty { + icon + } else { + icon.badge(unreadNotifications.count) + } + } +} + +struct SparkSubViewToolbarModifier: ViewModifier { + let shareItems: [Any] + let rawTitle: String + let rawPayload: String? + let refresh: () async -> Void + let reprocess: (() async -> Void)? + + @State private var showShareSheet = false + @State private var showRawSheet = false + + func body(content: Content) -> some View { + content + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + showShareSheet = true + } label: { + Image(systemName: "square.and.arrow.up") + .symbolRenderingMode(.monochrome) + .foregroundStyle(Color.primary) + } + .accessibilityLabel("Share") + + Menu { + Button("Tag") {} + .disabled(true) + Button { + Task { await refresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + Button { + Task { await reprocess?() } + } label: { + Label("Reprocess", systemImage: "wand.and.sparkles") + } + .disabled(reprocess == nil) + Button { + showRawSheet = true + } label: { + Label("Raw", systemImage: "curlybraces") + } + .disabled(rawPayload == nil) + } label: { + Image(systemName: "ellipsis.circle") + .symbolRenderingMode(.monochrome) + .foregroundStyle(Color.primary) + } + .accessibilityLabel("More") + } + } + .sheet(isPresented: $showShareSheet) { + SparkShareSheet(items: shareItems) + } + .sheet(isPresented: $showRawSheet) { + SparkSheetScaffold(rawTitle) { + SparkRawPayloadView(text: rawPayload ?? "{}") + } + } + } +} + +extension View { + func sparkMainAppToolbar(isVisible: Bool = true) -> some View { + modifier(SparkMainAppToolbarModifier(isVisible: isVisible)) + } + + func sparkSubViewToolbar( + shareItems: [Any], + rawTitle: String = "Raw", + rawPayload: String?, + refresh: @escaping () async -> Void, + reprocess: (() async -> Void)? = nil + ) -> some View { + modifier(SparkSubViewToolbarModifier( + shareItems: shareItems, + rawTitle: rawTitle, + rawPayload: rawPayload, + refresh: refresh, + reprocess: reprocess + )) + } +} + +enum SparkPrettyJSON { + static func string(for value: T) -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(value) else { return nil } + return String(data: data, encoding: .utf8) + } + + static func fallback(entity: String, id: String, title: String? = nil) -> String { + string(for: RawFallback(entity: entity, id: id, title: title)) ?? "{}" + } + + private struct RawFallback: Encodable { + let entity: String + let id: String + let title: String? + } +} diff --git a/SparkApp/Sources/SparkApp.swift b/SparkApp/Sources/SparkApp.swift index a2bc57d..14fd0e2 100644 --- a/SparkApp/Sources/SparkApp.swift +++ b/SparkApp/Sources/SparkApp.swift @@ -22,17 +22,26 @@ struct SparkApp: App { var body: some Scene { WindowGroup { - RootView() - .environment(model) - .modelContainer(model.container) - .tint(.sparkAccent) - .sparkDynamicTypeClamp() - .task(id: model.session) { - if model.session == .loggedIn { - HealthKitObserver.shared.startObserving() + ZStack { + SparkResolvedAppBackground() + + RootView() + .environment(model) + .modelContainer(model.container) + .tint(.sparkAccent) + .sparkDynamicTypeClamp() + .task(id: model.session) { + if model.session == .loggedIn { + HealthKitObserver.shared.startObserving() + } } - } - .onContinueUserActivity(CSSearchableItemActionType, perform: handle(spotlightActivity:)) + .onContinueUserActivity(CSSearchableItemActionType, perform: handle(spotlightActivity:)) + } + .overlay(alignment: .top) { + SparkResolvedStatusBarBackground() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() } .onChange(of: scenePhase) { _, phase in Task { @MainActor in @@ -49,11 +58,11 @@ struct SparkApp: App { } /// Spotlight tap handler. Identifiers have the form: - /// `co.cronx.spark.{type}.{id}` — parse the type prefix and route. + /// `co.cronx.sparkapp.{type}.{id}` — parse the type prefix and route. @MainActor private func handle(spotlightActivity activity: NSUserActivity) { guard let identifier = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String else { return } - let prefix = "co.cronx.spark." + let prefix = "co.cronx.sparkapp." guard identifier.hasPrefix(prefix) else { return } let rest = identifier.dropFirst(prefix.count) guard let dotRange = rest.firstIndex(of: ".") else { return } @@ -78,9 +87,20 @@ final class SparkAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio UNUserNotificationCenter.current().delegate = self registerNotificationCategories() registerBackgroundTasks() + application.registerForRemoteNotifications() return true } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let hex = deviceToken.map { String(format: "%02x", $0) }.joined() + UserDefaults.sparkAppGroup.set(hex, forKey: "spark.apnsToken") + Task { await AppModel.shared.registerDevice() } + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + SentrySDK.capture(error: error) + } + func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], @@ -212,18 +232,33 @@ enum SparkObservability { static let dsn = "https://1583f3671989ff49f2e578e5cef8ace9@sentry.cronx.co/5" static func start() { + guard !isRunningTests else { return } + SentrySDK.start { options in options.dsn = dsn options.environment = APIEnvironment.current().name options.releaseName = releaseName() + options.maxBreadcrumbs = 200 // Error monitoring + options.sampleRate = 1.0 options.enableCrashHandler = true options.enableWatchdogTerminationTracking = true options.attachScreenshot = true options.attachViewHierarchy = true options.enableTimeToFullDisplayTracing = true + // Network capture + let environment = APIEnvironment.current() + options.enableNetworkBreadcrumbs = true + options.enableCaptureFailedRequests = true + options.failedRequestStatusCodes = [HttpStatusCodeRange(min: 400, max: 599)] + options.failedRequestTargets = [ + environment.baseURL.host() ?? "spark.cronx.co", + environment.reverbHTTPBaseURL.host() ?? "ws.spark.cronx.co", + ] + options.tracePropagationTargets = options.failedRequestTargets + // Logging (captures OSLog output) options.enableLogs = true @@ -235,16 +270,21 @@ enum SparkObservability { $0.lifecycle = .trace } #else - options.tracesSampleRate = 0.2 + options.tracesSampleRate = 1.0 options.configureProfiling = { - $0.sessionSampleRate = 0.1 + $0.sessionSampleRate = 1.0 $0.lifecycle = .trace } #endif } + + Task { + await APITelemetry.shared.setSink(SentryAPITelemetrySink()) + } } static func captureHandled(_ error: Error) { + guard !error.isAPICancellation else { return } SentrySDK.capture(error: error) { scope in scope.setTag(value: "handled", key: "error_type") } @@ -254,6 +294,10 @@ enum SparkObservability { let info = Bundle.main.infoDictionary let short = info?["CFBundleShortVersionString"] as? String ?? "0.0.0" let build = info?["CFBundleVersion"] as? String ?? "0" - return "co.cronx.spark@\(short)+\(build)" + return "co.cronx.sparkapp@\(short)+\(build)" + } + + private static var isRunningTests: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil } } diff --git a/SparkApp/Sources/Today/Cards/ActivityCard.swift b/SparkApp/Sources/Today/Cards/ActivityCard.swift deleted file mode 100644 index c7bf18e..0000000 --- a/SparkApp/Sources/Today/Cards/ActivityCard.swift +++ /dev/null @@ -1,63 +0,0 @@ -import SparkUI -import SwiftUI - -struct ActivityCard: View { - let activity: ActivitySnapshot - - var body: some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "figure.walk", - tint: .domainActivity, - title: "Activity" - ) - - HStack(alignment: .center, spacing: SparkSpacing.md) { - ActivityRings( - move: activity.moveProgress, - exercise: activity.exerciseProgress, - stand: activity.standProgress - ) - .frame(width: 88, height: 88) - - VStack(alignment: .leading, spacing: 4) { - if let steps = activity.steps { - HStack(alignment: .firstTextBaseline, spacing: 2) { - Text(formatSteps(steps)) - .font(SparkFonts.display(.title, weight: .bold)) - Text("/ \(formatSteps(activity.stepsGoal))") - .font(SparkTypography.caption) - .foregroundStyle(.secondary) - } - .accessibilityLabel("\(steps) of \(activity.stepsGoal) steps") - } - - if let cal = activity.activeCalories { - Text("\(cal) cal active") - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - } - - if let workout = activity.lastWorkout { - Text(workout) - .font(SparkTypography.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - - Spacer(minLength: 0) - } - } - } - } - - private func formatSteps(_ count: Int) -> String { - if count >= 1_000 { - let k = Double(count) / 1_000 - return String(format: "%.1fk", k) - } - return String(count) - } -} diff --git a/SparkApp/Sources/Today/Cards/CheckInCard.swift b/SparkApp/Sources/Today/Cards/CheckInCard.swift index f41527c..50b9dd7 100644 --- a/SparkApp/Sources/Today/Cards/CheckInCard.swift +++ b/SparkApp/Sources/Today/Cards/CheckInCard.swift @@ -1,67 +1,293 @@ +import SparkKit import SparkUI +import SwiftData import SwiftUI -/// Today card surfacing the morning/afternoon check-in state. Tapping opens -/// the dedicated modal (placeholder until Day 15 wires in mood + tags + -/// note). When already logged for the current slot, the card flips to a -/// compact summary of the saved entry. struct CheckInCard: View { - let status: CheckInStatus - let onTap: () -> Void + let status: CheckInDayStatus + let onTapMorning: () -> Void + let onTapAfternoon: () -> Void + @Binding var showHistory: Bool + + @Environment(\.modelContext) private var modelContext + @State private var historyDays: [DayDotData] = [] + @State private var streakCount: Int = 0 + + private var isMorning: Bool { + Calendar.current.component(.hour, from: .now) < 12 + } var body: some View { - Button(action: onTap) { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - GlassCardHeader( - icon: "heart.text.clipboard", - tint: .sparkAccent, - title: title, - trailing: trailing - ) - - Text(message) - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - .multilineTextAlignment(.leading) + GlassCard { + VStack(alignment: .leading, spacing: 0) { + GlassCardHeader( + icon: "heart.text.clipboard", + tint: .sparkAccent, + title: "Check-ins" + ) + Divider() + .padding(.vertical, SparkSpacing.sm) + morningRow + if !isMorning { + afternoonRow } + Divider() + .padding(.vertical, SparkSpacing.sm) + streakSection + } + } + .accessibilityElement(children: .contain) + .task(id: status.stableKey) { + await loadHistory() + } + } + + // MARK: - Period rows + + @ViewBuilder + private var morningRow: some View { + switch status.morning { + case let .completed(physical, mental, notes): + CheckInPeriodRow(label: "Morning", status: .completed(physical: physical, mental: mental, notes: notes), onTap: {}) + case .pending where isMorning: + CheckInPeriodRow(label: "Morning", status: .pending, onTap: onTapMorning) + case .pending: + MissedPeriodRow(label: "Morning") + } + } + + @ViewBuilder + private var afternoonRow: some View { + CheckInPeriodRow(label: "Afternoon", status: status.afternoon, onTap: onTapAfternoon) + } + + // MARK: - Streak section + + private var streakSection: some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack { + SectionLabel("CHECK-IN STREAK") + Spacer() + Text("\(streakCount) day\(streakCount == 1 ? "" : "s")") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + HStack(spacing: 4) { + ForEach(historyDays) { day in + DayDotView(data: day) + } + } + } + .contentShape(Rectangle()) + .onTapGesture { showHistory = true } + } + + // MARK: - History loading + + private func loadHistory() async { + let calendar = Calendar.current + let today = calendar.startOfDay(for: .now) + var days: [DayDotData] = [] + + for offset in stride(from: 13, through: 0, by: -1) { + guard let day = calendar.date(byAdding: .day, value: -offset, to: today) else { continue } + let dateKey = Self.isoDate(day) + let label = String(calendar.component(.day, from: day)) + + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.date == dateKey } + ) + let rows = (try? modelContext.fetch(descriptor)) ?? [] + let morningDone = rows.first(where: { $0.period == "morning" })?.completed == true + let afternoonDone = rows.first(where: { $0.period == "afternoon" })?.completed == true + + days.append(DayDotData( + id: dateKey, + label: label, + morningDone: morningDone, + afternoonDone: afternoonDone + )) + } + + historyDays = days + + var streak = 0 + for day in days.reversed() { + if day.morningDone || day.afternoonDone { + streak += 1 + } else { + break + } + } + streakCount = streak + } + + private static func isoDate(_ date: Date) -> String { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f.string(from: date) + } +} + +// MARK: - Period row + +private struct CheckInPeriodRow: View { + let label: String + let status: PeriodStatus + let onTap: () -> Void + + var body: some View { + Group { + switch status { + case .pending: + Button(action: onTap) { rowContent } + .buttonStyle(.plain) + case .completed: + rowContent } } - .buttonStyle(.plain) .accessibilityElement(children: .combine) .accessibilityLabel(accessibilityLabel) - .accessibilityHint("Opens the check-in modal") } - private var title: String { - switch status { - case let .pending(slot): - return "\(slot.rawValue.capitalized) check-in" - case .logged: - return "Today's check-in" + private var rowContent: some View { + HStack { + Text(label) + .font(SparkTypography.bodySmall) + .foregroundStyle(.primary) + Spacer() + switch status { + case .pending: + Text("tap to log →") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + case let .completed(physical, mental, _): + ScoreDots(physical: physical, mental: mental) + } } + .padding(.vertical, SparkSpacing.xs) } - private var trailing: String? { + private var accessibilityLabel: String { switch status { - case .pending: return "tap to log" - case .logged: return "logged" + case .pending: + return "\(label) check-in pending. Tap to log." + case let .completed(physical, mental, notes): + let base = "\(label) check-in complete. Physical \(physical) of 5, mental \(mental) of 5." + if let notes, !notes.isEmpty { return "\(base) Note: \(notes)" } + return base } } +} - private var message: String { - switch status { - case .pending: return "How are you feeling? Mood, sleep quality, anything notable." - case let .logged(mood, note): - if let note, !note.isEmpty { return "\(mood.capitalized) — \(note)" } - return mood.capitalized +// MARK: - Missed period row + +private struct MissedPeriodRow: View { + let label: String + + var body: some View { + HStack { + Text(label) + .font(SparkTypography.bodySmall) + .foregroundStyle(Color.secondary.opacity(0.5)) + Spacer() + Text("—") + .font(SparkTypography.monoSmall) + .foregroundStyle(Color.secondary.opacity(0.4)) } + .padding(.vertical, SparkSpacing.xs) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(label) check-in missed") } +} - private var accessibilityLabel: String { - switch status { - case let .pending(slot): "\(slot.rawValue.capitalized) check-in pending" - case let .logged(mood, _): "Check-in logged. Feeling \(mood)." +// MARK: - Score dots + +private struct ScoreDots: View { + let physical: Int + let mental: Int + + private var combined: Int { physical + mental } + + private var score: Int { + max(1, min(5, (physical + mental + 1) / 2)) + } + + private var tint: Color { + switch combined { + case 2: Color(red: 212/255, green: 61/255, blue: 81/255) + case 3: Color(red: 226/255, green: 115/255, blue: 87/255) + case 4: Color(red: 235/255, green: 160/255, blue: 110/255) + case 5: Color(red: 242/255, green: 202/255, blue: 148/255) + case 6: Color(red: 253/255, green: 241/255, blue: 197/255) + case 7: Color(red: 205/255, green: 214/255, blue: 163/255) + case 8: Color(red: 153/255, green: 188/255, blue: 137/255) + case 9: Color(red: 96/255, green: 162/255, blue: 119/255) + case 10: Color(red: 0/255, green: 135/255, blue: 108/255) + default: .secondary + } + } + + var body: some View { + HStack(spacing: 3) { + ForEach(1...5, id: \.self) { i in + Circle() + .frame(width: 7, height: 7) + .foregroundStyle(i <= score ? tint : tint.opacity(0.2)) + } } } } + +// MARK: - Day dot + +private struct DayDotData: Identifiable { + let id: String + let label: String + let morningDone: Bool + let afternoonDone: Bool +} + +private struct DayDotView: View { + let data: DayDotData + + private var dotColor: Color { + if data.morningDone && data.afternoonDone { return .sparkAccent } + if data.morningDone || data.afternoonDone { return .sparkWarning } + return .clear + } + + private var strokeColor: Color { + if data.morningDone || data.afternoonDone { return .clear } + return Color.secondary.opacity(0.3) + } + + var body: some View { + VStack(spacing: 2) { + Circle() + .fill(dotColor) + .overlay(Circle().strokeBorder(strokeColor, lineWidth: 1)) + .frame(width: 10, height: 10) + Text(data.label) + .font(.system(size: 8, design: .monospaced)) + .foregroundStyle(.secondary) + } + .accessibilityLabel(accessibilityLabel) + } + + private var accessibilityLabel: String { + if data.morningDone && data.afternoonDone { return "Day \(data.label), both check-ins complete" } + if data.morningDone || data.afternoonDone { return "Day \(data.label), one check-in complete" } + return "Day \(data.label), no check-in" + } +} + +// MARK: - Stable key helper + +private extension CheckInDayStatus { + var stableKey: String { + let m: String = { if case .pending = morning { return "p" }; return "c" }() + let a: String = { if case .pending = afternoon { return "p" }; return "c" }() + return "\(m)\(a)" + } +} diff --git a/SparkApp/Sources/Today/Cards/FeedSection.swift b/SparkApp/Sources/Today/Cards/FeedSection.swift index 2155b4b..a28804c 100644 --- a/SparkApp/Sources/Today/Cards/FeedSection.swift +++ b/SparkApp/Sources/Today/Cards/FeedSection.swift @@ -5,9 +5,13 @@ import SwiftUI struct FeedSection: View { let date: Date + @Environment(AppModel.self) private var appModel + @State private var filter: TimelineFilter = .home + @State private var showRawFeed = false + @State private var rawFeedState: RawFeedState = .idle @Query private var allEvents: [CachedEvent] - private var dayEvents: [CachedEvent] { + private var rawDayEvents: [CachedEvent] { let cal = Calendar.current let start = cal.startOfDay(for: date) guard let end = cal.date(byAdding: .day, value: 1, to: start) else { return [] } @@ -17,49 +21,720 @@ struct FeedSection: View { return t >= start && t < end } .sorted { ($0.time ?? .distantPast) > ($1.time ?? .distantPast) } - .prefix(15) - .map { $0 } + } + + private var dayEvents: [CachedEvent] { + rawDayEvents.filter(filter.includes) + } + + private var hourGroups: [(hour: Int, events: [CachedEvent])] { + var grouped: [Int: [CachedEvent]] = [:] + for event in dayEvents { + guard let t = event.time else { continue } + let h = Calendar.current.component(.hour, from: t) + grouped[h, default: []].append(event) + } + return grouped.keys.sorted(by: >).map { h in (hour: h, events: grouped[h]!) } } var body: some View { - if !dayEvents.isEmpty { - GlassCard { - GlassCardHeader(icon: "list.bullet", tint: .sparkAccent, title: "Timeline") - ForEach(dayEvents) { event in - NavigationLink(value: DetailRoute.event(id: event.id)) { - EventRow( - title: event.actorTitle ?? event.action, - subtitle: event.targetTitle, - timestamp: event.time ?? .now, - iconSystemName: Self.icon(for: event.domain), - tintColor: Self.tint(for: event.domain) - ) + if !rawDayEvents.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + timelineHeader + + if dayEvents.isEmpty { + Text(emptyMessage) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, SparkSpacing.sm) + } else { + ForEach(hourGroups, id: \.hour) { group in + HourGroup(hour: group.hour, events: group.events) } - .buttonStyle(.plain) } + + rawFeedButton + } + .sheet(isPresented: $showRawFeed) { + rawFeedSheet + } + } + } + + private var timelineHeader: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: SparkSpacing.md) { + timelineTitle + Spacer(minLength: SparkSpacing.sm) + filterButtons + .fixedSize(horizontal: true, vertical: false) + } + + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + timelineTitle + filterRow + } + } + } + + private var timelineTitle: some View { + Text("Timeline") + .font(SparkFonts.display(.title2, weight: .bold)) + .lineLimit(1) + } + + private var filterRow: some View { + ScrollView(.horizontal, showsIndicators: false) { + filterButtons + } + } + + private var filterButtons: some View { + HStack(spacing: SparkSpacing.sm) { + ForEach(TimelineFilter.allCases, id: \.self) { option in + Button { + filter = option + } label: { + TimelineFilterChip( + option.label, + systemImage: option.systemImage, + isSelected: filter == option + ) + } + .buttonStyle(.plain) + .accessibilityLabel(option.accessibilityLabel) + } + } + } + + private var emptyMessage: String { + switch filter { + case .home: + "No home timeline events for this day." + case .all: + "No timeline events for this day." + case .money, .health, .knowledge: + "No \(filter.label.lowercased()) events for this day." + } + } + + private var rawFeedButton: some View { + Button { + showRawFeed = true + if case .loaded = rawFeedState { + return + } + Task { await loadRawFeed() } + } label: { + HStack(spacing: SparkSpacing.sm) { + Image(systemName: "curlybraces") + .font(.caption) + Text("Raw feed JSON") + .font(SparkTypography.bodySmall) + Spacer(minLength: 0) + Text(Self.isoKey(for: date)) + .font(SparkTypography.monoSmall) + .foregroundStyle(.tertiary) + .lineLimit(1) + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .foregroundStyle(.secondary) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + .buttonStyle(.plain) + .padding(.top, SparkSpacing.xs) + } + + private var rawFeedSheet: some View { + SparkSheetScaffold("Raw feed") { + switch rawFeedState { + case .idle, .loading: + ProgressView() + .frame(maxWidth: .infinity, minHeight: 220) + case .loaded(let json): + SparkRawPayloadView(text: json) + case .error(let message): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load raw feed", + message: message, + actionTitle: "Retry" + ) { Task { await loadRawFeed() } } + } + } + .presentationDetents([.medium, .large]) + } + + @MainActor + private func loadRawFeed() async { + rawFeedState = .loading + do { + let events = try await fetchAllFeedEvents() + rawFeedState = .loaded(prettyJSON(for: events)) + } catch APIError.notModified { + rawFeedState = .error("The feed endpoint returned 304 Not Modified, so no raw response body was available.") + } catch { + SparkObservability.captureHandled(error) + rawFeedState = .error((error as? LocalizedError)?.errorDescription ?? String(describing: error)) + } + } + + private func fetchAllFeedEvents() async throws -> [Event] { + var cursor: String? + var events: [Event] = [] + + repeat { + let page = try await appModel.apiClient.request( + FeedEndpoint.feed(cursor: cursor, limit: 100, date: Self.isoKey(for: date)) + ) + events.append(contentsOf: page.data) + cursor = page.hasMore ? page.nextCursor : nil + } while cursor != nil + + return events + } + + private func prettyJSON(for events: [Event]) -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + guard + let data = try? encoder.encode(events), + let string = String(data: data, encoding: .utf8) + else { + return "[]" + } + return string + } + + private static func isoKey(for date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = .current + return formatter.string(from: date) + } +} + +private enum RawFeedState { + case idle + case loading + case loaded(String) + case error(String) +} + +private enum TimelineFilter: CaseIterable { + case home + case money + case health + case knowledge + case all + + var label: String { + switch self { + case .home: "Home" + case .money: "Money" + case .health: "Health" + case .knowledge: "Knowledge" + case .all: "All" + } + } + + var systemImage: String? { + switch self { + case .money: "sterlingsign.circle.fill" + case .health: "heart.fill" + case .knowledge: "books.vertical.fill" + case .home, .all: nil + } + } + + var accessibilityLabel: String { + switch self { + case .all: "Show all timeline events, including hidden events" + default: "Show \(label.lowercased()) timeline events" + } + } + + func includes(_ event: CachedEvent) -> Bool { + switch self { + case .home: + return !event.hidden + case .money: + return !event.hidden && event.domain == "money" + case .health: + return !event.hidden && (event.domain == "health" || event.domain == "activity") + case .knowledge: + return !event.hidden && event.domain == "knowledge" + case .all: + return true + } + } +} + +private struct TimelineFilterChip: View { + let title: String + let systemImage: String? + let isSelected: Bool + + init(_ title: String, systemImage: String? = nil, isSelected: Bool) { + self.title = title + self.systemImage = systemImage + self.isSelected = isSelected + } + + var body: some View { + Group { + if let systemImage { + Image(systemName: systemImage) + .font(.caption.weight(.semibold)) + .frame(width: 16, height: 16) + .accessibilityHidden(true) + } else { + Text(title) + .font(SparkTypography.captionStrong) + .lineLimit(1) } } + .font(SparkTypography.captionStrong) + .frame(minWidth: systemImage == nil ? 0 : 20) + .padding(.horizontal, systemImage == nil ? SparkSpacing.md : SparkSpacing.sm) + .padding(.vertical, SparkSpacing.xs + 2) + .foregroundStyle(isSelected ? Color.sparkTextPrimary : Color.secondary) + .background { + Capsule() + .fill(isSelected ? Color.spark100 : Color.primary.opacity(0.04)) + } + .overlay { + Capsule() + .strokeBorder(Color.primary.opacity(isSelected ? 0 : 0.08), lineWidth: 1) + } } +} + +// MARK: - Hour group - private static func icon(for domain: String) -> String { - switch domain { - case "health": return "moon.zzz.fill" - case "activity": return "figure.walk" - case "money": return "creditcard.fill" - case "media": return "music.note" - case "knowledge": return "book.fill" - default: return "bolt.fill" +private enum EventGroup: Identifiable { + case single(CachedEvent) + case collapsed(events: [CachedEvent]) + + var id: String { + switch self { + case .single(let e): return e.id + case .collapsed(let es): return (es.first?.id ?? "") + "_group" } } +} - private static func tint(for domain: String) -> Color { - switch domain { - case "health": return .domainHealth - case "activity": return .domainActivity - case "money": return .domainMoney - case "media": return .domainMedia - case "knowledge": return .domainKnowledge - default: return .sparkAccent +private struct HourGroup: View { + let hour: Int + let events: [CachedEvent] + + private var eventGroups: [EventGroup] { + var result: [EventGroup] = [] + var i = 0 + while i < events.count { + let current = events[i] + var j = i + 1 + while j < events.count, + events[j].action == current.action, + events[j].service == current.service { j += 1 } + let run = Array(events[i..= 3 { + result.append(.collapsed(events: run)) + } else { + result.append(contentsOf: run.map { .single($0) }) + } + i = j } + return result + } + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + HStack(spacing: SparkSpacing.md) { + Text(String(format: "%02d:00", hour)) + .font(SparkFonts.mono(.title3)) + .foregroundStyle(Color.secondary.opacity(0.68)) + .monospacedDigit() + .frame(width: 72, alignment: .leading) + + Rectangle() + .fill(Color.primary.opacity(0.09)) + .frame(height: 1) + } + + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + ForEach(eventGroups) { group in + switch group { + case .single(let event): + NavigationLink(value: DetailRoute.event(id: event.id)) { + row(for: event) + } + .buttonStyle(.plain) + case .collapsed(let groupEvents): + NavigationLink(value: DetailRoute.event(id: groupEvents[0].id)) { + row(for: groupEvents[0], surplusCount: groupEvents.count - 1) + } + .buttonStyle(.plain) + } + } + } + } + } + + @ViewBuilder + private func row(for event: CachedEvent, surplusCount: Int = 0) -> some View { + if isWebDigest(event) { + WebDigestEventCard(event: event, surplusCount: surplusCount) + } else if isStandout(event) { + StandoutEventCard(event: event, surplusCount: surplusCount) + } else if isSubtle(event) { + SubtleEventRow(event: event, surplusCount: surplusCount) + } else { + RaisedEventCard(event: event, surplusCount: surplusCount) + } + } + + private func isWebDigest(_ event: CachedEvent) -> Bool { + event.domain == "knowledge" && (event.service == "fetch" || event.value?.lowercased().contains("web") == true) + } + + private func isStandout(_ event: CachedEvent) -> Bool { + guard event.domain == "money", + let value = event.value, + let amount = Double(value.replacingOccurrences(of: ",", with: "")) + else { return false } + return abs(amount) >= 100 + } + + private func isSubtle(_ event: CachedEvent) -> Bool { + event.value == nil || event.action.lowercased().contains("transfer") + } +} + +// MARK: - Raised event card + +private struct RaisedEventCard: View { + let event: CachedEvent + var surplusCount: Int = 0 + + var body: some View { + HStack(alignment: .center, spacing: SparkSpacing.md) { + iconBox + + VStack(alignment: .leading, spacing: 2) { + Text(metaLine(for: event)) + .font(SparkTypography.captionStrong) + .foregroundStyle(Color.secondary.opacity(0.68)) + .lineLimit(1) + Text(titledWithSurplus(primaryTitle(for: event), surplus: surplusCount)) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + + if let value = displayValue(for: event) { + Text(value) + .font(SparkFonts.display(.title3, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + } + .padding(.horizontal, SparkSpacing.md) + .padding(.vertical, SparkSpacing.md) + .background(Color.sparkElevated.opacity(0.86), in: .rect(cornerRadius: SparkRadii.lg)) + .overlay { + RoundedRectangle(cornerRadius: SparkRadii.lg) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + } + .shadow(color: .black.opacity(0.07), radius: 12, x: 0, y: 6) + } + + private var iconBox: some View { + Image(systemName: domainIcon(event.domain)) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 42, height: 42) + .background(Color.domainTint(for: event.domain), in: .rect(cornerRadius: 12)) + } +} + +// MARK: - Standout card + +private struct StandoutEventCard: View { + let event: CachedEvent + var surplusCount: Int = 0 + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack(alignment: .firstTextBaseline) { + Text(metaLine(for: event)) + .font(SparkTypography.captionStrong) + .foregroundStyle(Color.secondary.opacity(0.68)) + .lineLimit(1) + Spacer(minLength: SparkSpacing.sm) + if let time = event.time { + Text(shortTime(time)) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + + Text(titledWithSurplus(primaryTitle(for: event), surplus: surplusCount)) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + .lineLimit(2) + + if let value = displayValue(for: event) { + HStack(spacing: SparkSpacing.xs) { + Text(value) + .font(SparkFonts.display(.title, weight: .bold)) + .foregroundStyle(Color.sparkWarning) + Circle() + .fill(Color.sparkWarning) + .frame(width: 8, height: 8) + } + } + + HStack(spacing: SparkSpacing.xs) { + if let actor = event.actorTitle, !actor.isEmpty { + tag(actor) + } + if event.domain == "money" { + tag("money") + } + if let count = event.blocksCount, count > 0 { + tag("\(count) blocks") + } + } + } + .padding(SparkSpacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.sparkElevated.opacity(0.74), in: .rect(cornerRadius: SparkRadii.lg)) + .overlay { + RoundedRectangle(cornerRadius: SparkRadii.lg) + .stroke(Color.sparkWarning.opacity(0.16), lineWidth: 1) + } + .shadow(color: Color.sparkWarning.opacity(0.14), radius: 18, x: 0, y: 10) + } + + private func tag(_ value: String) -> some View { + Text("# \(value)") + .font(SparkTypography.captionStrong) + .foregroundStyle(.secondary) + .padding(.horizontal, SparkSpacing.sm) + .padding(.vertical, 5) + .background(Color.sparkSurface.opacity(0.72), in: .capsule) + .overlay { + Capsule() + .stroke(Color.primary.opacity(0.09), lineWidth: 1) + } + } +} + +// MARK: - Web digest card + +private struct WebDigestEventCard: View { + let event: CachedEvent + var surplusCount: Int = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Group { + if let urlString = event.targetMediaUrl, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + default: + gradientPlaceholder + } + } + } else { + gradientPlaceholder + } + } + .frame(height: 168) + .clipped() + + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + HStack { + Text(metaLine(for: event)) + .font(SparkTypography.captionStrong) + .foregroundStyle(Color.secondary.opacity(0.68)) + Spacer(minLength: SparkSpacing.sm) + if let time = event.time { + Text(shortTime(time)) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + + Text(titledWithSurplus(primaryTitle(for: event), surplus: surplusCount)) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + .lineLimit(2) + } + .padding(SparkSpacing.md) + } + .background(Color.sparkElevated.opacity(0.86), in: .rect(cornerRadius: SparkRadii.hero)) + .clipShape(.rect(cornerRadius: SparkRadii.hero)) + .overlay { + RoundedRectangle(cornerRadius: SparkRadii.hero) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + } + .shadow(color: .black.opacity(0.08), radius: 18, x: 0, y: 10) + } + + private var gradientPlaceholder: some View { + ZStack { + LinearGradient( + colors: [Color.sparkOcean.opacity(0.88), Color.sparkAccent.opacity(0.92)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + Image(systemName: "globe") + .font(.system(size: 54, weight: .regular)) + .foregroundStyle(.white.opacity(0.82)) + Text(event.targetTitle ?? event.value ?? event.action.sparkActionTitle) + .font(SparkTypography.captionStrong) + .foregroundStyle(.primary) + .padding(.horizontal, SparkSpacing.md) + .padding(.vertical, SparkSpacing.xs) + .background(Color.white.opacity(0.48), in: .capsule) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(SparkSpacing.sm) + } + } +} + +// MARK: - Subtle event row + +private struct SubtleEventRow: View { + let event: CachedEvent + var surplusCount: Int = 0 + + var body: some View { + HStack(alignment: .center, spacing: SparkSpacing.sm) { + Image(systemName: domainIcon(event.domain)) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 28) + + Text(titledWithSurplus(primaryTitle(for: event), surplus: surplusCount)) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer(minLength: SparkSpacing.sm) + + if let value = displayValue(for: event) { + Text(value) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, SparkSpacing.xs) + .padding(.vertical, SparkSpacing.xs) + } +} + +// MARK: - Helpers + +private func titledWithSurplus(_ title: String, surplus: Int) -> String { + surplus > 0 ? "\(title) + \(surplus) others" : title +} + +private func metaLine(for event: CachedEvent) -> String { + if isBalanceSnapshot(event), event.targetTitle?.isISODateString == true { + return event.action.sparkActionTitle + } + return event.actorTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? "" +} + +private func primaryTitle(for event: CachedEvent) -> String { + if isBalanceSnapshot(event), event.targetTitle?.isISODateString == true { + return event.actorTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ?? actionTitle(for: event) + } + + return eventTitle(for: event) +} + +private func eventTitle(for event: CachedEvent) -> String { + let action = actionTitle(for: event) + guard event.displayWithObject, + let target = event.targetTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + else { + return action + } + return "\(action) \(target)" +} + +private func actionTitle(for event: CachedEvent) -> String { + event.action.sparkActionTitle +} + +private func isBalanceSnapshot(_ event: CachedEvent) -> Bool { + event.action == "had_balance" +} + +private func displayValue(for event: CachedEvent) -> String? { + if let displayValue = event.displayValue?.sparkPlainTextFromHTMLFragment.nilIfEmpty { + return displayValue + } + return event.value.map { formattedValue($0, unit: event.unit) }?.sparkPlainTextFromHTMLFragment.nilIfEmpty +} + +private func formattedValue(_ v: String, unit: String?) -> String { + let plainValue = v.sparkPlainTextFromHTMLFragment + guard let u = unit, !u.isEmpty else { return plainValue } + if plainValue.localizedCaseInsensitiveContains(u) { + return plainValue + } + let currencyCodes = ["GBP", "USD", "EUR", "JPY"] + if currencyCodes.contains(u.uppercased()) { + if let amount = Double(plainValue.replacingOccurrences(of: ",", with: "")) { + let fmt = NumberFormatter() + fmt.numberStyle = .currency + fmt.currencyCode = u + fmt.maximumFractionDigits = 2 + return fmt.string(from: NSNumber(value: amount)) ?? "\(plainValue) \(u)" + } + } + return "\(plainValue) \(u)" +} + +private func shortTime(_ date: Date) -> String { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f.string(from: date) +} + +private func domainIcon(_ domain: String) -> String { + switch domain { + case "health": return "moon.zzz.fill" + case "activity": return "figure.walk" + case "money": return "creditcard.fill" + case "media": return "music.note" + case "knowledge": return "book.fill" + default: return "bolt.fill" + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } + + var isISODateString: Bool { + range(of: #"^\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) != nil } } diff --git a/SparkApp/Sources/Today/Cards/MediaCard.swift b/SparkApp/Sources/Today/Cards/MediaCard.swift deleted file mode 100644 index f76a6b6..0000000 --- a/SparkApp/Sources/Today/Cards/MediaCard.swift +++ /dev/null @@ -1,64 +0,0 @@ -import SparkUI -import SwiftUI - -struct MediaCard: View { - let media: MediaSnapshot - - var body: some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - GlassCardHeader( - icon: "music.note", - tint: .domainMedia, - title: "Listening", - trailing: media.spotifyMinutes.map { "\($0) min" } - ) - - HStack(spacing: SparkSpacing.md) { - Rectangle() - .fill( - LinearGradient( - colors: [.ember300, .ember200, .spark200], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 56, height: 56) - .clipShape(.rect(cornerRadius: SparkRadii.sm)) - .overlay( - RoundedRectangle(cornerRadius: SparkRadii.sm) - .strokeBorder(.white.opacity(0.4), lineWidth: 0.5) - ) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 2) { - if let track = media.topTrack { - Text(track) - .font(SparkTypography.bodyStrong) - .lineLimit(1) - } - if let artist = media.topArtist { - Text(artist) - .font(SparkTypography.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - if media.lastSongAt != nil { - HStack(spacing: 4) { - Circle() - .fill(Color.sparkSuccess) - .frame(width: 6, height: 6) - Text("PLAYING") - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - } - .padding(.top, 4) - } - } - - Spacer(minLength: 0) - } - } - } - } -} diff --git a/SparkApp/Sources/Today/Cards/MoneyCard.swift b/SparkApp/Sources/Today/Cards/MoneyCard.swift deleted file mode 100644 index f404c20..0000000 --- a/SparkApp/Sources/Today/Cards/MoneyCard.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SparkUI -import SwiftUI - -struct MoneyCard: View { - let money: MoneySnapshot - - var body: some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - GlassCardHeader( - icon: "sterlingsign", - tint: .domainMoney, - title: "Spent today" - ) - - if let display = money.spentTodayDisplay { - Text(display) - .font(SparkFonts.display(.title, weight: .bold)) - .accessibilityLabel("Spent today \(display)") - } - - if !money.recent.isEmpty { - Text("\(money.recent.count) transactions") - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - - VStack(spacing: SparkSpacing.xs) { - ForEach(money.recent.prefix(2)) { tx in - HStack { - Text(tx.merchant) - .font(SparkTypography.bodySmall) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: SparkSpacing.sm) - Text(MoneySnapshot.format(amount: abs(tx.amount), currency: tx.currency)) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - .monospacedDigit() - } - } - } - .padding(.top, SparkSpacing.xs) - } - } - } - } -} diff --git a/SparkApp/Sources/Today/Cards/SleepCard.swift b/SparkApp/Sources/Today/Cards/SleepCard.swift deleted file mode 100644 index 8684309..0000000 --- a/SparkApp/Sources/Today/Cards/SleepCard.swift +++ /dev/null @@ -1,64 +0,0 @@ -import SparkUI -import SwiftUI - -struct SleepCard: View { - let health: HealthSnapshot - - var body: some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "moon.fill", - tint: .domainHealth, - title: "Sleep", - trailing: "last night" - ) - - HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.md) { - if let score = health.sleepScore { - Text("\(score)") - .font(SparkFonts.display(.largeTitle, weight: .bold)) - .foregroundStyle(Color.domainHealth) - .accessibilityLabel("Sleep score \(score)") - } - - VStack(alignment: .leading, spacing: 2) { - if let duration = health.sleepDurationMinutes { - Text(formatDuration(minutes: duration)) - .font(SparkTypography.bodyStrong) - } - if let bedtime = health.bedtime, let wake = health.wakeTime { - Text("\(bedtime) → \(wake)") - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - } - } - - Spacer(minLength: 0) - } - - if !hypnogramStages.isEmpty { - SleepHypnogram(stages: hypnogramStages, tint: .domainHealth) - .accessibilityHidden(true) - } - } - } - } - - private func formatDuration(minutes: Int) -> String { - let hours = minutes / 60 - let m = minutes % 60 - return "\(hours)h \(m)m in bed" - } - - /// Phase 2 shows a synthetic hypnogram derived from total deep+REM share - /// since the backend ships only the totals; we replace this with real - /// stage data when the HealthKit ingestion delivers per-stage timeline. - private var hypnogramStages: [SleepHypnogram.Stage] { - let pattern: [Double] = [0.4, 0.6, 0.85, 0.9, 0.95, 1.0, 0.85, 0.7, 0.45, 0.3, - 0.5, 0.7, 0.9, 0.7, 0.4, 0.5, 0.6, 0.45, 0.3, 0.5, - 0.65, 0.85, 0.9, 0.7, 0.5, 0.4, 0.55, 0.3] - guard health.sleepDurationMinutes != nil || health.sleepScore != nil else { return [] } - return pattern.enumerated().map { SleepHypnogram.Stage(id: $0.offset, depth: $0.element) } - } -} diff --git a/SparkApp/Sources/Today/Cards/StatStripView.swift b/SparkApp/Sources/Today/Cards/StatStripView.swift new file mode 100644 index 0000000..a8d4829 --- /dev/null +++ b/SparkApp/Sources/Today/Cards/StatStripView.swift @@ -0,0 +1,88 @@ +import SparkKit +import SparkUI +import SwiftUI + +/// Horizontal scrolling strip of at-a-glance stat tiles. +/// Replaces the large domain cards in the Today redesign. +struct StatStripView: View { + let snapshot: TodaySnapshot + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + if let steps = snapshot.activity?.steps { + StatTile( + icon: "figure.walk", + tint: .domainActivity, + value: formatSteps(steps), + label: "Steps" + ) + } + if let display = snapshot.money?.spentTodayDisplay { + StatTile( + icon: "sterlingsign.circle.fill", + tint: .domainMoney, + value: display, + label: "Spent" + ) + } + if let score = snapshot.health?.sleepScore { + StatTile( + icon: "moon.zzz.fill", + tint: .domainHealth, + value: "\(score)", + label: "Sleep" + ) + } + if let bookmarks = snapshot.knowledge?.bookmarksToday, bookmarks > 0 { + StatTile( + icon: "book.fill", + tint: .domainKnowledge, + value: "\(bookmarks)", + label: "Read" + ) + } + StatTile( + icon: "heart.fill", + tint: .domainHealth, + value: snapshot.health?.restingHeartRate.map { "\($0)" } ?? "—", + label: "Heart" + ) + } + .padding(.horizontal, SparkSpacing.lg) + } + .padding(.horizontal, -SparkSpacing.lg) + } + + private func formatSteps(_ count: Int) -> String { + count >= 1_000 ? String(format: "%.1fk", Double(count) / 1_000) : String(count) + } +} + +private struct StatTile: View { + let icon: String + let tint: Color + let value: String + let label: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Image(systemName: icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tint) + Text(value) + .font(SparkTypography.heroSmall) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(label) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(width: 90, alignment: .leading) + .sparkGlass(.roundedRect(16)) + } +} diff --git a/SparkApp/Sources/Today/Cards/UpNextCard.swift b/SparkApp/Sources/Today/Cards/UpNextCard.swift deleted file mode 100644 index 673d588..0000000 --- a/SparkApp/Sources/Today/Cards/UpNextCard.swift +++ /dev/null @@ -1,44 +0,0 @@ -import SparkUI -import SwiftUI - -struct UpNextCard: View { - let event: KnowledgeSnapshot.CalendarEvent - - var body: some View { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - GlassCardHeader( - icon: "calendar", - tint: .domainKnowledge, - title: "Up next" - ) - - Text(event.title) - .font(SparkTypography.bodyStrong) - .lineLimit(2) - - HStack(spacing: SparkSpacing.sm) { - Image(systemName: "clock") - .font(.caption) - .foregroundStyle(.secondary) - Text("\(event.start) → \(event.end)") - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - if let location = event.location { - Text("·").foregroundStyle(.secondary) - Image(systemName: "mappin") - .font(.caption) - .foregroundStyle(.secondary) - Text(location) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel("From \(event.start) to \(event.end)\(event.location.map { " at \($0)" } ?? "")") - } - } - } -} diff --git a/SparkApp/Sources/Today/CheckInPlaceholderView.swift b/SparkApp/Sources/Today/CheckInPlaceholderView.swift deleted file mode 100644 index 2c255e3..0000000 --- a/SparkApp/Sources/Today/CheckInPlaceholderView.swift +++ /dev/null @@ -1,39 +0,0 @@ -import SparkUI -import SwiftUI - -/// Placeholder modal shown when a Today CheckInCard is tapped. Day 15 of -/// Phase 2 replaces this with the full mood/tags/note flow against -/// `/api/v1/mobile/check-ins`. -struct CheckInPlaceholderView: View { - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - VStack(spacing: SparkSpacing.lg) { - Spacer() - Image(systemName: "heart.text.clipboard") - .font(.system(size: 48)) - .foregroundStyle(Color.sparkAccent) - Text("Check-in") - .font(SparkTypography.hero) - Text("The full check-in flow lands later in Phase 2 — mood scale, contextual tags, and a free-text note.") - .font(SparkTypography.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, SparkSpacing.xl) - Spacer() - Button("Done") { dismiss() } - .buttonStyle(.borderedProminent) - .tint(.sparkAccent) - .frame(maxWidth: .infinity) - } - .padding(SparkSpacing.lg) - .background(Color.sparkSurface.ignoresSafeArea()) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Cancel") { dismiss() } - } - } - } - } -} diff --git a/SparkApp/Sources/Today/DayPagerView.swift b/SparkApp/Sources/Today/DayPagerView.swift index ef0daee..6eb69bd 100644 --- a/SparkApp/Sources/Today/DayPagerView.swift +++ b/SparkApp/Sources/Today/DayPagerView.swift @@ -11,34 +11,51 @@ struct DayPagerView: View { var body: some View { @Bindable var appModel = appModel - NavigationStack(path: $path) { - TabView(selection: $selectedOffset) { - ForEach(dates) { key in - TodayView(date: key.date) - .tag(key.offset) - } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .ignoresSafeArea() - .toolbar(.hidden, for: .navigationBar) - .toolbarBackground(.hidden, for: .navigationBar) - .navigationDestination(for: DetailRoute.self) { route in - switch route { - case .event(let id): - EventDetailView(eventId: id) - case .object(let id): - ObjectDetailView(objectId: id) - case .block(let id): - BlockDetailView(blockId: id) - case .metric(let identifier): - MetricDetailView(identifier: identifier) - case .place(let id): - PlaceDetailView(placeId: id) - case .integration(let service): - IntegrationDetailView(integrationId: service) + ZStack { + SparkResolvedAppBackground() + + NavigationStack(path: $path) { + ZStack { + SparkResolvedAppBackground() + + TabView(selection: $selectedOffset) { + ForEach(dates) { key in + TodayView( + date: key.date, + showsToolbar: key.offset == selectedOffset + ) + .tag(key.offset) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .ignoresSafeArea() } + .ignoresSafeArea() + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.hidden, for: .navigationBar) + .navigationDestination(for: DetailRoute.self) { route in + switch route { + case .event(let id): + EventDetailView(eventId: id) + case .object(let id): + ObjectDetailView(objectId: id) + case .block(let id): + BlockDetailView(blockId: id) + case .metric(let identifier): + MetricDetailView(identifier: identifier) + case .place(let id): + PlaceDetailView(placeId: id) + case .integration(let service): + IntegrationDetailView(integrationId: service) + case .account(let id): + AccountDetailView(accountId: id) + } + } } + .scrollContentBackground(.hidden) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() .onChange(of: appModel.pendingRoute) { _, route in apply(route: route) } @@ -66,6 +83,8 @@ struct DayPagerView: View { push(.place(id: id)) case .integration(let service): push(.integration(service: service)) + case .account(let id): + push(.account(id: id)) } appModel.pendingRoute = nil } @@ -93,19 +112,26 @@ enum DetailRoute: Hashable { case metric(identifier: String) case place(id: String) case integration(service: String) + case account(id: String) } private struct DayKey: Identifiable, Hashable { - let offset: Int let date: Date + let offset: Int let label: String var id: Int { offset } + init(date: Date, offset: Int, label: String? = nil) { + self.date = date + self.offset = offset + self.label = label ?? Self.label(for: date, offset: offset) + } + static func defaultWindow(anchor: Date = .now, calendar: Calendar = .current) -> [DayKey] { (-7 ... 1).compactMap { offset in guard let date = calendar.date(byAdding: .day, value: offset, to: anchor) else { return nil } - return DayKey(offset: offset, date: date, label: Self.label(for: date, offset: offset)) + return DayKey(date: date, offset: offset) } } @@ -113,7 +139,7 @@ private struct DayKey: Identifiable, Hashable { (0 ..< 8).compactMap { i in let offset = -i guard let date = calendar.date(byAdding: .day, value: offset, to: anchor) else { return nil } - return DayKey(offset: offset, date: date, label: Self.label(for: date, offset: offset)) + return DayKey(date: date, offset: offset) }.sorted(by: { $0.offset < $1.offset }) } diff --git a/SparkApp/Sources/Today/String+Action.swift b/SparkApp/Sources/Today/String+Action.swift new file mode 100644 index 0000000..0b726f3 --- /dev/null +++ b/SparkApp/Sources/Today/String+Action.swift @@ -0,0 +1,8 @@ +import Foundation +import SparkKit + +extension String { + var humanisedAction: String { + sparkActionTitle + } +} diff --git a/SparkApp/Sources/Today/TodaySnapshot.swift b/SparkApp/Sources/Today/TodaySnapshot.swift index 33dbb8b..472cae6 100644 --- a/SparkApp/Sources/Today/TodaySnapshot.swift +++ b/SparkApp/Sources/Today/TodaySnapshot.swift @@ -20,9 +20,9 @@ struct TodaySnapshot { let knowledge: KnowledgeSnapshot? let anomalies: [Anomaly] let heatmapRows: [DomainHeatmapRow] - let checkInStatus: CheckInStatus + let checkInStatus: CheckInDayStatus - init(summary: DaySummary?, date: Date, now: Date = .now) { + init(summary: DaySummary?, date: Date, now: Date = .now, checkInStatus: CheckInDayStatus = .allPending) { self.date = date self.timeOfDay = SparkTimeOfDay.from(date: now) self.dateLabel = Self.dateFormatter.string(from: date) @@ -33,8 +33,7 @@ struct TodaySnapshot { self.knowledge = KnowledgeSnapshot(summary?.sections.knowledge?.objectValue) self.anomalies = summary?.anomalies ?? [] self.heatmapRows = Self.buildHeatmapRows() - let slot = SparkTimeOfDay.from(date: now) - self.checkInStatus = Self.loadCheckIn(date: date, slot: slot) + self.checkInStatus = checkInStatus } private static let dateFormatter: DateFormatter = { @@ -43,26 +42,6 @@ struct TodaySnapshot { return f }() - private static func loadCheckIn(date: Date, slot: SparkTimeOfDay) -> CheckInStatus { - let dateKey = isoDate(date) - let key = "checkin_\(dateKey)_\(slot.rawValue)" - guard let data = UserDefaults(suiteName: "group.co.cronx.spark")?.data(forKey: key) else { - return .pending(slot: slot) - } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - guard let entry = try? decoder.decode(CheckIn.self, from: data) else { - return .pending(slot: slot) - } - return .logged(mood: entry.mood, note: entry.note) - } - - private static func isoDate(_ date: Date) -> String { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd" - return f.string(from: date) - } - private static func buildHeatmapRows() -> [DomainHeatmapRow] { // Backend `/api/v1/mobile/heatmap` doesn't exist yet — Phase 2 ships a // deterministic placeholder so the UX lands now and we swap data @@ -249,10 +228,16 @@ struct KnowledgeSnapshot { } } -/// Local-only check-in state for Phase 2. Backend `/check-ins` endpoint -/// lands in a follow-up phase; until then we surface a "pending" prompt and -/// the modal stub. -enum CheckInStatus { - case pending(slot: SparkTimeOfDay) - case logged(mood: String, note: String?) +// MARK: - Check-in status types + +enum PeriodStatus: Sendable { + case pending + case completed(physical: Int, mental: Int, notes: String?) +} + +struct CheckInDayStatus: Sendable { + let morning: PeriodStatus + let afternoon: PeriodStatus + + static let allPending = CheckInDayStatus(morning: .pending, afternoon: .pending) } diff --git a/SparkApp/Sources/Today/TodayView.swift b/SparkApp/Sources/Today/TodayView.swift index 91bf2bc..202f930 100644 --- a/SparkApp/Sources/Today/TodayView.swift +++ b/SparkApp/Sources/Today/TodayView.swift @@ -6,93 +6,67 @@ import UIKit struct TodayView: View { let date: Date + var showsToolbar = true @Environment(AppModel.self) private var appModel @State private var viewModel: TodayViewModel? @State private var showCheckIn = false - @State private var showSettings = false - @State private var showNotifications = false - - @Query(filter: #Predicate { !$0.isRead }) - private var unreadNotifications: [CachedNotification] - @Query private var allIntegrations: [CachedIntegration] - - private var errorIntegrations: [CachedIntegration] { - let healthy: Set = ["up_to_date", "ok", "active", "syncing", "running"] - return allIntegrations.filter { !healthy.contains($0.status) } - } + @State private var showHistory = false + @State private var checkInInitialPeriod: CheckInPeriod = .morning var body: some View { - let snapshot = TodaySnapshot(summary: viewModel?.cached, date: date) + let snapshot = TodaySnapshot( + summary: viewModel?.cached, + date: date, + checkInStatus: viewModel?.checkInDayStatus ?? .allPending + ) ZStack { - TodayBackground(snapshot.timeOfDay) - .ignoresSafeArea() + SparkResolvedAppBackground() ScrollView { VStack(alignment: .leading, spacing: SparkSpacing.lg) { hero(snapshot: snapshot) - anomalyPill(for: snapshot) - - if let health = snapshot.health, health.hasSleep { - SleepCard(health: health) - } - - if shouldShowActivityMoneyRow(snapshot) { - HStack(alignment: .top, spacing: SparkSpacing.md) { - if let activity = snapshot.activity, activity.hasAny { - ActivityCard(activity: activity) - } - if let money = snapshot.money, money.hasAny { - MoneyCard(money: money) - } - } - } + StatStripView(snapshot: snapshot) - if let media = snapshot.media, media.hasAny { - MediaCard(media: media) - } + anomalyPill(for: snapshot) - if let next = snapshot.knowledge?.nextCalendarEvent { - UpNextCard(event: next) - } - - CheckInCard(status: snapshot.checkInStatus) { - showCheckIn = true - } + CheckInCard( + status: snapshot.checkInStatus, + onTapMorning: { + checkInInitialPeriod = .morning + showCheckIn = true + }, + onTapAfternoon: { + checkInInitialPeriod = .afternoon + showCheckIn = true + }, + showHistory: $showHistory + ) FeedSection(date: date) if !snapshot.hasAnyDomainData { loadingOrEmptyState } - - HeatmapSection(rows: snapshot.heatmapRows) - .padding(.top, SparkSpacing.md) } .padding(.horizontal, SparkSpacing.lg) - .padding(.top, deviceSafeAreaTop + SparkSpacing.xl) + .padding(.top, SparkSpacing.xl + 72) .padding(.bottom, deviceSafeAreaBottom + 66) } .scrollContentBackground(.hidden) .refreshable { await viewModel?.refresh() } - - headerButtons } - .environment(\.colorScheme, snapshot.timeOfDay.prefersDarkTreatment ? .dark : .light) + .sparkMainAppToolbar(isVisible: showsToolbar) .sheet(isPresented: $showCheckIn) { - let snapshot = TodaySnapshot(summary: viewModel?.cached, date: date) - if case .pending(let slot) = snapshot.checkInStatus { - CheckInModalView(slot: slot.rawValue, date: date) - } else { - CheckInModalView(slot: SparkTimeOfDay.from(date: .now).rawValue, date: date) + if let vm = viewModel { + CheckInModalView(viewModel: vm, date: date, initialPeriod: checkInInitialPeriod) } } - .sheet(isPresented: $showSettings) { - SettingsRootView() - } - .sheet(isPresented: $showNotifications) { - NotificationsInboxView() + .sheet(isPresented: $showHistory) { + if let vm = viewModel { + CheckInHistoryView(apiClient: appModel.apiClient, container: appModel.container, todayViewModel: vm) + } } .task(id: date) { if viewModel == nil { @@ -106,95 +80,56 @@ struct TodayView: View { } } - // MARK: - Header buttons - - private var headerButtons: some View { - SparkGlassStack(spacing: 0) { - HStack(spacing: 0) { - Button { - showSettings = true - } label: { - Image(systemName: "gearshape") - .font(.body.weight(.semibold)) - .foregroundStyle(errorIntegrations.isEmpty ? Color.primary : Color.sparkError) - .frame(width: 36, height: 36) - .sparkGlass(.circle) - } - .accessibilityLabel("Settings") - - Rectangle() - .fill(Color.primary.opacity(0.12)) - .frame(width: 1, height: 22) - - Button { - showNotifications = true - } label: { - ZStack(alignment: .topTrailing) { - Image(systemName: "bell") - .font(.body.weight(.semibold)) - .foregroundStyle(unreadNotifications.isEmpty ? Color.primary : Color.sparkAccent) - .frame(width: 36, height: 36) - .sparkGlass(.circle) - if !unreadNotifications.isEmpty { - Circle() - .fill(Color.sparkError) - .frame(width: 9, height: 9) - .offset(x: 3, y: -3) - } - } - } - .accessibilityLabel( - unreadNotifications.isEmpty - ? "Notifications" - : "Notifications, \(unreadNotifications.count) unread" - ) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .padding(.top, deviceSafeAreaTop + SparkSpacing.xl) - .padding(.trailing, SparkSpacing.lg) - } - // MARK: - Hero private func hero(snapshot: TodaySnapshot) -> some View { - let isDark = snapshot.timeOfDay.prefersDarkTreatment + let title = heroTitle(snapshot: snapshot) + let titleLines = title.components(separatedBy: "\n") + return VStack(alignment: .leading, spacing: SparkSpacing.sm) { - Text(heroTitle(snapshot: snapshot)) - .font(SparkFonts.display(.title, weight: .bold)) - .lineLimit(3) - .fixedSize(horizontal: false, vertical: true) - .foregroundStyle(isDark ? Color.white : Color.primary) - .accessibilityAddTraits(.isHeader) + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(titleLines.enumerated()), id: \.offset) { index, line in + Text(line) + .font(heroTitleFont(isFirstLine: index == 0)) + .lineLimit(1) + .minimumScaleFactor(0.88) + } + } + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Color.primary) + .accessibilityElement(children: .ignore) + .accessibilityLabel(title.replacingOccurrences(of: "\n", with: " ")) + .accessibilityAddTraits(.isHeader) - if let subtitle = heroSubtitle(snapshot: snapshot) { + if let subtitle = viewModel?.briefingSummaryLine { Text(subtitle) .font(SparkTypography.body) - .foregroundStyle(isDark ? Color.white.opacity(0.7) : Color.secondary) + .foregroundStyle(Color.secondary) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity, alignment: .leading) } + private func heroTitleFont(isFirstLine: Bool) -> Font { + Font.custom(SparkFonts.displayPostScriptName, size: 32, relativeTo: .largeTitle) + .weight(isFirstLine ? .bold : .regular) + } + private func heroTitle(snapshot: TodaySnapshot) -> String { if Calendar.current.isDateInToday(date) { - return "\(snapshot.timeOfDay.greeting),\n\(firstName)." + return "\(firstName),\nyour day so far." } else if Calendar.current.isDateInYesterday(date) { - return "Yesterday." + return "Yesterday\nin review" } else if let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: .now), Calendar.current.isDate(date, inSameDayAs: tomorrow) { - return "Tomorrow." + return "Looking ahead" } else { - return snapshot.dateLabel + return Self.dayTitleFormatter.string(from: date) } } - private var deviceSafeAreaTop: CGFloat { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene }.first? - .keyWindow?.safeAreaInsets.top ?? 59 - } - private var deviceSafeAreaBottom: CGFloat { UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene }.first? @@ -202,31 +137,15 @@ struct TodayView: View { } private var firstName: String { - // TODO: source from /me endpoint when Settings → Profile lands. - "Will" + let name = appModel.profile?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return name.split(separator: " ").first.map(String.init) ?? "Your" } - private func heroSubtitle(snapshot: TodaySnapshot) -> String? { - var parts: [String] = [] - if let dur = snapshot.health?.sleepDurationMinutes { - parts.append("slept \(dur / 60)h \(dur % 60)m") - } - if let steps = snapshot.activity?.steps { - parts.append("walked \(formatSteps(steps)) steps") - } - if let display = snapshot.money?.spentTodayDisplay { - parts.append("spent \(display)") - } - guard !parts.isEmpty else { return nil } - return "You " + parts.joined(separator: ", ") + " so far." - } - - private func formatSteps(_ count: Int) -> String { - if count >= 1_000 { - return String(format: "%.1fk", Double(count) / 1_000) - } - return String(count) - } + private static let dayTitleFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEEE\nd MMMM yyyy" + return f + }() // MARK: - Anomaly pill @@ -245,10 +164,6 @@ struct TodayView: View { } } - private func shouldShowActivityMoneyRow(_ snapshot: TodaySnapshot) -> Bool { - (snapshot.activity?.hasAny ?? false) || (snapshot.money?.hasAny ?? false) - } - // MARK: - Loading / empty @ViewBuilder diff --git a/SparkApp/Sources/Today/TodayViewModel.swift b/SparkApp/Sources/Today/TodayViewModel.swift index b0bd8a6..480d7b6 100644 --- a/SparkApp/Sources/Today/TodayViewModel.swift +++ b/SparkApp/Sources/Today/TodayViewModel.swift @@ -2,6 +2,7 @@ import Foundation import Observation import SparkKit import SwiftData +import WidgetKit enum TodayNetworkState: Equatable { case idle @@ -14,25 +15,113 @@ enum TodayNetworkState: Equatable { final class TodayViewModel { let date: Date private(set) var cached: DaySummary? + private(set) var briefingSummaryLine: String? private(set) var networkState: TodayNetworkState = .idle + private(set) var checkInDayStatus: CheckInDayStatus = .allPending private let apiClient: APIClient private let container: ModelContainer + private let defaults: UserDefaults + private var summaryLineTask: Task? - init(date: Date, apiClient: APIClient, container: ModelContainer) { + private static let summaryLinePromptVersion = "today-hero-summary-v1" + private static let summaryLineCachePrefix = "spark.today.heroSummary" + + init( + date: Date, + apiClient: APIClient, + container: ModelContainer, + defaults: UserDefaults = .sparkAppGroup + ) { self.date = date self.apiClient = apiClient self.container = container + self.defaults = defaults } func load() async { loadCached() + loadCachedCheckIns() await revalidate() + await revalidateCheckIns() await loadFeed() } func refresh() async { await revalidate(force: true) + await revalidateCheckIns() + } + + func submitCheckIn(request: CheckInRequest) async throws { + let event = try await apiClient.request(CheckInsEndpoint.submit(request)) + let context = ModelContext(container) + CachedCheckIn.upsert( + date: request.date, + period: request.period, + completed: true, + physical: request.physical, + mental: request.mental, + notes: request.notes.flatMap { $0.isEmpty ? nil : $0 }, + eventId: event.id, + in: context + ) + try? context.save() + loadCachedCheckIns() + WidgetCenter.shared.reloadAllTimelines() + } + + private func loadCachedCheckIns() { + let key = Self.isoKey(for: date) + let context = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.date == key } + ) + let rows = (try? context.fetch(descriptor)) ?? [] + let morningRow = rows.first { $0.period == "morning" } + let afternoonRow = rows.first { $0.period == "afternoon" } + + func status(from row: CachedCheckIn?) -> PeriodStatus { + guard let row, row.completed, let phy = row.physical, let men = row.mental else { + return .pending + } + return .completed(physical: phy, mental: men, notes: row.notes) + } + + checkInDayStatus = CheckInDayStatus( + morning: status(from: morningRow), + afternoon: status(from: afternoonRow) + ) + } + + private func revalidateCheckIns() async { + let key = Self.isoKey(for: date) + do { + let response = try await apiClient.request(CheckInsEndpoint.today(date: key)) + let context = ModelContext(container) + + func upsertPeriod(_ detail: CheckInPeriodDetail, period: CheckInPeriod) { + CachedCheckIn.upsert( + date: key, + period: period, + completed: detail.completed, + physical: detail.event?.physical(), + mental: detail.event?.mental(), + eventId: detail.event?.id, + in: context + ) + } + + upsertPeriod(response.morning, period: .morning) + upsertPeriod(response.afternoon, period: .afternoon) + try? context.save() + loadCachedCheckIns() + } catch APIError.notModified { + } catch is CancellationError { + } catch APIError.transport(let underlying) + where (underlying as? URLError)?.code == .cancelled { + } catch { + // Non-fatal: check-in status uses cached data if network fails + } } private func loadCached() { @@ -41,7 +130,7 @@ final class TodayViewModel { let descriptor = FetchDescriptor(predicate: #Predicate { $0.date == key }) if let cached = try? context.fetch(descriptor).first, let decoded = try? cached.decoded() { - self.cached = decoded + apply(summary: decoded) } } @@ -51,7 +140,7 @@ final class TodayViewModel { let summary = try await apiClient.request( BriefingEndpoint.today(date: Self.isoKey(for: date)) ) - cached = summary + apply(summary: summary) try await persist(summary) networkState = .idle } catch APIError.notModified { @@ -70,24 +159,19 @@ final class TodayViewModel { } private func loadFeed() async { - guard Calendar.current.isDateInToday(date) else { return } do { - let page = try await apiClient.request(FeedEndpoint.feed(limit: 50)) + var cursor: String? + let dateKey = Self.isoKey(for: date) let context = ModelContext(container) - for event in page.data { - context.insert(CachedEvent( - id: event.id, - time: event.time, - service: event.service, - domain: event.domain, - action: event.action, - value: event.value, - unit: event.unit, - url: event.url, - actorTitle: event.actor?.title, - targetTitle: event.target?.title - )) - } + + repeat { + let page = try await apiClient.request(FeedEndpoint.feed(cursor: cursor, limit: 100, date: dateKey)) + for event in page.data { + upsert(event, in: context) + } + cursor = page.hasMore ? page.nextCursor : nil + } while cursor != nil + try? context.save() } catch APIError.notModified { // feed unchanged — no action needed @@ -97,6 +181,58 @@ final class TodayViewModel { } catch { /* non-fatal */ } } + private func upsert(_ event: Event, in context: ModelContext) { + let eventId = event.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == eventId } + ) + if let existing = (try? context.fetch(descriptor))?.first { + existing.time = event.time + existing.service = event.service + existing.domain = event.domain + existing.action = event.action + existing.value = event.value + existing.unit = event.unit + existing.url = event.url + existing.displayName = event.displayName + existing.hidden = event.hidden + existing.displayWithObject = event.displayWithObject + existing.displayValue = event.displayValue + existing.tagNames = CachedEvent.encodeTagNames(event.tags) + existing.blocksCount = event.blocksCount + existing.actorTitle = event.actor?.title + existing.actorType = event.actor?.type + existing.actorMediaUrl = event.actor?.mediaUrl + existing.targetTitle = event.target?.title + existing.targetType = event.target?.type + existing.targetMediaUrl = event.target?.mediaUrl + existing.lastSyncedAt = .now + } else { + context.insert(CachedEvent( + id: event.id, + time: event.time, + service: event.service, + domain: event.domain, + action: event.action, + value: event.value, + unit: event.unit, + url: event.url, + displayName: event.displayName, + hidden: event.hidden, + displayWithObject: event.displayWithObject, + displayValue: event.displayValue, + tagNames: CachedEvent.encodeTagNames(event.tags), + blocksCount: event.blocksCount, + actorTitle: event.actor?.title, + actorType: event.actor?.type, + actorMediaUrl: event.actor?.mediaUrl, + targetTitle: event.target?.title, + targetType: event.target?.type, + targetMediaUrl: event.target?.mediaUrl + )) + } + } + private func persist(_ summary: DaySummary) async throws { let context = ModelContext(container) let data = try JSONEncoder().encode(summary) @@ -118,6 +254,98 @@ final class TodayViewModel { try context.save() } + private func apply(summary: DaySummary) { + cached = summary + generateSummaryLine(for: summary) + } + + private func generateSummaryLine(for summary: DaySummary) { + summaryLineTask?.cancel() + + guard let context = summaryLineContext else { + briefingSummaryLine = nil + return + } + + let facts = FlintBriefingFacts(summary: summary) + let cacheKey = summaryLineCacheKey(for: summary, context: context) + if let cachedLine = defaults.string(forKey: cacheKey), !cachedLine.isEmpty { + briefingSummaryLine = cachedLine + return + } + + briefingSummaryLine = facts.fallbackSummaryLine(context: context) + + summaryLineTask = Task { + do { + let result = try await FlintGenerationService.generateTodaySummaryLine( + from: facts, + context: context + ) + guard !Task.isCancelled else { return } + let line = sanitizedSummaryLine(result.note.summary) + guard !line.isEmpty else { return } + briefingSummaryLine = line + if result.usedAppleIntelligence { + defaults.set(line, forKey: cacheKey) + } + } catch where error.isAPICancellation { + } catch { + SparkObservability.captureHandled(error) + } + } + } + + private var summaryLineContext: FlintBriefingFacts.SummaryLineContext? { + if Calendar.current.isDateInToday(date) { + return .daySoFar + } + if date < Calendar.current.startOfDay(for: .now) { + return .dayInReview + } + return nil + } + + private func summaryLineCacheKey( + for summary: DaySummary, + context: FlintBriefingFacts.SummaryLineContext + ) -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = (try? encoder.encode(summary)) ?? Data(summary.date.utf8) + let contextKey = switch context { + case .daySoFar: "soFar" + case .dayInReview: "review" + } + return "\(Self.summaryLineCachePrefix).\(Self.summaryLinePromptVersion).\(contextKey).\(summary.date).\(Self.stableHash(data))" + } + + private func sanitizedSummaryLine(_ text: String) -> String { + var line = text + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + + if line.hasPrefix("\""), line.hasSuffix("\""), line.count >= 2 { + line.removeFirst() + line.removeLast() + } + + if line.count > 160 { + let end = line.index(line.startIndex, offsetBy: 157) + line = String(line[.. String { + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in data { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(hash, radix: 16) + } + static func isoKey(for date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" diff --git a/SparkApp/SparkApp.entitlements b/SparkApp/SparkApp.entitlements index 913d363..e0c85a5 100644 --- a/SparkApp/SparkApp.entitlements +++ b/SparkApp/SparkApp.entitlements @@ -2,25 +2,29 @@ - aps-environment - development - com.apple.developer.associated-domains - - applinks:spark.cronx.co - - com.apple.developer.healthkit - - com.apple.developer.healthkit.access - - com.apple.developer.healthkit.background-delivery - - com.apple.security.application-groups - - group.co.cronx.spark - - keychain-access-groups - - $(AppIdentifierPrefix)co.cronx.spark - + aps-environment + $(APS_ENVIRONMENT) + com.apple.developer.associated-domains + + applinks:spark.cronx.co + + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + com.apple.developer.healthkit.background-delivery + + com.apple.developer.messages.critical-messaging + + com.apple.developer.usernotifications.time-sensitive + + com.apple.security.application-groups + + group.co.cronx.sparkapp + + keychain-access-groups + + $(AppIdentifierPrefix)co.cronx.sparkapp + diff --git a/Tests/SparkAppTests/SparkAppSmokeTests.swift b/Tests/SparkAppTests/SparkAppSmokeTests.swift index 91bc25f..03f1227 100644 --- a/Tests/SparkAppTests/SparkAppSmokeTests.swift +++ b/Tests/SparkAppTests/SparkAppSmokeTests.swift @@ -1,5 +1,7 @@ +import Foundation import Testing +@testable import Spark @testable import SparkKit @Suite("SparkApp smoke") @@ -8,4 +10,151 @@ struct SparkAppSmokeTests { #expect(APIEnvironment.production.name == "production") #expect(APIEnvironment.production.baseURL.host() == "spark.cronx.co") } + + @Test func metricBaselineStatusShowsNormalInsideBaseline() throws { + let status = try #require(MetricBaselineStatus.make( + event: event(value: "18", unit: "GBP"), + metric: metric(low: 16, high: 22) + )) + + #expect(status.state == .normal) + #expect(status.title == "Normal") + #expect(status.trailing == "£16-£22") + } + + @Test func metricBaselineStatusCapsDisplayedNormalRangeAtZero() throws { + let status = try #require(MetricBaselineStatus.make( + event: event(value: "2", unit: "GBP"), + metric: metric(low: -4, high: 8) + )) + + #expect(status.state == .normal) + #expect(status.title == "Normal") + #expect(status.trailing == "£0-£8") + } + + @Test func metricBaselineStatusShowsHighDeviationOutsideBaseline() throws { + let status = try #require(MetricBaselineStatus.make( + event: event(value: "23", unit: "GBP"), + metric: metric(low: 16, high: 22) + )) + + #expect(status.state == .high) + #expect(status.title == "Outside Normal Range") + #expect(status.trailing == "+5%") + } + + @Test func metricBaselineStatusShowsLowDeviationOutsideBaseline() throws { + let status = try #require(MetricBaselineStatus.make( + event: event(value: "14", unit: "GBP"), + metric: metric(low: 16, high: 22) + )) + + #expect(status.state == .low) + #expect(status.title == "Outside Normal Range") + #expect(status.trailing == "-13%") + } + + @Test func metricBaselineStatusHidesWhenBaselineIsMissing() { + let status = MetricBaselineStatus.make( + event: event(value: "18", unit: "GBP"), + metric: metric(low: nil, high: nil) + ) + + #expect(status == nil) + } + + @Test func metricBaselineStatusUsesMetricIdentifierNotEventUnit() throws { + let status = try #require(MetricBaselineStatus.make( + event: event(value: "1.4", unit: "m/s"), + metric: MetricDetail( + id: "apple_health.had_stair_speed_up.m/s", + title: "Stair Speed Up", + domain: "health", + unit: "m/s", + baseline: MetricDetail.Baseline(low: 1, high: 2) + ), + metricIdentifier: "apple_health.had_stair_speed_up" + )) + + #expect(status.metricIdentifier == "apple_health.had_stair_speed_up") + #expect(!status.metricIdentifier.contains("m/s")) + } + + @Test func metricAnomalyRowShowsHighValueAndMatchingEvent() throws { + let date = try #require(Self.dayFormatter.date(from: "2026-05-04")) + let event = event(value: "23", unit: "GBP", time: date) + let row = MetricAnomalyRowModel.make( + anomaly: MetricDetail.AnomalyPoint(id: "a1", date: date, severity: "high", value: 23), + detail: metric(low: 16, high: 22, series: []), + recentEvents: [event] + ) + + #expect(row.title == "Above Normal Range") + #expect(row.trailing == "23 GBP") + #expect(row.state == .high) + #expect(row.eventId == "evt_1") + } + + @Test func metricAnomalyRowShowsLowValueAgainstZeroCappedBaseline() throws { + let date = try #require(Self.dayFormatter.date(from: "2026-05-04")) + let row = MetricAnomalyRowModel.make( + anomaly: MetricDetail.AnomalyPoint(id: "a1", date: date, severity: "high", value: -1), + detail: metric(low: -4, high: 22, series: []), + recentEvents: [] + ) + + #expect(row.title == "Below Normal Range") + #expect(row.trailing == "-1 GBP") + #expect(row.state == .low) + #expect(row.eventId == nil) + } + + @Test func metricAnomalyRowFallsBackWithoutBaselineOrValue() throws { + let date = try #require(Self.dayFormatter.date(from: "2026-05-04")) + let row = MetricAnomalyRowModel.make( + anomaly: MetricDetail.AnomalyPoint(id: "a1", date: date, severity: "high", value: nil), + detail: metric(low: nil, high: nil, series: []), + recentEvents: [] + ) + + #expect(row.title == "Outside Normal Range") + #expect(row.trailing == nil) + #expect(row.state == .unknown) + #expect(row.eventId == nil) + } + + private func event(value: String, unit: String?, time: Date? = nil) -> Event { + Event( + id: "evt_1", + time: time, + service: "gocardless", + domain: "money", + action: "payment_to", + value: value, + unit: unit + ) + } + + private func metric(low: Double?, high: Double?, series: [MetricDetail.Point] = []) -> MetricDetail { + MetricDetail( + id: "gocardless.payment_to", + title: "Payment To", + domain: "money", + unit: "GBP", + baseline: { + guard let low, let high else { return nil } + return MetricDetail.Baseline(low: low, high: high) + }(), + series: series + ) + } + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter + }() } diff --git a/Tuist.swift b/Tuist.swift index bb641d4..2a9a0d1 100644 --- a/Tuist.swift +++ b/Tuist.swift @@ -2,7 +2,7 @@ import ProjectDescription let tuist = Tuist( project: .tuist( - compatibleXcodeVersions: ["26.0", "26.0.1", "26.1", "26.2", "26.4", "26.4.1"], + compatibleXcodeVersions: .upToNextMajor("26.0"), swiftVersion: "6.0" ) ) diff --git a/Watch/SparkWatch/SparkWatch.entitlements b/Watch/SparkWatch/SparkWatch.entitlements index 868ed52..5ffae07 100644 --- a/Watch/SparkWatch/SparkWatch.entitlements +++ b/Watch/SparkWatch/SparkWatch.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.co.cronx.spark + group.co.cronx.sparkapp keychain-access-groups - $(AppIdentifierPrefix)co.cronx.spark + $(AppIdentifierPrefix)co.cronx.sparkapp diff --git a/Watch/SparkWatchWidgets/Sources/SparkWatchWidgetsBundle.swift b/Watch/SparkWatchWidgets/Sources/SparkWatchWidgetsBundle.swift index e00cd96..260b21b 100644 --- a/Watch/SparkWatchWidgets/Sources/SparkWatchWidgetsBundle.swift +++ b/Watch/SparkWatchWidgets/Sources/SparkWatchWidgetsBundle.swift @@ -10,7 +10,7 @@ struct SparkWatchWidgetsBundle: WidgetBundle { struct PlaceholderWatchWidget: Widget { var body: some WidgetConfiguration { - StaticConfiguration(kind: "co.cronx.spark.watch.widgets.placeholder", provider: Provider()) { _ in + StaticConfiguration(kind: "co.cronx.sparkapp.watch.widgets.placeholder", provider: Provider()) { _ in Text("Spark") .containerBackground(for: .widget) { Color.black } } diff --git a/Watch/SparkWatchWidgets/SparkWatchWidgets.entitlements b/Watch/SparkWatchWidgets/SparkWatchWidgets.entitlements index 868ed52..5ffae07 100644 --- a/Watch/SparkWatchWidgets/SparkWatchWidgets.entitlements +++ b/Watch/SparkWatchWidgets/SparkWatchWidgets.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.co.cronx.spark + group.co.cronx.sparkapp keychain-access-groups - $(AppIdentifierPrefix)co.cronx.spark + $(AppIdentifierPrefix)co.cronx.sparkapp