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[^>]*>.*?\1>"#, with: " ")
+ text = text.replacingHTMLMatches(pattern: #"(?s)"#, with: " ")
+ text = text.replacingHTMLMatches(pattern: #"(?i)
"#, with: " ")
+ text = text.replacingHTMLMatches(pattern: #"(?i)?(p|div|li|tr|section|article|h[1-6])\b[^>]*>"#, 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