diff --git a/.gitignore b/.gitignore index fba34b0..384573c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ Package.resolved .env .env.local docs/plans +docs/schema # macOS .DS_Store diff --git a/Extensions/SparkControls/Sources/SparkControlsBundle.swift b/Extensions/SparkControls/Sources/SparkControlsBundle.swift index abc35b3..7fa788e 100644 --- a/Extensions/SparkControls/Sources/SparkControlsBundle.swift +++ b/Extensions/SparkControls/Sources/SparkControlsBundle.swift @@ -5,24 +5,74 @@ import WidgetKit @main struct SparkControlsBundle: WidgetBundle { var body: some Widget { - PlaceholderControl() + QuickCheckInControl() + OpenTodayControl() + FocusDomainControl() } } -/// Phase 1 stub. A real control ships in Phase 3. -struct PlaceholderControl: ControlWidget { +// MARK: - Quick Check-In + +struct QuickCheckInControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "co.cronx.spark.controls.checkin") { + ControlWidgetButton(action: QuickCheckInAction()) { + Label("Check In", systemImage: "plus.circle.fill") + } + } + .displayName("Quick Check-In") + .description("Log a mood check-in without opening Spark.") + } +} + +struct QuickCheckInAction: AppIntent { + static let title: LocalizedStringResource = "Quick Check-In" + static let openAppWhenRun = true + + func perform() async throws -> some IntentResult { + // Phase 3 Week 3: drive LogCheckInIntent from SparkIntelligence + .result() + } +} + +// MARK: - Open Today + +struct OpenTodayControl: ControlWidget { var body: some ControlWidgetConfiguration { - StaticControlConfiguration(kind: "co.cronx.spark.controls.placeholder") { - ControlWidgetButton(action: NoopIntent()) { - Label("Spark", systemImage: "sparkles") + StaticControlConfiguration(kind: "co.cronx.spark.controls.open-today") { + ControlWidgetButton(action: OpenTodayAction()) { + Label("Open Spark", systemImage: "sparkles") } } - .displayName("Spark") - .description("Placeholder control โ€” real surface lands in Phase 3.") + .displayName("Open Spark") + .description("Open the Spark Today view from Control Center.") } } -struct NoopIntent: AppIntent { - static let title: LocalizedStringResource = "No-op" +struct OpenTodayAction: AppIntent { + static let title: LocalizedStringResource = "Open Today" + static let openAppWhenRun = true + + func perform() async throws -> some IntentResult { .result() } +} + +// MARK: - Focus Domain Toggle + +struct FocusDomainControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "co.cronx.spark.controls.focus-domain") { + ControlWidgetButton(action: FocusDomainAction()) { + Label("Focus", systemImage: "scope") + } + } + .displayName("Spark Focus") + .description("Toggle the active focus domain filter in Spark.") + } +} + +struct FocusDomainAction: AppIntent { + static let title: LocalizedStringResource = "Toggle Focus Domain" + static let openAppWhenRun = true + func perform() async throws -> some IntentResult { .result() } } diff --git a/Extensions/SparkIntents/Sources/SparkIntentsBundle.swift b/Extensions/SparkIntents/Sources/SparkIntentsBundle.swift index 2ac5552..c516693 100644 --- a/Extensions/SparkIntents/Sources/SparkIntentsBundle.swift +++ b/Extensions/SparkIntents/Sources/SparkIntentsBundle.swift @@ -1,22 +1,4 @@ -import AppIntents - -/// Phase 1 stub. Real intents land in Phase 3. -struct PingSparkIntent: AppIntent { - static let title: LocalizedStringResource = "Ping Spark" - static let description = IntentDescription("Placeholder intent to prove the target compiles.") - - func perform() async throws -> some IntentResult { - .result() - } -} - -struct SparkAppShortcuts: AppShortcutsProvider { - static var appShortcuts: [AppShortcut] { - AppShortcut( - intent: PingSparkIntent(), - phrases: ["Ping \(.applicationName)"], - shortTitle: "Ping Spark", - systemImageName: "sparkles" - ) - } -} +// Importing SparkIntelligence embeds all AppIntents (read + action) and +// SparkShortcuts into this extension's binary, making them discoverable +// by the system for Siri and Shortcuts. +@_exported import SparkIntelligence diff --git a/Extensions/SparkLiveActivities/Sources/DailyActivityLiveActivityViews.swift b/Extensions/SparkLiveActivities/Sources/DailyActivityLiveActivityViews.swift new file mode 100644 index 0000000..b04919f --- /dev/null +++ b/Extensions/SparkLiveActivities/Sources/DailyActivityLiveActivityViews.swift @@ -0,0 +1,98 @@ +import ActivityKit +import SparkKit +import SwiftUI +import WidgetKit + +// MARK: - Lock Screen layout + +struct RingsLockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack(spacing: 20) { + // Nested rings (move โ†’ exercise โ†’ stand) + ZStack { + ring(progress: context.state.moveProgress, color: .red, size: 70, lineWidth: 8) + ring(progress: context.state.exerciseProgress, color: .green, size: 52, lineWidth: 7) + ring(progress: context.state.standProgress, color: .cyan, size: 36, lineWidth: 6) + } + + VStack(alignment: .leading, spacing: 6) { + metricRow(icon: "figure.walk", color: .green, label: "\(context.state.stepsDisplay) steps") + metricRow(icon: "flame.fill", color: .red, label: moveLabel) + metricRow(icon: "bolt.fill", color: .cyan, label: standLabel) + } + + Spacer() + } + .padding(16) + .containerBackground(for: .widget) { + LinearGradient( + colors: [Color.green.opacity(0.2), Color.teal.opacity(0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } + + private var moveLabel: String { + let pct = Int(context.state.moveProgress * 100) + return "Move \(pct)%" + } + + private var standLabel: String { + let pct = Int(context.state.standProgress * 100) + return "Stand \(pct)%" + } + + private func ring(progress: Double, color: Color, size: CGFloat, lineWidth: CGFloat) -> some View { + ZStack { + Circle() + .stroke(color.opacity(0.2), lineWidth: lineWidth) + Circle() + .trim(from: 0, to: min(1, max(0, progress))) + .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: size, height: size) + } + + private func metricRow(icon: String, color: Color, label: String) -> some View { + Label(label, systemImage: icon) + .font(.caption.weight(.medium)) + .foregroundStyle(color) + .lineLimit(1) + } +} + +// MARK: - Dynamic Island compact views + +struct RingsIslandCompactLeading: View { + let state: DailyActivityAttributes.DailyContentState + var body: some View { + HStack(spacing: 2) { + miniRing(progress: state.moveProgress, color: .red) + miniRing(progress: state.exerciseProgress, color: .green) + miniRing(progress: state.standProgress, color: .cyan) + } + } + private func miniRing(progress: Double, color: Color) -> some View { + ZStack { + Circle().stroke(color.opacity(0.3), lineWidth: 2.5) + Circle() + .trim(from: 0, to: min(1, max(0, progress))) + .stroke(color, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: 14, height: 14) + } +} + +struct RingsIslandCompactTrailing: View { + let state: DailyActivityAttributes.DailyContentState + var body: some View { + Text(state.stepsDisplay) + .font(.caption.weight(.bold).monospacedDigit()) + .foregroundStyle(.green) + } +} diff --git a/Extensions/SparkLiveActivities/Sources/SleepLiveActivityViews.swift b/Extensions/SparkLiveActivities/Sources/SleepLiveActivityViews.swift new file mode 100644 index 0000000..2cb7ce0 --- /dev/null +++ b/Extensions/SparkLiveActivities/Sources/SleepLiveActivityViews.swift @@ -0,0 +1,97 @@ +import ActivityKit +import SparkKit +import SwiftUI +import WidgetKit + +// MARK: - Lock Screen layout + +struct SleepLockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack(spacing: 16) { + Text(phaseEmoji) + .font(.system(size: 36)) + + VStack(alignment: .leading, spacing: 4) { + Text(context.state.phaseLabel) + .font(.headline.weight(.semibold)) + + if let dur = context.state.durationDisplay { + Text(dur) + .font(.subheadline) + .foregroundStyle(.secondary) + } else if let wake = context.attributes.targetWakeTime { + Text("Wake at \(timeString(wake))") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if let score = context.state.sleepScore { + Text("Score: \(score)/100") + .font(.caption.weight(.medium)) + .foregroundStyle(.indigo) + } + } + + Spacer() + } + .padding(16) + .containerBackground(for: .widget) { + LinearGradient( + colors: [Color.indigo.opacity(0.3), Color.purple.opacity(0.15)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } + + private var phaseEmoji: String { + switch context.state.phase { + case .preparing: return "๐ŸŒ™" + case .sleeping: return "๐Ÿ˜ด" + case .wakingUp: return "โ˜€๏ธ" + case .resolved: return "โœ…" + } + } + + private func timeString(_ date: Date) -> String { + let f = DateFormatter() + f.timeStyle = .short + f.dateStyle = .none + return f.string(from: date) + } +} + +// MARK: - Dynamic Island compact views + +struct SleepIslandCompactLeading: View { + let state: SleepActivityAttributes.SleepContentState + var body: some View { + Text(emoji(state.phase)) + .font(.caption) + } + private func emoji(_ phase: SleepActivityAttributes.SleepContentState.Phase) -> String { + switch phase { + case .preparing: return "๐ŸŒ™" + case .sleeping: return "๐Ÿ˜ด" + case .wakingUp: return "โ˜€๏ธ" + case .resolved: return "โœ…" + } + } +} + +struct SleepIslandCompactTrailing: View { + let state: SleepActivityAttributes.SleepContentState + var body: some View { + if let score = state.sleepScore { + Text("\(score)") + .font(.caption.weight(.bold).monospacedDigit()) + .foregroundStyle(.indigo) + } else if let dur = state.durationDisplay { + Text(dur) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + } +} diff --git a/Extensions/SparkLiveActivities/Sources/SparkLiveActivitiesBundle.swift b/Extensions/SparkLiveActivities/Sources/SparkLiveActivitiesBundle.swift index 5280407..dd2851d 100644 --- a/Extensions/SparkLiveActivities/Sources/SparkLiveActivitiesBundle.swift +++ b/Extensions/SparkLiveActivities/Sources/SparkLiveActivitiesBundle.swift @@ -1,42 +1,102 @@ import ActivityKit +import SparkKit import SwiftUI import WidgetKit @main struct SparkLiveActivitiesBundle: WidgetBundle { var body: some Widget { - PlaceholderLiveActivity() + SleepLiveActivity() + DailyActivityLiveActivity() } } -/// Phase 1 stub. The real Live Activity lives in Phase 3. -public struct PlaceholderActivityAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { - public var status: String - public init(status: String) { self.status = status } - } +// MARK: - Sleep Live Activity - public var title: String - public init(title: String) { self.title = title } +struct SleepLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: SleepActivityAttributes.self) { context in + SleepLockScreenView(context: context) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label { + Text(context.state.phaseLabel) + .font(.caption.weight(.semibold)) + .lineLimit(1) + } icon: { + SleepIslandCompactLeading(state: context.state) + } + } + DynamicIslandExpandedRegion(.trailing) { + SleepIslandCompactTrailing(state: context.state) + } + DynamicIslandExpandedRegion(.bottom) { + if let dur = context.state.durationDisplay { + Text(dur) + .font(.caption) + .foregroundStyle(.secondary) + } + } + DynamicIslandExpandedRegion(.center) { + EmptyView() + } + } compactLeading: { + SleepIslandCompactLeading(state: context.state) + } compactTrailing: { + SleepIslandCompactTrailing(state: context.state) + } minimal: { + SleepIslandCompactLeading(state: context.state) + } + } + } } -struct PlaceholderLiveActivity: Widget { +// MARK: - Daily Activity Rings Live Activity + +struct DailyActivityLiveActivity: Widget { var body: some WidgetConfiguration { - ActivityConfiguration(for: PlaceholderActivityAttributes.self) { _ in - Text("Spark") - .containerBackground(for: .widget) { Color(.systemBackground) } - } dynamicIsland: { _ in + ActivityConfiguration(for: DailyActivityAttributes.self) { context in + RingsLockScreenView(context: context) + } dynamicIsland: { context in DynamicIsland { - DynamicIslandExpandedRegion(.leading) { Text("Spark") } - DynamicIslandExpandedRegion(.trailing) { EmptyView() } - DynamicIslandExpandedRegion(.center) { EmptyView() } - DynamicIslandExpandedRegion(.bottom) { EmptyView() } + DynamicIslandExpandedRegion(.leading) { + RingsIslandCompactLeading(state: context.state) + } + DynamicIslandExpandedRegion(.trailing) { + RingsIslandCompactTrailing(state: context.state) + } + DynamicIslandExpandedRegion(.bottom) { + HStack(spacing: 12) { + Label("Move \(Int(context.state.moveProgress * 100))%", systemImage: "flame.fill") + .foregroundStyle(.red) + Label("Exercise \(Int(context.state.exerciseProgress * 100))%", systemImage: "bolt.fill") + .foregroundStyle(.green) + Label("Stand \(Int(context.state.standProgress * 100))%", systemImage: "figure.stand") + .foregroundStyle(.cyan) + } + .font(.caption2) + } + DynamicIslandExpandedRegion(.center) { + EmptyView() + } } compactLeading: { - Text("โœฆ") + RingsIslandCompactLeading(state: context.state) } compactTrailing: { - EmptyView() + RingsIslandCompactTrailing(state: context.state) } minimal: { - Text("โœฆ") + // Show the most-progressed ring as the minimal indicator + let p = max(context.state.moveProgress, + context.state.exerciseProgress, + context.state.standProgress) + ZStack { + Circle().stroke(Color.green.opacity(0.3), lineWidth: 3) + Circle() + .trim(from: 0, to: p) + .stroke(Color.green, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: 14, height: 14) } } } diff --git a/Extensions/SparkNotificationService/Sources/NotificationService.swift b/Extensions/SparkNotificationService/Sources/NotificationService.swift index e2dcf50..7608b3e 100644 --- a/Extensions/SparkNotificationService/Sources/NotificationService.swift +++ b/Extensions/SparkNotificationService/Sources/NotificationService.swift @@ -1,7 +1,6 @@ -import UserNotifications +@preconcurrency import UserNotifications -/// Phase 1 stub. Real rich-notification mutation lands in Phase 2. -final class NotificationService: UNNotificationServiceExtension { +final class NotificationService: UNNotificationServiceExtension, @unchecked Sendable { private var handler: ((UNNotificationContent) -> Void)? private var bestAttempt: UNMutableNotificationContent? @@ -10,11 +9,33 @@ final class NotificationService: UNNotificationServiceExtension { withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { self.handler = contentHandler - bestAttempt = request.content.mutableCopy() as? UNMutableNotificationContent - if let bestAttempt { - contentHandler(bestAttempt) - } else { + guard let mutable = request.content.mutableCopy() as? UNMutableNotificationContent else { contentHandler(request.content) + return + } + bestAttempt = mutable + + if let domain = request.content.userInfo["spark.domain"] as? String { + mutable.threadIdentifier = domain + } + + guard let urlString = request.content.userInfo["spark.media_url"] as? String, + let mediaURL = URL(string: urlString) + else { + contentHandler(mutable) + return + } + + // Access via self (which is @unchecked Sendable) to avoid Sendable + // violations on local non-Sendable captures. + let notificationID = request.identifier + Task { [self] in + if let attachment = await self.downloadAttachment(from: mediaURL, notificationID: notificationID) { + self.bestAttempt?.attachments = [attachment] + } + if let h = self.handler, let b = self.bestAttempt { + h(b) + } } } @@ -23,4 +44,33 @@ final class NotificationService: UNNotificationServiceExtension { handler(bestAttempt) } } + + // MARK: - Attachment download + + private func downloadAttachment(from url: URL, notificationID: String) async -> UNNotificationAttachment? { + let cacheDir = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: "group.co.cronx.spark")? + .appendingPathComponent("NotificationMedia", isDirectory: true) + ?? FileManager.default.temporaryDirectory + + try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + + let ext = url.pathExtension.isEmpty ? "jpg" : url.pathExtension + let fileName = notificationID.replacingOccurrences(of: "/", with: "_") + "." + ext + let localURL = cacheDir.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: localURL.path) { + return try? UNNotificationAttachment(identifier: fileName, url: localURL) + } + + do { + let (tempURL, _) = try await URLSession.shared.download(from: url) + if !FileManager.default.fileExists(atPath: localURL.path) { + try FileManager.default.moveItem(at: tempURL, to: localURL) + } + return try UNNotificationAttachment(identifier: fileName, url: localURL) + } catch { + return nil + } + } } diff --git a/Extensions/SparkShare/Sources/ShareViewController.swift b/Extensions/SparkShare/Sources/ShareViewController.swift index a517b66..ab16977 100644 --- a/Extensions/SparkShare/Sources/ShareViewController.swift +++ b/Extensions/SparkShare/Sources/ShareViewController.swift @@ -1,23 +1,177 @@ +import SparkKit +import UniformTypeIdentifiers import UIKit -/// Phase 1 stub share extension. The real sharing flow is Phase 3. +/// Share extension โ€” handles URL, image, and text items from the share sheet. @objc(ShareViewController) final class ShareViewController: UIViewController { + private let tokenStore = KeychainTokenStore() + override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = .systemBackground + handleSharedItems() + } + + // MARK: - Item routing + + private func handleSharedItems() { + guard let items = extensionContext?.inputItems as? [NSExtensionItem] else { + complete() + return + } + + let providers = items.flatMap { $0.attachments ?? [] } + + if let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) }) { + provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] item, _ in + // Cast to Sendable type before crossing actor boundary. + let url: URL? = item as? URL + Task { @MainActor [weak self] in + if let url { self?.shareURL(url) } else { self?.complete() } + } + } + return + } + + if let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) }) { + provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] item, _ in + let fileURL: URL? = item as? URL + // UIImage โ†’ convert to Data (Sendable) before crossing boundary. + let imageData: Data? = (item as? UIImage)?.jpegData(compressionQuality: 0.8) + Task { @MainActor [weak self] in + if let fileURL { self?.shareImage(at: fileURL) } + else if let imageData { self?.shareImageData(imageData) } + else { self?.complete() } + } + } + return + } + + if let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) }) { + provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] item, _ in + let text: String? = item as? String + Task { @MainActor [weak self] in + if let text { self?.shareText(text) } else { self?.complete() } + } + } + return + } + + complete() + } + + // MARK: - URL sharing (bookmark) + + private func shareURL(_ url: URL) { + Task { + do { + let client = APIClient(tokenStore: tokenStore, etagCache: ETagCache()) + let body = try? JSONEncoder().encode(["url": url.absoluteString]) + let endpoint = Endpoint( + method: .post, path: "/bookmarks", + body: body, contentType: "application/json" + ) + _ = try await client.request(endpoint) + await MainActor.run { self.showToast("Bookmarked!") } + } catch { + await MainActor.run { self.showToast("Couldn't save bookmark.") } + } + complete() + } + } + + // MARK: - Image sharing + + private func shareImage(at fileURL: URL) { + scheduleBackgroundImageUpload(fileURL: fileURL) + showToast("Photo saved to Spark.") + complete() + } + + private func shareImageData(_ data: Data) { + let dir = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: "group.co.cronx.spark")? + .appendingPathComponent("ShareUploads", isDirectory: true) + ?? FileManager.default.temporaryDirectory + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let dest = dir.appendingPathComponent("\(UUID().uuidString).jpg") + if (try? data.write(to: dest)) != nil { + scheduleBackgroundImageUpload(fileURL: dest) + } + showToast("Photo saved to Spark.") + complete() + } + + private func scheduleBackgroundImageUpload(fileURL: URL) { + guard let token = syncAccessToken() else { return } + let uploadURL = APIEnvironment.current().baseURL.appendingPathComponent("check-ins/media") + var request = URLRequest(url: uploadURL) + 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" + URLSession(configuration: config).uploadTask(with: request, fromFile: fileURL).resume() + } + + // MARK: - Text sharing (note) + + private func shareText(_ text: String) { + Task { + do { + let client = APIClient(tokenStore: tokenStore, etagCache: ETagCache()) + let body = try? JSONEncoder().encode(["content": text, "type": "note"]) + let endpoint = Endpoint( + method: .post, path: "/notes", + body: body, contentType: "application/json" + ) + _ = try await client.request(endpoint) + await MainActor.run { self.showToast("Note saved to Spark.") } + } catch { + await MainActor.run { self.showToast("Couldn't save note.") } + } + complete() + } + } + + // MARK: - Helpers + + private func syncAccessToken() -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: "co.cronx.spark.accessToken", + kSecAttrAccessGroup: "$(AppIdentifierPrefix)co.cronx.spark", + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private func showToast(_ message: String) { let label = UILabel() - label.text = "Spark sharing coming soon" + label.text = message label.textAlignment = .center + label.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.9) + label.layer.cornerRadius = 12 + label.layer.masksToBounds = true + label.font = .systemFont(ofSize: 15, weight: .medium) label.translatesAutoresizingMaskIntoConstraints = false view.addSubview(label) NSLayoutConstraint.activate([ label.centerXAnchor.constraint(equalTo: view.centerXAnchor), label.centerYAnchor.constraint(equalTo: view.centerYAnchor), + label.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, multiplier: 0.8), + label.heightAnchor.constraint(equalToConstant: 44), ]) } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) + private func complete() { extensionContext?.completeRequest(returningItems: nil) } } + +private struct EmptyShareResponse: Decodable, Sendable {} diff --git a/Extensions/SparkWidgets/Sources/LockScreenWidgets.swift b/Extensions/SparkWidgets/Sources/LockScreenWidgets.swift new file mode 100644 index 0000000..4c9d943 --- /dev/null +++ b/Extensions/SparkWidgets/Sources/LockScreenWidgets.swift @@ -0,0 +1,157 @@ +import SwiftUI +import WidgetKit + +// MARK: - Circular (sleep ring + steps ring) + +struct SleepCircularWidget: Widget { + let kind = "co.cronx.spark.widgets.sleep-circular" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in + SleepCircularView(entry: entry) + } + .configurationDisplayName("Sleep Ring") + .description("Sleep score progress ring on the Lock Screen.") + .supportedFamilies([.accessoryCircular]) + } +} + +struct SleepCircularView: View { + let entry: SparkWidgetEntry + + var body: some View { + let progress = Double(entry.snapshot.sleepScore ?? 0) / 100.0 + ZStack { + RingView( + progress: progress, + lineWidth: 5, + gradient: AngularGradient( + colors: [.indigo, .purple], + center: .center, + startAngle: .degrees(-90), + endAngle: .degrees(270) + ) + ) + if let score = entry.snapshot.sleepScore { + Text("\(score)") + .font(.system(size: 14, weight: .bold, design: .rounded)) + } else { + Image(systemName: "moon.fill") + .font(.caption) + } + } + .widgetURL(URL(string: "https://spark.cronx.co/metrics/sleep.score")) + } +} + +struct StepsCircularWidget: Widget { + let kind = "co.cronx.spark.widgets.steps-circular" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in + StepsCircularView(entry: entry) + } + .configurationDisplayName("Steps Ring") + .description("Step count progress ring on the Lock Screen.") + .supportedFamilies([.accessoryCircular]) + } +} + +struct StepsCircularView: View { + let entry: SparkWidgetEntry + + var body: some View { + let snap = entry.snapshot + let progress = Double(snap.steps ?? 0) / Double(snap.stepsGoal) + ZStack { + RingView( + progress: progress, + lineWidth: 5, + gradient: AngularGradient( + colors: [.green, .mint], + center: .center, + startAngle: .degrees(-90), + endAngle: .degrees(270) + ) + ) + Text(snap.stepsDisplay) + .font(.system(size: 10, weight: .bold, design: .rounded)) + } + .widgetURL(URL(string: "https://spark.cronx.co/metrics/health.steps")) + } +} + +// MARK: - Rectangular (top metric) + +struct TopMetricRectangularWidget: Widget { + let kind = "co.cronx.spark.widgets.top-metric-rect" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in + TopMetricRectangularView(entry: entry) + } + .configurationDisplayName("Top Metric") + .description("Your most important metric on the Lock Screen.") + .supportedFamilies([.accessoryRectangular]) + } +} + +struct TopMetricRectangularView: View { + let entry: SparkWidgetEntry + + var body: some View { + let snap = entry.snapshot + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Label("Sleep", systemImage: "moon.fill") + .font(.caption2) + .foregroundStyle(.secondary) + Text(snap.sleepScore.map { "\($0)" } ?? "โ€”") + .font(.system(size: 18, weight: .bold, design: .rounded)) + if let dur = snap.sleepDurationDisplay { + Text(dur) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Label("Steps", systemImage: "figure.walk") + .font(.caption2) + .foregroundStyle(.secondary) + Text(snap.stepsDisplay) + .font(.system(size: 18, weight: .bold, design: .rounded)) + } + } + .widgetURL(URL(string: "https://spark.cronx.co/today")) + } +} + +// MARK: - Inline (next event) + +struct NextEventInlineWidget: Widget { + let kind = "co.cronx.spark.widgets.next-event-inline" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in + NextEventInlineView(entry: entry) + } + .configurationDisplayName("Next Event") + .description("Your next calendar event as a Lock Screen inline widget.") + .supportedFamilies([.accessoryInline]) + } +} + +struct NextEventInlineView: View { + let entry: SparkWidgetEntry + + 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") + } + } +} diff --git a/Extensions/SparkWidgets/Sources/NextEventWidget.swift b/Extensions/SparkWidgets/Sources/NextEventWidget.swift new file mode 100644 index 0000000..05726d4 --- /dev/null +++ b/Extensions/SparkWidgets/Sources/NextEventWidget.swift @@ -0,0 +1,69 @@ +import SwiftUI +import WidgetKit + +struct NextEventWidget: Widget { + let kind = "co.cronx.spark.widgets.nextevent" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in + NextEventWidgetView(entry: entry) + } + .configurationDisplayName("Next Event") + .description("Your next calendar event.") + .supportedFamilies([.systemSmall]) + .contentMarginsDisabled() + } +} + +struct NextEventWidgetView: View { + let entry: SparkWidgetEntry + + var body: some View { + let snap = entry.snapshot + ZStack { + containerBG + VStack(alignment: .leading, spacing: 4) { + Label("Up next", systemImage: "calendar") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + + Spacer() + + if let title = snap.nextEventTitle { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 15, weight: .semibold)) + .lineLimit(3) + .minimumScaleFactor(0.8) + + if let start = snap.nextEventStart { + Text(start) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + if let location = snap.nextEventLocation { + Label(location, systemImage: "location.fill") + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + } else { + Text("No upcoming events") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(14) + } + .widgetURL(URL(string: "https://spark.cronx.co/today")) + } + + private var containerBG: some View { + ContainerRelativeShape() + .fill(.blue.opacity(0.08)) + .containerBackground(for: .widget) { Color(.systemBackground) } + } +} diff --git a/Extensions/SparkWidgets/Sources/RingView.swift b/Extensions/SparkWidgets/Sources/RingView.swift new file mode 100644 index 0000000..a0a6ba0 --- /dev/null +++ b/Extensions/SparkWidgets/Sources/RingView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +/// Circular progress ring reused across all widget families. +struct RingView: View { + let progress: Double + let lineWidth: CGFloat + let gradient: AngularGradient + var backgroundColor: Color = Color.secondary.opacity(0.2) + + var body: some View { + ZStack { + Circle() + .stroke(backgroundColor, lineWidth: lineWidth) + Circle() + .trim(from: 0, to: min(1, max(0, progress))) + .stroke(gradient, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + } +} + +extension RingView { + static func sleep(progress: Double, size: CGFloat = 48) -> some View { + RingView( + progress: progress, + lineWidth: size * 0.12, + gradient: AngularGradient( + colors: [.indigo, .purple, .indigo], + center: .center, + startAngle: .degrees(-90), + endAngle: .degrees(270) + ) + ) + .frame(width: size, height: size) + } + + static func steps(progress: Double, size: CGFloat = 48) -> some View { + RingView( + progress: progress, + lineWidth: size * 0.12, + gradient: AngularGradient( + colors: [.green, .mint, .green], + center: .center, + startAngle: .degrees(-90), + endAngle: .degrees(270) + ) + ) + .frame(width: size, height: size) + } + + static func move(progress: Double, size: CGFloat = 40) -> some View { + RingView( + progress: progress, + lineWidth: size * 0.14, + gradient: AngularGradient( + colors: [.red, .orange, .red], + center: .center, + startAngle: .degrees(-90), + endAngle: .degrees(270) + ) + ) + .frame(width: size, height: size) + } + + static func exercise(progress: Double, size: CGFloat = 30) -> some View { + RingView( + progress: progress, + lineWidth: size * 0.14, + gradient: AngularGradient( + colors: [.green, .mint, .green], + center: .center, + startAngle: .degrees(-90), + endAngle: .degrees(270) + ) + ) + .frame(width: size, height: size) + } + + static func stand(progress: Double, size: CGFloat = 20) -> some View { + RingView( + progress: progress, + lineWidth: size * 0.14, + gradient: AngularGradient( + colors: [.cyan, .blue, .cyan], + center: .center, + startAngle: .degrees(-90), + endAngle: .degrees(270) + ) + ) + .frame(width: size, height: size) + } +} diff --git a/Extensions/SparkWidgets/Sources/SleepScoreWidget.swift b/Extensions/SparkWidgets/Sources/SleepScoreWidget.swift new file mode 100644 index 0000000..52073f5 --- /dev/null +++ b/Extensions/SparkWidgets/Sources/SleepScoreWidget.swift @@ -0,0 +1,77 @@ +import SwiftUI +import WidgetKit + +struct SleepScoreWidget: Widget { + let kind = "co.cronx.spark.widgets.sleep" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in + SleepScoreWidgetView(entry: entry) + } + .configurationDisplayName("Sleep Score") + .description("Today's sleep score and duration.") + .supportedFamilies([.systemSmall]) + .contentMarginsDisabled() + } +} + +struct SleepScoreWidgetView: View { + let entry: SparkWidgetEntry + + var body: some View { + let snap = entry.snapshot + ZStack { + containerBG + VStack(alignment: .leading, spacing: 4) { + Label("Sleep", systemImage: "moon.fill") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + + Spacer() + + HStack(alignment: .center, spacing: 10) { + RingView.sleep( + progress: sleepProgress(snap), + size: 52 + ) + .overlay { + if let score = snap.sleepScore { + Text("\(score)") + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + } + } + VStack(alignment: .leading, spacing: 2) { + if let score = snap.sleepScore { + Text("\(score)") + .font(.system(size: 28, weight: .bold, design: .rounded)) + } else { + Text("โ€”") + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundStyle(.secondary) + } + if let dur = snap.sleepDurationDisplay { + Text(dur) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + Spacer() + } + .padding(14) + } + .widgetURL(URL(string: "https://spark.cronx.co/metrics/sleep.score")) + } + + private var containerBG: some View { + ContainerRelativeShape() + .fill(.indigo.opacity(0.12)) + .containerBackground(for: .widget) { Color(.systemBackground) } + } + + private func sleepProgress(_ snap: WidgetDataSnapshot) -> Double { + guard let score = snap.sleepScore else { return 0 } + return Double(score) / 100.0 + } +} diff --git a/Extensions/SparkWidgets/Sources/SparkTimelineProvider.swift b/Extensions/SparkWidgets/Sources/SparkTimelineProvider.swift new file mode 100644 index 0000000..a7c8821 --- /dev/null +++ b/Extensions/SparkWidgets/Sources/SparkTimelineProvider.swift @@ -0,0 +1,35 @@ +import Foundation +import WidgetKit + +/// TimelineEntry carrying a fully decoded today-snapshot. All widget families +/// share this entry type โ€” each view renders whichever fields it needs. +struct SparkWidgetEntry: TimelineEntry { + let date: Date + let snapshot: WidgetDataSnapshot +} + +/// Base TimelineProvider shared by all Spark widgets. Reads cached data from +/// SwiftData (App Group) โ€” never makes network calls. +struct SparkTimelineProvider: TimelineProvider { + func placeholder(in _: Context) -> SparkWidgetEntry { + SparkWidgetEntry(date: .now, snapshot: .placeholder) + } + + func getSnapshot(in _: Context, completion: @escaping @Sendable (SparkWidgetEntry) -> Void) { + Task.detached { + let snapshot = await WidgetDataSnapshot.fetchToday() + completion(SparkWidgetEntry(date: .now, snapshot: snapshot)) + } + } + + func getTimeline(in _: Context, completion: @escaping @Sendable (Timeline) -> Void) { + Task.detached { + let snapshot = await WidgetDataSnapshot.fetchToday() + let entry = SparkWidgetEntry(date: .now, snapshot: snapshot) + // Reload every 15 minutes during the day; widgets are also + // explicitly reloaded after a silent push applies delta changes. + let reload = Date(timeIntervalSinceNow: 15 * 60) + completion(Timeline(entries: [entry], policy: .after(reload))) + } + } +} diff --git a/Extensions/SparkWidgets/Sources/SparkWidgetsBundle.swift b/Extensions/SparkWidgets/Sources/SparkWidgetsBundle.swift index 4e4ace9..38d9acd 100644 --- a/Extensions/SparkWidgets/Sources/SparkWidgetsBundle.swift +++ b/Extensions/SparkWidgets/Sources/SparkWidgetsBundle.swift @@ -4,6 +4,20 @@ import WidgetKit @main struct SparkWidgetsBundle: WidgetBundle { var body: some Widget { - PlumbingSmokeTestWidget() + // Home Screen โ€” small + SleepScoreWidget() + StepsRingWidget() + SpendTodayWidget() + NextEventWidget() + // Home Screen โ€” medium / large + TodayGlanceWidget() + TodayDashboardWidget() + // Lock Screen + SleepCircularWidget() + StepsCircularWidget() + TopMetricRectangularWidget() + NextEventInlineWidget() + // StandBy + StandByWidget() } } diff --git a/Extensions/SparkWidgets/Sources/SpendTodayWidget.swift b/Extensions/SparkWidgets/Sources/SpendTodayWidget.swift new file mode 100644 index 0000000..93e8ebc --- /dev/null +++ b/Extensions/SparkWidgets/Sources/SpendTodayWidget.swift @@ -0,0 +1,63 @@ +import SwiftUI +import WidgetKit + +struct SpendTodayWidget: Widget { + let kind = "co.cronx.spark.widgets.spend" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in + SpendTodayWidgetView(entry: entry) + } + .configurationDisplayName("Daily Spend") + .description("How much you've spent today.") + .supportedFamilies([.systemSmall]) + .contentMarginsDisabled() + } +} + +struct SpendTodayWidgetView: View { + let entry: SparkWidgetEntry + + var body: some View { + let snap = entry.snapshot + ZStack { + containerBG + VStack(alignment: .leading, spacing: 4) { + Label("Spend", systemImage: "creditcard.fill") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + + Spacer() + + VStack(alignment: .leading, spacing: 2) { + if let display = snap.spentTodayDisplay { + Text(display) + .font(.system(size: 26, weight: .bold, design: .rounded)) + .minimumScaleFactor(0.7) + .lineLimit(1) + Text("spent today") + .font(.caption2) + .foregroundStyle(.secondary) + } else { + Text("No spend") + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + Text("today") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + Spacer() + } + .padding(14) + } + .widgetURL(URL(string: "https://spark.cronx.co/metrics/money.spend")) + } + + private var containerBG: some View { + ContainerRelativeShape() + .fill(.orange.opacity(0.10)) + .containerBackground(for: .widget) { Color(.systemBackground) } + } +} diff --git a/Extensions/SparkWidgets/Sources/StandByWidget.swift b/Extensions/SparkWidgets/Sources/StandByWidget.swift new file mode 100644 index 0000000..3b12148 --- /dev/null +++ b/Extensions/SparkWidgets/Sources/StandByWidget.swift @@ -0,0 +1,83 @@ +import SwiftUI +import WidgetKit + +/// A small widget optimized for StandBy mode โ€” full-bleed dark background +/// 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" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in + StandByWidgetView(entry: entry) + } + .configurationDisplayName("Spark StandBy") + .description("Spark summary in StandBy mode.") + .supportedFamilies([.systemSmall]) + .contentMarginsDisabled() + } +} + +struct StandByWidgetView: View { + let entry: SparkWidgetEntry + @Environment(\.widgetFamily) private var family + + var body: some View { + let snap = entry.snapshot + ZStack { + // Tertiary fill adapts to StandBy's dark, night-optimized display. + Color(.tertiarySystemBackground) + .containerBackground(.fill.tertiary, for: .widget) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: "sparkles") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.yellow) + Text("Spark") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + Text(entry.date, style: .time) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + } + + Spacer() + + VStack(alignment: .leading, spacing: 4) { + // Sleep + HStack(spacing: 6) { + Image(systemName: "moon.fill") + .font(.caption2) + .foregroundStyle(.indigo) + Text(snap.sleepScore.map { "\($0)/100" } ?? "No sleep data") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + } + // Steps + HStack(spacing: 6) { + Image(systemName: "figure.walk") + .font(.caption2) + .foregroundStyle(.green) + Text(snap.steps.map { "\($0) steps" } ?? "No step data") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + } + // Spend + if let display = snap.spentTodayDisplay { + HStack(spacing: 6) { + Image(systemName: "creditcard.fill") + .font(.caption2) + .foregroundStyle(.orange) + Text(display) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + } + } + } + + Spacer() + } + .padding(12) + } + .widgetURL(URL(string: "https://spark.cronx.co/today")) + } +} diff --git a/Extensions/SparkWidgets/Sources/StepsRingWidget.swift b/Extensions/SparkWidgets/Sources/StepsRingWidget.swift new file mode 100644 index 0000000..3a05d9e --- /dev/null +++ b/Extensions/SparkWidgets/Sources/StepsRingWidget.swift @@ -0,0 +1,58 @@ +import SwiftUI +import WidgetKit + +struct StepsRingWidget: Widget { + let kind = "co.cronx.spark.widgets.steps" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in + StepsRingWidgetView(entry: entry) + } + .configurationDisplayName("Steps") + .description("Today's step count and progress ring.") + .supportedFamilies([.systemSmall]) + .contentMarginsDisabled() + } +} + +struct StepsRingWidgetView: View { + let entry: SparkWidgetEntry + + var body: some View { + let snap = entry.snapshot + ZStack { + containerBG + VStack(alignment: .leading, spacing: 4) { + Label("Steps", systemImage: "figure.walk") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + + Spacer() + + HStack(alignment: .center, spacing: 10) { + RingView.steps( + progress: Double(snap.steps ?? 0) / Double(snap.stepsGoal), + size: 52 + ) + VStack(alignment: .leading, spacing: 2) { + Text(snap.stepsDisplay) + .font(.system(size: 28, weight: .bold, design: .rounded)) + Text("of \(snap.stepsGoal / 1_000)k goal") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + Spacer() + } + .padding(14) + } + .widgetURL(URL(string: "https://spark.cronx.co/metrics/health.steps")) + } + + private var containerBG: some View { + ContainerRelativeShape() + .fill(.green.opacity(0.10)) + .containerBackground(for: .widget) { Color(.systemBackground) } + } +} diff --git a/Extensions/SparkWidgets/Sources/TodayDashboardWidget.swift b/Extensions/SparkWidgets/Sources/TodayDashboardWidget.swift new file mode 100644 index 0000000..7785650 --- /dev/null +++ b/Extensions/SparkWidgets/Sources/TodayDashboardWidget.swift @@ -0,0 +1,130 @@ +import AppIntents +import SparkIntelligence +import SparkKit +import SwiftUI +import WidgetKit + +struct TodayDashboardWidget: Widget { + let kind = "co.cronx.spark.widgets.today-dashboard" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in + TodayDashboardWidgetView(entry: entry) + } + .configurationDisplayName("Today Dashboard") + .description("Full today summary with anomalies.") + .supportedFamilies([.systemLarge]) + .contentMarginsDisabled() + } +} + +struct TodayDashboardWidgetView: View { + let entry: SparkWidgetEntry + + var body: some View { + let snap = entry.snapshot + VStack(alignment: .leading, spacing: 12) { + headerRow(snap) + Divider().opacity(0.3) + metricsRow(snap) + if !snap.anomalies.isEmpty { + Divider().opacity(0.3) + anomalyList(snap.anomalies) + } + Spacer(minLength: 0) + } + .padding(16) + .containerBackground(for: .widget) { Color(.systemBackground) } + .widgetURL(URL(string: "https://spark.cronx.co/today")) + } + + // MARK: - Sub-views + + private func headerRow(_ snap: WidgetDataSnapshot) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Today") + .font(.headline.weight(.bold)) + Text(snap.date, style: .date) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + HStack(spacing: 6) { + RingView.move(progress: snap.moveProgress, size: 36) + RingView.exercise(progress: snap.exerciseProgress, size: 26) + RingView.stand(progress: snap.standProgress, size: 18) + } + } + } + + private func metricsRow(_ snap: WidgetDataSnapshot) -> some View { + HStack(spacing: 16) { + metricTile( + icon: "moon.fill", + color: .indigo, + value: snap.sleepScore.map { "\($0)" } ?? "โ€”", + sub: snap.sleepDurationDisplay ?? "No data", + url: "https://spark.cronx.co/metrics/sleep.score" + ) + Divider().frame(maxHeight: 48).opacity(0.3) + metricTile( + icon: "figure.walk", + color: .green, + value: snap.stepsDisplay, + sub: "steps", + url: "https://spark.cronx.co/metrics/health.steps" + ) + Divider().frame(maxHeight: 48).opacity(0.3) + metricTile( + icon: "creditcard.fill", + color: .orange, + value: snap.spentTodayDisplay ?? "โ€”", + sub: "spent", + url: "https://spark.cronx.co/metrics/money.spend" + ) + } + } + + 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) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func anomalyList(_ anomalies: [Anomaly]) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("Anomalies") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + ForEach(anomalies.prefix(3)) { anomaly in + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(.yellow) + Text(anomaly.displayName ?? anomaly.metric ?? anomaly.id) + .font(.caption) + .lineLimit(1) + Spacer() + // Interactive acknowledge button (iOS 17+) + Button( + intent: AcknowledgeAnomalyIntent(anomalyID: anomaly.id) + ) { + Image(systemName: "checkmark.circle") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + } + } +} diff --git a/Extensions/SparkWidgets/Sources/TodayGlanceWidget.swift b/Extensions/SparkWidgets/Sources/TodayGlanceWidget.swift new file mode 100644 index 0000000..6861ae5 --- /dev/null +++ b/Extensions/SparkWidgets/Sources/TodayGlanceWidget.swift @@ -0,0 +1,92 @@ +import SwiftUI +import WidgetKit + +struct TodayGlanceWidget: Widget { + let kind = "co.cronx.spark.widgets.today-glance" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in + TodayGlanceWidgetView(entry: entry) + } + .configurationDisplayName("Today at a Glance") + .description("Sleep, steps, spend, and your next event in one view.") + .supportedFamilies([.systemMedium]) + .contentMarginsDisabled() + } +} + +struct TodayGlanceWidgetView: View { + let entry: SparkWidgetEntry + + var body: some View { + let snap = entry.snapshot + HStack(spacing: 0) { + 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" + ) + Divider().frame(maxHeight: 60).opacity(0.3) + tileView( + systemImage: "creditcard.fill", + color: .orange, + value: snap.spentTodayDisplay ?? "โ€”", + label: "Spend" + ) + Divider().frame(maxHeight: 60).opacity(0.3) + nextEventTile(snap) + } + .containerBackground(for: .widget) { Color(.systemBackground) } + .widgetURL(URL(string: "https://spark.cronx.co/today")) + } + + private func tileView(systemImage: String, color: Color, value: String, label: String) -> some View { + VStack(spacing: 4) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(color) + Text(value) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .minimumScaleFactor(0.7) + .lineLimit(1) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + + private func nextEventTile(_ snap: WidgetDataSnapshot) -> some View { + VStack(spacing: 4) { + Image(systemName: "calendar") + .font(.title3) + .foregroundStyle(.blue) + if let title = snap.nextEventTitle { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .minimumScaleFactor(0.7) + .lineLimit(2) + .multilineTextAlignment(.center) + if let start = snap.nextEventStart { + Text(start) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + } else { + Text("No events") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 4) + } +} diff --git a/Extensions/SparkWidgets/Sources/WidgetDataSnapshot.swift b/Extensions/SparkWidgets/Sources/WidgetDataSnapshot.swift new file mode 100644 index 0000000..7eb211d --- /dev/null +++ b/Extensions/SparkWidgets/Sources/WidgetDataSnapshot.swift @@ -0,0 +1,165 @@ +import Foundation +import SparkKit +import SwiftData + +/// Strongly-typed projection of today's cached DaySummary for widget rendering. +/// Constructed entirely from SwiftData โ€” no network calls from widget code. +struct WidgetDataSnapshot: Sendable { + // Health + let sleepScore: Int? + let sleepDurationMinutes: Int? + + // Activity rings + let steps: Int? + let stepsGoal: Int + let moveProgress: Double + let exerciseProgress: Double + let standProgress: Double + + // Money + let spentToday: Double? + let currency: String + + // Calendar + let nextEventTitle: String? + let nextEventStart: String? + let nextEventLocation: String? + + // Anomalies (for dashboard widget) + let anomalies: [Anomaly] + + // When this snapshot was read + let date: Date + + // MARK: - Placeholder + + static let placeholder = WidgetDataSnapshot( + sleepScore: 84, + sleepDurationMinutes: 7 * 60 + 23, + steps: 6_210, + stepsGoal: 10_000, + moveProgress: 0.62, + exerciseProgress: 0.40, + standProgress: 0.75, + spentToday: 24.50, + currency: "GBP", + nextEventTitle: "Team standup", + nextEventStart: "09:30", + nextEventLocation: nil, + anomalies: [], + date: .now + ) + + // MARK: - Fetch from SwiftData + + static func fetchToday() async -> WidgetDataSnapshot { + guard let container = try? SparkDataStore.makeContainer() else { + return .placeholder + } + let context = ModelContext(container) + let dateStr = Self.todayDateString() + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.date == dateStr } + ) + let summary = (try? context.fetch(descriptor).first).flatMap { try? $0.decoded() } + return WidgetDataSnapshot(date: .now, summary: summary) + } + + // MARK: - Decode from DaySummary + + init(date: Date, summary: DaySummary?) { + self.date = date + + let health = summary?.sections.health?.objectValue + sleepScore = health?["sleep_score"]?.objectValue?["score"]?.intValue + let durSec = health?["sleep_duration"]?.objectValue?["duration_seconds"]?.intValue + sleepDurationMinutes = durSec.map { $0 / 60 } + + let activity = summary?.sections.activity?.objectValue + let stepsObj = activity?["steps"]?.objectValue + steps = stepsObj?["value"]?.intValue + stepsGoal = stepsObj?["goal"]?.intValue ?? 10_000 + let kcal = activity?["active_energy_kcal"]?.objectValue?["value"]?.intValue ?? 0 + let kcalGoal = activity?["active_energy_kcal"]?.objectValue?["goal"]?.intValue ?? 600 + moveProgress = min(1.0, Double(kcal) / Double(kcalGoal)) + let ex = activity?["exercise_minutes"]?.objectValue?["value"]?.intValue ?? 0 + let exGoal = activity?["exercise_minutes"]?.objectValue?["goal"]?.intValue ?? 30 + exerciseProgress = min(1.0, Double(ex) / Double(exGoal)) + let stand = activity?["stand_hours"]?.objectValue?["value"]?.intValue ?? 0 + let standGoal = activity?["stand_hours"]?.objectValue?["goal"]?.intValue ?? 12 + standProgress = min(1.0, Double(stand) / Double(standGoal)) + + let money = summary?.sections.money?.objectValue + spentToday = money?["total_spend"]?.doubleValue + let firstTx = money?["transactions"]?.arrayValue?.first?.objectValue + currency = firstTx?["currency"]?.stringValue ?? "GBP" + + nextEventTitle = nil + nextEventStart = nil + nextEventLocation = nil + + anomalies = summary?.anomalies ?? [] + } + + // Internal designated init (for placeholder + tests). + init( + sleepScore: Int?, + sleepDurationMinutes: Int?, + steps: Int?, + stepsGoal: Int, + moveProgress: Double, + exerciseProgress: Double, + standProgress: Double, + spentToday: Double?, + currency: String, + nextEventTitle: String?, + nextEventStart: String?, + nextEventLocation: String?, + anomalies: [Anomaly], + date: Date + ) { + self.sleepScore = sleepScore + self.sleepDurationMinutes = sleepDurationMinutes + self.steps = steps + self.stepsGoal = stepsGoal + self.moveProgress = moveProgress + self.exerciseProgress = exerciseProgress + self.standProgress = standProgress + self.spentToday = spentToday + self.currency = currency + self.nextEventTitle = nextEventTitle + self.nextEventStart = nextEventStart + self.nextEventLocation = nextEventLocation + self.anomalies = anomalies + self.date = date + } + + // MARK: - Helpers + + var sleepDurationDisplay: String? { + guard let mins = sleepDurationMinutes else { return nil } + let h = mins / 60 + let m = mins % 60 + return m == 0 ? "\(h)h" : "\(h)h \(m)m" + } + + var stepsDisplay: String { + guard let s = steps else { return "โ€”" } + return s >= 1_000 ? String(format: "%.1fk", Double(s) / 1_000) : "\(s)" + } + + var spentTodayDisplay: String? { + guard let amount = spentToday else { return nil } + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currency + formatter.maximumFractionDigits = 2 + return formatter.string(from: NSNumber(value: abs(amount))) + } + + private static func todayDateString() -> String { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f.string(from: .now) + } +} diff --git a/Packages/SparkHealth/Package.swift b/Packages/SparkHealth/Package.swift index 2572339..9abf4ea 100644 --- a/Packages/SparkHealth/Package.swift +++ b/Packages/SparkHealth/Package.swift @@ -10,7 +10,15 @@ let package = Package( .target( name: "SparkHealth", dependencies: ["SparkKit"], - path: "Sources/SparkHealth" + path: "Sources/SparkHealth", + linkerSettings: [ + .linkedFramework("HealthKit"), + ] + ), + .testTarget( + name: "SparkHealthTests", + dependencies: ["SparkHealth"], + path: "Tests/SparkHealthTests" ), ] ) diff --git a/Packages/SparkHealth/Sources/SparkHealth/HealthKitAnchorStore.swift b/Packages/SparkHealth/Sources/SparkHealth/HealthKitAnchorStore.swift new file mode 100644 index 0000000..8a531b1 --- /dev/null +++ b/Packages/SparkHealth/Sources/SparkHealth/HealthKitAnchorStore.swift @@ -0,0 +1,31 @@ +import Foundation +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 keyPrefix = "hk.anchor." + + public static let shared = HealthKitAnchorStore() + + private init() {} + + public func anchor(for key: String) -> HKQueryAnchor? { + guard let defaults = UserDefaults(suiteName: Self.suiteName), + let data = defaults.data(forKey: Self.keyPrefix + key) + else { return nil } + return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data) + } + + public func save(_ anchor: HKQueryAnchor, for key: String) { + guard let defaults = UserDefaults(suiteName: Self.suiteName), + let data = try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true) + else { return } + defaults.set(data, forKey: Self.keyPrefix + key) + } + + public func remove(for key: String) { + UserDefaults(suiteName: Self.suiteName)?.removeObject(forKey: Self.keyPrefix + key) + } +} diff --git a/Packages/SparkHealth/Sources/SparkHealth/HealthKitObserver.swift b/Packages/SparkHealth/Sources/SparkHealth/HealthKitObserver.swift new file mode 100644 index 0000000..b8abd29 --- /dev/null +++ b/Packages/SparkHealth/Sources/SparkHealth/HealthKitObserver.swift @@ -0,0 +1,126 @@ +import Foundation +import HealthKit +import SparkKit + +/// Registers HKObserverQuery for each authorised type. On each fire, runs an +/// HKAnchoredObjectQuery and hands new samples to HealthSampleUploader. +/// Background delivery is enabled per-type so iOS can wake the app. +/// +/// Observer queries do not fire on the simulator โ€” test on device. +public final class HealthKitObserver: @unchecked Sendable { + public static let shared = HealthKitObserver() + + private let store = HKHealthStore() + private let anchorStore = HealthKitAnchorStore.shared + private let uploader = HealthSampleUploader.shared + private let lock = NSLock() + private var observerQueries: [String: HKObserverQuery] = [:] + + private init() {} + + // MARK: - Public API + + public func startObserving() { + guard HKHealthStore.isHealthDataAvailable() else { return } + + for identifier in HealthKitTypeMap.quantityTypes { + let type = HKQuantityType(identifier) + let freq = HealthKitTypeMap.backgroundFrequency(for: identifier) + register(type: type, key: identifier.rawValue, frequency: freq) + } + + for identifier in HealthKitTypeMap.categoryTypes { + let type = HKCategoryType(identifier) + let freq = HealthKitTypeMap.backgroundFrequency(for: identifier) + register(type: type, key: identifier.rawValue, frequency: freq) + } + } + + public func stopObserving() { + let queries = lock.withLock { observerQueries } + for query in queries.values { store.stop(query) } + lock.withLock { observerQueries.removeAll() } + } + + // MARK: - Private + + private func register(type: HKObjectType, key: String, frequency: HKUpdateFrequency) { + store.enableBackgroundDelivery(for: type, frequency: frequency) { _, _ in } + + let query = HKObserverQuery(sampleType: type as! HKSampleType, predicate: nil) { [weak self] _, _, error in + guard error == nil, let self else { return } + self.fetchNewSamples(for: type, key: key) + } + store.execute(query) + lock.withLock { observerQueries[key] = query } + } + + private func fetchNewSamples(for objectType: HKObjectType, key: String) { + guard let sampleType = objectType as? HKSampleType else { return } + let anchor = anchorStore.anchor(for: key) + + let anchoredQuery = HKAnchoredObjectQuery( + type: sampleType, + predicate: nil, + anchor: anchor, + limit: HKObjectQueryNoLimit + ) { [weak self] _, samples, deleted, newAnchor, error in + guard let self, error == nil else { return } + + if let newAnchor { + let converted = self.convert(samples: samples ?? [], key: key) + if !converted.isEmpty { + self.uploader.upload(samples: converted) + self.anchorStore.save(newAnchor, for: key) + } + } + } + store.execute(anchoredQuery) + } + + private func convert(samples: [HKSample], key: String) -> [HealthSample] { + samples.compactMap { sample -> HealthSample? in + let sourceBundle = sample.sourceRevision.source.bundleIdentifier + + if let qty = sample as? HKQuantitySample { + let identifier = HKQuantityTypeIdentifier(rawValue: key) + let (unit, unitStr) = HealthKitTypeMap.unit(for: identifier) + return HealthSample( + externalId: sample.uuid.uuidString, + type: key, + start: sample.startDate, + end: sample.endDate, + value: qty.quantity.doubleValue(for: unit), + unit: unitStr, + source: sourceBundle + ) + } + + if let cat = sample as? HKCategorySample { + return HealthSample( + externalId: sample.uuid.uuidString, + type: key, + start: sample.startDate, + end: sample.endDate, + value: Double(cat.value), + unit: "category", + source: sourceBundle + ) + } + + if sample is HKWorkout { + return HealthSample( + externalId: sample.uuid.uuidString, + type: "HKWorkoutTypeIdentifier", + start: sample.startDate, + end: sample.endDate, + value: sample.endDate.timeIntervalSince(sample.startDate), + unit: "s", + source: sourceBundle + ) + } + + return nil + } + } +} diff --git a/Packages/SparkHealth/Sources/SparkHealth/HealthKitPermissionManager.swift b/Packages/SparkHealth/Sources/SparkHealth/HealthKitPermissionManager.swift new file mode 100644 index 0000000..617ce49 --- /dev/null +++ b/Packages/SparkHealth/Sources/SparkHealth/HealthKitPermissionManager.swift @@ -0,0 +1,109 @@ +import Foundation +import HealthKit +import Observation + +/// Three-wave HealthKit authorization manager. Each wave is independently +/// requestable and skippable per Apple HIG just-in-time guidelines. +@MainActor +@Observable +public final class HealthKitPermissionManager { + public enum Wave: String, Sendable { + case essentials + case activity + case advanced + } + + public enum AuthState: Sendable { + case notDetermined + case granted + case denied + } + + public private(set) var essentialsState: AuthState = .notDetermined + public private(set) var activityState: AuthState = .notDetermined + public private(set) var advancedState: AuthState = .notDetermined + + private let store = HKHealthStore() + private let defaults = UserDefaults(suiteName: "group.co.cronx.spark") + + public static let shared = HealthKitPermissionManager() + + private init() { + loadPersistedState() + } + + // MARK: - Public API + + public var isHealthAvailable: Bool { HKHealthStore.isHealthDataAvailable() } + + public func requestEssentials() async { + let read: Set = [ + HKQuantityType(.stepCount), + HKQuantityType(.heartRate), + HKCategoryType(.sleepAnalysis), + ] + await request(read: read, wave: .essentials) + } + + public func requestActivity() async { + let read: Set = [ + HKWorkoutType.workoutType(), + HKQuantityType(.activeEnergyBurned), + HKQuantityType(.distanceWalkingRunning), + HKQuantityType(.appleExerciseTime), + HKCategoryType(.appleStandHour), + ] + await request(read: read, wave: .activity) + } + + public func requestAdvanced() async { + let read: Set = [ + HKQuantityType(.heartRateVariabilitySDNN), + HKQuantityType(.vo2Max), + HKQuantityType(.respiratoryRate), + HKQuantityType(.oxygenSaturation), + HKCategoryType(.mindfulSession), + ] + await request(read: read, wave: .advanced) + } + + // MARK: - Private + + private func request(read: Set, wave: Wave) async { + guard isHealthAvailable else { return } + do { + try await store.requestAuthorization(toShare: [], read: read) + let granted = read.allSatisfy { type in + store.authorizationStatus(for: type) != .notDetermined + } + let state: AuthState = granted ? .granted : .denied + setAuthState(state, for: wave) + persistState(state, for: wave) + } catch { + setAuthState(.denied, for: wave) + } + } + + private func setAuthState(_ state: AuthState, for wave: Wave) { + switch wave { + case .essentials: essentialsState = state + case .activity: activityState = state + case .advanced: advancedState = state + } + } + + private func persistState(_ state: AuthState, for wave: Wave) { + defaults?.set(state == .granted, forKey: "hk.auth.\(wave.rawValue)") + } + + private func loadPersistedState() { + essentialsState = boolToAuthState(defaults?.bool(forKey: "hk.auth.essentials")) + activityState = boolToAuthState(defaults?.bool(forKey: "hk.auth.activity")) + advancedState = boolToAuthState(defaults?.bool(forKey: "hk.auth.advanced")) + } + + private func boolToAuthState(_ value: Bool?) -> AuthState { + guard let value else { return .notDetermined } + return value ? .granted : .notDetermined + } +} diff --git a/Packages/SparkHealth/Sources/SparkHealth/HealthKitTypeMap.swift b/Packages/SparkHealth/Sources/SparkHealth/HealthKitTypeMap.swift new file mode 100644 index 0000000..5951fa5 --- /dev/null +++ b/Packages/SparkHealth/Sources/SparkHealth/HealthKitTypeMap.swift @@ -0,0 +1,72 @@ +import Foundation +import HealthKit +import SparkKit + +/// Bidirectional mapping between HK type identifiers and SparkKit server strings. +/// Server strings are the HK identifier raw values per API spec ยง5.6. +public enum HealthKitTypeMap { + // MARK: - Quantity types + + public static let quantityTypes: [HKQuantityTypeIdentifier] = [ + .stepCount, + .heartRate, + .activeEnergyBurned, + .distanceWalkingRunning, + .appleExerciseTime, + .heartRateVariabilitySDNN, + .vo2Max, + .respiratoryRate, + .oxygenSaturation, + ] + + // MARK: - Category types + + public static let categoryTypes: [HKCategoryTypeIdentifier] = [ + .sleepAnalysis, + .appleStandHour, + .mindfulSession, + ] + + // MARK: - Lookup + + public static func serverType(for quantityIdentifier: HKQuantityTypeIdentifier) -> String { + quantityIdentifier.rawValue + } + + public static func serverType(for categoryIdentifier: HKCategoryTypeIdentifier) -> String { + categoryIdentifier.rawValue + } + + public static func unit(for identifier: HKQuantityTypeIdentifier) -> (HKUnit, String) { + switch identifier { + case .stepCount: return (.count(), "count") + case .heartRate: return (.count().unitDivided(by: .minute()), "count/min") + case .activeEnergyBurned: return (.kilocalorie(), "kcal") + 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 .respiratoryRate: return (.count().unitDivided(by: .minute()), "count/min") + case .oxygenSaturation: return (.percent(), "%") + default: return (.count(), "count") + } + } + + public static func backgroundFrequency(for identifier: HKQuantityTypeIdentifier) -> HKUpdateFrequency { + switch identifier { + case .heartRate, .activeEnergyBurned: + return .immediate + case .stepCount, .distanceWalkingRunning, .appleExerciseTime: + return .hourly + default: + return .daily + } + } + + public static func backgroundFrequency(for identifier: HKCategoryTypeIdentifier) -> HKUpdateFrequency { + switch identifier { + case .sleepAnalysis: return .immediate + default: return .daily + } + } +} diff --git a/Packages/SparkHealth/Sources/SparkHealth/HealthSampleUploader.swift b/Packages/SparkHealth/Sources/SparkHealth/HealthSampleUploader.swift new file mode 100644 index 0000000..abba5af --- /dev/null +++ b/Packages/SparkHealth/Sources/SparkHealth/HealthSampleUploader.swift @@ -0,0 +1,112 @@ +import Foundation +import SparkKit + +/// Uploads HealthKit samples to the backend via a background URLSession. +/// Persists pending batches to App Group caches so uploads survive termination. +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 lazy var session: URLSession = { + let config = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier) + config.isDiscretionary = false + config.sessionSendsLaunchEvents = true + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + private let lock = NSLock() + private var completionHandlers: [String: @Sendable () -> Void] = [:] + private var environment: APIEnvironment = .current() + private var accessToken: String? + + private override init() { super.init() } + + // MARK: - Public API + + public func configure(environment: APIEnvironment, accessToken: String?) { + lock.withLock { + self.environment = environment + self.accessToken = accessToken + } + } + + public func addCompletionHandler(_ handler: @escaping @Sendable () -> Void, for identifier: String) { + guard identifier == Self.sessionIdentifier else { return } + lock.withLock { completionHandlers[identifier] = handler } + _ = session // Force lazy init to reconnect to the existing background session + } + + public func upload(samples: [HealthSample]) { + guard !samples.isEmpty else { return } + let env = lock.withLock { environment } + let token = lock.withLock { accessToken } + + let batch = HealthSampleBatch(samples: samples) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + guard let body = try? encoder.encode(batch) else { return } + + // Background URLSession requires a file-based body. + let tmpURL = cacheURL(for: UUID().uuidString) + do { + try body.write(to: tmpURL) + } catch { return } + + guard var components = URLComponents(url: env.baseURL, resolvingAgainstBaseURL: false) else { return } + components.path = joinedPath(basePath: components.path, endpointPath: "/health/samples") + guard let url = components.url else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + + let task = session.uploadTask(with: request, fromFile: tmpURL) + task.taskDescription = tmpURL.lastPathComponent + task.resume() + } + + // MARK: - Private + + private func cacheURL(for name: String) -> URL { + let dir = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: Self.suiteName)? + .appendingPathComponent("Caches/health_uploads", isDirectory: true) + ?? URL(fileURLWithPath: NSTemporaryDirectory()) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("\(name).json") + } + + private func joinedPath(basePath: String, endpointPath: String) -> String { + let base = basePath == "/" ? "" : basePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let endpoint = endpointPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return base.isEmpty ? "/\(endpoint)" : "/\(base)/\(endpoint)" + } +} + +// MARK: - URLSessionDelegate + +extension HealthSampleUploader: URLSessionDelegate, URLSessionTaskDelegate { + nonisolated public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + let handlers = lock.withLock { completionHandlers } + for handler in handlers.values { + DispatchQueue.main.async { handler() } + } + lock.withLock { completionHandlers.removeAll() } + } + + nonisolated public func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + 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) + } + } +} diff --git a/Packages/SparkHealth/Sources/SparkHealth/SparkHealth.swift b/Packages/SparkHealth/Sources/SparkHealth/SparkHealth.swift index 977ea72..ad1eeb1 100644 --- a/Packages/SparkHealth/Sources/SparkHealth/SparkHealth.swift +++ b/Packages/SparkHealth/Sources/SparkHealth/SparkHealth.swift @@ -1,5 +1,3 @@ -import Foundation - -/// Placeholder. Populated in Phase 2 with HealthKit observers, -/// progressive permission flow, and batched sample upload. +/// SparkHealth โ€” HealthKit ingestion pipeline. +/// Public API: HealthKitPermissionManager, HealthKitObserver, HealthSampleUploader. public enum SparkHealth {} diff --git a/Packages/SparkHealth/Tests/SparkHealthTests/SparkHealthTests.swift b/Packages/SparkHealth/Tests/SparkHealthTests/SparkHealthTests.swift new file mode 100644 index 0000000..245ce6f --- /dev/null +++ b/Packages/SparkHealth/Tests/SparkHealthTests/SparkHealthTests.swift @@ -0,0 +1,61 @@ +import Foundation +import HealthKit +import Testing + +@testable import SparkHealth + +@MainActor +struct AnchorStoreTests { + @Test("anchor round-trip encodes and decodes") + func anchorRoundTrip() { + let store = HealthKitAnchorStore.shared + let anchor = HKQueryAnchor(fromValue: 42) + let key = "test_anchor_\(UUID().uuidString)" + store.save(anchor, for: key) + let loaded = store.anchor(for: key) + store.remove(for: key) + #expect(loaded != nil) + } +} + +struct TypeMapTests { + @Test("quantity type server string equals raw value") + func quantityTypeServerString() { + for identifier in HealthKitTypeMap.quantityTypes { + let server = HealthKitTypeMap.serverType(for: identifier) + #expect(server == identifier.rawValue) + } + } + + @Test("category type server string equals raw value") + func categoryTypeServerString() { + for identifier in HealthKitTypeMap.categoryTypes { + let server = HealthKitTypeMap.serverType(for: identifier) + #expect(server == identifier.rawValue) + } + } + + @Test("unit returns non-empty string for all quantity types") + func unitStrings() { + for identifier in HealthKitTypeMap.quantityTypes { + let (_, unitStr) = HealthKitTypeMap.unit(for: identifier) + #expect(!unitStr.isEmpty) + } + } +} + +@MainActor +struct PermissionManagerTests { + @Test("initial state is notDetermined on fresh instance") + func initialState() { + // HealthKitPermissionManager.shared is MainActor โ€” safe here. + // Can't actually request auth in unit tests, just verify initial state. + let mgr = HealthKitPermissionManager.shared + // State may be .granted if previously authorised on device. + // Just ensure the property is accessible and has a defined value. + let states: [HealthKitPermissionManager.AuthState] = [.notDetermined, .granted, .denied] + #expect(states.contains(mgr.essentialsState)) + #expect(states.contains(mgr.activityState)) + #expect(states.contains(mgr.advancedState)) + } +} diff --git a/Packages/SparkIntelligence/Sources/SparkIntelligence/ActionIntents.swift b/Packages/SparkIntelligence/Sources/SparkIntelligence/ActionIntents.swift new file mode 100644 index 0000000..9b5a917 --- /dev/null +++ b/Packages/SparkIntelligence/Sources/SparkIntelligence/ActionIntents.swift @@ -0,0 +1,207 @@ +import AppIntents +import Foundation +import SparkKit + +// MARK: - Log Check-In + +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 openAppWhenRun: Bool = true + + @Parameter(title: "Mood", optionsProvider: MoodOptionsProvider()) + public var mood: String + + @Parameter(title: "Note") + public var note: String? + + public init() {} + public init(mood: String, note: String? = nil) { + self.mood = mood + 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" + } + } +} + +private struct MoodOptionsProvider: DynamicOptionsProvider { + func results() async throws -> [String] { + ["great", "good", "okay", "low", "stressed", "tired", "energised", "calm", "anxious", "grateful"] + } +} + +// MARK: - Add Bookmark + +public struct AddBookmarkIntent: AppIntent { + public static let title: LocalizedStringResource = "Add Bookmark" + public static let description = IntentDescription("Bookmark a URL in Spark.") + + @Parameter(title: "URL") + public var url: URL + + public init() {} + public init(url: URL) { self.url = url } + + public func perform() async throws -> some IntentResult & ProvidesDialog { + let service = await IntentService() + let body = try? JSONEncoder().encode(["url": url.absoluteString]) + let endpoint = Endpoint( + method: .post, + path: "/bookmarks", + body: body, + contentType: "application/json" + ) + _ = try? await service.apiClient.request(endpoint) + return .result(dialog: "Bookmarked \(url.host ?? url.absoluteString).") + } +} + +// MARK: - Start / End Sleep + +public struct StartSleepIntent: AppIntent { + public static let title: LocalizedStringResource = "Start Sleep" + public static let description = IntentDescription("Start tracking sleep in Spark.") + public static let openAppWhenRun: Bool = true + + public init() {} + + public func perform() async throws -> some IntentResult & ProvidesDialog { + await MainActor.run { + IntentService.setPendingRoute("action:startSleep") + } + return .result(dialog: "Starting sleep tracking. Good night!") + } +} + +public struct EndSleepIntent: AppIntent { + public static let title: LocalizedStringResource = "End Sleep" + public static let description = IntentDescription("Stop sleep tracking and see your score.") + public static let openAppWhenRun: Bool = true + + public init() {} + + public func perform() async throws -> some IntentResult & ProvidesDialog { + await MainActor.run { + IntentService.setPendingRoute("action:endSleep") + } + return .result(dialog: "Sleep tracking stopped. Check your score in Spark.") + } +} + +// MARK: - Open intents (navigate to specific screens) + +public struct OpenTodayIntent: AppIntent { + public static let title: LocalizedStringResource = "Open Spark Today" + public static let description = IntentDescription("Open the Spark Today view.") + public static let openAppWhenRun: Bool = true + + public init() {} + + public func perform() async throws -> some IntentResult { + await MainActor.run { + IntentService.setPendingRoute("today") + } + return .result() + } +} + +public struct OpenEventIntent: AppIntent { + public static let title: LocalizedStringResource = "Open Event" + public static let description = IntentDescription("Open a specific Spark event.") + public static let openAppWhenRun: Bool = true + + @Parameter(title: "Event ID") + public var eventID: String + + public init() {} + public init(eventID: String) { self.eventID = eventID } + + public func perform() async throws -> some IntentResult { + await MainActor.run { + IntentService.setPendingRoute("event:\(eventID)") + } + return .result() + } +} + +public struct OpenMetricIntent: AppIntent { + public static let title: LocalizedStringResource = "Open Metric" + public static let description = IntentDescription("Open a Spark metric detail view.") + public static let openAppWhenRun: Bool = true + + @Parameter(title: "Metric") + public var identifier: String + + public init() {} + public init(identifier: String) { self.identifier = identifier } + + public func perform() async throws -> some IntentResult { + await MainActor.run { + IntentService.setPendingRoute("metric:\(identifier)") + } + return .result() + } +} + +// MARK: - Search Spark + +public struct SearchSparkIntent: AppIntent { + public static let title: LocalizedStringResource = "Search Spark" + public static let description = IntentDescription("Search your Spark data.") + public static let openAppWhenRun: Bool = true + + @Parameter(title: "Query") + public var query: String + + public init() {} + public init(query: String) { self.query = query } + + public func perform() async throws -> some IntentResult & ProvidesDialog { + await MainActor.run { + IntentService.setPendingRoute("search:\(query)") + } + return .result(dialog: "Opening Spark with search for \(query).") + } +} + +// MARK: - Acknowledge Anomaly + +public struct AcknowledgeAnomalyIntent: AppIntent { + public static let title: LocalizedStringResource = "Acknowledge Anomaly" + public static let description = IntentDescription("Acknowledge a Spark anomaly.") + + @Parameter(title: "Anomaly ID") + public var anomalyID: String + + public init() {} + public init(anomalyID: String) { self.anomalyID = anomalyID } + + public func perform() async throws -> some IntentResult & ProvidesDialog { + // Phase 3 D12: wire to AnomaliesEndpoint.acknowledge(id:) once endpoint exists. + return .result(dialog: "Anomaly acknowledged.") + } +} + +// MARK: - Shared types + +private struct EmptyResponse: Decodable, Sendable {} diff --git a/Packages/SparkIntelligence/Sources/SparkIntelligence/IntentService.swift b/Packages/SparkIntelligence/Sources/SparkIntelligence/IntentService.swift new file mode 100644 index 0000000..2ea343a --- /dev/null +++ b/Packages/SparkIntelligence/Sources/SparkIntelligence/IntentService.swift @@ -0,0 +1,93 @@ +import Foundation +import SparkKit +import SwiftData + +/// Shared service providing API access and SwiftData reads to AppIntents. +/// Constructed on-demand in each intent's `perform()` โ€” intents may run in +/// the extension process where `AppModel.shared` is not available. +@MainActor +public struct IntentService { + public let apiClient: APIClient + private let tokenStore: KeychainTokenStore + + public init() { + let store = KeychainTokenStore() + let cache = ETagCache() + self.tokenStore = store + self.apiClient = APIClient(tokenStore: store, etagCache: cache) + } + + // MARK: - SwiftData reads + + public func todaySnapshot() -> TodayIntentSnapshot? { + guard let container = try? SparkDataStore.makeContainer() else { return nil } + let context = ModelContext(container) + let dateStr = todayDateString() + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.date == dateStr } + ) + guard let cached = (try? context.fetch(descriptor))?.first, + let summary = try? cached.decoded() + else { return nil } + return TodayIntentSnapshot(summary: summary) + } + + // MARK: - UserDefaults routing (for open-app intents) + + public static func setPendingRoute(_ route: String) { + UserDefaults(suiteName: "group.co.cronx.spark")? + .set(route, forKey: "spark.pendingRoute") + } + + // MARK: - Helpers + + private func todayDateString() -> String { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f.string(from: .now) + } +} + +// MARK: - Typed snapshot for intents (avoids AnyCodable in intent code) + +public struct TodayIntentSnapshot: Sendable { + public let sleepScore: Int? + public let sleepDurationMinutes: Int? + public let steps: Int? + public let stepsGoal: Int + public let spentToday: Double? + public let currency: String + public let anomalyCount: Int + + public init(summary: DaySummary) { + let health = summary.sections.health?.objectValue + sleepScore = health?["sleep_score"]?.objectValue?["score"]?.intValue + let durSec = health?["sleep_duration"]?.objectValue?["duration_seconds"]?.intValue + sleepDurationMinutes = durSec.map { $0 / 60 } + + let activity = summary.sections.activity?.objectValue + steps = activity?["steps"]?.objectValue?["value"]?.intValue + stepsGoal = activity?["steps"]?.objectValue?["goal"]?.intValue ?? 10_000 + + let money = summary.sections.money?.objectValue + spentToday = money?["total_spend"]?.doubleValue + currency = money?["transactions"]?.arrayValue?.first?.objectValue?["currency"]?.stringValue ?? "GBP" + + anomalyCount = summary.anomalies.count + } + + public var sleepDurationDisplay: String { + guard let mins = sleepDurationMinutes else { return "unknown duration" } + let h = mins / 60; let m = mins % 60 + return m == 0 ? "\(h) hours" : "\(h) hours and \(m) minutes" + } + + public var spentDisplay: String { + guard let amount = spentToday else { return "nothing" } + let f = NumberFormatter() + f.numberStyle = .currency + f.currencyCode = currency + f.maximumFractionDigits = 2 + return f.string(from: NSNumber(value: abs(amount))) ?? "\(amount)" + } +} diff --git a/Packages/SparkIntelligence/Sources/SparkIntelligence/ReadIntents.swift b/Packages/SparkIntelligence/Sources/SparkIntelligence/ReadIntents.swift new file mode 100644 index 0000000..c34bde3 --- /dev/null +++ b/Packages/SparkIntelligence/Sources/SparkIntelligence/ReadIntents.swift @@ -0,0 +1,80 @@ +import AppIntents +import Foundation +import SparkKit + +// MARK: - Get Sleep Score + +public struct GetSleepScoreIntent: AppIntent { + public static let title: LocalizedStringResource = "Get Sleep Score" + public static let description = IntentDescription("Get your sleep score for today.") + + public init() {} + + public func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let snapshot = await IntentService().todaySnapshot() + let score = snapshot?.sleepScore + let dur = snapshot?.sleepDurationDisplay ?? "unknown duration" + let dialog: IntentDialog = score.map { + "Your sleep score is \($0) out of 100. You slept \(dur)." + } ?? "No sleep data available for today yet." + return .result(value: score, dialog: dialog) + } +} + +// MARK: - Get Steps Today + +public struct GetStepsTodayIntent: AppIntent { + public static let title: LocalizedStringResource = "Get Steps Today" + public static let description = IntentDescription("Get your step count for today.") + + public init() {} + + public func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let snapshot = await IntentService().todaySnapshot() + let steps = snapshot?.steps + let goal = snapshot?.stepsGoal ?? 10_000 + let dialog: IntentDialog = steps.map { + let pct = Int(Double($0) / Double(goal) * 100) + return "You've taken \($0) steps today, which is \(pct)% of your \(goal) step goal." + } ?? "No step data available yet." + return .result(value: steps, dialog: dialog) + } +} + +// MARK: - Get Spend Today + +public struct GetSpendTodayIntent: AppIntent { + public static let title: LocalizedStringResource = "Get Daily Spend" + public static let description = IntentDescription("Get how much you've spent today.") + + public init() {} + + public func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let snapshot = await IntentService().todaySnapshot() + let amount = snapshot?.spentToday + let dialog: IntentDialog = snapshot.map { + "You've spent \($0.spentDisplay) today." + } ?? "No spending data available yet." + return .result(value: amount, dialog: dialog) + } +} + +// MARK: - Get Readiness + +public struct GetReadinessIntent: AppIntent { + public static let title: LocalizedStringResource = "Get Readiness" + public static let description = IntentDescription("Get your daily readiness score based on sleep and recovery.") + + public init() {} + + public func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let snapshot = await IntentService().todaySnapshot() + // Readiness is proxied by sleep score until a dedicated readiness + // endpoint ships on the backend. + let score = snapshot?.sleepScore + let dialog: IntentDialog = score.map { + "Your readiness score today is \($0) out of 100." + } ?? "No readiness data available yet." + return .result(value: score, dialog: dialog) + } +} diff --git a/Packages/SparkIntelligence/Sources/SparkIntelligence/SparkIntelligence.swift b/Packages/SparkIntelligence/Sources/SparkIntelligence/SparkIntelligence.swift index ecd1946..3aac572 100644 --- a/Packages/SparkIntelligence/Sources/SparkIntelligence/SparkIntelligence.swift +++ b/Packages/SparkIntelligence/Sources/SparkIntelligence/SparkIntelligence.swift @@ -1,5 +1,2 @@ -import Foundation - -/// Placeholder. Populated in Phase 3 with App Intents, Shortcuts, -/// and the CoreSpotlight indexer. -public enum SparkIntelligence {} +// SparkIntelligence โ€” App Intents, Siri shortcuts, and CoreSpotlight indexing. +// Phase 3: read intents, action intents, AppShortcutsProvider, SpotlightIndexer. diff --git a/Packages/SparkIntelligence/Sources/SparkIntelligence/SparkShortcuts.swift b/Packages/SparkIntelligence/Sources/SparkIntelligence/SparkShortcuts.swift new file mode 100644 index 0000000..5d522fb --- /dev/null +++ b/Packages/SparkIntelligence/Sources/SparkIntelligence/SparkShortcuts.swift @@ -0,0 +1,71 @@ +import AppIntents + +/// Publishes Spark's curated App Shortcuts to Siri and the Shortcuts app. +/// Phrases containing "$(applicationName)" work in any language; Siri +/// substitutes the app name automatically. +public struct SparkShortcuts: AppShortcutsProvider { + public static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: GetSleepScoreIntent(), + phrases: [ + "What's my sleep score in \(.applicationName)", + "How did I sleep in \(.applicationName)", + ], + shortTitle: "Sleep Score", + systemImageName: "moon.fill" + ) + AppShortcut( + intent: GetStepsTodayIntent(), + phrases: [ + "How many steps today in \(.applicationName)", + "Step count in \(.applicationName)", + ], + shortTitle: "Steps Today", + systemImageName: "figure.walk" + ) + AppShortcut( + intent: GetSpendTodayIntent(), + phrases: [ + "How much did I spend today in \(.applicationName)", + "Daily spend in \(.applicationName)", + ], + shortTitle: "Daily Spend", + systemImageName: "creditcard.fill" + ) + AppShortcut( + intent: GetReadinessIntent(), + phrases: [ + "What's my readiness score in \(.applicationName)", + "Am I ready for today in \(.applicationName)", + ], + shortTitle: "Readiness", + systemImageName: "heart.fill" + ) + AppShortcut( + intent: LogCheckInIntent(), + phrases: [ + "Log a check-in in \(.applicationName)", + "How am I feeling in \(.applicationName)", + ], + shortTitle: "Check-In", + systemImageName: "plus.circle.fill" + ) + AppShortcut( + intent: OpenTodayIntent(), + phrases: [ + "Open \(.applicationName) Today", + "Show my day in \(.applicationName)", + ], + shortTitle: "Open Today", + systemImageName: "sparkles" + ) + AppShortcut( + intent: SearchSparkIntent(), + phrases: [ + "Search \(.applicationName)", + ], + shortTitle: "Search Spark", + systemImageName: "magnifyingglass" + ) + } +} diff --git a/Packages/SparkIntelligence/Sources/SparkIntelligence/SpotlightIndexer.swift b/Packages/SparkIntelligence/Sources/SparkIntelligence/SpotlightIndexer.swift new file mode 100644 index 0000000..8312d04 --- /dev/null +++ b/Packages/SparkIntelligence/Sources/SparkIntelligence/SpotlightIndexer.swift @@ -0,0 +1,135 @@ +import CoreSpotlight +import Foundation +import SparkKit +import SwiftData +import UniformTypeIdentifiers + +/// Incrementally indexes SwiftData cache into CoreSpotlight so the user can +/// find events, blocks, places, and integrations from the iOS home-screen search. +/// +/// Called by BGTaskCoordinator's nightly prefetch task. Items older than +/// `ttlDays` are purged on each run. +public enum SpotlightIndexer { + private static let batchSize = 200 + private static let ttlDays = 30 + + // MARK: - Index + + @MainActor + public static func indexBatch(container: ModelContainer) async { + let context = ModelContext(container) + var items: [CSSearchableItem] = [] + + if let events = try? context.fetch(FetchDescriptor()) { + items += events.map(makeItem(for:)) + } + if let blocks = try? context.fetch(FetchDescriptor()) { + items += blocks.map(makeItem(for:)) + } + if let places = try? context.fetch(FetchDescriptor()) { + items += places.map(makeItem(for:)) + } + if let integrations = try? context.fetch(FetchDescriptor()) { + items += integrations.map(makeItem(for:)) + } + + let chunks = stride(from: 0, to: items.count, by: batchSize).map { + Array(items[$0..(predicate: #Predicate { $0.lastSyncedAt < cutoff }) + if let stale = try? context.fetch(eventDesc) { + identifiers += stale.map { "co.cronx.spark.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)" } + } + + let placeDesc = FetchDescriptor(predicate: #Predicate { $0.lastSyncedAt < cutoff }) + if let stale = try? context.fetch(placeDesc) { + identifiers += stale.map { "co.cronx.spark.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)" } + } + + if !identifiers.isEmpty { + try? await CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: identifiers) + } + } + + // MARK: - Item factories + + private static func makeItem(for event: CachedEvent) -> CSSearchableItem { + let attrs = CSSearchableItemAttributeSet(contentType: .text) + let actionLabel = event.action.replacingOccurrences(of: "_", with: " ").capitalized + let domainLabel = event.domain.replacingOccurrences(of: "_", with: " ").capitalized + attrs.title = "\(actionLabel) \(domainLabel)" + attrs.contentDescription = event.service.capitalized + attrs.keywords = [event.service, event.domain, event.action] + 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", + attributeSet: attrs + ) + } + + private static func makeItem(for block: CachedBlock) -> CSSearchableItem { + let attrs = CSSearchableItemAttributeSet(contentType: .text) + attrs.title = block.title + attrs.contentDescription = block.content + attrs.lastUsedDate = block.time + 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", + attributeSet: attrs + ) + } + + private static func makeItem(for place: CachedPlace) -> CSSearchableItem { + let attrs = CSSearchableItemAttributeSet(contentType: .text) + attrs.title = place.title + attrs.contentDescription = place.address + if let lat = place.latitude { attrs.latitude = NSNumber(value: lat) } + 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", + attributeSet: attrs + ) + } + + private static func makeItem(for integration: CachedIntegration) -> CSSearchableItem { + let attrs = CSSearchableItemAttributeSet(contentType: .text) + attrs.title = integration.name + attrs.contentDescription = integration.service.capitalized + 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", + attributeSet: attrs + ) + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift b/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift index 5764b75..5751732 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift @@ -6,7 +6,7 @@ public enum APIError: Error, Sendable { case transport(Error) case unauthorized case notModified - case httpStatus(Int, Data?) + case httpStatus(Int, Data?, URL) case decoding(Error) case noData } @@ -38,7 +38,18 @@ public actor APIClient { self.tokenStore = tokenStore self.etagCache = etagCache self.decoder = JSONDecoder() - self.decoder.dateDecodingStrategy = .iso8601 + self.decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + let withFrac = ISO8601DateFormatter() + withFrac.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = withFrac.date(from: string) { return d } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + if let d = plain.date(from: string) { return d } + throw DecodingError.dataCorruptedError(in: container, + debugDescription: "Cannot parse date: \(string)") + } self.encoder = JSONEncoder() self.encoder.dateEncodingStrategy = .iso8601 } @@ -101,13 +112,18 @@ public actor APIClient { } guard (200..<300).contains(http.statusCode) else { - throw APIError.httpStatus(http.statusCode, data) + throw APIError.httpStatus(http.statusCode, data, url) } if let etag = http.value(forHTTPHeaderField: "ETag") { await etagCache.store(etag, for: url) } + #if DEBUG + let bodyPreview = String(data: data, encoding: .utf8) ?? "" + logger.info("[\(endpoint.path, privacy: .public)] HTTP \(http.statusCode, privacy: .public) โ€” \(bodyPreview, privacy: .public)") + #endif + if data.isEmpty, let empty = EmptyResponse() as? Response { return empty } @@ -115,7 +131,8 @@ public actor APIClient { do { return try decoder.decode(Response.self, from: data) } catch { - logger.error("Decoding failed for \(endpoint.path): \(error.localizedDescription)") + let bodyString = String(data: data, encoding: .utf8) ?? "" + logger.error("Decoding failed for \(endpoint.path, privacy: .public): \(error.localizedDescription, privacy: .public) โ€” body: \(bodyString, privacy: .public)") throw APIError.decoding(error) } } @@ -152,13 +169,42 @@ public actor APIClient { guard var components = URLComponents(url: base, resolvingAgainstBaseURL: false) else { throw APIError.invalidURL } - components.path += endpoint.path + components.path = joinedPath(basePath: components.path, endpointPath: endpoint.path) if !endpoint.query.isEmpty { components.queryItems = endpoint.query } guard let url = components.url else { throw APIError.invalidURL } return url } + + private func oauthSiteRootURL() -> URL { + guard var components = URLComponents( + url: environment.oauthAuthorizeURL, + resolvingAgainstBaseURL: false + ) else { + return environment.baseURL + } + components.path = "/" + components.query = nil + components.fragment = nil + return components.url ?? environment.baseURL + } + + private func joinedPath(basePath: String, endpointPath: String) -> String { + let normalizedBase = basePath == "/" ? "" : basePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let normalizedEndpoint = endpointPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + if normalizedBase.isEmpty && normalizedEndpoint.isEmpty { + return "/" + } + if normalizedBase.isEmpty { + return "/\(normalizedEndpoint)" + } + if normalizedEndpoint.isEmpty { + return "/\(normalizedBase)" + } + return "/\(normalizedBase)/\(normalizedEndpoint)" + } } /// Sentinel for endpoints that return an empty 204. diff --git a/Packages/SparkKit/Sources/SparkKit/API/APIEnvironment.swift b/Packages/SparkKit/Sources/SparkKit/API/APIEnvironment.swift index 67c065d..9e1491c 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/APIEnvironment.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/APIEnvironment.swift @@ -8,10 +8,41 @@ public struct APIEnvironment: Sendable, Hashable { public let oauthAuthorizeURL: URL public let name: String - public init(baseURL: URL, oauthAuthorizeURL: URL, name: String) { + /// Reverb WebSocket config. `reverbHost` is the bare hostname (no scheme). + /// The client connects to `wss://{reverbHost}/app/{reverbAppKey}?protocol=7`. + public let reverbHost: String + public let reverbAppKey: String + public let reverbPort: Int + public let reverbUseTLS: Bool + + public init( + baseURL: URL, + oauthAuthorizeURL: URL, + name: String, + reverbHost: String = "spark.cronx.co", + reverbAppKey: String = "lw0lmvu5kovdvtfycyub", + reverbPort: Int = 443, + reverbUseTLS: Bool = true + ) { self.baseURL = baseURL self.oauthAuthorizeURL = oauthAuthorizeURL self.name = name + self.reverbHost = reverbHost + self.reverbAppKey = reverbAppKey + self.reverbPort = reverbPort + self.reverbUseTLS = reverbUseTLS + } + + /// WebSocket URL for Reverb, e.g. wss://spark.cronx.co/app/key?protocol=7 + public var reverbWebSocketURL: URL { + let scheme = reverbUseTLS ? "wss" : "ws" + return URL(string: "\(scheme)://\(reverbHost):\(reverbPort)/app/\(reverbAppKey)?protocol=7&client=spark-ios&version=1.0")! + } + + /// The base HTTP URL for the Reverb host (used for the auth endpoint). + public var reverbHTTPBaseURL: URL { + let scheme = reverbUseTLS ? "https" : "http" + return URL(string: "\(scheme)://\(reverbHost):\(reverbPort)")! } public static let production = APIEnvironment( @@ -39,10 +70,18 @@ public struct APIEnvironment: Sendable, Hashable { else { return .production } + let reverbHost = userDefaults.string(forKey: "spark.env.reverbHost") ?? "spark.cronx.co" + let reverbAppKey = userDefaults.string(forKey: "spark.env.reverbAppKey") ?? "lw0lmvu5kovdvtfycyub" + let reverbPort = userDefaults.integer(forKey: "spark.env.reverbPort") + let reverbUseTLS = userDefaults.object(forKey: "spark.env.reverbUseTLS") as? Bool ?? true return APIEnvironment( baseURL: baseURL, oauthAuthorizeURL: authURL, - name: userDefaults.string(forKey: "spark.env.name") ?? "custom" + name: userDefaults.string(forKey: "spark.env.name") ?? "custom", + reverbHost: reverbHost, + reverbAppKey: reverbAppKey, + reverbPort: reverbPort > 0 ? reverbPort : 443, + reverbUseTLS: reverbUseTLS ) } } diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/ApiTokensEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/ApiTokensEndpoint.swift new file mode 100644 index 0000000..2dea8be --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/ApiTokensEndpoint.swift @@ -0,0 +1,19 @@ +import Foundation + +public enum ApiTokensEndpoint { + /// GET /api-tokens + public static func list() -> Endpoint<[ApiToken]> { + Endpoint(method: .get, path: "/api-tokens") + } + + /// POST /api-tokens โ€” returns `CreatedApiToken` containing the one-time plaintext. + public static func create(name: String, abilities: [String] = ["mcp:read"]) -> Endpoint { + let body = try? JSONEncoder().encode(CreateRequest(name: name, abilities: abilities)) + return Endpoint(method: .post, path: "/api-tokens", body: body, contentType: "application/json") + } + + private struct CreateRequest: Encodable { + let name: String + let abilities: [String] + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/BlocksEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/BlocksEndpoint.swift new file mode 100644 index 0000000..cc280f8 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/BlocksEndpoint.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum BlocksEndpoint { + /// GET /blocks/{id} + public static func detail(id: String) -> Endpoint { + Endpoint(method: .get, path: "/blocks/\(id)") + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/CheckInsEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/CheckInsEndpoint.swift new file mode 100644 index 0000000..06856f7 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/CheckInsEndpoint.swift @@ -0,0 +1,16 @@ +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)]) + } + + /// 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") + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/DevicesEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/DevicesEndpoint.swift new file mode 100644 index 0000000..6b40213 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/DevicesEndpoint.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum DevicesEndpoint { + /// GET /devices + public static func list() -> Endpoint<[RegisteredDevice]> { + 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)) + return Endpoint(method: .post, path: "/devices", body: body, contentType: "application/json") + } + + /// DELETE /devices/{id} + public static func revoke(id: String) -> Endpoint { + Endpoint(method: .delete, path: "/devices/\(id)") + } + + private struct RegisterRequest: Encodable { + let name: String + let platform: String + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/EventsEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/EventsEndpoint.swift new file mode 100644 index 0000000..c589afd --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/EventsEndpoint.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum EventsEndpoint { + /// GET /events/{id} + public static func detail(id: String) -> Endpoint { + Endpoint(method: .get, path: "/events/\(id)") + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FeedEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FeedEndpoint.swift new file mode 100644 index 0000000..5d32ce7 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FeedEndpoint.swift @@ -0,0 +1,17 @@ +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> { + var query: [URLQueryItem] = [] + if let cursor { + query.append(URLQueryItem(name: "cursor", value: cursor)) + } + query.append(URLQueryItem(name: "limit", value: String(limit))) + if let domain { + query.append(URLQueryItem(name: "domain", value: domain)) + } + return Endpoint(method: .get, path: "/feed", query: query) + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/HealthEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/HealthEndpoint.swift new file mode 100644 index 0000000..51a615d --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/HealthEndpoint.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum HealthEndpoint { + /// POST /health/samples + public static func submit(samples: [HealthSample]) -> Endpoint { + let body = try? JSONEncoder().encode(HealthSampleBatch(samples: samples)) + return Endpoint(method: .post, path: "/health/samples", body: body, contentType: "application/json") + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/IntegrationsEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/IntegrationsEndpoint.swift new file mode 100644 index 0000000..a916a31 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/IntegrationsEndpoint.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum IntegrationsEndpoint { + /// GET /integrations + public static func list() -> Endpoint<[Integration]> { + Endpoint(method: .get, path: "/integrations") + } + + /// GET /integrations/{id} + public static func detail(id: String) -> Endpoint { + Endpoint(method: .get, path: "/integrations/\(id)") + } + + /// POST /integrations/{id}/sync + public static func syncNow(id: String) -> Endpoint { + Endpoint(method: .post, path: "/integrations/\(id)/sync") + } + + public struct OAuthStartResponse: Decodable, Sendable { + public let url: URL + } + + /// POST /integrations/{id}/oauth/start โ€” returns the URL to open in + /// `ASWebAuthenticationSession` for re-authorisation. + public static func oauthStart(id: String) -> Endpoint { + Endpoint(method: .post, path: "/integrations/\(id)/oauth/start") + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/LiveActivitiesEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/LiveActivitiesEndpoint.swift new file mode 100644 index 0000000..b27c797 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/LiveActivitiesEndpoint.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum LiveActivitiesEndpoint { + /// Register or update the APNs push token for a Live Activity. + /// Called whenever `Activity.pushTokenUpdates` emits a new token. + public static func registerToken( + activityID: String, + token: String, + type: String + ) -> Endpoint { + let body = try? JSONEncoder().encode([ + "token": token, + "type": type, + ]) + return Endpoint( + method: .post, + path: "/live-activities/\(activityID)/tokens", + body: body, + contentType: "application/json" + ) + } + + /// Notify the server the Live Activity has ended. + public static func end(activityID: String) -> Endpoint { + Endpoint(method: .delete, path: "/live-activities/\(activityID)") + } + + /// An empty server response โ€” used when we only care about the status code. + public struct EmptyResponse: Decodable, Sendable {} +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MapEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MapEndpoint.swift new file mode 100644 index 0000000..c3f261c --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MapEndpoint.swift @@ -0,0 +1,23 @@ +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]> { + var query: [URLQueryItem] = [ + URLQueryItem(name: "bbox", value: bbox.queryValue) + ] + if let date { + query.append(URLQueryItem(name: "date", value: Self.dayFormatter.string(from: date))) + } + return Endpoint(method: .get, path: "/map/data", query: query) + } + + private static let dayFormatter: DateFormatter = { + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = .current + f.dateFormat = "yyyy-MM-dd" + return f + }() +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MeEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MeEndpoint.swift new file mode 100644 index 0000000..64f23e6 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MeEndpoint.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum MeEndpoint { + /// GET /me + public static func get() -> Endpoint { + Endpoint(method: .get, path: "/me") + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MetricsEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MetricsEndpoint.swift new file mode 100644 index 0000000..81e2b30 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/MetricsEndpoint.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum MetricsEndpoint { + public enum Range: String, Sendable, CaseIterable { + case sevenDays = "7d" + case thirtyDays = "30d" + case ninetyDays = "90d" + case year = "1y" + + public var label: String { + switch self { + case .sevenDays: "7D" + case .thirtyDays: "30D" + case .ninetyDays: "90D" + case .year: "1Y" + } + } + } + + /// GET /metrics/{identifier}?range=โ€ฆ + public static func detail(identifier: String, range: Range = .thirtyDays) -> Endpoint { + Endpoint( + method: .get, + path: "/metrics/\(identifier)", + query: [URLQueryItem(name: "range", value: range.rawValue)] + ) + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/NotificationsEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/NotificationsEndpoint.swift new file mode 100644 index 0000000..72862bd --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/NotificationsEndpoint.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum NotificationsEndpoint { + /// GET /notifications?cursor=โ€ฆ + public static func list(cursor: String? = nil) -> Endpoint> { + var query: [URLQueryItem] = [] + if let cursor { + query.append(URLQueryItem(name: "cursor", value: cursor)) + } + return Endpoint(method: .get, path: "/notifications", query: query) + } + + /// POST /notifications/{id}/read + public static func markRead(id: String) -> Endpoint { + Endpoint(method: .post, path: "/notifications/\(id)/read") + } + + /// POST /notifications/read-all + public static func markAllRead() -> Endpoint { + Endpoint(method: .post, path: "/notifications/read-all") + } + + /// DELETE /notifications/{id} + public static func delete(id: String) -> Endpoint { + Endpoint(method: .delete, path: "/notifications/\(id)") + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/NotificationsPreferencesEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/NotificationsPreferencesEndpoint.swift new file mode 100644 index 0000000..177899b --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/NotificationsPreferencesEndpoint.swift @@ -0,0 +1,14 @@ +import Foundation + +public enum NotificationsPreferencesEndpoint { + /// GET /settings/notifications + public static func get() -> Endpoint { + Endpoint(method: .get, path: "/settings/notifications") + } + + /// PATCH /settings/notifications + public static func update(_ prefs: NotificationPreferences) -> Endpoint { + let body = try? JSONEncoder().encode(prefs) + return Endpoint(method: .patch, path: "/settings/notifications", body: body, contentType: "application/json") + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/ObjectsEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/ObjectsEndpoint.swift new file mode 100644 index 0000000..27a0ed9 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/ObjectsEndpoint.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum ObjectsEndpoint { + /// GET /objects/{id} + public static func detail(id: String) -> Endpoint { + Endpoint(method: .get, path: "/objects/\(id)") + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/PlacesEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/PlacesEndpoint.swift new file mode 100644 index 0000000..2f6a26d --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/PlacesEndpoint.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum PlacesEndpoint { + /// GET /places/{id} + public static func detail(id: String) -> Endpoint { + Endpoint(method: .get, path: "/places/\(id)") + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SearchEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SearchEndpoint.swift new file mode 100644 index 0000000..e14f03c --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SearchEndpoint.swift @@ -0,0 +1,48 @@ +import Foundation + +public enum SearchEndpoint { + public enum Mode: String, Sendable, CaseIterable { + case `default` + case actions + case tags + case metrics + case integrations + case semantic + + /// Single-character prefix used in the web Spotlight (`>` etc.). The + /// search bar swallows the prefix and switches `Mode`. + public var symbol: String? { + switch self { + case .default: nil + case .actions: ">" + case .tags: "#" + case .metrics: "$" + case .integrations: "@" + case .semantic: "~" + } + } + + public var label: String { + switch self { + case .default: "All" + case .actions: "Actions" + case .tags: "Tags" + case .metrics: "Metrics" + case .integrations: "Integrations" + case .semantic: "Semantic" + } + } + } + + /// GET /search?q=โ€ฆ&mode=โ€ฆ + public static func query(text: String, mode: Mode = .default) -> Endpoint { + Endpoint( + method: .get, + path: "/search", + query: [ + URLQueryItem(name: "q", value: text), + URLQueryItem(name: "mode", value: mode.rawValue), + ] + ) + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SyncEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SyncEndpoint.swift new file mode 100644 index 0000000..1ae5f9a --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SyncEndpoint.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Endpoints for delta-sync between the server and local SwiftData cache. +/// The delta response shape is defined in App\Services\Mobile\DeltaSync (backend). +public enum SyncEndpoint { + /// GET /sync/delta?since={cursor} + /// Returns events that changed since the cursor. No cursor = last 24h. + public static func delta(since cursor: String?) -> Endpoint { + var query: [URLQueryItem] = [] + if let cursor { + query.append(URLQueryItem(name: "since", value: cursor)) + } + return Endpoint(method: .get, path: "/sync/delta", query: query) + } + + /// Wire-format response. Shape is load-bearing โ€” only change through + /// an explicit backend migration coordinated with the iOS release. + public struct DeltaResponse: Decodable, Sendable { + public let created: [Event] + public let updated: [Event] + public let deleted: [String] + /// Opaque cursor string: "{iso8601_updated_at}|{uuid}" or plain ISO-8601. + public let nextCursor: String + + enum CodingKeys: String, CodingKey { + case created, updated, deleted + case nextCursor = "next_cursor" + } + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/WidgetsEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/WidgetsEndpoint.swift new file mode 100644 index 0000000..da9807b --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/WidgetsEndpoint.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum WidgetsEndpoint { + /// GET /widgets/spend โ€” today's spend summary for the Monzo spend widget. + public static func spend() -> Endpoint { + Endpoint(method: .get, path: "/widgets/spend") + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Auth/AuthenticationService.swift b/Packages/SparkKit/Sources/SparkKit/Auth/AuthenticationService.swift index 6286b66..5264849 100644 --- a/Packages/SparkKit/Sources/SparkKit/Auth/AuthenticationService.swift +++ b/Packages/SparkKit/Sources/SparkKit/Auth/AuthenticationService.swift @@ -1,6 +1,8 @@ import Foundation @preconcurrency import AuthenticationServices +#if canImport(UIKit) import UIKit +#endif public enum AuthenticationError: Error, Sendable { case cancelled @@ -20,6 +22,9 @@ public final class AuthenticationService: NSObject, Sendable { private let callbackScheme = "spark" // Retained for the duration of the OAuth web session; released on completion. nonisolated(unsafe) private var activeSession: ASWebAuthenticationSession? + // `presentationContextProvider` is weak on `ASWebAuthenticationSession`, so + // retain the provider for the full session lifecycle. + nonisolated(unsafe) private var activeAnchorProvider: AnchorProvider? public init( environment: APIEnvironment = .current(), @@ -36,7 +41,7 @@ public final class AuthenticationService: NSObject, Sendable { let verifier = PKCE.generateVerifier() let challenge = PKCE.challenge(for: verifier) let state = PKCE.generateState() - let deviceName = UIDevice.current.name + let deviceName = currentDeviceName let authorizeURL = buildAuthorizeURL(challenge: challenge, state: state, deviceName: deviceName) let callbackURL: URL = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in @@ -45,6 +50,7 @@ public final class AuthenticationService: NSObject, Sendable { callbackURLScheme: callbackScheme ) { [weak self] url, error in self?.activeSession = nil + self?.activeAnchorProvider = nil if let error { if (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue { continuation.resume(throwing: AuthenticationError.cancelled) @@ -59,8 +65,10 @@ public final class AuthenticationService: NSObject, Sendable { } continuation.resume(returning: url) } - session.presentationContextProvider = AnchorProvider(anchor: presentationAnchor) + let anchorProvider = AnchorProvider(anchor: presentationAnchor) + session.presentationContextProvider = anchorProvider session.prefersEphemeralWebBrowserSession = false + activeAnchorProvider = anchorProvider activeSession = session session.start() } @@ -109,6 +117,15 @@ public final class AuthenticationService: NSObject, Sendable { let state = components.queryItems?.first(where: { $0.name == "state" })?.value return (code, state) } + + @MainActor + private var currentDeviceName: String { +#if canImport(UIKit) + UIDevice.current.name +#else + ProcessInfo.processInfo.hostName +#endif + } } private final class AnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding { diff --git a/Packages/SparkKit/Sources/SparkKit/Auth/IntegrationReauthService.swift b/Packages/SparkKit/Sources/SparkKit/Auth/IntegrationReauthService.swift new file mode 100644 index 0000000..7346551 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Auth/IntegrationReauthService.swift @@ -0,0 +1,63 @@ +import Foundation +@preconcurrency import AuthenticationServices + +public enum IntegrationReauthError: Error, Sendable { + case cancelled + case invalidCallback + case underlying(Error) +} + +/// Wraps `ASWebAuthenticationSession` for per-integration OAuth re-authorisation. +/// Same strong-reference dance as `AuthenticationService` โ€” the session and +/// presentation anchor provider must outlive the call to `start()`. +public final class IntegrationReauthService: NSObject, Sendable { + private let callbackScheme = "spark" + + nonisolated(unsafe) private var activeSession: ASWebAuthenticationSession? + nonisolated(unsafe) private var activeAnchorProvider: AnchorProvider? + + public override init() { super.init() } + + @MainActor + public func reauthorise( + startURL: URL, + presentationAnchor: ASPresentationAnchor + ) async throws { + let _: URL = try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession( + url: startURL, + callbackURLScheme: callbackScheme + ) { [weak self] url, error in + self?.activeSession = nil + self?.activeAnchorProvider = nil + if let error { + if (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue { + continuation.resume(throwing: IntegrationReauthError.cancelled) + } else { + continuation.resume(throwing: IntegrationReauthError.underlying(error)) + } + return + } + guard let url else { + continuation.resume(throwing: IntegrationReauthError.invalidCallback) + return + } + continuation.resume(returning: url) + } + let anchorProvider = AnchorProvider(anchor: presentationAnchor) + session.presentationContextProvider = anchorProvider + session.prefersEphemeralWebBrowserSession = false + activeAnchorProvider = anchorProvider + activeSession = session + session.start() + } + } +} + +private final class AnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + let anchor: ASPresentationAnchor + init(anchor: ASPresentationAnchor) { self.anchor = anchor } + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + anchor + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Auth/KeychainTokenStore.swift b/Packages/SparkKit/Sources/SparkKit/Auth/KeychainTokenStore.swift index f2a6d51..2180359 100644 --- a/Packages/SparkKit/Sources/SparkKit/Auth/KeychainTokenStore.swift +++ b/Packages/SparkKit/Sources/SparkKit/Auth/KeychainTokenStore.swift @@ -32,7 +32,7 @@ public actor KeychainTokenStore { public init( service: String = "co.cronx.spark.oauth", account: String = "primary", - accessGroup: String? = "co.cronx.spark" + accessGroup: String? = nil ) { self.service = service self.account = account diff --git a/Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift b/Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift index f78e55d..c94f754 100644 --- a/Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift +++ b/Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift @@ -1,6 +1,7 @@ import Foundation -/// Routable links the Phase 1 app understands. +/// Routable links the app understands. Mirrors the AASA paths declared in +/// `public/.well-known/apple-app-site-association` on the backend. public enum DeepLink: Sendable, Equatable { /// OAuth callback from `ASWebAuthenticationSession`. case authCallback(code: String, state: String) @@ -8,11 +9,17 @@ public enum DeepLink: Sendable, Equatable { case today(date: Date?) /// Day pager for an arbitrary date (`/day/YYYY-MM-DD`). case day(Date) - /// Event detail โ€” resolved by id; Phase 2 fills in the detail view. + case event(id: String) + case object(id: String) + case block(id: String) + case metric(identifier: String) + case place(id: String) + case integration(service: String) - /// Parse an incoming URL against the Phase 1 routing table. Returns nil - /// when the URL doesn't match anything we handle on device yet. + /// Parse an incoming URL. Returns nil when the URL doesn't match any + /// route โ€” caller can fall through to default handling (e.g. opening + /// the URL in Safari). public static func parse( _ url: URL, host: String = "spark.cronx.co", @@ -24,18 +31,35 @@ public enum DeepLink: Sendable, Equatable { guard url.host == host else { return nil } - let path = url.path - let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + let parts = url.path + .split(separator: "/", omittingEmptySubsequences: true) + .map(String.init) - switch components.first { + switch parts.first { case "today": - return .today(date: components.dropFirst().first.flatMap(Self.date(from:))) + return .today(date: parts.dropFirst().first.flatMap(Self.date(from:))) case "day": - guard components.count >= 2, let date = Self.date(from: components[1]) else { return nil } + guard parts.count >= 2, let date = Self.date(from: parts[1]) else { return nil } return .day(date) - case "event": - guard components.count >= 2 else { return nil } - return .event(id: components[1]) + case "events", "event": + guard parts.count >= 2 else { return nil } + return .event(id: parts[1]) + case "objects", "object": + guard parts.count >= 2 else { return nil } + return .object(id: parts[1]) + case "blocks", "block": + guard parts.count >= 2 else { return nil } + return .block(id: parts[1]) + case "metrics", "metric": + guard parts.count >= 2 else { return nil } + return .metric(identifier: parts[1]) + case "places", "place": + guard parts.count >= 2 else { return nil } + return .place(id: parts[1]) + case "integrations": + // /integrations/{service}/details + guard parts.count >= 3, parts[2] == "details" else { return nil } + return .integration(service: parts[1]) default: return nil } diff --git a/Packages/SparkKit/Sources/SparkKit/Models/Anomaly.swift b/Packages/SparkKit/Sources/SparkKit/Models/Anomaly.swift index 48928fd..cccf411 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/Anomaly.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/Anomaly.swift @@ -5,26 +5,60 @@ import Foundation public struct Anomaly: Codable, Sendable, Hashable, Identifiable { public let id: String public let metric: String? - public let severity: String? - public let description: String? + public let displayName: String? + public let type: String? + public let direction: String? + public let currentValue: Double? + public let baselineValue: Double? + public let deviation: Double? + public let streakDays: Int? public let detectedAt: Date? enum CodingKeys: String, CodingKey { - case id, metric, severity, description + case metric, type, direction, deviation + case displayName = "display_name" + case currentValue = "current_value" + case baselineValue = "baseline_value" + case streakDays = "streak_days" case detectedAt = "detected_at" } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + metric = try c.decodeIfPresent(String.self, forKey: .metric) + displayName = try c.decodeIfPresent(String.self, forKey: .displayName) + type = try c.decodeIfPresent(String.self, forKey: .type) + direction = try c.decodeIfPresent(String.self, forKey: .direction) + currentValue = try c.decodeIfPresent(Double.self, forKey: .currentValue) + baselineValue = try c.decodeIfPresent(Double.self, forKey: .baselineValue) + deviation = try c.decodeIfPresent(Double.self, forKey: .deviation) + streakDays = try c.decodeIfPresent(Int.self, forKey: .streakDays) + detectedAt = try c.decodeIfPresent(Date.self, forKey: .detectedAt) + let detectedStr = detectedAt.map { ISO8601DateFormatter().string(from: $0) } ?? "unknown" + id = "\(metric ?? "anomaly")|\(detectedStr)" + } + public init( id: String, metric: String? = nil, - severity: String? = nil, - description: String? = nil, + displayName: String? = nil, + type: String? = nil, + direction: String? = nil, + currentValue: Double? = nil, + baselineValue: Double? = nil, + deviation: Double? = nil, + streakDays: Int? = nil, detectedAt: Date? = nil ) { self.id = id self.metric = metric - self.severity = severity - self.description = description + self.displayName = displayName + self.type = type + self.direction = direction + self.currentValue = currentValue + self.baselineValue = baselineValue + self.deviation = deviation + self.streakDays = streakDays self.detectedAt = detectedAt } } diff --git a/Packages/SparkKit/Sources/SparkKit/Models/ApiToken.swift b/Packages/SparkKit/Sources/SparkKit/Models/ApiToken.swift new file mode 100644 index 0000000..a4805e6 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/ApiToken.swift @@ -0,0 +1,36 @@ +import Foundation + +public struct ApiToken: Codable, Sendable, Identifiable { + public let id: String + public let name: String + public let abilities: [String] + public let lastUsedAt: Date? + public let createdAt: Date + + enum CodingKeys: String, CodingKey { + case id, name, abilities + case lastUsedAt = "last_used_at" + case createdAt = "created_at" + } + + public init(id: String, name: String, abilities: [String], lastUsedAt: Date? = nil, createdAt: Date) { + self.id = id + self.name = name + self.abilities = abilities + self.lastUsedAt = lastUsedAt + self.createdAt = createdAt + } +} + +/// Returned exactly once on token creation โ€” contains the plaintext secret. +public struct CreatedApiToken: Codable, Sendable { + public let id: String + public let name: String + public let plaintext: String + + public init(id: String, name: String, plaintext: String) { + self.id = id + self.name = name + self.plaintext = plaintext + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/BlockDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/BlockDetail.swift new file mode 100644 index 0000000..ff86425 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/BlockDetail.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Richer block payload returned by `/api/v1/mobile/blocks/{id}`. Adds the +/// underlying event stub the detail screen needs to wire navigation back to +/// its parent event. +public struct BlockDetail: Codable, Sendable, Hashable, Identifiable { + public let block: Block + public let event: Event? + public let aiSummary: String? + + public var id: String { block.id } + + enum CodingKeys: String, CodingKey { + case block, event + case aiSummary = "summary_ai" + } + + public init(block: Block, event: Event? = nil, aiSummary: String? = nil) { + self.block = block + self.event = event + self.aiSummary = aiSummary + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift b/Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift new file mode 100644 index 0000000..ddf1ffb --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift @@ -0,0 +1,22 @@ +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 + + enum CodingKeys: String, CodingKey { + case slot, mood, tags, note + case loggedAt = "logged_at" + } + + 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 + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/DailyActivityAttributes.swift b/Packages/SparkKit/Sources/SparkKit/Models/DailyActivityAttributes.swift new file mode 100644 index 0000000..db1b9be --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/DailyActivityAttributes.swift @@ -0,0 +1,46 @@ +// ActivityKit is iOS-only; watchOS targets skip this file. +#if os(iOS) +import ActivityKit +import Foundation + +/// ActivityAttributes for the daily activity rings Live Activity. +/// Shared between SparkApp (start/update) and SparkLiveActivities extension (render). +public struct DailyActivityAttributes: ActivityAttributes { + public typealias ContentState = DailyContentState + + public struct DailyContentState: Codable, Hashable, Sendable { + public var steps: Int + public var stepsGoal: Int + public var moveProgress: Double + public var exerciseProgress: Double + public var standProgress: Double + + public var stepsDisplay: String { + steps >= 1_000 + ? String(format: "%.1fk", Double(steps) / 1_000) + : "\(steps)" + } + + public init( + steps: Int = 0, + stepsGoal: Int = 10_000, + moveProgress: Double = 0, + exerciseProgress: Double = 0, + standProgress: Double = 0 + ) { + self.steps = steps + self.stepsGoal = stepsGoal + self.moveProgress = min(1, max(0, moveProgress)) + self.exerciseProgress = min(1, max(0, exerciseProgress)) + self.standProgress = min(1, max(0, standProgress)) + } + } + + // Static context: the day this activity was started. + public var startDate: Date + + public init(startDate: Date = .now) { + self.startDate = startDate + } +} +#endif diff --git a/Packages/SparkKit/Sources/SparkKit/Models/DaySummary.swift b/Packages/SparkKit/Sources/SparkKit/Models/DaySummary.swift index f8e9502..7fed15f 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/DaySummary.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/DaySummary.swift @@ -30,11 +30,11 @@ public struct DaySummary: Codable, Sendable, Hashable { } public struct Sections: Codable, Sendable, Hashable { - public let health: [String: AnyCodable]? - public let activity: [String: AnyCodable]? - public let money: [String: AnyCodable]? - public let media: [String: AnyCodable]? - public let knowledge: [String: AnyCodable]? + public let health: AnyCodable? + public let activity: AnyCodable? + public let money: AnyCodable? + public let media: AnyCodable? + public let knowledge: AnyCodable? } public init( diff --git a/Packages/SparkKit/Sources/SparkKit/Models/Event.swift b/Packages/SparkKit/Sources/SparkKit/Models/Event.swift index daa5c14..c1f7015 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/Event.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/Event.swift @@ -10,18 +10,30 @@ public struct Event: Codable, Sendable, Hashable, Identifiable { public let value: String? public let unit: String? public let url: String? + public let tldr: String? public let actor: ActorTarget? public let target: ActorTarget? + enum CodingKeys: String, CodingKey { + case id, time, service, domain, action, value, unit, url, tldr, actor, target + } + public struct ActorTarget: Codable, Sendable, Hashable { public let id: String public let title: String public let concept: String + public let mediaUrl: String? + + enum CodingKeys: String, CodingKey { + case id, title, concept + case mediaUrl = "media_url" + } - public init(id: String, title: String, concept: String) { + public init(id: String, title: String, concept: String, mediaUrl: String? = nil) { self.id = id self.title = title self.concept = concept + self.mediaUrl = mediaUrl } } @@ -34,6 +46,7 @@ public struct Event: Codable, Sendable, Hashable, Identifiable { value: String? = nil, unit: String? = nil, url: String? = nil, + tldr: String? = nil, actor: ActorTarget? = nil, target: ActorTarget? = nil ) { @@ -45,7 +58,34 @@ public struct Event: Codable, Sendable, Hashable, Identifiable { self.value = value self.unit = unit self.url = url + self.tldr = tldr self.actor = actor self.target = target } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + time = try container.decodeIfPresent(Date.self, forKey: .time) + service = try container.decode(String.self, forKey: .service) + domain = try container.decode(String.self, forKey: .domain) + action = try container.decode(String.self, forKey: .action) + unit = try container.decodeIfPresent(String.self, forKey: .unit) + url = try container.decodeIfPresent(String.self, forKey: .url) + tldr = try container.decodeIfPresent(String.self, forKey: .tldr) + actor = try container.decodeIfPresent(ActorTarget.self, forKey: .actor) + target = try container.decodeIfPresent(ActorTarget.self, forKey: .target) + + 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/EventDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/EventDetail.swift new file mode 100644 index 0000000..9d43006 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/EventDetail.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Richer event payload returned by `/api/v1/mobile/events/{id}`. Wraps the +/// compact `Event` and adds the relations the detail screen needs. +/// +/// Every relation field is optional/empty-tolerant โ€” backend rollout may +/// land in stages, and the view should degrade gracefully. +public struct EventDetail: Codable, Sendable, Hashable, Identifiable { + public let event: Event + public let actor: ActorTarget? + public let target: ActorTarget? + public let blocks: [Block] + public let related: [RelatedEvent] + public let tags: [String] + public let aiSummary: String? + public let location: Location? + + public var id: String { event.id } + + public struct ActorTarget: Codable, Sendable, Hashable { + public let id: String? + public let title: String + public let subtitle: String? + public let concept: String? + public let type: String? + + public init(id: String? = nil, title: String, subtitle: String? = nil, concept: String? = nil, type: String? = nil) { + self.id = id + self.title = title + self.subtitle = subtitle + self.concept = concept + self.type = type + } + } + + public struct RelatedEvent: Codable, Sendable, Hashable, Identifiable { + public let id: String + public let title: String + public let meta: String? + public let time: Date? + + public init(id: String, title: String, meta: String? = nil, time: Date? = nil) { + self.id = id + self.title = title + self.meta = meta + self.time = time + } + } + + public struct Location: Codable, Sendable, Hashable { + public let lat: Double + public let lng: Double + + public init(lat: Double, lng: Double) { + self.lat = lat + self.lng = lng + } + } + + enum CodingKeys: String, CodingKey { + case event, actor, target, blocks, related, tags, location + case aiSummary = "summary_ai" + } + + public init( + event: Event, + actor: ActorTarget? = nil, + target: ActorTarget? = nil, + blocks: [Block] = [], + related: [RelatedEvent] = [], + tags: [String] = [], + aiSummary: String? = nil, + location: Location? = nil + ) { + self.event = event + self.actor = actor + self.target = target + self.blocks = blocks + self.related = related + self.tags = tags + self.aiSummary = aiSummary + self.location = location + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.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) + event = rootEvent + + 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) + } + 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) + } + blocks = try container.decodeIfPresent([Block].self, forKey: .blocks) ?? [] + related = try container.decodeIfPresent([RelatedEvent].self, forKey: .related) ?? [] + tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + aiSummary = try container.decodeIfPresent(String.self, forKey: .aiSummary) + location = try container.decodeIfPresent(Location.self, forKey: .location) + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/HealthSample.swift b/Packages/SparkKit/Sources/SparkKit/Models/HealthSample.swift new file mode 100644 index 0000000..ccde966 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/HealthSample.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Pure-Foundation mirror of the ยง5.6 health sample upload payload. +/// No HealthKit imports โ€” stays in SparkKit so widgets and extensions can use it. +public struct HealthSample: Codable, Sendable { + public let externalId: String + public let type: String + public let start: Date + public let end: Date + public let value: Double + public let unit: String + public let source: String + public let metadata: [String: String]? + + enum CodingKeys: String, CodingKey { + case type, value, unit, source, metadata + case externalId = "external_id" + case start, end + } + + public init( + externalId: String, + type: String, + start: Date, + end: Date, + value: Double, + unit: String, + source: String, + metadata: [String: String]? = nil + ) { + self.externalId = externalId + self.type = type + self.start = start + self.end = end + self.value = value + self.unit = unit + self.source = source + self.metadata = metadata + } +} + +public struct HealthSubmitResponse: Codable, Sendable { + public let accepted: Int + public let rejected: Int + + public init(accepted: Int, rejected: Int) { + self.accepted = accepted + self.rejected = rejected + } +} + +/// Batch payload for POST /health/samples. +public struct HealthSampleBatch: Codable, Sendable { + public let samples: [HealthSample] + + public init(samples: [HealthSample]) { + self.samples = samples + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/IntegrationDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/IntegrationDetail.swift new file mode 100644 index 0000000..48f49f4 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/IntegrationDetail.swift @@ -0,0 +1,69 @@ +import Foundation + +public enum IntegrationStatus: Sendable, Hashable { + case upToDate + case syncing + case needsReauth + case error(String) + + public var label: String { + switch self { + case .upToDate: "Up to date" + case .syncing: "Syncing" + case .needsReauth: "Reauth required" + case .error(let msg): msg + } + } +} + +/// Richer integration payload returned by `/api/v1/mobile/integrations/{id}`. +/// Wraps the compact `Integration` and adds sync state, coverage, recent +/// events, and an optional reauth start URL the client opens in +/// `ASWebAuthenticationSession`. +public struct IntegrationDetail: Codable, Sendable, Hashable, Identifiable { + public let integration: Integration + public let lastSyncAt: Date? + public let coveragePercent: Double? + public let recentEvents: [Event] + public let oauthStartURL: URL? + public let domain: String? + public let statusMessage: String? + + public var id: String { integration.id } + + public var status: IntegrationStatus { + switch integration.status.lowercased() { + case "up_to_date", "ok", "active": .upToDate + case "syncing", "running": .syncing + case "needs_reauth", "reauth", "expired": .needsReauth + default: .error(statusMessage ?? integration.status) + } + } + + enum CodingKeys: String, CodingKey { + case integration, domain + case lastSyncAt = "last_sync_at" + case coveragePercent = "coverage_percent" + case recentEvents = "recent_events" + case oauthStartURL = "oauth_start_url" + case statusMessage = "status_message" + } + + public init( + integration: Integration, + lastSyncAt: Date? = nil, + coveragePercent: Double? = nil, + recentEvents: [Event] = [], + oauthStartURL: URL? = nil, + domain: String? = nil, + statusMessage: String? = nil + ) { + self.integration = integration + self.lastSyncAt = lastSyncAt + self.coveragePercent = coveragePercent + self.recentEvents = recentEvents + self.oauthStartURL = oauthStartURL + self.domain = domain + self.statusMessage = statusMessage + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/MapDataPoint.swift b/Packages/SparkKit/Sources/SparkKit/Models/MapDataPoint.swift new file mode 100644 index 0000000..e3cdebb --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/MapDataPoint.swift @@ -0,0 +1,67 @@ +import Foundation + +/// A single point shown on the map โ€” a place, transaction, workout, or event +/// the user generated. Mirrors the backend's compact map-data resource. +public struct MapDataPoint: Codable, Sendable, Hashable, Identifiable { + public enum Kind: String, Codable, Sendable, CaseIterable { + case place + case transaction + case workout + case event + } + + public let id: String + public let kind: Kind + public let lat: Double + public let lng: Double + public let title: String + public let subtitle: String? + public let time: Date? + public let service: String? + + public init( + id: String, + kind: Kind, + lat: Double, + lng: Double, + title: String, + subtitle: String? = nil, + time: Date? = nil, + service: String? = nil + ) { + self.id = id + self.kind = kind + self.lat = lat + self.lng = lng + self.title = title + self.subtitle = subtitle + self.time = time + self.service = service + } +} + +/// Bounding box used to constrain a `/map/data` request to the visible region. +public struct BoundingBox: Sendable, Hashable { + public let southWest: Coordinate + public let northEast: Coordinate + + public init(southWest: Coordinate, northEast: Coordinate) { + self.southWest = southWest + self.northEast = northEast + } + + public struct Coordinate: Sendable, Hashable { + public let lat: Double + public let lng: Double + + public init(lat: Double, lng: Double) { + self.lat = lat + self.lng = lng + } + } + + /// Serialise as `lat1,lng1,lat2,lng2` per the backend contract. + public var queryValue: String { + "\(southWest.lat),\(southWest.lng),\(northEast.lat),\(northEast.lng)" + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift new file mode 100644 index 0000000..ebc40ea --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift @@ -0,0 +1,196 @@ +import Foundation + +/// Returned by `/api/v1/mobile/metrics/{metric}?range=โ€ฆ`. Carries the trend +/// series, baseline band, and any anomalies the screen needs to render +/// without follow-up requests. +public struct MetricDetail: Sendable, Hashable, Identifiable { + public let id: String + public let title: String + public let domain: String + public let unit: String? + public let today: Double? + public let yesterday: Double? + public let average30d: Double? + public let baseline: Baseline? + public let series: [Point] + public let anomalies: [AnomalyPoint] + public let compares: [Compare]? + + public struct Baseline: Sendable, Hashable { + public let low: Double + public let high: Double + public init(low: Double, high: Double) { + self.low = low + self.high = high + } + } + + public struct Point: Sendable, Hashable, Identifiable { + public let date: Date + public let value: Double + public var id: Date { date } + public init(date: Date, value: Double) { + self.date = date + self.value = value + } + } + + public struct AnomalyPoint: Sendable, Hashable, Identifiable { + public let id: String + public let date: Date + public let severity: String + public let note: String? + public let value: Double? + public init(id: String, date: Date, severity: String, note: String? = nil, value: Double? = nil) { + self.id = id + self.date = date + self.severity = severity + self.note = note + self.value = value + } + } + + public struct Compare: Sendable, Hashable, Identifiable { + public let label: String + public let value: Double + public let delta: Double? + public var id: String { label } + public init(label: String, value: Double, delta: Double? = nil) { + self.label = label + self.value = value + self.delta = delta + } + } + + public init( + id: String, + title: String, + domain: String, + unit: String? = nil, + today: Double? = nil, + yesterday: Double? = nil, + average30d: Double? = nil, + baseline: Baseline? = nil, + series: [Point] = [], + anomalies: [AnomalyPoint] = [], + compares: [Compare]? = nil + ) { + self.id = id + self.title = title + self.domain = domain + self.unit = unit + self.today = today + self.yesterday = yesterday + self.average30d = average30d + self.baseline = baseline + self.series = series + self.anomalies = anomalies + self.compares = compares + } +} + +// MARK: - Codable (maps the actual API response shape) + +extension MetricDetail: Codable { + private struct APIResponse: Codable { + let metric: String + let service: String + let action: String + let unit: String? + let dailyValues: [DailyValue] + let summary: Summary? + let baseline: APIBaseline? + + struct DailyValue: Codable { + let date: String + let value: Double + let isAnomaly: Bool + + enum CodingKeys: String, CodingKey { + case date, value + case isAnomaly = "is_anomaly" + } + } + + struct Summary: Codable { + let mean: Double? + } + + struct APIBaseline: Codable { + let normalLower: Double? + let normalUpper: Double? + + enum CodingKeys: String, CodingKey { + case normalLower = "normal_lower" + case normalUpper = "normal_upper" + } + } + + enum CodingKeys: String, CodingKey { + case metric, service, action, unit, summary, baseline + case dailyValues = "daily_values" + } + } + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + return f + }() + + public init(from decoder: Decoder) throws { + let api = try APIResponse(from: decoder) + + id = api.metric + domain = api.service + unit = api.unit + average30d = api.summary?.mean + compares = nil + + // 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: " ") + + if let lo = api.baseline?.normalLower, let hi = api.baseline?.normalUpper { + baseline = Baseline(low: lo, high: hi) + } else { + baseline = nil + } + + 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) + } + + today = series.last?.value + yesterday = series.count >= 2 ? series[series.count - 2].value : nil + + anomalies = api.dailyValues.compactMap { dv -> AnomalyPoint? in + guard dv.isAnomaly, let date = fmt.date(from: dv.date) else { return nil } + return AnomalyPoint(id: dv.date, date: date, severity: "high", value: dv.value) + } + } + + public func encode(to encoder: Encoder) throws { + // Encoding is not needed for read-only API responses; satisfy Codable + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + } + + private enum CodingKeys: String, CodingKey { + case id + } +} + +public extension MetricDetail { + /// Match an anomaly to its y-value from the series so the chart can pin + /// it accurately even when the backend omits per-anomaly values. + func valueForAnomaly(_ anomaly: AnomalyPoint) -> Double? { + if let value = anomaly.value { return value } + return series.first(where: { Calendar.current.isDate($0.date, inSameDayAs: anomaly.date) })?.value + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/NotificationItem.swift b/Packages/SparkKit/Sources/SparkKit/Models/NotificationItem.swift new file mode 100644 index 0000000..3fc5fb8 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/NotificationItem.swift @@ -0,0 +1,51 @@ +import Foundation + +/// A push or in-app alert delivered to the user. Mirrors +/// `CompactNotificationResource` on the backend. +public struct NotificationItem: Codable, Sendable, Hashable, Identifiable { + public enum EntityKind: String, Codable, Sendable { + case event, object, metric, place, anomaly, integration + } + + public struct EntityRef: Codable, Sendable, Hashable { + public let kind: EntityKind + public let id: String + + public init(kind: EntityKind, id: String) { + self.kind = kind + self.id = id + } + } + + public let id: String + public let title: String + public let body: String? + public let domain: String? + public let isRead: Bool + public let receivedAt: Date + public let entity: EntityRef? + + enum CodingKeys: String, CodingKey { + case id, title, body, domain, entity + case isRead = "is_read" + case receivedAt = "received_at" + } + + public init( + id: String, + title: String, + body: String? = nil, + domain: String? = nil, + isRead: Bool = false, + receivedAt: Date = .init(), + entity: EntityRef? = nil + ) { + self.id = id + self.title = title + self.body = body + self.domain = domain + self.isRead = isRead + self.receivedAt = receivedAt + self.entity = entity + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/NotificationPreferences.swift b/Packages/SparkKit/Sources/SparkKit/Models/NotificationPreferences.swift new file mode 100644 index 0000000..271bb0c --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/NotificationPreferences.swift @@ -0,0 +1,83 @@ +import Foundation + +public struct NotificationPreferences: Codable, Sendable { + public enum Category: String, Codable, Sendable, CaseIterable { + case anomaly + case digest + case integrationFailed = "integration_failed" + case newBookmark = "new_bookmark" + case calendarEvent = "calendar_event" + + public var displayName: String { + switch self { + case .anomaly: "Anomaly Alerts" + case .digest: "Daily Digest" + case .integrationFailed: "Integration Failures" + case .newBookmark: "New Bookmarks" + case .calendarEvent: "Calendar Events" + } + } + + public var subtitle: String { + switch self { + case .anomaly: "When a baseline shifts unexpectedly" + case .digest: "A summary of your day each morning" + case .integrationFailed: "When a connected service stops syncing" + case .newBookmark: "When Spark saves something from the web" + case .calendarEvent: "Reminders before upcoming meetings" + } + } + } + + public enum DeliveryMode: String, Codable, Sendable, CaseIterable { + case immediate + case workHours = "work_hours" + case dailyDigest = "daily_digest" + + public var displayName: String { + switch self { + case .immediate: "Immediate" + case .workHours: "Work Hours" + case .dailyDigest: "Daily Digest" + } + } + } + + public var categories: [Category: Bool] + public var deliveryMode: DeliveryMode + public var digestTime: String? + + enum CodingKeys: String, CodingKey { + case categories + case deliveryMode = "delivery_mode" + case digestTime = "digest_time" + } + + public init(categories: [Category: Bool] = [:], deliveryMode: DeliveryMode = .immediate, digestTime: String? = nil) { + self.categories = categories + self.deliveryMode = deliveryMode + self.digestTime = digestTime + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + deliveryMode = try container.decodeIfPresent(DeliveryMode.self, forKey: .deliveryMode) ?? .immediate + digestTime = try container.decodeIfPresent(String.self, forKey: .digestTime) + let raw = try container.decodeIfPresent([String: Bool].self, forKey: .categories) ?? [:] + var cats: [Category: Bool] = [:] + for (key, value) in raw { + if let cat = Category(rawValue: key) { + cats[cat] = value + } + } + categories = cats + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(deliveryMode, forKey: .deliveryMode) + try container.encodeIfPresent(digestTime, forKey: .digestTime) + let raw = Dictionary(uniqueKeysWithValues: categories.map { ($0.key.rawValue, $0.value) }) + try container.encode(raw, forKey: .categories) + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/ObjectDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/ObjectDetail.swift new file mode 100644 index 0000000..9a523ad --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/ObjectDetail.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Richer object payload returned by `/api/v1/mobile/objects/{id}`. Wraps +/// the compact `EventObject` and adds related events / objects counts. +public struct ObjectDetail: Codable, Sendable, Hashable, Identifiable { + public let object: EventObject + public let recentEvents: [Event] + public let relatedObjects: [Related] + public let tags: [String] + public let aiSummary: String? + + public var id: String { object.id } + + public struct Related: Codable, Sendable, Hashable, Identifiable { + public let id: String + public let title: String + public let concept: String + public let relationship: String? + + public init(id: String, title: String, concept: String, relationship: String? = nil) { + self.id = id + self.title = title + self.concept = concept + self.relationship = relationship + } + } + + enum CodingKeys: String, CodingKey { + case object, tags + case recentEvents = "recent_events" + case relatedObjects = "related_objects" + case aiSummary = "summary_ai" + } + + public init( + object: EventObject, + recentEvents: [Event] = [], + relatedObjects: [Related] = [], + tags: [String] = [], + aiSummary: String? = nil + ) { + self.object = object + self.recentEvents = recentEvents + self.relatedObjects = relatedObjects + self.tags = tags + self.aiSummary = aiSummary + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/Page.swift b/Packages/SparkKit/Sources/SparkKit/Models/Page.swift new file mode 100644 index 0000000..a6b7773 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/Page.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Cursor-paginated response wrapper used by paginated mobile endpoints. +/// Mirrors the backend's `{ "data": [...], "next_cursor": "...", "has_more": true }` shape. +public struct Page: Codable, Sendable { + public let data: [Item] + public let nextCursor: String? + public let hasMore: Bool + + enum CodingKeys: String, CodingKey { + case data + case nextCursor = "next_cursor" + case hasMore = "has_more" + } + + public init(data: [Item], nextCursor: String? = nil, hasMore: Bool = false) { + self.data = data + self.nextCursor = nextCursor + self.hasMore = hasMore + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/PlaceDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/PlaceDetail.swift new file mode 100644 index 0000000..ff9f8ae --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/PlaceDetail.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Richer place payload returned by `/api/v1/mobile/places/{id}`. Wraps the +/// compact `Place` and adds visit history, nearby places, and recent events. +public struct PlaceDetail: Codable, Sendable, Hashable, Identifiable { + public let place: Place + public let visitCount: Int + public let streakDays: Int? + public let lastVisitedAt: Date? + public let events: [Event] + public let nearby: [Place] + + public var id: String { place.id } + + enum CodingKeys: String, CodingKey { + case place + case events + case nearby + case visitCount = "visit_count" + case streakDays = "streak_days" + case lastVisitedAt = "last_visited_at" + } + + public init( + place: Place, + visitCount: Int = 0, + streakDays: Int? = nil, + lastVisitedAt: Date? = nil, + events: [Event] = [], + nearby: [Place] = [] + ) { + self.place = place + self.visitCount = visitCount + self.streakDays = streakDays + self.lastVisitedAt = lastVisitedAt + self.events = events + self.nearby = nearby + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/RegisteredDevice.swift b/Packages/SparkKit/Sources/SparkKit/Models/RegisteredDevice.swift new file mode 100644 index 0000000..98b3685 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/RegisteredDevice.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct RegisteredDevice: Codable, Sendable, Identifiable { + public let id: String + public let name: String + public let platform: String + public let lastSeenAt: Date? + public let isCurrentDevice: Bool + + enum CodingKeys: String, CodingKey { + case id, name, platform + case lastSeenAt = "last_seen_at" + case isCurrentDevice = "is_current_device" + } + + public init(id: String, name: String, platform: String, lastSeenAt: Date? = nil, isCurrentDevice: Bool = false) { + self.id = id + self.name = name + self.platform = platform + self.lastSeenAt = lastSeenAt + self.isCurrentDevice = isCurrentDevice + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift b/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift new file mode 100644 index 0000000..a3eeaff --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift @@ -0,0 +1,210 @@ +import Foundation + +/// A single search hit. Decodes a polymorphic backend payload of the form: +/// `{ "kind": "event", "id": "...", "title": "...", ... }`. +public enum SearchResult: Codable, Sendable, Hashable, Identifiable { + case event(EventHit) + case object(ObjectHit) + case block(BlockHit) + case metric(MetricHit) + case integration(IntegrationHit) + case place(PlaceHit) + case intent(IntentHit) + + public var id: String { + switch self { + case .event(let h): "event:\(h.id)" + case .object(let h): "object:\(h.id)" + case .block(let h): "block:\(h.id)" + case .metric(let h): "metric:\(h.identifier)" + case .integration(let h): "integration:\(h.id)" + case .place(let h): "place:\(h.id)" + case .intent(let h): "intent:\(h.id)" + } + } + + public var title: String { + switch self { + case .event(let h): h.title + case .object(let h): h.title + case .block(let h): h.title + case .metric(let h): h.title + case .integration(let h): h.title + case .place(let h): h.title + case .intent(let h): h.title + } + } + + public var subtitle: String? { + switch self { + case .event(let h): h.subtitle + case .object(let h): h.subtitle + case .block(let h): h.subtitle + case .metric(let h): h.subtitle + case .integration(let h): h.subtitle + case .place(let h): h.subtitle + case .intent(let h): h.subtitle + } + } + + public var sectionLabel: String { + switch self { + case .event: "Events" + case .object: "Objects" + case .block: "Blocks" + case .metric: "Metrics" + case .integration: "Integrations" + case .place: "Places" + case .intent: "Actions" + } + } + + // MARK: - Codable + + enum CodingKeys: String, CodingKey { case kind } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + let single = try decoder.singleValueContainer() + switch kind { + case "event": self = .event(try single.decode(EventHit.self)) + case "object": self = .object(try single.decode(ObjectHit.self)) + case "block": self = .block(try single.decode(BlockHit.self)) + case "metric": self = .metric(try single.decode(MetricHit.self)) + case "integration": self = .integration(try single.decode(IntegrationHit.self)) + case "place": self = .place(try single.decode(PlaceHit.self)) + case "intent": self = .intent(try single.decode(IntentHit.self)) + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "Unknown search result kind \(kind)" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var single = encoder.singleValueContainer() + switch self { + case .event(let h): try single.encode(h) + case .object(let h): try single.encode(h) + case .block(let h): try single.encode(h) + case .metric(let h): try single.encode(h) + case .integration(let h): try single.encode(h) + case .place(let h): try single.encode(h) + case .intent(let h): try single.encode(h) + } + } + + // MARK: - Hits + + public struct EventHit: Codable, Sendable, Hashable { + public let id: String + public let title: String + public let subtitle: String? + public let domain: String? + } + + public struct ObjectHit: Codable, Sendable, Hashable { + public let id: String + public let title: String + public let subtitle: String? + public let concept: String? + } + + public struct BlockHit: Codable, Sendable, Hashable { + public let id: String + public let title: String + public let subtitle: String? + public let blockType: String? + + enum CodingKeys: String, CodingKey { + case id, title, subtitle + case blockType = "block_type" + } + } + + public struct MetricHit: Codable, Sendable, Hashable { + public let identifier: String + public let title: String + public let subtitle: String? + public let domain: String? + } + + public struct IntegrationHit: Codable, Sendable, Hashable { + public let id: String + public let title: String + public let subtitle: String? + public let service: String? + } + + public struct PlaceHit: Codable, Sendable, Hashable { + public let id: String + public let title: String + public let subtitle: String? + } + + public struct IntentHit: Codable, Sendable, Hashable { + public let id: String + public let title: String + public let subtitle: String? + public let symbol: String? + } +} + +/// Search payload returned by `/search`. +/// Backend can return either a raw array (`[SearchResult]`) or an envelope +/// containing the array under a known key. +public struct SearchResponse: Codable, Sendable, Hashable { + public let results: [SearchResult] + + enum CodingKeys: String, CodingKey { + case results + case data + case items + case hits + } + + public init(results: [SearchResult]) { + self.results = results + } + + public init(from decoder: Decoder) throws { + 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 + return + } + if let wrapped = try container.decodeIfPresent([SearchResult].self, forKey: .data) { + results = wrapped + return + } + if let wrapped = try container.decodeIfPresent([SearchResult].self, forKey: .items) { + results = wrapped + return + } + if let wrapped = try container.decodeIfPresent([SearchResult].self, forKey: .hits) { + 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." + ) + ) + } + + public func encode(to encoder: Encoder) throws { + var single = encoder.singleValueContainer() + try single.encode(results) + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/SleepActivityAttributes.swift b/Packages/SparkKit/Sources/SparkKit/Models/SleepActivityAttributes.swift new file mode 100644 index 0000000..9ec7dc4 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/SleepActivityAttributes.swift @@ -0,0 +1,62 @@ +// ActivityKit is iOS-only; watchOS targets skip this file. +#if os(iOS) +import ActivityKit +import Foundation + +/// ActivityAttributes for the sleep Live Activity. +/// Shared between SparkApp (start/update) and SparkLiveActivities extension (render). +public struct SleepActivityAttributes: ActivityAttributes { + public typealias ContentState = SleepContentState + + public struct SleepContentState: Codable, Hashable, Sendable { + public enum Phase: String, Codable, Hashable, Sendable { + case preparing + case sleeping + case wakingUp + case resolved + } + + public var phase: Phase + public var startedAt: Date? + public var sleepScore: Int? + public var durationMinutes: Int? + + public var phaseLabel: String { + switch phase { + case .preparing: return "Getting ready for sleep" + case .sleeping: return "Sleeping" + case .wakingUp: return "Good morning โ˜€๏ธ" + case .resolved: + return sleepScore.map { "Sleep score: \($0)" } ?? "Sleep complete" + } + } + + public var durationDisplay: String? { + guard let mins = durationMinutes else { return nil } + let h = mins / 60 + let m = mins % 60 + return m == 0 ? "\(h)h" : "\(h)h \(m)m" + } + + public init( + phase: Phase, + startedAt: Date? = nil, + sleepScore: Int? = nil, + durationMinutes: Int? = nil + ) { + self.phase = phase + self.startedAt = startedAt + self.sleepScore = sleepScore + self.durationMinutes = durationMinutes + } + } + + public var bedtime: Date + public var targetWakeTime: Date? + + public init(bedtime: Date, targetWakeTime: Date? = nil) { + self.bedtime = bedtime + self.targetWakeTime = targetWakeTime + } +} +#endif diff --git a/Packages/SparkKit/Sources/SparkKit/Models/SpendWidget.swift b/Packages/SparkKit/Sources/SparkKit/Models/SpendWidget.swift new file mode 100644 index 0000000..70b0efa --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/SpendWidget.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Response from `GET /widgets/spend`. +public struct SpendWidget: Codable, Sendable { + public let date: String + public let total: Double + public let unit: String + public let currency: String + public let transactionCount: Int + public let topMerchants: [Merchant] + + public struct Merchant: Codable, Sendable, Identifiable { + public let name: String + public let total: Double + public let count: Int + public var id: String { name } + + public init(name: String, total: Double, count: Int) { + self.name = name + self.total = total + self.count = count + } + } + + enum CodingKeys: String, CodingKey { + case date, total, unit, currency + case transactionCount = "transaction_count" + case topMerchants = "top_merchants" + } + + public init(date: String, total: Double, unit: String, currency: String, transactionCount: Int, topMerchants: [Merchant]) { + self.date = date + self.total = total + self.unit = unit + self.currency = currency + self.transactionCount = transactionCount + self.topMerchants = topMerchants + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/UserProfile.swift b/Packages/SparkKit/Sources/SparkKit/Models/UserProfile.swift new file mode 100644 index 0000000..5a8a3db --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/UserProfile.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct UserProfile: Codable, Sendable { + public let id: String + public let name: String + public let email: String + public let timezone: String? + public let avatarURL: URL? + + enum CodingKeys: String, CodingKey { + case id, name, email, timezone + case avatarURL = "avatar_url" + } + + public init(id: String, name: String, email: String, timezone: String? = nil, avatarURL: URL? = nil) { + self.id = id + self.name = name + self.email = email + self.timezone = timezone + self.avatarURL = avatarURL + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedNotification.swift b/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedNotification.swift new file mode 100644 index 0000000..5bdbb95 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedNotification.swift @@ -0,0 +1,39 @@ +import Foundation +import SwiftData + +@Model +public final class CachedNotification { + #Unique([\.id]) + + @Attribute(.unique) public var id: String + public var title: String + public var body: String? + public var domain: String? + public var isRead: Bool + public var receivedAt: Date + public var entityKind: String? + public var entityId: String? + public var lastSyncedAt: Date + + public init( + id: String, + title: String, + body: String? = nil, + domain: String? = nil, + isRead: Bool = false, + receivedAt: Date, + entityKind: String? = nil, + entityId: String? = nil, + lastSyncedAt: Date = .init() + ) { + self.id = id + self.title = title + self.body = body + self.domain = domain + self.isRead = isRead + self.receivedAt = receivedAt + self.entityKind = entityKind + self.entityId = entityId + self.lastSyncedAt = lastSyncedAt + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Persistence/SchemaV1.swift b/Packages/SparkKit/Sources/SparkKit/Persistence/SchemaV1.swift index 337fe4d..f0d3543 100644 --- a/Packages/SparkKit/Sources/SparkKit/Persistence/SchemaV1.swift +++ b/Packages/SparkKit/Sources/SparkKit/Persistence/SchemaV1.swift @@ -14,6 +14,7 @@ public enum SparkSchemaV1: VersionedSchema { CachedPlace.self, CachedMetric.self, CachedAnomaly.self, + CachedNotification.self, SyncCursor.self, ] } diff --git a/Packages/SparkKit/Tests/SparkKitTests/APIClientTests.swift b/Packages/SparkKit/Tests/SparkKitTests/APIClientTests.swift index ca8fda4..1e85659 100644 --- a/Packages/SparkKit/Tests/SparkKitTests/APIClientTests.swift +++ b/Packages/SparkKit/Tests/SparkKitTests/APIClientTests.swift @@ -127,4 +127,45 @@ struct APIClientTests { _ = try await client.request(BriefingEndpoint.today()) } } + + @Test("site-root requests do not include a double slash") + func siteRootPathIsNormalized() async throws { + let (client, _) = makeClient() + await StubURLProtocol.set { _ in + let payload = """ + {"token_type":"Bearer","access_token":"new","refresh_token":"r-2","expires_in":3600} + """.data(using: .utf8)! + return (payload, 200, [:]) + } + + _ = try await client.requestSiteRoot(AuthEndpoint.exchange(code: "abc", verifier: "verifier")) + + let captured = await StubURLProtocol.recorded() + let request = try #require(captured.first) + #expect(request.url?.path == "/oauth/token") + } + + @Test("site-root requests use oauth host when base URL has a trailing slash") + func siteRootUsesOAuthHost() async throws { + let environment = APIEnvironment( + baseURL: URL(string: "https://api.spark.cronx.co/api/v1/mobile/")!, + oauthAuthorizeURL: URL(string: "https://auth.spark.cronx.co/oauth/authorize")!, + name: "test" + ) + let (client, _) = makeClient(environment: environment) + + await StubURLProtocol.set { _ in + let payload = """ + {"token_type":"Bearer","access_token":"new","refresh_token":"r-2","expires_in":3600} + """.data(using: .utf8)! + return (payload, 200, [:]) + } + + _ = try await client.requestSiteRoot(AuthEndpoint.exchange(code: "abc", verifier: "verifier")) + + let captured = await StubURLProtocol.recorded() + let request = try #require(captured.first) + #expect(request.url?.host == "auth.spark.cronx.co") + #expect(request.url?.path == "/oauth/token") + } } diff --git a/Packages/SparkKit/Tests/SparkKitTests/DeepLinkTests.swift b/Packages/SparkKit/Tests/SparkKitTests/DeepLinkTests.swift index fc8e2a3..44713f8 100644 --- a/Packages/SparkKit/Tests/SparkKitTests/DeepLinkTests.swift +++ b/Packages/SparkKit/Tests/SparkKitTests/DeepLinkTests.swift @@ -58,4 +58,46 @@ struct DeepLinkTests { let url = try #require(URL(string: "https://spark.cronx.co/totally-unknown")) #expect(DeepLink.parse(url) == nil) } + + @Test("parses /events/:id (plural)") + func eventsPlural() throws { + let url = try #require(URL(string: "https://spark.cronx.co/events/evt_xyz")) + #expect(DeepLink.parse(url) == .event(id: "evt_xyz")) + } + + @Test("parses /objects/:id") + func object() throws { + let url = try #require(URL(string: "https://spark.cronx.co/objects/obj_123")) + #expect(DeepLink.parse(url) == .object(id: "obj_123")) + } + + @Test("parses /blocks/:id") + func block() throws { + let url = try #require(URL(string: "https://spark.cronx.co/blocks/blk_abc")) + #expect(DeepLink.parse(url) == .block(id: "blk_abc")) + } + + @Test("parses /metrics/:identifier") + func metric() throws { + let url = try #require(URL(string: "https://spark.cronx.co/metrics/sleep_score")) + #expect(DeepLink.parse(url) == .metric(identifier: "sleep_score")) + } + + @Test("parses /places/:id") + func place() throws { + let url = try #require(URL(string: "https://spark.cronx.co/places/plc_42")) + #expect(DeepLink.parse(url) == .place(id: "plc_42")) + } + + @Test("parses /integrations/:service/details") + func integration() throws { + let url = try #require(URL(string: "https://spark.cronx.co/integrations/monzo/details")) + #expect(DeepLink.parse(url) == .integration(service: "monzo")) + } + + @Test("/integrations without /details suffix returns nil") + func integrationWithoutDetails() throws { + let url = try #require(URL(string: "https://spark.cronx.co/integrations/monzo")) + #expect(DeepLink.parse(url) == nil) + } } diff --git a/Packages/SparkKit/Tests/SparkKitTests/EventDetailDecodingTests.swift b/Packages/SparkKit/Tests/SparkKitTests/EventDetailDecodingTests.swift new file mode 100644 index 0000000..9458563 --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/EventDetailDecodingTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("EventDetail decoding") +struct EventDetailDecodingTests { + @Test("decodes wrapped detail payload") + func decodesWrappedPayload() throws { + let json = """ + { + "event": { + "id": "evt_wrapped", + "time": null, + "service": "calendar", + "domain": "knowledge", + "action": "read" + }, + "blocks": [], + "related": [], + "tags": ["news"], + "summary_ai": "Summary text" + } + """ + + 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.aiSummary == "Summary text") + } + + @Test("decodes flat event payload with defaults") + func decodesFlatPayload() throws { + let json = """ + { + "id": "evt_flat", + "time": null, + "service": "google_news", + "domain": "knowledge", + "action": "published", + "actor": { + "id": "src_1", + "title": "The Times", + "concept": "publisher" + }, + "target": { + "id": "story_1", + "title": "Aurora Watch", + "concept": "article" + } + } + """ + + let detail = try JSONDecoder().decode(EventDetail.self, from: Data(json.utf8)) + #expect(detail.id == "evt_flat") + #expect(detail.blocks.isEmpty) + #expect(detail.related.isEmpty) + #expect(detail.tags.isEmpty) + #expect(detail.actor?.title == "The Times") + #expect(detail.target?.title == "Aurora Watch") + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/SearchResponseDecodingTests.swift b/Packages/SparkKit/Tests/SparkKitTests/SearchResponseDecodingTests.swift new file mode 100644 index 0000000..b7aaa93 --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/SearchResponseDecodingTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("Search response decoding") +struct SearchResponseDecodingTests { + @Test("decodes top-level array payload") + func decodesArrayPayload() throws { + let json = """ + [ + { "kind": "event", "id": "evt_1", "title": "Morning run", "subtitle": "07:00" }, + { "kind": "metric", "identifier": "oura.sleep_score", "title": "Sleep score", "subtitle": "82" } + ] + """ + + let response = try JSONDecoder().decode(SearchResponse.self, from: Data(json.utf8)) + #expect(response.results.count == 2) + } + + @Test("decodes wrapped results payload") + func decodesWrappedPayload() throws { + let json = """ + { + "results": [ + { "kind": "integration", "id": "int_1", "title": "Monzo", "subtitle": "Connected" } + ] + } + """ + + let response = try JSONDecoder().decode(SearchResponse.self, from: Data(json.utf8)) + #expect(response.results.count == 1) + if case .integration(let hit) = try #require(response.results.first) { + #expect(hit.title == "Monzo") + } else { + Issue.record("Expected an integration hit.") + } + } +} diff --git a/Packages/SparkSync/Package.swift b/Packages/SparkSync/Package.swift index 23a7744..8e9e2d9 100644 --- a/Packages/SparkSync/Package.swift +++ b/Packages/SparkSync/Package.swift @@ -10,7 +10,11 @@ let package = Package( .target( name: "SparkSync", dependencies: ["SparkKit"], - path: "Sources/SparkSync" + path: "Sources/SparkSync", + linkerSettings: [ + .linkedFramework("BackgroundTasks"), + .linkedFramework("WidgetKit"), + ] ), ] ) diff --git a/Packages/SparkSync/Sources/SparkSync/BGTaskCoordinator.swift b/Packages/SparkSync/Sources/SparkSync/BGTaskCoordinator.swift new file mode 100644 index 0000000..f54d203 --- /dev/null +++ b/Packages/SparkSync/Sources/SparkSync/BGTaskCoordinator.swift @@ -0,0 +1,141 @@ +import BackgroundTasks +import Foundation +import OSLog +import SparkKit +import SwiftData +import WidgetKit + +/// Manages the two background task identifiers Spark registers with the OS. +/// +/// `co.cronx.spark.refresh` โ€” BGAppRefreshTask, fires ~every 2 h. +/// Fetches /sync/delta, writes to SwiftData, reloads widget timelines. +/// +/// `co.cronx.spark.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. +/// +/// **Registration must happen synchronously during app launch** โ€” call +/// `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" + + private static let logger = Logger(subsystem: "co.cronx.spark", category: "BGTask") + + // MARK: - Registration + + /// Register task handlers with BGTaskScheduler. Must be called during launch. + /// - Parameters: + /// - apiClient: Called lazily when the task fires to obtain the API client. + /// - container: Called lazily when the task fires to obtain the SwiftData container. + /// - onPrefetch: Optional additional work to run during the prefetch task + /// (e.g. Spotlight indexing). The closure is `@Sendable` and async. + public static func register( + apiClient: @escaping @Sendable () async -> APIClient?, + container: @escaping @Sendable () throws -> ModelContainer, + onPrefetch: (@Sendable () async -> Void)? = nil + ) { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: refreshTaskIdentifier, + using: nil + ) { task in + guard let task = task as? BGAppRefreshTask else { return } + handleRefresh(task: task, apiClient: apiClient, container: container) + } + + BGTaskScheduler.shared.register( + forTaskWithIdentifier: prefetchTaskIdentifier, + using: nil + ) { task in + guard let task = task as? BGProcessingTask else { return } + handlePrefetch(task: task, container: container, onPrefetch: onPrefetch) + } + } + + // MARK: - Scheduling + + /// Submit a BGAppRefreshTaskRequest so the OS wakes the app in ~2 h. + public static func scheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: refreshTaskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 2 * 3600) + try? BGTaskScheduler.shared.submit(request) + } + + /// Submit a BGProcessingTaskRequest for nightly prefetch (power + network required). + public static func scheduleProcessingTask() { + let request = BGProcessingTaskRequest(identifier: prefetchTaskIdentifier) + request.requiresNetworkConnectivity = true + request.requiresExternalPower = true + request.earliestBeginDate = Calendar.current.nextDate( + after: .now, + matching: DateComponents(hour: 3, minute: 0), + matchingPolicy: .nextTime + ) + try? BGTaskScheduler.shared.submit(request) + } + + // MARK: - Handlers + + private static func handleRefresh( + task: BGAppRefreshTask, + apiClient: @escaping @Sendable () async -> APIClient?, + container: @escaping @Sendable () throws -> ModelContainer + ) { + logger.info("BGAppRefreshTask began: \(task.identifier, privacy: .public)") + + // BGAppRefreshTask is not Sendable but setTaskCompleted is documented + // thread-safe โ€” suppress the isolation check. + nonisolated(unsafe) let taskRef = task + + let workTask = Task { @MainActor in + defer { + scheduleAppRefresh() + logger.info("BGAppRefreshTask completed") + } + guard + let client = await apiClient(), + let cont = try? container() + else { + taskRef.setTaskCompleted(success: false) + return + } + let changed = await DeltaSyncer.sync(using: client, container: cont) + WidgetCenter.shared.reloadAllTimelines() + taskRef.setTaskCompleted(success: true) + logger.info("Delta sync result: changed=\(changed, privacy: .public)") + } + + task.expirationHandler = { + workTask.cancel() + taskRef.setTaskCompleted(success: false) + } + } + + private static func handlePrefetch( + task: BGProcessingTask, + container: @escaping @Sendable () throws -> ModelContainer, + onPrefetch: (@Sendable () async -> Void)? + ) { + logger.info("BGProcessingTask began: \(task.identifier, privacy: .public)") + + // Same rationale as handleRefresh โ€” BGProcessingTask is not Sendable. + nonisolated(unsafe) let taskRef = task + + let workTask = Task { @MainActor in + defer { + scheduleProcessingTask() + logger.info("BGProcessingTask completed") + } + if let extra = onPrefetch { + await extra() + } + taskRef.setTaskCompleted(success: true) + } + + task.expirationHandler = { + workTask.cancel() + taskRef.setTaskCompleted(success: false) + } + } +} diff --git a/Packages/SparkSync/Sources/SparkSync/DeltaSyncer.swift b/Packages/SparkSync/Sources/SparkSync/DeltaSyncer.swift new file mode 100644 index 0000000..1417f82 --- /dev/null +++ b/Packages/SparkSync/Sources/SparkSync/DeltaSyncer.swift @@ -0,0 +1,111 @@ +import Foundation +import OSLog +import SparkKit +import SwiftData + +/// Fetches the /sync/delta endpoint and applies the response to 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") + + /// 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. + @MainActor + public static func sync(using apiClient: APIClient, container: ModelContainer) async -> Bool { + let context = ModelContext(container) + let cursor = readCursor(resource: "events", from: context) + + do { + let delta = try await apiClient.request(SyncEndpoint.delta(since: cursor)) + let created = delta.created.count + let updated = delta.updated.count + let deleted = delta.deleted.count + applyDelta(delta, to: context) + try context.save() + logger.info("Delta sync: +\(created, privacy: .public) ~\(updated, privacy: .public) -\(deleted, privacy: .public) cursor=\(delta.nextCursor, privacy: .public)") + return created > 0 || updated > 0 || deleted > 0 + } catch APIError.notModified { + return false + } catch { + logger.error("Delta sync error: \(error, privacy: .public)") + return false + } + } + + // MARK: - Private + + private static func readCursor(resource: String, from context: ModelContext) -> String? { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.resource == resource } + ) + return (try? context.fetch(descriptor))?.first?.cursor + } + + private static func applyDelta(_ delta: SyncEndpoint.DeltaResponse, to context: ModelContext) { + let now = Date.now + + for event in delta.created + delta.updated { + upsertEvent(event, in: context, syncedAt: now) + } + for id in delta.deleted { + deleteEvent(id: id, from: context) + } + saveNextCursor(delta.nextCursor, resource: "events", in: context) + } + + private static func upsertEvent(_ event: Event, in context: ModelContext, syncedAt: Date) { + 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.actorTitle = event.actor?.title + existing.targetTitle = event.target?.title + existing.lastSyncedAt = syncedAt + } 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, + actorTitle: event.actor?.title, + targetTitle: event.target?.title, + lastSyncedAt: syncedAt + )) + } + } + + private static func deleteEvent(id: String, from context: ModelContext) { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + if let cached = (try? context.fetch(descriptor))?.first { + context.delete(cached) + } + } + + private static func saveNextCursor(_ cursor: String, resource: String, in context: ModelContext) { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.resource == resource } + ) + if let existing = (try? context.fetch(descriptor))?.first { + existing.cursor = cursor + existing.lastSyncedAt = .now + } else { + context.insert(SyncCursor(resource: resource, cursor: cursor, lastSyncedAt: .now)) + } + } +} diff --git a/Packages/SparkSync/Sources/SparkSync/ReverbClient.swift b/Packages/SparkSync/Sources/SparkSync/ReverbClient.swift new file mode 100644 index 0000000..21115b4 --- /dev/null +++ b/Packages/SparkSync/Sources/SparkSync/ReverbClient.swift @@ -0,0 +1,327 @@ +import Foundation +import OSLog +import SparkKit + +/// Pusher-protocol WebSocket client connecting to Laravel Reverb. +/// +/// Connects to `wss://{host}/app/{key}?protocol=7`, subscribes to +/// `private-App.Models.User.{userId}` after authenticating via +/// `POST /broadcasting/auth`, then forwards decoded broadcast events +/// to any registered handlers. +/// +/// **Lifecycle**: call `connect(userId:)` on `.sceneDidBecomeActive` and +/// `disconnect()` on `.sceneWillResignActive`. The actor serialises all +/// state mutations; callers can await these methods from any context. +/// +/// **Deduplication**: a rolling 100-entry set prevents double-applying the +/// same broadcast when both a silent push and a WebSocket message arrive. +public actor ReverbClient { + + // MARK: - Types + + /// A decoded broadcast from the server. Handlers receive raw JSON `data` + /// so they can decode only what they care about. + public struct BroadcastEvent: Sendable { + public let eventName: String + public let channel: String + public let data: Data + } + + public typealias EventHandler = @Sendable (BroadcastEvent) async -> Void + + // MARK: - Private state + + private let environment: APIEnvironment + private let tokenStore: KeychainTokenStore + private let session: URLSession + private let logger = Logger(subsystem: "co.cronx.spark", category: "ReverbClient") + + private var socketTask: URLSessionWebSocketTask? + private var receiveLoopTask: Task? + private var pingTask: Task? + private var reconnectTask: Task? + private var socketId: String? + private var currentUserId: String? + private var isConnected = false + private var isStopped = false + + private var handlers: [EventHandler] = [] + private var seenBroadcastIds: [String] = [] // rolling 100-entry dedup queue + + private var reconnectDelay: TimeInterval = 1 + + // MARK: - Init + + public init( + environment: APIEnvironment = .current(), + tokenStore: KeychainTokenStore, + session: URLSession = .shared + ) { + self.environment = environment + self.tokenStore = tokenStore + self.session = session + } + + // MARK: - Public API + + /// Register a handler that receives every broadcast event. Thread-safe. + public func addHandler(_ handler: @escaping EventHandler) { + handlers.append(handler) + } + + /// Open the WebSocket and subscribe to the user's private channel. + public func connect(userId: String) async { + isStopped = false + currentUserId = userId + reconnectDelay = 1 + await openSocket() + } + + /// Tear down the WebSocket permanently. Does not reconnect. + public func disconnect() async { + isStopped = true + currentUserId = nil + tearDown() + logger.info("Reverb disconnected by caller") + } + + // MARK: - Connection lifecycle + + private func openSocket() async { + tearDown() + let url = environment.reverbWebSocketURL + var request = URLRequest(url: url) + request.setValue("permessage-deflate", forHTTPHeaderField: "Sec-WebSocket-Extensions") + socketTask = session.webSocketTask(with: request) + socketTask?.resume() + logger.info("Reverb socket opened โ†’ \(url.absoluteString, privacy: .public)") + startReceiveLoop() + startPingLoop() + } + + private func tearDown() { + receiveLoopTask?.cancel() + pingTask?.cancel() + reconnectTask?.cancel() + receiveLoopTask = nil + pingTask = nil + reconnectTask = nil + socketTask?.cancel(with: .normalClosure, reason: nil) + socketTask = nil + socketId = nil + isConnected = false + } + + // MARK: - Receive loop + + private func startReceiveLoop() { + receiveLoopTask = Task { + guard let task = socketTask else { return } + while !Task.isCancelled { + do { + let message = try await task.receive() + await handleMessage(message) + } catch { + if Task.isCancelled { return } + logger.warning("Reverb receive error: \(error, privacy: .public)") + scheduleReconnect() + return + } + } + } + } + + private func handleMessage(_ message: URLSessionWebSocketTask.Message) async { + let text: String + switch message { + case .string(let s): text = s + case .data(let d): text = String(decoding: d, as: UTF8.self) + @unknown default: return + } + + guard + let data = text.data(using: .utf8), + let envelope = try? JSONDecoder().decode(PusherEnvelope.self, from: data) + else { return } + + switch envelope.event { + case "pusher:connection_established": + await handleConnectionEstablished(envelope.dataString) + + case "pusher:ping": + try? await socketTask?.send(.string(#"{"event":"pusher:pong","data":{}}"#)) + + case "pusher_internal:subscription_succeeded": + isConnected = true + reconnectDelay = 1 + logger.info("Reverb subscribed to \(envelope.channel ?? "?", privacy: .public)") + + case "pusher:error": + logger.error("Reverb server error: \(envelope.dataString ?? "", privacy: .public)") + + default: + guard let channel = envelope.channel, + let dataStr = envelope.dataString, + let dataBytes = dataStr.data(using: .utf8) + else { return } + + let dedupKey = "\(envelope.event)|" + dataStr.prefix(200) + guard !isDuplicate(dedupKey) else { return } + + let broadcast = BroadcastEvent( + eventName: envelope.event, + channel: channel, + data: dataBytes + ) + for handler in handlers { + await handler(broadcast) + } + } + } + + private func handleConnectionEstablished(_ dataString: String?) async { + guard + let raw = dataString, + let innerData = raw.data(using: .utf8), + let inner = try? JSONDecoder().decode(ConnectionData.self, from: innerData) + else { return } + + socketId = inner.socketId + logger.info("Reverb connection established, socket_id=\(inner.socketId, privacy: .public)") + + if let userId = currentUserId { + await subscribeToPrivateChannel(userId: userId) + } + } + + // MARK: - Private channel auth + + private func subscribeToPrivateChannel(userId: String) async { + let channel = "private-App.Models.User.\(userId)" + + guard + let socketId, + let auth = await fetchChannelAuth(channel: channel, socketId: socketId) + else { + logger.error("Reverb: channel auth failed for \(channel, privacy: .public)") + return + } + + let payload = SubscribePayload(channel: channel, auth: auth) + guard + let payloadData = try? JSONEncoder().encode(payload), + let payloadString = String(data: payloadData, encoding: .utf8) + else { return } + + let message = #"{"event":"pusher:subscribe","data":"# + payloadString + "}" + try? await socketTask?.send(.string(message)) + logger.info("Reverb: subscribe sent for \(channel, privacy: .public)") + } + + private func fetchChannelAuth(channel: String, socketId: String) async -> String? { + guard let token = await tokenStore.accessToken() else { return nil } + + var components = URLComponents(url: environment.baseURL, resolvingAgainstBaseURL: false)! + components.path = "/broadcasting/auth" + components.queryItems = nil + let authURL = components.url! + var request = URLRequest(url: authURL) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + request.httpBody = "channel_name=\(channel)&socket_id=\(socketId)".data(using: .utf8) + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.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)") + return nil + } + } + + // MARK: - Ping loop + + private func startPingLoop() { + pingTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(30)) + if Task.isCancelled { return } + try? await socketTask?.send(.string(#"{"event":"pusher:ping","data":{}}"#)) + } + } + } + + // MARK: - Reconnect + + private func scheduleReconnect() { + guard !isStopped, currentUserId != nil else { return } + let delay = reconnectDelay + reconnectDelay = min(reconnectDelay * 2, 30) + logger.info("Reverb reconnecting in \(delay, format: .fixed(precision: 0), privacy: .public)s") + reconnectTask = Task { + try? await Task.sleep(for: .seconds(delay)) + if Task.isCancelled || isStopped { return } + await openSocket() + } + } + + // MARK: - Deduplication + + private func isDuplicate(_ key: String) -> Bool { + if seenBroadcastIds.contains(key) { return true } + seenBroadcastIds.append(key) + if seenBroadcastIds.count > 100 { + seenBroadcastIds.removeFirst() + } + return false + } + + // MARK: - Codable helpers (internal wire types) + + private struct PusherEnvelope: Decodable { + let event: String + let channel: String? + let data: PusherData? + + var dataString: String? { + switch data { + case .string(let s): return s + case .object: return nil + case nil: return nil + } + } + + enum PusherData: Decodable { + case string(String) + case object + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let s = try? container.decode(String.self) { + self = .string(s) + } else { + self = .object + } + } + } + } + + private struct ConnectionData: Decodable { + let socketId: String + enum CodingKeys: String, CodingKey { case socketId = "socket_id" } + } + + private struct SubscribePayload: Encodable { + let channel: String + let auth: String + } + + private struct AuthResponse: Decodable { + let auth: String + } +} diff --git a/Packages/SparkSync/Sources/SparkSync/SilentPushHandler.swift b/Packages/SparkSync/Sources/SparkSync/SilentPushHandler.swift new file mode 100644 index 0000000..6d9873b --- /dev/null +++ b/Packages/SparkSync/Sources/SparkSync/SilentPushHandler.swift @@ -0,0 +1,77 @@ +import Foundation +import OSLog +import SparkKit +import SwiftData +import WidgetKit + +#if canImport(UIKit) +import UIKit +#endif + +/// Handles `aps.content-available = 1` silent push notifications. +/// +/// The completion handler is called exactly once โ€” either when the delta sync +/// finishes, or after 24 s if the sync hasn't completed (leaving 1 s before +/// the OS terminates the background task at 25 s). +/// +/// Wire in `SparkAppDelegate.application(_:didReceiveRemoteNotification:fetchCompletionHandler:)`. +public enum SilentPushHandler { + private static let logger = Logger(subsystem: "co.cronx.spark", category: "SilentPush") + private static let signposter = OSSignposter(logger: logger) + + /// All mutable handler state lives on the MainActor โ€” both tasks are + /// Task { @MainActor in } so the flag is always read/written serially. + @MainActor + private final class State { + var completed = false + // Completion is always called from @MainActor โ€” @Sendable not required. + let completion: (UIBackgroundFetchResult) -> Void + + init(completion: @escaping (UIBackgroundFetchResult) -> Void) { + self.completion = completion + } + + func finish(_ result: UIBackgroundFetchResult) { + guard !completed else { return } + completed = true + completion(result) + } + } + + @MainActor + public static func handle( + userInfo: [AnyHashable: Any], + apiClient: APIClient, + container: ModelContainer, + completion: @escaping (UIBackgroundFetchResult) -> Void + ) { + let aps = userInfo["aps"] as? [String: Any] + guard aps?["content-available"] as? Int == 1 else { + completion(.noData) + return + } + + let signpostState = signposter.beginInterval("SilentPush") + let state = State(completion: completion) + + // Budget watchdog โ€” fires if sync doesn't finish within 24 s. + let budgetTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(24)) + guard !Task.isCancelled else { return } + logger.warning("Silent push budget exceeded") + state.finish(.noData) + } + + // Primary sync task. + Task { @MainActor in + defer { + budgetTask.cancel() + signposter.endInterval("SilentPush", signpostState) + } + let changed = await DeltaSyncer.sync(using: apiClient, container: container) + WidgetCenter.shared.reloadAllTimelines() + logger.info("Silent push handled, changed=\(changed, privacy: .public)") + state.finish(changed ? .newData : .noData) + } + } +} diff --git a/Packages/SparkSync/Sources/SparkSync/SparkSync.swift b/Packages/SparkSync/Sources/SparkSync/SparkSync.swift index 7813170..9e4f526 100644 --- a/Packages/SparkSync/Sources/SparkSync/SparkSync.swift +++ b/Packages/SparkSync/Sources/SparkSync/SparkSync.swift @@ -1,5 +1,7 @@ -import Foundation - -/// Placeholder. Populated in Phase 3 with the Reverb WebSocket client, background -/// refresh coordinator, silent-push handler, and delta-sync engine. -public enum SparkSync {} +/// SparkSync โ€” background refresh, silent push, and real-time WebSocket. +/// +/// Public surface: +/// - `DeltaSyncer` fetch /sync/delta and write to SwiftData +/// - `BGTaskCoordinator` BGAppRefreshTask + BGProcessingTask registration +/// - `SilentPushHandler` silent push (content-available=1) handler +/// - `ReverbClient` Pusher-protocol Reverb WebSocket actor diff --git a/Packages/SparkUI/Package.swift b/Packages/SparkUI/Package.swift index c5bfc45..c790e70 100644 --- a/Packages/SparkUI/Package.swift +++ b/Packages/SparkUI/Package.swift @@ -17,7 +17,10 @@ let package = Package( .target( name: "SparkUI", dependencies: ["SparkKit"], - path: "Sources/SparkUI" + path: "Sources/SparkUI", + resources: [ + .process("Resources"), + ] ), .testTarget( name: "SparkUITests", diff --git a/Packages/SparkUI/Sources/SparkUI/Charts/MetricTrendChart.swift b/Packages/SparkUI/Sources/SparkUI/Charts/MetricTrendChart.swift new file mode 100644 index 0000000..d993f65 --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Charts/MetricTrendChart.swift @@ -0,0 +1,154 @@ +import Charts +import SparkKit +import SwiftUI + +/// Swift Charts wrapper for metric trends. Renders: +/// โ€ข baseline band (RectangleMark) under everything +/// โ€ข area fill + line trend (AreaMark + LineMark) +/// โ€ข anomaly pins (PointMark with warning tint) +/// โ€ข a final marker on the latest data point +/// +/// VoiceOver gets an `AccessibilityChartDescriptor` so users can navigate +/// the series with the rotor. +public struct MetricTrendChart: View { + public let series: [MetricDetail.Point] + public let baseline: MetricDetail.Baseline? + public let anomalies: [MetricDetail.AnomalyPoint] + public let valueForAnomaly: (MetricDetail.AnomalyPoint) -> Double? + public let tint: Color + public let height: CGFloat + + public init( + series: [MetricDetail.Point], + baseline: MetricDetail.Baseline?, + anomalies: [MetricDetail.AnomalyPoint], + valueForAnomaly: @escaping (MetricDetail.AnomalyPoint) -> Double?, + tint: Color = .sparkAccent, + height: CGFloat = 180 + ) { + self.series = series + self.baseline = baseline + self.anomalies = anomalies + self.valueForAnomaly = valueForAnomaly + self.tint = tint + self.height = height + } + + public var body: some View { + Chart { + if let baseline { + RectangleMark( + yStart: .value("Baseline low", baseline.low), + yEnd: .value("Baseline high", baseline.high) + ) + .foregroundStyle(.primary.opacity(0.05)) + + RuleMark(y: .value("Baseline low", baseline.low)) + .foregroundStyle(.secondary.opacity(0.35)) + .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [3, 3])) + RuleMark(y: .value("Baseline high", baseline.high)) + .foregroundStyle(.secondary.opacity(0.35)) + .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [3, 3])) + } + + ForEach(series) { point in + AreaMark( + x: .value("Date", point.date), + y: .value("Value", point.value) + ) + .foregroundStyle( + LinearGradient( + colors: [tint.opacity(0.40), tint.opacity(0.00)], + startPoint: .top, + endPoint: .bottom + ) + ) + + LineMark( + x: .value("Date", point.date), + y: .value("Value", point.value) + ) + .foregroundStyle(tint) + .lineStyle(StrokeStyle(lineWidth: 2, lineJoin: .round)) + .interpolationMethod(.catmullRom) + } + + ForEach(anomalies) { anomaly in + if let value = valueForAnomaly(anomaly) { + PointMark( + x: .value("Date", anomaly.date), + y: .value("Value", value) + ) + .foregroundStyle(Color.sparkWarning) + .symbolSize(80) + .annotation(position: .top, spacing: 2) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 9)) + .foregroundStyle(Color.sparkWarning) + .accessibilityHidden(true) + } + } + } + + if let last = series.last { + PointMark( + x: .value("Today", last.date), + y: .value("Today", last.value) + ) + .foregroundStyle(tint) + .symbolSize(100) + .symbol(.circle) + } + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 3)) { value in + AxisValueLabel(format: .dateTime.month(.abbreviated).day()) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + .chartYAxis(.hidden) + .frame(height: height) + .accessibilityChartDescriptor(self) + } +} + +extension MetricTrendChart: AXChartDescriptorRepresentable { + nonisolated public func makeChartDescriptor() -> AXChartDescriptor { + let xAxis = AXNumericDataAxisDescriptor( + title: "Date", + range: (series.first?.date.timeIntervalSince1970 ?? 0) + ... (series.last?.date.timeIntervalSince1970 ?? 1), + gridlinePositions: [] + ) { value in + Date(timeIntervalSince1970: value).formatted(date: .abbreviated, time: .omitted) + } + + let values = series.map(\.value) + let yAxis = AXNumericDataAxisDescriptor( + title: "Value", + range: (values.min() ?? 0) ... (values.max() ?? 1), + gridlinePositions: [] + ) { "\($0)" } + + let dataSeries = AXDataSeriesDescriptor( + name: "Trend", + isContinuous: true, + dataPoints: series.map { point in + AXDataPoint( + x: point.date.timeIntervalSince1970, + y: point.value + ) + } + ) + + return AXChartDescriptor( + title: "Metric trend", + summary: nil, + xAxis: xAxis, + yAxis: yAxis, + additionalAxes: [], + series: [dataSeries] + ) + } +} diff --git a/Packages/SparkUI/Sources/SparkUI/Components/ActivityRings.swift b/Packages/SparkUI/Sources/SparkUI/Components/ActivityRings.swift new file mode 100644 index 0000000..2ecf178 --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Components/ActivityRings.swift @@ -0,0 +1,98 @@ +import SwiftUI + +/// Apple-style concentric Move / Exercise / Stand rings. Drawn with `Canvas` +/// so the geometry is crisp at every scale and the component has zero layout +/// cost. Colours match the Activity app for instant recognition. +public struct ActivityRings: View { + public let move: Double + public let exercise: Double + public let stand: Double + public let lineWidth: CGFloat + public let spacing: CGFloat + + public init( + move: Double, + exercise: Double, + stand: Double, + lineWidth: CGFloat = 10, + spacing: CGFloat = 4 + ) { + self.move = move + self.exercise = exercise + self.stand = stand + self.lineWidth = lineWidth + self.spacing = spacing + } + + public var body: some View { + Canvas { ctx, size in + let radius = min(size.width, size.height) / 2 + let center = CGPoint(x: size.width / 2, y: size.height / 2) + + draw(progress: move, color: Self.moveColor, ringRadius: radius - lineWidth / 2, + lineWidth: lineWidth, center: center, in: &ctx) + + let exR = radius - lineWidth - spacing - lineWidth / 2 + draw(progress: exercise, color: Self.exerciseColor, ringRadius: exR, + lineWidth: lineWidth, center: center, in: &ctx) + + let stR = radius - 2 * (lineWidth + spacing) - lineWidth / 2 + draw(progress: stand, color: Self.standColor, ringRadius: stR, + lineWidth: lineWidth, center: center, in: &ctx) + } + .aspectRatio(1, contentMode: .fit) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Activity rings") + .accessibilityValue( + "Move \(Int((move * 100).rounded())) percent, exercise \(Int((exercise * 100).rounded())) percent, stand \(Int((stand * 100).rounded())) percent" + ) + } + + private func draw( + progress: Double, + color: Color, + ringRadius: CGFloat, + lineWidth: CGFloat, + center: CGPoint, + in ctx: inout GraphicsContext + ) { + guard ringRadius > 0 else { return } + + // Faint full-track underlay. + var track = Path() + track.addArc(center: center, radius: ringRadius, + startAngle: .degrees(0), endAngle: .degrees(360), clockwise: false) + ctx.stroke(track, with: .color(color.opacity(0.18)), + style: StrokeStyle(lineWidth: lineWidth)) + + guard progress > 0 else { return } + + let clamped = min(progress, 1.0) + var arc = Path() + arc.addArc( + center: center, + radius: ringRadius, + startAngle: .degrees(-90), + endAngle: .degrees(-90 + 360 * clamped), + clockwise: false + ) + ctx.stroke(arc, with: .color(color), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + } + + // Apple Activity ring colours (close approximations). + public static let moveColor = Color(red: 1.000, green: 0.122, blue: 0.337) + public static let exerciseColor = Color(red: 0.573, green: 0.902, blue: 0.165) + public static let standColor = Color(red: 0.094, green: 0.886, blue: 1.000) +} + +#Preview("Activity Rings") { + HStack(spacing: 24) { + ActivityRings(move: 0.84, exercise: 0.62, stand: 0.75) + .frame(width: 100, height: 100) + ActivityRings(move: 1.2, exercise: 1.0, stand: 1.0) + .frame(width: 100, height: 100) + } + .padding() + .background(Color.sparkSurface) +} diff --git a/Packages/SparkUI/Sources/SparkUI/Components/DomainGlyph.swift b/Packages/SparkUI/Sources/SparkUI/Components/DomainGlyph.swift new file mode 100644 index 0000000..feca437 --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Components/DomainGlyph.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// Small tinted SF symbol in a muted square โ€” used as the leading glyph on +/// domain cards, list rows, and headers. Background is a faint material so +/// the glyph reads at every size class without colour overload. +public struct DomainGlyph: View { + public let icon: String + public let tint: Color + public let size: CGFloat + + public init(icon: String, tint: Color, size: CGFloat = 30) { + self.icon = icon + self.tint = tint + self.size = size + } + + public var body: some View { + Image(systemName: icon) + .font(.system(size: size * 0.5, weight: .medium)) + .foregroundStyle(tint) + .frame(width: size, height: size) + .background(.thinMaterial, in: .rect(cornerRadius: SparkRadii.sm)) + .accessibilityHidden(true) + } +} diff --git a/Packages/SparkUI/Sources/SparkUI/Components/GlassCard.swift b/Packages/SparkUI/Sources/SparkUI/Components/GlassCard.swift new file mode 100644 index 0000000..c4289f7 --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Components/GlassCard.swift @@ -0,0 +1,61 @@ +import SwiftUI + +/// Card wrapper that applies the standard Spark glass treatment with hero or +/// regular radii. Use for Today, detail-screen sections, anywhere a grouped +/// surface needs a subtle glass shell. +public struct GlassCard: View { + let radius: CGFloat + let padding: CGFloat + let tint: Color? + let content: Content + + public init( + radius: CGFloat = SparkRadii.lg, + padding: CGFloat = SparkSpacing.lg, + tint: Color? = nil, + @ViewBuilder content: () -> Content + ) { + self.radius = radius + self.padding = padding + self.tint = tint + self.content = content() + } + + public var body: some View { + content + .padding(padding) + .frame(maxWidth: .infinity, alignment: .leading) + .sparkGlass(.roundedRect(radius), tint: tint) + } +} + +/// Standard card header โ€” icon + title + optional trailing meta. Pair with +/// `GlassCard` content for the Today card pattern. +public struct GlassCardHeader: View { + public let icon: String + public let tint: Color + public let title: String + public let trailing: String? + + public init(icon: String, tint: Color, title: String, trailing: String? = nil) { + self.icon = icon + self.tint = tint + self.title = title + self.trailing = trailing + } + + public var body: some View { + HStack(spacing: SparkSpacing.sm) { + DomainGlyph(icon: icon, tint: tint, size: 22) + Text(title) + .font(SparkTypography.bodyStrong) + Spacer(minLength: SparkSpacing.sm) + if let trailing { + Text(trailing) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .accessibilityLabel(trailing) + } + } + } +} diff --git a/Packages/SparkUI/Sources/SparkUI/Components/Heatmap45.swift b/Packages/SparkUI/Sources/SparkUI/Components/Heatmap45.swift new file mode 100644 index 0000000..34b6a5c --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Components/Heatmap45.swift @@ -0,0 +1,93 @@ +import SwiftUI + +/// One row in the Today heatmap โ€” a 45-day intensity strip per domain. +public struct DomainHeatmapRow: Identifiable, Sendable { + public let id: String + public let label: String + public let values: [Double] + public let tint: Color + + public init(id: String, label: String, values: [Double], tint: Color) { + self.id = id + self.label = label + self.values = values + self.tint = tint + } +} + +/// Small-multiples heatmap pinned to the bottom of Today. Each row is a +/// 45-day strip per domain, with intensity derived from the row's tint. +/// Communicates rhythm without bombarding the chrome with colour. +public struct Heatmap45: View { + public let rows: [DomainHeatmapRow] + public let cellSpacing: CGFloat + public let labelWidth: CGFloat + + public init( + rows: [DomainHeatmapRow], + cellSpacing: CGFloat = 1.5, + labelWidth: CGFloat = 56 + ) { + self.rows = rows + self.cellSpacing = cellSpacing + self.labelWidth = labelWidth + } + + public var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + ForEach(rows) { row in + HStack(spacing: SparkSpacing.sm) { + Text(row.label.uppercased()) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .frame(width: labelWidth, alignment: .leading) + .accessibilityHidden(true) + + HStack(spacing: cellSpacing) { + ForEach(Array(row.values.suffix(45).enumerated()), id: \.offset) { _, v in + RoundedRectangle(cornerRadius: 1.5) + .fill(row.tint.opacity(max(0.05, min(v, 1.0)))) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(row.label) over the last 45 days") + } + } + } +} + +/// Deterministic heatmap fixture so Today renders before the backend ships +/// the 45-day endpoint. Replace once `/api/v1/mobile/heatmap` is live. +public enum HeatmapPlaceholder { + public static func generate(seed: UInt64 = 12_345, length: Int = 45) -> [String: [Double]] { + var s = seed + let lcg: () -> Double = { + s = s &* 9_301 &+ 49_297 + s = s % 233_280 + return Double(s) / 233_280.0 + } + return ["sleep", "activity", "spend", "mood"].reduce(into: [:]) { acc, key in + var row: [Double] = [] + for i in 0 ..< length { + let weekly = sin(Double(i) / 3.5) * 0.25 + 0.55 + row.append(max(0.05, min(1.0, weekly + (lcg() - 0.5) * 0.4))) + } + acc[key] = row + } + } +} + +#Preview("Heatmap45") { + let raw = HeatmapPlaceholder.generate() + Heatmap45(rows: [ + .init(id: "sleep", label: "Sleep", values: raw["sleep"] ?? [], tint: .domainHealth), + .init(id: "activity", 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), + ]) + .padding() + .background(Color.sparkSurface) +} diff --git a/Packages/SparkUI/Sources/SparkUI/Components/InspectorRow.swift b/Packages/SparkUI/Sources/SparkUI/Components/InspectorRow.swift new file mode 100644 index 0000000..266d07f --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Components/InspectorRow.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// Key/value row for the Inspector layout โ€” small mono key on the left, +/// regular value on the right with an optional mono treatment for +/// timestamps/IDs. Stack rows directly without padding wrappers; the row +/// draws its own bottom hairline so a series reads as a clean ledger. +public struct InspectorRow: View { + public let key: String + public let isMono: Bool + public let value: Value + + public init(_ key: String, isMono: Bool = false, @ViewBuilder value: () -> Value) { + self.key = key + self.isMono = isMono + self.value = value() + } + + public var body: some View { + HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.md) { + Text(key.uppercased()) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .frame(width: 84, alignment: .leading) + .accessibilityHidden(true) + + value + .font(isMono ? SparkTypography.mono : SparkTypography.bodySmall) + .foregroundStyle(.primary) + + Spacer(minLength: 0) + } + .padding(.vertical, SparkSpacing.sm + 3) + .padding(.horizontal, SparkSpacing.md) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.primary.opacity(0.06)) + .frame(height: 0.5) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(key) + } +} + +public extension InspectorRow where Value == Text { + /// Convenience for plain-text values. + init(_ key: String, _ value: String, isMono: Bool = false) { + self.init(key, isMono: isMono) { Text(value) } + } +} diff --git a/Packages/SparkUI/Sources/SparkUI/Components/SectionLabel.swift b/Packages/SparkUI/Sources/SparkUI/Components/SectionLabel.swift new file mode 100644 index 0000000..fe1ac26 --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Components/SectionLabel.swift @@ -0,0 +1,18 @@ +import SwiftUI + +/// Small all-caps mono label used as a section heading inside detail views +/// and Today cards. Sits flush-left above the section content. +public struct SectionLabel: View { + public let text: String + + public init(_ text: String) { + self.text = text + } + + public var body: some View { + Text(text.uppercased()) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .accessibilityAddTraits(.isHeader) + } +} diff --git a/Packages/SparkUI/Sources/SparkUI/Components/SleepHypnogram.swift b/Packages/SparkUI/Sources/SparkUI/Components/SleepHypnogram.swift new file mode 100644 index 0000000..335f5a5 --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Components/SleepHypnogram.swift @@ -0,0 +1,65 @@ +import Charts +import SwiftUI + +/// Compact bar-chart hypnogram rendering sleep depth (0โ€ฆ1) over the night. +/// Used inline in the Sleep card. Decorative โ€” the value is conveyed by the +/// surrounding metric copy, so this component is hidden from VoiceOver. +public struct SleepHypnogram: View { + public struct Stage: Identifiable, Sendable { + public let id: Int + public let depth: Double + + public init(id: Int, depth: Double) { + self.id = id + self.depth = depth + } + } + + public let stages: [Stage] + public let tint: Color + public let height: CGFloat + + public init( + stages: [Stage], + tint: Color = .ocean300, + height: CGFloat = 36 + ) { + self.stages = stages + self.tint = tint + self.height = height + } + + public var body: some View { + Chart(stages) { stage in + BarMark( + x: .value("Stage", stage.id), + y: .value("Depth", stage.depth) + ) + .foregroundStyle( + LinearGradient( + colors: [tint.opacity(0.55), tint], + startPoint: .bottom, + endPoint: .top + ) + ) + .cornerRadius(1) + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartYScale(domain: 0 ... 1) + .chartPlotStyle { $0.padding(.vertical, 0) } + .frame(height: height) + .accessibilityHidden(true) + } +} + +#Preview("Hypnogram") { + SleepHypnogram(stages: (0 ..< 28).map { i in + let v = [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] + return SleepHypnogram.Stage(id: i, depth: v[i % v.count]) + }) + .padding() + .background(Color.sparkSurface) +} diff --git a/Packages/SparkUI/Sources/SparkUI/Components/StatusPill.swift b/Packages/SparkUI/Sources/SparkUI/Components/StatusPill.swift new file mode 100644 index 0000000..c29b60c --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Components/StatusPill.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// Inline status indicator โ€” a coloured dot plus a short message. Used as +/// the "All baselines holding" / "N anomalies" pill on Today and elsewhere +/// where a quiet state needs to be readable in one glance. +public struct StatusPill: View { + public enum Tone: Sendable { + case ok + case warning + case neutral + } + + public let tone: Tone + public let message: String + public let trailing: String? + + public init(_ tone: Tone, message: String, trailing: String? = nil) { + self.tone = tone + self.message = message + self.trailing = trailing + } + + public var body: some View { + HStack(spacing: SparkSpacing.sm) { + Circle() + .fill(dotColor) + .frame(width: 8, height: 8) + Text(message) + .font(SparkTypography.bodySmall) + .foregroundStyle(.primary) + Spacer(minLength: SparkSpacing.sm) + if let trailing { + Text(trailing) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .sparkGlass(.roundedRect(SparkRadii.md)) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityText) + } + + private var dotColor: Color { + switch tone { + case .ok: .sparkSuccess + case .warning: .sparkWarning + case .neutral: .secondary + } + } + + private var accessibilityText: String { + if let trailing { "\(message). \(trailing)." } else { message } + } +} + +#Preview("StatusPill") { + VStack(spacing: 12) { + StatusPill(.ok, message: "All baselines holding", trailing: "0 anomalies") + StatusPill(.warning, message: "Resting HR โ†‘ 18 bpm", trailing: "1 anomaly") + StatusPill(.neutral, message: "Last sync 3 min ago") + } + .padding() + .background(Color.sparkSurface) +} diff --git a/Packages/SparkUI/Sources/SparkUI/Components/TagChip.swift b/Packages/SparkUI/Sources/SparkUI/Components/TagChip.swift new file mode 100644 index 0000000..594ce2d --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Components/TagChip.swift @@ -0,0 +1,115 @@ +import SwiftUI + +/// Small `#tag` style chip used in detail views and the tag editor. Ghost +/// variant carries dashed outline for "add" affordances. +public struct TagChip: View { + public let text: String + public let isGhost: Bool + + public init(_ text: String, isGhost: Bool = false) { + self.text = text + self.isGhost = isGhost + } + + public var body: some View { + Text(isGhost ? text : "#\(text)") + .font(SparkTypography.monoSmall) + .foregroundStyle(.primary) + .padding(.horizontal, SparkSpacing.md - 2) + .padding(.vertical, SparkSpacing.xs + 1) + .background(background) + .clipShape(.capsule) + .overlay { + if isGhost { + Capsule() + .strokeBorder(.secondary.opacity(0.4), + style: StrokeStyle(lineWidth: 0.5, dash: [3, 3])) + } + } + .accessibilityLabel(isGhost ? "Add tag" : "Tag \(text)") + } + + @ViewBuilder + private var background: some View { + if isGhost { + Color.clear + } else { + Color.primary.opacity(0.06) + } + } +} + +/// A flowing chip cluster that wraps tags onto multiple lines. +public struct TagChipRow: View { + public let tags: [String] + public let allowAdd: Bool + public let onAdd: (() -> Void)? + + public init(_ tags: [String], allowAdd: Bool = false, onAdd: (() -> Void)? = nil) { + self.tags = tags + self.allowAdd = allowAdd + self.onAdd = onAdd + } + + public var body: some View { + FlowLayout(spacing: SparkSpacing.xs + 2) { + ForEach(tags, id: \.self) { TagChip($0) } + if allowAdd { + Button(action: { onAdd?() }) { + TagChip("+", isGhost: true) + } + .buttonStyle(.plain) + .accessibilityLabel("Add tag") + } + } + } +} + +/// Minimal flow layout for chip rows. Wraps to next line when the current +/// line fills. Avoids dragging in a heavier external layout helper. +public struct FlowLayout: Layout { + public let spacing: CGFloat + + public init(spacing: CGFloat = 6) { self.spacing = spacing } + + public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var origin = CGPoint.zero + var lineHeight: CGFloat = 0 + var totalHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if origin.x + size.width > maxWidth, origin.x > 0 { + origin.x = 0 + origin.y += lineHeight + spacing + lineHeight = 0 + } + origin.x += size.width + spacing + lineHeight = max(lineHeight, size.height) + totalWidth = max(totalWidth, origin.x) + totalHeight = origin.y + lineHeight + } + return CGSize(width: totalWidth, height: totalHeight) + } + + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let maxWidth = bounds.width + var origin = bounds.origin + var lineHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if origin.x + size.width > bounds.maxX, origin.x > bounds.origin.x { + origin.x = bounds.origin.x + origin.y += lineHeight + spacing + lineHeight = 0 + } + subview.place(at: origin, proposal: ProposedViewSize(size)) + origin.x += size.width + spacing + lineHeight = max(lineHeight, size.height) + _ = maxWidth + } + } +} diff --git a/Packages/SparkUI/Sources/SparkUI/Resources/Fonts/Comfortaa-VariableFont_wght.ttf b/Packages/SparkUI/Sources/SparkUI/Resources/Fonts/Comfortaa-VariableFont_wght.ttf new file mode 100644 index 0000000..9acab52 Binary files /dev/null and b/Packages/SparkUI/Sources/SparkUI/Resources/Fonts/Comfortaa-VariableFont_wght.ttf differ diff --git a/Packages/SparkUI/Sources/SparkUI/Resources/Fonts/PTMono-Regular.ttf b/Packages/SparkUI/Sources/SparkUI/Resources/Fonts/PTMono-Regular.ttf new file mode 100644 index 0000000..b198383 Binary files /dev/null and b/Packages/SparkUI/Sources/SparkUI/Resources/Fonts/PTMono-Regular.ttf differ diff --git a/Packages/SparkUI/Sources/SparkUI/Theme/Color+Spark.swift b/Packages/SparkUI/Sources/SparkUI/Theme/Color+Spark.swift index 127ced8..166d69b 100644 --- a/Packages/SparkUI/Sources/SparkUI/Theme/Color+Spark.swift +++ b/Packages/SparkUI/Sources/SparkUI/Theme/Color+Spark.swift @@ -1,26 +1,109 @@ import SwiftUI +// MARK: - Spark brand palette +// +// Tokens mirror the Spark Design System (see `tokens.css` in the design +// bundle). Brand colours are constants โ€” they don't adapt to light/dark by +// themselves. Surface/text tokens further down DO adapt. + +public extension Color { + // Spark โ€” warm amber to orange. + static let spark50 = Color(red: 1.000, green: 0.969, blue: 0.839) + static let spark100 = Color(red: 1.000, green: 0.914, blue: 0.604) + static let spark200 = Color(red: 1.000, green: 0.851, blue: 0.400) + static let spark300 = Color(red: 1.000, green: 0.800, blue: 0.200) + /// Brand primary โ€” used for CTAs, hero values, active selection. + static let spark400 = Color(red: 1.000, green: 0.749, blue: 0.000) + static let spark500 = Color(red: 0.969, green: 0.569, blue: 0.161) + static let spark600 = Color(red: 0.851, green: 0.455, blue: 0.102) + static let spark700 = Color(red: 0.690, green: 0.314, blue: 0.059) + + // Flame + static let flame100 = Color(red: 0.980, green: 0.741, blue: 0.498) + static let flame200 = Color(red: 0.969, green: 0.569, blue: 0.161) + static let flame300 = Color(red: 0.690, green: 0.141, blue: 0.067) + static let flame400 = Color(red: 0.455, green: 0.094, blue: 0.043) + static let flame500 = Color(red: 0.235, green: 0.047, blue: 0.024) + + // Ember + static let ember100 = Color(red: 0.973, green: 0.757, blue: 0.725) + static let ember200 = Color(red: 0.961, green: 0.643, blue: 0.600) + static let ember300 = Color(red: 0.933, green: 0.388, blue: 0.322) + + // Ocean โ€” cool blues, used for sleep/health. + static let ocean100 = Color(red: 0.553, green: 0.725, blue: 0.867) + static let ocean200 = Color(red: 0.392, green: 0.620, blue: 0.753) + static let ocean300 = Color(red: 0.247, green: 0.533, blue: 0.773) + static let ocean400 = Color(red: 0.192, green: 0.431, blue: 0.631) + static let ocean500 = Color(red: 0.169, green: 0.369, blue: 0.612) + static let ocean600 = Color(red: 0.141, green: 0.310, blue: 0.514) + static let ocean700 = Color(red: 0.086, green: 0.188, blue: 0.314) + static let ocean800 = Color(red: 0.051, green: 0.122, blue: 0.369) + static let ocean900 = Color(red: 0.035, green: 0.082, blue: 0.251) + static let ocean950 = Color(red: 0.024, green: 0.051, blue: 0.157) + static let sky100 = Color(red: 0.820, green: 0.855, blue: 0.902) + + // Slate โ€” used as cool dark base in evening/night gradients. + static let slate500 = Color(red: 0.004, green: 0.086, blue: 0.153) + static let slate600 = Color(red: 0.004, green: 0.055, blue: 0.098) + static let slate700 = Color(red: 0.004, green: 0.071, blue: 0.125) + + // Ash โ€” light neutrals. + static let ash100 = Color(red: 0.988, green: 0.988, blue: 0.988) + static let ash200 = Color(red: 0.961, green: 0.961, blue: 0.961) + static let ash300 = Color(red: 0.922, green: 0.922, blue: 0.922) + static let ash400 = Color(red: 0.851, green: 0.851, blue: 0.851) +} + +// MARK: - Semantic colours + public extension Color { - /// Spark accent โ€” warm amber, legible on both light and dark backgrounds. - static let sparkAccent = Color(red: 0.95, green: 0.55, blue: 0.18) + /// Brand primary. Use for CTAs, active tab tint, hero values. + static let sparkAccent = Color.spark400 + /// Cool accent โ€” sleep, health, depth. + static let sparkOcean = Color.ocean300 + + static let sparkSuccess = Color(red: 0.478, green: 0.729, blue: 0.631) + static let sparkWarning = Color(red: 0.694, green: 0.424, blue: 0.537) + static let sparkError = Color(red: 0.886, green: 0.412, blue: 0.412) + static let sparkInfo = Color.ocean200 + + // Backwards-compat for Phase 1 callers. + static let sparkPositive = sparkSuccess + static let sparkNegative = sparkError +} + +// MARK: - Domain tints +// +// One canonical accent per domain so cards/widgets stay coherent. + +public extension Color { + static let domainHealth = Color.sparkSuccess + static let domainActivity = Color.spark500 + static let domainMoney = Color.spark400 + static let domainMedia = Color.ember300 + static let domainKnowledge = Color.ocean300 + static let domainAnomaly = Color.sparkWarning +} + +// MARK: - Surfaces (light/dark adaptive) + +public extension Color { /// Primary surface used under cards and sheets. static let sparkSurface = Color("SparkSurface", bundle: nil).fallback( - light: Color(red: 0.98, green: 0.97, blue: 0.95), - dark: Color(red: 0.09, green: 0.09, blue: 0.10) + light: Color(red: 0.969, green: 0.957, blue: 0.925), + dark: Color(red: 0.024, green: 0.051, blue: 0.090) ) /// Elevated surface for grouped cards. static let sparkElevated = Color("SparkElevated", bundle: nil).fallback( light: Color(red: 1, green: 1, blue: 1), - dark: Color(red: 0.13, green: 0.13, blue: 0.14) + dark: Color(red: 0.090, green: 0.106, blue: 0.149) ) static let sparkTextPrimary = Color.primary static let sparkTextSecondary = Color.secondary - static let sparkPositive = Color.green - static let sparkNegative = Color.red - static let sparkWarning = Color.yellow } private extension Color { diff --git a/Packages/SparkUI/Sources/SparkUI/Theme/Radii.swift b/Packages/SparkUI/Sources/SparkUI/Theme/Radii.swift index 58cc0b0..46824bd 100644 --- a/Packages/SparkUI/Sources/SparkUI/Theme/Radii.swift +++ b/Packages/SparkUI/Sources/SparkUI/Theme/Radii.swift @@ -4,5 +4,6 @@ public enum SparkRadii { public static let sm: CGFloat = 8 public static let md: CGFloat = 14 public static let lg: CGFloat = 22 + public static let hero: CGFloat = 28 public static let pill: CGFloat = 1000 } diff --git a/Packages/SparkUI/Sources/SparkUI/Theme/SparkFonts.swift b/Packages/SparkUI/Sources/SparkUI/Theme/SparkFonts.swift new file mode 100644 index 0000000..ef4eb12 --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Theme/SparkFonts.swift @@ -0,0 +1,66 @@ +import CoreText +import Foundation +import SwiftUI + +/// Bundled type. Comfortaa is used for hero/display moments, PT Mono for +/// timestamps and IDs. Body and UI text stay on SF Pro so Dynamic Type and +/// VoiceOver behave like every other iOS app. +public enum SparkFonts { + /// Postscript names (what `Font.custom(...)` looks up after registration). + public static let displayPostScriptName = "Comfortaa" + public static let monoPostScriptName = "PTMono-Regular" + + /// Register the bundled fonts with Core Text. Call once at app launch + /// before any view that depends on them renders. Idempotent. + public static func registerBundledFonts() { + guard !hasRegistered else { return } + register("Comfortaa-VariableFont_wght", ext: "ttf") + register("PTMono-Regular", ext: "ttf") + hasRegistered = true + } + + /// Comfortaa display font scaled against the given system text style so + /// Dynamic Type still works. + public static func display(_ style: Font.TextStyle = .largeTitle, weight: Font.Weight = .bold) -> Font { + Font.custom(displayPostScriptName, size: pointSize(for: style), relativeTo: style) + .weight(weight) + } + + /// PT Mono at the given style. Good for timestamps, IDs, hex codes. + public static func mono(_ style: Font.TextStyle = .footnote) -> Font { + Font.custom(monoPostScriptName, size: pointSize(for: style), relativeTo: style) + } + + // MARK: - Private + + private nonisolated(unsafe) static var hasRegistered = false + + private static func register(_ name: String, ext: String) { + guard let url = Bundle.module.url(forResource: name, withExtension: ext) else { + assertionFailure("Missing bundled font: \(name).\(ext)") + return + } + var error: Unmanaged? + if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) { + // Silent in release; the next Font.custom call will fall back to system. + assertionFailure("Failed to register \(name): \(String(describing: error?.takeRetainedValue()))") + } + } + + private static func pointSize(for style: Font.TextStyle) -> CGFloat { + switch style { + case .largeTitle: 34 + case .title: 28 + case .title2: 22 + case .title3: 20 + case .headline: 17 + case .body: 17 + case .callout: 16 + case .subheadline: 15 + case .footnote: 13 + case .caption: 12 + case .caption2: 11 + @unknown default: 17 + } + } +} diff --git a/Packages/SparkUI/Sources/SparkUI/Theme/TimeOfDay.swift b/Packages/SparkUI/Sources/SparkUI/Theme/TimeOfDay.swift new file mode 100644 index 0000000..518c1d2 --- /dev/null +++ b/Packages/SparkUI/Sources/SparkUI/Theme/TimeOfDay.swift @@ -0,0 +1,138 @@ +import SwiftUI + +/// The four time-of-day moods Today renders against. Each maps to a +/// background gradient + a default greeting. +public enum SparkTimeOfDay: String, CaseIterable, Sendable { + case morning + case afternoon + case evening + case night + + /// Pick the slot for the given hour of day (24h, local time). + public static func from(hour: Int) -> SparkTimeOfDay { + switch hour { + case 5..<11: .morning + case 11..<17: .afternoon + case 17..<21: .evening + default: .night + } + } + + public static func from(date: Date, calendar: Calendar = .current) -> SparkTimeOfDay { + from(hour: calendar.component(.hour, from: date)) + } + + /// Default greeting copy. Callers can override. + public var greeting: String { + switch self { + case .morning: "Good morning" + case .afternoon: "Good afternoon" + case .evening: "Good evening" + case .night: "Still up?" + } + } + + /// Whether the slot prefers light-on-dark text. + public var prefersDarkTreatment: Bool { + self == .evening || self == .night + } +} + +/// Today-only background. Two stacked radial gradients give the design's +/// dawn/day/evening/night washes a sense of depth without fighting the system +/// material under cards. +public struct TodayBackground: View { + public let timeOfDay: SparkTimeOfDay + + public init(_ timeOfDay: SparkTimeOfDay) { + self.timeOfDay = timeOfDay + } + + public var body: some View { + ZStack { + base + top + bottom + } + .ignoresSafeArea() + } + + private var base: some View { + switch timeOfDay { + case .morning: + return LinearGradient( + colors: [Color(red: 0.996, green: 0.969, blue: 0.922), Color(red: 0.961, green: 0.937, blue: 0.898)], + startPoint: .top, endPoint: .bottom + ) + case .afternoon: + return LinearGradient( + colors: [Color(red: 0.984, green: 0.973, blue: 0.941), Color(red: 0.953, green: 0.933, blue: 0.878)], + startPoint: .top, endPoint: .bottom + ) + case .evening: + return LinearGradient( + colors: [Color(red: 0.173, green: 0.212, blue: 0.329), Color(red: 0.047, green: 0.082, blue: 0.188)], + startPoint: .top, endPoint: .bottom + ) + case .night: + return LinearGradient( + colors: [Color(red: 0.020, green: 0.043, blue: 0.110), Color(red: 0.004, green: 0.024, blue: 0.078)], + startPoint: .top, endPoint: .bottom + ) + } + } + + @ViewBuilder + private var top: some View { + switch timeOfDay { + case .morning: + radial(Color(red: 1.0, green: 0.961, blue: 0.847), at: .topTrailing, span: 0.6) + case .afternoon: + radial(Color(red: 1.0, green: 0.984, blue: 0.902), at: .topTrailing, span: 0.55) + case .evening: + radial(Color(red: 0.722, green: 0.800, blue: 0.875), at: .topTrailing, span: 0.5) + case .night: + radial(Color(red: 0.114, green: 0.176, blue: 0.329), at: .topTrailing, span: 0.4) + } + } + + @ViewBuilder + private var bottom: some View { + switch timeOfDay { + case .morning: + radial(Color(red: 1.0, green: 0.910, blue: 0.780), at: .bottomLeading, span: 0.6) + case .afternoon: + radial(Color(red: 0.941, green: 0.902, blue: 0.843), at: .bottomLeading, span: 0.6) + case .evening: + radial(Color(red: 0.102, green: 0.153, blue: 0.278), at: .bottomLeading, span: 0.6) + case .night: + radial(Color(red: 0.039, green: 0.071, blue: 0.157), at: .bottomLeading, span: 0.7) + } + } + + private func radial(_ colour: Color, at unit: UnitPoint, span: CGFloat) -> some View { + GeometryReader { proxy in + let size = max(proxy.size.width, proxy.size.height) * (span * 1.3) + RadialGradient( + colors: [colour.opacity(0.85), .clear], + center: unit, + startRadius: 0, + endRadius: size + ) + .blendMode(.plusLighter) + } + } +} + +#Preview("Morning / Afternoon / Evening / Night") { + VStack(spacing: 0) { + ForEach(SparkTimeOfDay.allCases, id: \.self) { slot in + ZStack { + TodayBackground(slot) + Text(slot.greeting) + .font(SparkTypography.hero) + .foregroundStyle(slot.prefersDarkTreatment ? .white : .black) + } + } + } +} diff --git a/Packages/SparkUI/Sources/SparkUI/Theme/Typography.swift b/Packages/SparkUI/Sources/SparkUI/Theme/Typography.swift index 8bf24ea..1517cac 100644 --- a/Packages/SparkUI/Sources/SparkUI/Theme/Typography.swift +++ b/Packages/SparkUI/Sources/SparkUI/Theme/Typography.swift @@ -1,19 +1,36 @@ import SwiftUI +/// Type system. Hero/display uses Comfortaa via `SparkFonts.display`. Mono +/// uses PT Mono via `SparkFonts.mono`. Body/UI stays on SF Pro so Dynamic +/// Type behaves like a first-party app. public enum SparkTypography { - public static let displayLarge = Font.system(.largeTitle, design: .rounded).weight(.bold) - public static let display = Font.system(.title, design: .rounded).weight(.semibold) - public static let titleStrong = Font.system(.title2, design: .rounded).weight(.semibold) - public static let title = Font.system(.title3, design: .rounded) + // Hero / display โ€” Comfortaa + public static let heroXL = SparkFonts.display(.largeTitle, weight: .bold) + public static let hero = SparkFonts.display(.title, weight: .bold) + public static let heroSmall = SparkFonts.display(.title2, weight: .bold) + + // Backwards-compat aliases used by Phase 1 components. + public static let displayLarge = heroXL + public static let display = hero + public static let titleStrong = heroSmall + + // Body / UI โ€” SF Pro (Font.system gives free Dynamic Type) + public static let title = Font.system(.title3) public static let bodyStrong = Font.system(.body).weight(.semibold) public static let body = Font.system(.body) public static let bodySmall = Font.system(.callout) public static let caption = Font.system(.caption) public static let captionStrong = Font.system(.caption).weight(.semibold) - public static let monoBody = Font.system(.body, design: .monospaced) + + // 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) + public static let monoBody = SparkFonts.mono(.body) } public extension View { + /// Clamp Dynamic Type to a3 so hero glyphs don't overflow the iPhone + /// frame. Apply at the app root. func sparkDynamicTypeClamp() -> some View { self.dynamicTypeSize(...DynamicTypeSize.accessibility3) } diff --git a/Project.swift b/Project.swift index c22051b..89837f0 100644 --- a/Project.swift +++ b/Project.swift @@ -19,6 +19,7 @@ func appEntitlements() -> Entitlements { "com.apple.developer.associated-domains": .array([.string(associatedDomain)]), "com.apple.developer.healthkit": .boolean(true), "com.apple.developer.healthkit.access": .array([]), + "com.apple.developer.healthkit.background-delivery": .boolean(true), "com.apple.security.application-groups": .array([.string(appGroup)]), "keychain-access-groups": .array([.string(keychainGroup)]), ]) @@ -56,9 +57,14 @@ func appInfoPlist() -> InfoPlist { "Spark writes workouts and mindful sessions you log in the app.", "NSLocationWhenInUseUsageDescription": "Spark uses your location to tag check-ins and detect place visits.", + "BGTaskSchedulerPermittedIdentifiers": [ + "co.cronx.spark.refresh", + "co.cronx.spark.prefetch", + ], "NSUserActivityTypes": [ "co.cronx.spark.openToday", "co.cronx.spark.openEvent", + "com.apple.corespotlight.search-continue", ], "CFBundleURLTypes": [ [ @@ -194,6 +200,9 @@ let sparkApp: Target = .target( dependencies: [ .package(product: "SparkKit"), .package(product: "SparkUI"), + .package(product: "SparkHealth"), + .package(product: "SparkIntelligence"), + .package(product: "SparkSync"), .package(product: "Sentry"), .target(name: "SparkWidgets"), .target(name: "SparkControls"), @@ -202,7 +211,16 @@ let sparkApp: Target = .target( .target(name: "SparkIntents"), .target(name: "SparkNotificationService"), ], - settings: sharedSettings(bundleId: bundleIdBase) + settings: .settings( + base: baseSettings.merging([ + "PRODUCT_BUNDLE_IDENTIFIER": .string(bundleIdBase), + "ASSETCATALOG_COMPILER_APPICON_NAME": "SparkIcon", + ]), + configurations: [ + .debug(name: "Debug"), + .release(name: "Release"), + ] + ) ) let sparkWidgets: Target = .target( @@ -218,6 +236,7 @@ let sparkWidgets: Target = .target( dependencies: [ .package(product: "SparkKit"), .package(product: "SparkUI"), + .package(product: "SparkIntelligence"), ], settings: sharedSettings(bundleId: "\(bundleIdBase).Widgets") ) @@ -279,6 +298,7 @@ let sparkIntents: Target = .target( entitlements: .file(path: "Extensions/SparkIntents/SparkIntents.entitlements"), dependencies: [ .package(product: "SparkKit"), + .package(product: "SparkIntelligence"), ], settings: sharedSettings(bundleId: "\(bundleIdBase).Intents") ) diff --git a/SparkApp/Sources/App/AppModel.swift b/SparkApp/Sources/App/AppModel.swift index e17b60d..b884ce8 100644 --- a/SparkApp/Sources/App/AppModel.swift +++ b/SparkApp/Sources/App/AppModel.swift @@ -1,8 +1,11 @@ import Foundation import Observation import Sentry +import SparkHealth import SparkKit +import SparkSync import SwiftData +import WidgetKit enum SessionState: Equatable { case unknown @@ -14,6 +17,11 @@ enum AppRoute: Hashable { case today(date: Date?) case day(Date) case event(id: String) + case object(id: String) + case block(id: String) + case metric(identifier: String) + case place(id: String) + case integration(service: String) } @MainActor @@ -40,8 +48,11 @@ final class AppModel { let etagCache: ETagCache let apiClient: APIClient let authService: AuthenticationService + let healthPermissions = HealthKitPermissionManager.shared + let reverb: ReverbClient var session: SessionState = .unknown + var onboardingComplete: Bool var lastError: String? var pendingRoute: AppRoute? @@ -54,16 +65,100 @@ final class AppModel { self.etagCache = etagCache 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 } func bootstrap() async { - if await tokenStore.accessToken() != nil { + if let token = await tokenStore.accessToken() { + onboardingComplete = true session = .loggedIn + await registerDevice() + await fetchAndCacheUserId() + configureHealthUploader(accessToken: token) + consumePendingIntentRoute() + await wireReverbHandler() + await reverbConnect() } else { session = .loggedOut } } + private func wireReverbHandler() async { + let client = apiClient + let cont = container + await reverb.addHandler { event in + let syncEvents: Set = [ + "event.created", "event.updated", "event.deleted", + "anomaly.raised", "notification.received", + ] + guard syncEvents.contains(event.eventName) else { return } + Task { @MainActor in + _ = await DeltaSyncer.sync(using: client, container: cont) + WidgetCenter.shared.reloadAllTimelines() + } + } + } + + /// Connect Reverb when the app is in the foreground. + /// The user ID is cached in UserDefaults after bootstrap via GET /me. + func reverbConnect() async { + guard session == .loggedIn else { return } + let userId = UserDefaults.sparkAppGroup.string(forKey: "spark.userId") ?? "" + guard !userId.isEmpty else { return } + await reverb.connect(userId: userId) + } + + /// Disconnect Reverb when the app moves to the background. + func reverbDisconnect() async { + await reverb.disconnect() + } + + /// 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") + 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) + guard let kind = parts.first else { return } + switch kind { + case "today": pendingRoute = .today(date: nil) + case "event": if let id = parts.last { pendingRoute = .event(id: id) } + case "metric": if let id = parts.last { pendingRoute = .metric(identifier: id) } + case "place": if let id = parts.last { pendingRoute = .place(id: id) } + case "search": break // SearchView picks up the query separately + case "action": + if parts.last == "startSleep" { + Task { await LiveActivityManager.shared.startSleepActivity(bedtime: .now, targetWakeTime: nil) } + } else if parts.last == "endSleep" { + Task { await LiveActivityManager.shared.endSleepActivity(score: 0, durationMinutes: 0) } + } + default: break + } + } + + private func fetchAndCacheUserId() async { + guard let profile = try? await apiClient.request(MeEndpoint.get()) else { return } + UserDefaults.sparkAppGroup.set(profile.id, forKey: "spark.userId") + } + + private func configureHealthUploader(accessToken: String) { + HealthSampleUploader.shared.configure( + environment: APIEnvironment.current(), + accessToken: accessToken + ) + } + + 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 signIn(anchor: ASPresentationAnchorHandle) async { do { try await authService.signIn(presentationAnchor: anchor.value) @@ -73,6 +168,9 @@ final class AppModel { lastError = nil } catch { lastError = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + if case let APIError.httpStatus(status, _, url) = error { + SentrySDK.capture(message: "Auth sign-in HTTP error \(status) at \(url.absoluteString)") + } SentrySDK.capture(error: error) } } diff --git a/SparkApp/Sources/App/LiveActivityManager.swift b/SparkApp/Sources/App/LiveActivityManager.swift new file mode 100644 index 0000000..ca908f7 --- /dev/null +++ b/SparkApp/Sources/App/LiveActivityManager.swift @@ -0,0 +1,138 @@ +@preconcurrency import ActivityKit +import Foundation +import OSLog +import SparkKit + +/// Manages the lifecycle of Spark Live Activities: start, update, end, +/// and push-token registration with the backend. +@MainActor +@Observable +final class LiveActivityManager { + static let shared = LiveActivityManager() + + private var sleepActivity: Activity? + private var dailyActivity: Activity? + private var tokenTasks: [String: Task] = [:] + + private nonisolated let logger = Logger(subsystem: "co.cronx.spark", category: "LiveActivity") + + // MARK: - Sleep LA + + func startSleepActivity(bedtime: Date, targetWakeTime: Date?) async { + guard sleepActivity == nil else { return } + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + logger.warning("Live Activities disabled by user") + return + } + + let attributes = SleepActivityAttributes(bedtime: bedtime, targetWakeTime: targetWakeTime) + let initialState = SleepActivityAttributes.SleepContentState(phase: .preparing) + + do { + let activity = try Activity.request( + attributes: attributes, + content: .init(state: initialState, staleDate: nil), + pushType: .token + ) + sleepActivity = activity + logger.info("Started sleep Live Activity \(activity.id)") + let apiClient = AppModel.shared.apiClient + observePushTokens(for: activity, type: "sleep", apiClient: apiClient) + } catch { + logger.error("Failed to start sleep LA: \(error)") + } + } + + func updateSleepActivity(state: SleepActivityAttributes.SleepContentState) async { + guard let activity = sleepActivity else { return } + await activity.update(.init(state: state, staleDate: nil)) + } + + func endSleepActivity(score: Int, durationMinutes: Int) async { + guard let activity = sleepActivity else { return } + let resolvedState = SleepActivityAttributes.SleepContentState( + phase: .resolved, + sleepScore: score, + durationMinutes: durationMinutes + ) + await activity.end( + .init(state: resolvedState, staleDate: nil), + dismissalPolicy: .after(.now.addingTimeInterval(60)) + ) + cancelTokenTask(for: activity.id) + sleepActivity = nil + } + + // MARK: - Daily Activity Rings LA + + func startDailyActivity() async { + guard dailyActivity == nil else { return } + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + + let attributes = DailyActivityAttributes() + let initialState = DailyActivityAttributes.DailyContentState() + + do { + let activity = try Activity.request( + attributes: attributes, + content: .init(state: initialState, staleDate: nil), + pushType: .token + ) + dailyActivity = activity + logger.info("Started daily activity Live Activity \(activity.id)") + let apiClient = AppModel.shared.apiClient + observePushTokens(for: activity, type: "daily", apiClient: apiClient) + } catch { + logger.error("Failed to start daily activity LA: \(error)") + } + } + + func updateDailyActivity(state: DailyActivityAttributes.DailyContentState) async { + guard let activity = dailyActivity else { return } + await activity.update(.init(state: state, staleDate: nil)) + } + + func endDailyActivity() async { + guard let activity = dailyActivity else { return } + await activity.end( + .init(state: activity.content.state, staleDate: nil), + dismissalPolicy: .immediate + ) + cancelTokenTask(for: activity.id) + dailyActivity = nil + } + + // MARK: - Push token observation + + private func observePushTokens( + for activity: Activity, + type activityType: String, + apiClient: APIClient + ) { + let activityID = activity.id + let log = logger + let task = Task { + for await tokenData in activity.pushTokenUpdates { + let tokenString = tokenData.map { String(format: "%02x", $0) }.joined() + do { + _ = try await apiClient.request( + LiveActivitiesEndpoint.registerToken( + activityID: activityID, + token: tokenString, + type: activityType + ) + ) + log.info("Registered LA push token for \(activityID)") + } catch { + log.error("Failed to register LA token: \(error)") + } + } + } + tokenTasks[activityID] = task + } + + private func cancelTokenTask(for activityID: String) { + tokenTasks[activityID]?.cancel() + tokenTasks.removeValue(forKey: activityID) + } +} diff --git a/SparkApp/Sources/App/MainTabView.swift b/SparkApp/Sources/App/MainTabView.swift index a4f75ed..798bd76 100644 --- a/SparkApp/Sources/App/MainTabView.swift +++ b/SparkApp/Sources/App/MainTabView.swift @@ -1,42 +1,37 @@ +import SparkKit import SparkUI import SwiftUI struct MainTabView: View { @Environment(AppModel.self) private var model - @State private var selection: Tab = .today + @State private var selection: AppTab = .day var body: some View { @Bindable var model = model TabView(selection: $selection) { - DayPagerView() - .tabItem { Label("Today", systemImage: "sun.max.fill") } - .tag(Tab.today) - - ComingSoonTab(title: "Timeline", systemImage: "clock") - .tabItem { Label("Timeline", systemImage: "clock") } - .tag(Tab.timeline) - - ComingSoonTab(title: "Settings", systemImage: "gear") - .tabItem { Label("Settings", systemImage: "gear") } - .tag(Tab.settings) + Tab("Day", systemImage: "sun.max.fill", value: AppTab.day) { + DayPagerView() + } + Tab("Explore", systemImage: "safari", value: AppTab.explore) { + ExploreView() + } + Tab("Knowledge", systemImage: "books.vertical.fill", value: AppTab.knowledge) { + KnowledgeView() + } + Tab("Flint", systemImage: "sparkles", value: AppTab.flint) { + FlintView() + } + Tab(value: AppTab.search, role: .search) { + SearchView() + } } .onChange(of: model.pendingRoute) { _, new in guard new != nil else { return } - selection = .today + selection = .day } } - - enum Tab: Hashable { case today, timeline, settings } } -private struct ComingSoonTab: View { - let title: String - let systemImage: String - - var body: some View { - NavigationStack { - EmptyState(systemImage: systemImage, title: title, message: "Coming in Phase 2.") - .navigationTitle(title) - } - } +enum AppTab: Hashable { + case day, explore, knowledge, flint, search } diff --git a/SparkApp/Sources/App/RootView.swift b/SparkApp/Sources/App/RootView.swift index f801317..292cb66 100644 --- a/SparkApp/Sources/App/RootView.swift +++ b/SparkApp/Sources/App/RootView.swift @@ -6,15 +6,20 @@ struct RootView: View { @Environment(AppModel.self) private var model var body: some View { + @Bindable var model = model Group { switch model.session { case .unknown: ProgressView() .task { await model.bootstrap() } case .loggedOut: - LoginView() + OnboardingFlow(isComplete: $model.onboardingComplete) case .loggedIn: - MainTabView() + if model.onboardingComplete { + MainTabView() + } else { + OnboardingFlow(isComplete: $model.onboardingComplete) + } } } .onOpenURL(perform: handle(url:)) @@ -24,13 +29,23 @@ struct RootView: View { guard let link = DeepLink.parse(url) else { return } switch link { case .authCallback: - break // ASWebAuthenticationSession owns the callback. + break case .today(let date): model.pendingRoute = .today(date: date) case .day(let date): model.pendingRoute = .day(date) case .event(let id): model.pendingRoute = .event(id: id) + case .object(let id): + model.pendingRoute = .object(id: id) + case .block(let id): + model.pendingRoute = .block(id: id) + case .metric(let identifier): + model.pendingRoute = .metric(identifier: identifier) + case .place(let id): + model.pendingRoute = .place(id: id) + case .integration(let service): + model.pendingRoute = .integration(service: service) } } } diff --git a/SparkApp/Sources/CheckIn/CheckInModalView.swift b/SparkApp/Sources/CheckIn/CheckInModalView.swift new file mode 100644 index 0000000..3d0dc0b --- /dev/null +++ b/SparkApp/Sources/CheckIn/CheckInModalView.swift @@ -0,0 +1,212 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct CheckInModalView: View { + @Environment(AppModel.self) private var appModel + @Environment(\.dismiss) private var dismiss + + let slot: String + let date: Date + + @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? + + 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), + ] + + private let defaultTags = ["restless", "dreams", "headache", "energised", "stressed", "calm"] + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.xl) { + moodSection + tagsSection + noteSection + if let err = logError { + Text(err) + .font(SparkTypography.bodySmall) + .foregroundStyle(Color.sparkError) + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.xl) + } + .scrollContentBackground(.hidden) + .background(Color.sparkSurface.ignoresSafeArea()) + .navigationTitle("\(slot.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() + } + } + } + } + + // 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 tagsSection: 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) + } + } + } + } + } + } + + private var noteSection: some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + HStack { + SectionLabel("NOTE") + Spacer() + Text("\(note.count) / 500") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + TextEditor(text: Binding( + get: { note }, + set: { note = String($0.prefix(500)) } + )) + .font(SparkTypography.body) + .frame(minHeight: 100, maxHeight: 200) + .scrollContentBackground(.hidden) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + } + + // 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 + ) + + // Persist locally first (optimistic) + persistLocally(entry) + + // POST to backend (best-effort) + _ = try? await appModel.apiClient.request(CheckInsEndpoint.create(entry)) + + dismiss() + } + + 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 static func dateKey(_ date: Date) -> String { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f.string(from: date) + } +} + +// MARK: - Components + +private struct MoodChip: View { + let label: String + let color: Color + let isSelected: Bool + let onTap: () -> Void + + 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) + } + .buttonStyle(.plain) + .accessibilityLabel("Mood: \(label)\(isSelected ? ", selected" : "")") + } +} + +private struct SelectableTagChip: View { + let tag: String + let isSelected: Bool + let onTap: () -> 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) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Tag \(tag)\(isSelected ? ", selected" : "")") + } +} diff --git a/SparkApp/Sources/Detail/BlockDetailView.swift b/SparkApp/Sources/Detail/BlockDetailView.swift new file mode 100644 index 0000000..6dd4e39 --- /dev/null +++ b/SparkApp/Sources/Detail/BlockDetailView.swift @@ -0,0 +1,163 @@ +import SparkKit +import SparkUI +import SwiftUI + +@MainActor +@Observable +final class BlockDetailViewModel { + let blockId: String + private(set) var state: DetailLoadState = .loading + + private let apiClient: APIClient + + init(blockId: String, apiClient: APIClient) { + self.blockId = blockId + self.apiClient = apiClient + } + + func load() async { + state = .loading + do { + let detail = try await apiClient.request(BlocksEndpoint.detail(id: blockId)) + state = .loaded(detail) + } catch APIError.notModified { + return + } catch { + SparkObservability.captureHandled(error) + let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + state = .error(msg) + } + } +} + +struct BlockDetailView: View { + let blockId: String + @Environment(AppModel.self) private var appModel + @State private var viewModel: BlockDetailViewModel? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + switch viewModel?.state { + case .loaded(let detail): + content(for: detail) + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load block", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel?.load() } } + default: + LoadingShimmerCard() + LoadingShimmerCard() + } + } + .padding(SparkSpacing.lg) + } + .background(Color.sparkSurface.ignoresSafeArea()) + .navigationTitle("Block") + .navigationBarTitleDisplayMode(.inline) + .task(id: blockId) { + if viewModel == nil { + viewModel = BlockDetailViewModel(blockId: blockId, apiClient: appModel.apiClient) + } + await viewModel?.load() + } + } + + @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) + } + + if let body = detail.block.content, !body.isEmpty { + GlassCard { + Text(LocalizedStringKey(body)) + .font(SparkTypography.body) + .accessibilityLabel(body) + } + } + + 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) + } + } + } + + 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) + } + } + } + } + } + + 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 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 isValueBlock(_ block: Block) -> Bool { + block.blockType.lowercased().contains("value") && block.value != nil + } + + private static let shortTimeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "d MMM, HH:mm" + return f + }() +} diff --git a/SparkApp/Sources/Detail/EventDetailView.swift b/SparkApp/Sources/Detail/EventDetailView.swift new file mode 100644 index 0000000..8044ee5 --- /dev/null +++ b/SparkApp/Sources/Detail/EventDetailView.swift @@ -0,0 +1,260 @@ +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? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + switch viewModel?.state { + case .loaded(let detail): + content(for: detail) + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load event", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel?.retry() } } + default: + LoadingShimmerCard() + LoadingShimmerCard() + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.lg) + } + .background(Color.sparkSurface.ignoresSafeArea()) + .navigationTitle("Event") + .navigationBarTitleDisplayMode(.inline) + .task(id: eventId) { + if viewModel == nil { + viewModel = EventDetailViewModel(eventId: eventId, apiClient: appModel.apiClient) + } + await viewModel?.load() + } + } + + @ViewBuilder + private func content(for detail: EventDetail) -> some View { + heroCard(for: detail) + inspectorRows(for: detail) + + if let actor = detail.actor { + actorTargetCard(label: "Actor", entity: actor) + } + if let target = detail.target { + actorTargetCard(label: "Target", entity: target) + } + + if let summary = detail.aiSummary, !summary.isEmpty { + aiSummaryCard(summary) + } + + if !detail.blocks.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("Linked blocks (\(detail.blocks.count))") + ForEach(detail.blocks) { block in + blockRow(block) + } + } + } + + if !detail.tags.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("Tags") + TagChipRow(detail.tags) + } + } + + if !detail.related.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("Recurring at this place") + ForEach(detail.related) { rel in + relatedRow(rel) + } + } + } + } + + // MARK: - Hero + + 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) + } + } + + 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) + } + } + } + } + } + + private func heroBadge(for event: Event) -> String { + [event.action, event.domain, event.service] + .map { $0.uppercased() } + .joined(separator: " ยท ") + } + + // 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) + } + } + } + } + } + + // MARK: - Actor / Target + + 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 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() + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("AI summary. \(summary)") + } + + // MARK: - Blocks / related + + 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) + } + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .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) + .font(SparkTypography.bodySmall) + if let meta = rel.meta { + Text(meta) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + 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 + }() +} + +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 + } + } +} diff --git a/SparkApp/Sources/Detail/EventDetailViewModel.swift b/SparkApp/Sources/Detail/EventDetailViewModel.swift new file mode 100644 index 0000000..cf3284c --- /dev/null +++ b/SparkApp/Sources/Detail/EventDetailViewModel.swift @@ -0,0 +1,42 @@ +import Foundation +import Observation +import SparkKit + +enum DetailLoadState: Sendable { + case loading + case loaded(T) + case error(String) +} + +@MainActor +@Observable +final class EventDetailViewModel { + let eventId: String + private(set) var state: DetailLoadState = .loading + + private let apiClient: APIClient + + init(eventId: String, apiClient: APIClient) { + self.eventId = eventId + self.apiClient = apiClient + } + + func load() async { + state = .loading + do { + let detail = try await apiClient.request(EventsEndpoint.detail(id: eventId)) + state = .loaded(detail) + } catch APIError.notModified { + // Already loaded โ€” keep current state. + return + } catch { + SparkObservability.captureHandled(error) + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + state = .error(message) + } + } + + func retry() async { + await load() + } +} diff --git a/SparkApp/Sources/Detail/MetricDetailView.swift b/SparkApp/Sources/Detail/MetricDetailView.swift new file mode 100644 index 0000000..cd27634 --- /dev/null +++ b/SparkApp/Sources/Detail/MetricDetailView.swift @@ -0,0 +1,280 @@ +import SparkKit +import SparkUI +import SwiftUI + +@MainActor +@Observable +final class MetricDetailViewModel { + let identifier: String + var range: MetricsEndpoint.Range + private(set) var state: DetailLoadState = .loading + + private let apiClient: APIClient + + init(identifier: String, range: MetricsEndpoint.Range = .thirtyDays, apiClient: APIClient) { + self.identifier = identifier + self.range = range + self.apiClient = apiClient + } + + func load() async { + state = .loading + do { + let detail = try await apiClient.request( + MetricsEndpoint.detail(identifier: identifier, range: range) + ) + state = .loaded(detail) + } catch APIError.notModified { + return + } catch { + SparkObservability.captureHandled(error) + let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + state = .error(msg) + } + } + + func setRange(_ newRange: MetricsEndpoint.Range) async { + guard newRange != range else { return } + range = newRange + await load() + } +} + +struct MetricDetailView: View { + let identifier: String + @Environment(AppModel.self) private var appModel + @State private var viewModel: MetricDetailViewModel? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + switch viewModel?.state { + case .loaded(let detail): + content(for: detail) + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load metric", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel?.load() } } + default: + LoadingShimmerCard() + LoadingShimmerCard() + } + } + .padding(SparkSpacing.lg) + } + .background(Color.sparkSurface.ignoresSafeArea()) + .navigationTitle("Metric") + .navigationBarTitleDisplayMode(.inline) + .task(id: identifier) { + if viewModel == nil { + viewModel = MetricDetailViewModel( + identifier: identifier, + apiClient: appModel.apiClient + ) + } + await viewModel?.load() + } + } + + @ViewBuilder + private func content(for detail: MetricDetail) -> some View { + heroSection(detail) + rangePicker(detail) + chartCard(detail) + legend(detail) + if let compares = detail.compares, !compares.isEmpty { + compareSection(compares) + } + anomalyList(detail) + } + + // MARK: - Hero + + private func heroSection(_ detail: MetricDetail) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("\(detail.domain) ยท \(detail.id)") + Text(detail.title) + .font(SparkFonts.display(.title, weight: .bold)) + .accessibilityAddTraits(.isHeader) + + HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.lg) { + if let today = detail.today { + Text(format(value: today, unit: detail.unit)) + .font(SparkFonts.display(.largeTitle, weight: .bold)) + .foregroundStyle(Color.domainTint(for: detail.domain)) + .accessibilityLabel("Today \(format(value: today, unit: detail.unit))") + } + + VStack(alignment: .leading, spacing: 2) { + if let avg = detail.average30d, let today = detail.today { + Text(deltaLabel(today: today, average: avg)) + .font(SparkTypography.bodySmall) + .foregroundStyle(today >= avg ? Color.sparkSuccess : Color.sparkWarning) + } + if let avg = detail.average30d { + Text("30d avg \(format(value: avg, unit: detail.unit))") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 0) + } + } + } + + private func deltaLabel(today: Double, average: Double) -> String { + let diff = today - average + let sign = diff >= 0 ? "+" : "" + return "\(sign)\(formatNumber(diff)) vs avg" + } + + // MARK: - Range picker + + private func rangePicker(_ detail: MetricDetail) -> some View { + let bound = Binding( + get: { viewModel?.range ?? .thirtyDays }, + set: { newValue in Task { await viewModel?.setRange(newValue) } } + ) + + return Picker("Range", selection: bound) { + ForEach(MetricsEndpoint.Range.allCases, id: \.self) { range in + Text(range.label).tag(range) + } + } + .pickerStyle(.segmented) + .accessibilityLabel("Date range") + } + + // MARK: - Chart + + private func chartCard(_ detail: MetricDetail) -> some View { + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + MetricTrendChart( + series: detail.series, + baseline: detail.baseline, + anomalies: detail.anomalies, + valueForAnomaly: { detail.valueForAnomaly($0) }, + tint: Color.domainTint(for: detail.domain) + ) + } + } + } + + private func legend(_ detail: MetricDetail) -> some View { + HStack(spacing: SparkSpacing.lg) { + HStack(spacing: SparkSpacing.xs + 2) { + Rectangle() + .fill(Color.domainTint(for: detail.domain)) + .frame(width: 14, height: 2) + Text(detail.title.lowercased()) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + } + if detail.baseline != nil { + HStack(spacing: SparkSpacing.xs + 2) { + RoundedRectangle(cornerRadius: 2) + .stroke(.secondary.opacity(0.4), + style: StrokeStyle(lineWidth: 0.5, dash: [3, 3])) + .frame(width: 14, height: 8) + Text("baseline") + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + } + } + if !detail.anomalies.isEmpty { + HStack(spacing: SparkSpacing.xs + 2) { + Circle() + .fill(Color.sparkWarning) + .frame(width: 8, height: 8) + Text("anomaly") + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + } + } + + // MARK: - Compare grid + + private func compareSection(_ compares: [MetricDetail.Compare]) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("Compare") + HStack(spacing: SparkSpacing.sm) { + ForEach(compares.prefix(3)) { compare in + GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { + VStack(alignment: .leading, spacing: 2) { + Text(compare.label.uppercased()) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + Text(formatNumber(compare.value)) + .font(SparkFonts.display(.title3, weight: .bold)) + if let delta = compare.delta { + Text("\(delta >= 0 ? "+" : "")\(formatNumber(delta))") + .font(SparkTypography.captionStrong) + .foregroundStyle(delta >= 0 ? Color.sparkSuccess : Color.sparkWarning) + } + } + } + } + } + } + } + + // MARK: - Anomalies list + + @ViewBuilder + private func anomalyList(_ detail: MetricDetail) -> some View { + if !detail.anomalies.isEmpty { + 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) + } + } + } + } + } + } + + // MARK: - Formatting + + private func format(value: Double, unit: String?) -> String { + let formatted = formatNumber(value) + guard let unit, !unit.isEmpty else { return formatted } + return "\(formatted) \(unit)" + } + + private 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) + } + + private 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 new file mode 100644 index 0000000..72ed768 --- /dev/null +++ b/SparkApp/Sources/Detail/ObjectDetailView.swift @@ -0,0 +1,203 @@ +import SparkKit +import SparkUI +import SwiftUI + +@MainActor +@Observable +final class ObjectDetailViewModel { + let objectId: String + private(set) var state: DetailLoadState = .loading + + private let apiClient: APIClient + + init(objectId: String, apiClient: APIClient) { + self.objectId = objectId + self.apiClient = apiClient + } + + func load() async { + state = .loading + do { + let detail = try await apiClient.request(ObjectsEndpoint.detail(id: objectId)) + state = .loaded(detail) + } catch APIError.notModified { + return + } catch { + SparkObservability.captureHandled(error) + let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + state = .error(msg) + } + } +} + +struct ObjectDetailView: View { + let objectId: String + @Environment(AppModel.self) private var appModel + @State private var viewModel: ObjectDetailViewModel? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + switch viewModel?.state { + case .loaded(let detail): + content(for: detail) + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load object", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel?.load() } } + default: + LoadingShimmerCard() + LoadingShimmerCard() + } + } + .padding(SparkSpacing.lg) + } + .background(Color.sparkSurface.ignoresSafeArea()) + .navigationTitle("Object") + .navigationBarTitleDisplayMode(.inline) + .task(id: objectId) { + if viewModel == nil { + viewModel = ObjectDetailViewModel(objectId: objectId, apiClient: appModel.apiClient) + } + await viewModel?.load() + } + } + + @ViewBuilder + private func content(for detail: ObjectDetail) -> some View { + heroCard(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)) + } + } + } + } + + if !detail.tags.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("Tags") + TagChipRow(detail.tags) + } + } + + if !detail.relatedObjects.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("Related") + ForEach(detail.relatedObjects) { rel in + relatedObjectRow(rel) + } + } + } + + if !detail.recentEvents.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("Recent events") + ForEach(detail.recentEvents) { event in + eventRowSummary(event) + } + } + } + } + + 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 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 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) + } + } + } + + 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 + }() +} diff --git a/SparkApp/Sources/Detail/PlaceDetailView.swift b/SparkApp/Sources/Detail/PlaceDetailView.swift new file mode 100644 index 0000000..0f05b10 --- /dev/null +++ b/SparkApp/Sources/Detail/PlaceDetailView.swift @@ -0,0 +1,225 @@ +import MapKit +import Observation +import SparkKit +import SparkUI +import SwiftUI + +@MainActor +@Observable +final class PlaceDetailViewModel { + let placeId: String + private(set) var state: DetailLoadState = .loading + + private let apiClient: APIClient + + init(placeId: String, apiClient: APIClient) { + self.placeId = placeId + self.apiClient = apiClient + } + + func load() async { + state = .loading + do { + let detail = try await apiClient.request(PlacesEndpoint.detail(id: placeId)) + state = .loaded(detail) + } catch APIError.notModified { + return + } catch { + SparkObservability.captureHandled(error) + let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + state = .error(msg) + } + } +} + +struct PlaceDetailView: View { + let placeId: String + @Environment(AppModel.self) private var appModel + @State private var viewModel: PlaceDetailViewModel? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + switch viewModel?.state { + case .loaded(let detail): + content(for: detail) + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load place", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel?.load() } } + default: + LoadingShimmerCard() + LoadingShimmerCard() + } + } + .padding(SparkSpacing.lg) + } + .background(Color.sparkSurface.ignoresSafeArea()) + .navigationTitle("Place") + .navigationBarTitleDisplayMode(.inline) + .task(id: placeId) { + if viewModel == nil { + viewModel = PlaceDetailViewModel(placeId: placeId, apiClient: appModel.apiClient) + } + await viewModel?.load() + } + } + + @ViewBuilder + private func content(for detail: PlaceDetail) -> some View { + heroCard(for: detail) + if let region = mapRegion(for: detail.place) { + mapCard(region: region, place: detail.place) + } + inspectorRows(for: detail) + if !detail.events.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("Events here (\(detail.events.count))") + ForEach(detail.events) { event in + eventRow(event) + } + } + } + if !detail.nearby.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("Nearby") + TagChipRow(detail.nearby.map(\.title)) + } + } + } + + // MARK: - Hero + + private func heroCard(for detail: PlaceDetail) -> some View { + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack(spacing: SparkSpacing.sm) { + DomainGlyph(icon: "mappin.and.ellipse", tint: .sparkAccent, size: 28) + if let category = detail.place.category { + Text(category.uppercased()) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + if let streak = detail.streakDays, streak > 0 { + Text("\(streak)d streak") + .font(SparkTypography.monoSmall) + .foregroundStyle(Color.sparkAccent) + .padding(.horizontal, SparkSpacing.sm) + .padding(.vertical, SparkSpacing.xxs) + .background(.thinMaterial, in: Capsule()) + } + } + + Text(detail.place.title) + .font(SparkFonts.display(.title2, weight: .bold)) + .accessibilityAddTraits(.isHeader) + + if let address = detail.place.address { + Text(address) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + } + } + } + + // MARK: - Map + + private func mapCard(region: MKCoordinateRegion, place: Place) -> some View { + let coord = CLLocationCoordinate2D( + latitude: place.latitude ?? region.center.latitude, + longitude: place.longitude ?? region.center.longitude + ) + return Map(initialPosition: .region(region), interactionModes: []) { + Annotation(place.title, coordinate: coord) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(Color.sparkAccent) + .shadow(color: .black.opacity(0.2), radius: 2, x: 0, y: 1) + } + } + .frame(height: 180) + .clipShape(RoundedRectangle(cornerRadius: SparkRadii.lg, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: SparkRadii.lg, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5) + ) + .accessibilityLabel("Map showing \(place.title)") + } + + private func mapRegion(for place: Place) -> MKCoordinateRegion? { + guard let lat = place.latitude, let lng = place.longitude else { return nil } + return MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: lat, longitude: lng), + span: MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005) + ) + } + + // MARK: - Ledger + + private func inspectorRows(for detail: PlaceDetail) -> some View { + GlassCard(radius: SparkRadii.md, padding: 0) { + VStack(spacing: 0) { + InspectorRow("Visits", "\(detail.visitCount)") + if let type = detail.place.type { + InspectorRow("Type", type) + } + if let last = detail.lastVisitedAt { + InspectorRow("Last", isMono: true) { + Text(Self.fullTimeFormatter.string(from: last)) + } + } + if let lat = detail.place.latitude, let lng = detail.place.longitude { + InspectorRow("Coords", isMono: true) { + Text("\(format(lat)), \(format(lng))") + } + } + } + } + } + + private func eventRow(_ event: Event) -> some View { + GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { + HStack(spacing: SparkSpacing.md) { + 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) + } + } + } + + private func format(_ coord: Double) -> String { + String(format: "%.4f", coord) + } + + 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" + return f + }() +} diff --git a/SparkApp/Sources/Explore/ExploreView.swift b/SparkApp/Sources/Explore/ExploreView.swift new file mode 100644 index 0000000..b33df8a --- /dev/null +++ b/SparkApp/Sources/Explore/ExploreView.swift @@ -0,0 +1,101 @@ +import SparkUI +import SwiftUI + +struct ExploreView: View { + @State private var section: ExploreSection = .map + + var body: some View { + ZStack(alignment: .top) { + currentSectionView + .frame(maxWidth: .infinity, maxHeight: .infinity) + + sectionPicker + } + } + + @ViewBuilder + private var currentSectionView: some View { + switch section { + case .map: + MapView(isEmbedded: true) + case .health: + HealthExploreView() + case .metrics: + MetricsExploreView() + case .money: + MoneyExploreView() + } + } + + 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) + } + } + .padding(.horizontal, SparkSpacing.lg) + } + .safeAreaPadding(.top) + .padding(.vertical, SparkSpacing.sm) + .background(.ultraThinMaterial) + } +} + +enum ExploreSection: CaseIterable { + case map, health, metrics, money + + var label: String { + switch self { + case .map: "Map" + case .health: "Health" + case .metrics: "Metrics" + case .money: "Money" + } + } + + var icon: String { + switch self { + case .map: "map" + 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) + } + .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 new file mode 100644 index 0000000..f1429f5 --- /dev/null +++ b/SparkApp/Sources/Explore/HealthExploreView.swift @@ -0,0 +1,237 @@ +import Charts +import SparkKit +import SparkUI +import SwiftUI + +struct HealthExploreView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: HealthExploreViewModel? + @State private var path: [DetailRoute] = [] + + 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) + } + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.xl) + } + .background(Color.sparkSurface.ignoresSafeArea()) + .navigationTitle("Health") + .navigationBarTitleDisplayMode(.large) + .navigationDestination(for: DetailRoute.self) { route in + switch route { + case .metric(let identifier): + MetricDetailView(identifier: identifier) + case .event(let id): + EventDetailView(eventId: id) + default: + EmptyView() + } + } + .refreshable { + await viewModel?.refresh() + } + } + .task { + if viewModel == nil { + viewModel = HealthExploreViewModel(apiClient: appModel.apiClient) + } + await viewModel?.load() + } + } + + // MARK: - Card groups + + private func sleepRecoveryCard(vm: HealthExploreViewModel) -> some View { + GlassCard { + 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) + } + } + } + } + + 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) + } + } + } + } + + 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) + } + } + } + + @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) + } + .buttonStyle(.plain) + } else if case .loading = vm.loadState { + LoadingShimmerCard() + } + // If loaded and nil โ†’ metric not connected; show nothing. + } + + // MARK: - Shimmers + + 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) + } + } + } + } + } + } +} + +// MARK: - Metric Tile Card + +private struct MetricTileCard: View { + let detail: MetricDetail + let tint: Color + + private var recentSeries: [MetricDetail.Point] { + Array(detail.series.suffix(7)) + } + + private var delta: (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 { + 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) + } + } + + 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) + } + + 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) + } + + if !recentSeries.isEmpty { + SparklineMiniChart(series: recentSeries, tint: tint) + .frame(height: 32) + .padding(.top, SparkSpacing.xxs) + } + } + .padding(SparkSpacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .sparkGlass(.roundedRect(SparkRadii.md)) + } + + private func formatValue(_ v: Double, unit: String?) -> String { + switch unit { + case "score", "bpm", "percent": + 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 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" + } +} + +// MARK: - Sparkline mini chart + +private struct SparklineMiniChart: View { + let series: [MetricDetail.Point] + let tint: Color + + var body: some View { + Chart(series) { point in + AreaMark( + x: .value("Date", point.date), + y: .value("Value", point.value) + ) + .foregroundStyle( + LinearGradient( + colors: [tint.opacity(0.3), tint.opacity(0.0)], + startPoint: .top, endPoint: .bottom + ) + ) + LineMark( + x: .value("Date", point.date), + y: .value("Value", point.value) + ) + .foregroundStyle(tint) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartLegend(.hidden) + } +} diff --git a/SparkApp/Sources/Explore/HealthExploreViewModel.swift b/SparkApp/Sources/Explore/HealthExploreViewModel.swift new file mode 100644 index 0000000..435a6ec --- /dev/null +++ b/SparkApp/Sources/Explore/HealthExploreViewModel.swift @@ -0,0 +1,62 @@ +import Foundation +import Observation +import OSLog +import SparkKit + +@Observable +@MainActor +final class HealthExploreViewModel { + private static let identifiers: [String] = [ + "oura.sleep_score", + "oura.heart_rate", + "oura.hrv", + "oura.steps", + "oura.calories", + ] + + enum LoadState { case idle, loading, loaded, error(String) } + + private(set) var snapshots: [String: MetricDetail] = [:] + private(set) var loadState: LoadState = .idle + + private let apiClient: APIClient + private let logger = Logger(subsystem: "co.cronx.spark", category: "HealthExplore") + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + func load() async { + guard case .idle = loadState else { return } + loadState = .loading + await fetchAll() + } + + func refresh() async { + snapshots = [:] + loadState = .idle + await fetchAll() + } + + private func fetchAll() async { + await withTaskGroup(of: (String, MetricDetail?).self) { group in + let client = apiClient + for id in Self.identifiers { + group.addTask { + do { + let detail = try await client.request( + MetricsEndpoint.detail(identifier: id, range: .sevenDays) + ) + return (id, detail) + } catch { + return (id, nil) + } + } + } + for await (id, detail) in group { + if let detail { snapshots[id] = detail } + } + } + loadState = .loaded + } +} diff --git a/SparkApp/Sources/Explore/MetricsExploreView.swift b/SparkApp/Sources/Explore/MetricsExploreView.swift new file mode 100644 index 0000000..16e9a5d --- /dev/null +++ b/SparkApp/Sources/Explore/MetricsExploreView.swift @@ -0,0 +1,239 @@ +import Charts +import SparkKit +import SparkUI +import SwiftUI + +struct MetricsExploreView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: MetricsExploreViewModel? + @State private var filterDomain: MetricDomain? = nil + @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 allIdentifiers: [String] { Self.categories.map(\.identifier) } + + private var visibleCategories: [MetricCategory] { + guard let filter = filterDomain else { return Self.categories } + return Self.categories.filter { $0.domain == filter } + } + + var body: some View { + NavigationStack(path: $path) { + ScrollView { + VStack(spacing: SparkSpacing.lg) { + domainFilter + .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) + } + } + .padding(.horizontal, SparkSpacing.lg) + } + } + .padding(.vertical, SparkSpacing.xl) + } + .background(Color.sparkSurface.ignoresSafeArea()) + .navigationTitle("Metrics") + .navigationBarTitleDisplayMode(.large) + .navigationDestination(for: DetailRoute.self) { route in + switch route { + case .metric(let identifier): + MetricDetailView(identifier: identifier) + default: + EmptyView() + } + } + .refreshable { + await viewModel?.refresh(identifiers: allIdentifiers) + } + } + .task { + if viewModel == nil { + viewModel = MetricsExploreViewModel(apiClient: appModel.apiClient) + } + await viewModel?.load(identifiers: allIdentifiers) + } + } + + 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) + } + .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)) + } label: { + MetricsTileCard(detail: detail, tint: category.tint) + } + .buttonStyle(.plain) + } else if viewModel?.loadState == .loading || viewModel == nil { + LoadingShimmerCard() + } else { + EmptyState( + systemImage: category.icon, + title: "No data yet", + message: "Metrics will appear here once your integration syncs." + ) + } + } +} + +// MARK: - Supporting types + +private enum MetricDomain: CaseIterable { + case health, activity, money, media + + var label: String { + switch self { + case .health: "Health" + case .activity: "Activity" + case .money: "Money" + case .media: "Media" + } + } +} + +private struct MetricCategory { + let domain: MetricDomain + let title: String + let icon: String + let tint: Color + let identifier: String +} + +// MARK: - Compact metric tile for Metrics Explore + +private struct MetricsTileCard: View { + let detail: MetricDetail + let tint: Color + + private var recentSeries: [MetricDetail.Point] { Array(detail.series.suffix(7)) } + + private var delta: (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) + } + + 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) + } + } + + Spacer(minLength: 0) + + if !recentSeries.isEmpty { + SparklineMiniChart(series: recentSeries, tint: tint) + .frame(width: 100, height: 50) + } + } + .padding(.top, SparkSpacing.xs) + } + + 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))" + } +} + +// MARK: - Sparkline mini chart (shared with HealthExploreView) + +private struct SparklineMiniChart: View { + let series: [MetricDetail.Point] + let tint: Color + + var body: some View { + Chart(series) { point in + AreaMark( + x: .value("Date", point.date), + y: .value("Value", point.value) + ) + .foregroundStyle( + LinearGradient( + colors: [tint.opacity(0.3), tint.opacity(0)], + startPoint: .top, endPoint: .bottom + ) + ) + LineMark( + x: .value("Date", point.date), + y: .value("Value", point.value) + ) + .foregroundStyle(tint) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartLegend(.hidden) + } +} + +extension MetricsExploreViewModel.LoadState: Equatable { + static func == (lhs: MetricsExploreViewModel.LoadState, rhs: MetricsExploreViewModel.LoadState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), (.loading, .loading), (.loaded, .loaded): return true + case (.error(let l), .error(let r)): return l == r + default: return false + } + } +} diff --git a/SparkApp/Sources/Explore/MetricsExploreViewModel.swift b/SparkApp/Sources/Explore/MetricsExploreViewModel.swift new file mode 100644 index 0000000..03981d8 --- /dev/null +++ b/SparkApp/Sources/Explore/MetricsExploreViewModel.swift @@ -0,0 +1,54 @@ +import Foundation +import Observation +import OSLog +import SparkKit + +@Observable +@MainActor +final class MetricsExploreViewModel { + enum LoadState { case idle, loading, loaded, error(String) } + + private(set) var snapshots: [String: MetricDetail] = [:] + private(set) var loadState: LoadState = .idle + + private let apiClient: APIClient + private let logger = Logger(subsystem: "co.cronx.spark", category: "MetricsExplore") + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + func load(identifiers: [String]) async { + guard case .idle = loadState else { return } + loadState = .loading + await fetchAll(identifiers: identifiers) + } + + func refresh(identifiers: [String]) async { + snapshots = [:] + loadState = .idle + await fetchAll(identifiers: identifiers) + } + + private func fetchAll(identifiers: [String]) async { + 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) + ) + return (id, detail) + } catch { + return (id, nil) + } + } + } + for await (id, detail) in group { + if let detail { snapshots[id] = detail } + } + } + loadState = .loaded + } +} diff --git a/SparkApp/Sources/Explore/MoneyExploreView.swift b/SparkApp/Sources/Explore/MoneyExploreView.swift new file mode 100644 index 0000000..374856a --- /dev/null +++ b/SparkApp/Sources/Explore/MoneyExploreView.swift @@ -0,0 +1,263 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct MoneyExploreView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: MoneyExploreViewModel? + @State private var path: [DetailRoute] = [] + + 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 + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.xl) + } + .background(Color.sparkSurface.ignoresSafeArea()) + .navigationTitle("Money") + .navigationBarTitleDisplayMode(.large) + .navigationDestination(for: DetailRoute.self) { route in + switch route { + case .event(let id): + EventDetailView(eventId: id) + default: + EmptyView() + } + } + .refreshable { + await viewModel?.refresh() + } + } + .task { + if viewModel == nil { + viewModel = MoneyExploreViewModel(apiClient: appModel.apiClient) + } + await viewModel?.load() + } + } + + // MARK: - Spending overview + + 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: "โ€”") + } + } + } + } + } + + // MARK: - Top merchants + + private func topMerchantsCard(merchants: [SpendWidget.Merchant], currency: String) -> some View { + GlassCard { + 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) + } + 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) + } + } + } + } + } + } + + // MARK: - Transactions + + 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 { + 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) + } + } + } + } + } + } + } + + // MARK: - Shimmer placeholder + + private var shimmerPlaceholder: some View { + VStack(spacing: SparkSpacing.lg) { + LoadingShimmerCard().frame(height: 120) + LoadingShimmerCard().frame(height: 180) + LoadingShimmerCard().frame(height: 200) + } + } + + // MARK: - Helpers + + private func formatAmount(_ value: Double, currency: String) -> String { + let symbol: String = switch currency { + case "GBP": "ยฃ" + case "EUR": "โ‚ฌ" + case "USD": "$" + default: currency + " " + } + return "\(symbol)\(String(format: "%.2f", value))" + } +} + +// MARK: - Transaction row + +private struct TransactionRow: View { + let event: Event + + private var merchant: String { + event.target?.title ?? event.actor?.title ?? event.service.capitalized + } + + 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 + " " + } + return "\(symbol)\(value)" + } + + 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) + } + + 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) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: SparkSpacing.sm) + + if !amount.isEmpty { + Text(amount) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + } + + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.vertical, SparkSpacing.sm) + .contentShape(Rectangle()) + } +} + +// MARK: - Spending period cell + +private struct SpendingPeriodCell: View { + let period: String + let amount: String + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.xxs) { + Text(amount) + .font(SparkTypography.titleStrong) + .foregroundStyle(.primary) + Text(period) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.sm)) + } +} diff --git a/SparkApp/Sources/Explore/MoneyExploreViewModel.swift b/SparkApp/Sources/Explore/MoneyExploreViewModel.swift new file mode 100644 index 0000000..301d1a7 --- /dev/null +++ b/SparkApp/Sources/Explore/MoneyExploreViewModel.swift @@ -0,0 +1,50 @@ +import Foundation +import Observation +import OSLog +import SparkKit + +@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 loadState: LoadState = .idle + + private let apiClient: APIClient + private let logger = Logger(subsystem: "co.cronx.spark", category: "MoneyExplore") + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + func load() async { + guard case .idle = loadState else { return } + loadState = .loading + await fetchAll() + } + + func refresh() async { + spend = nil + transactions = [] + loadState = .idle + await fetchAll() + } + + private func fetchAll() async { + async let spendResult = apiClient.request(WidgetsEndpoint.spend()) + async let feedResult = apiClient.request(FeedEndpoint.feed(limit: 30, domain: "money")) + + 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.") + } + } +} diff --git a/SparkApp/Sources/Flint/FlintView.swift b/SparkApp/Sources/Flint/FlintView.swift new file mode 100644 index 0000000..d8dc9ad --- /dev/null +++ b/SparkApp/Sources/Flint/FlintView.swift @@ -0,0 +1,57 @@ +import SparkUI +import SwiftUI + +struct FlintView: View { + 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." + ) + } + } + + 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) + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.xl) + } + .navigationTitle("Flint") + .navigationBarTitleDisplayMode(.large) + } + } +} diff --git a/SparkApp/Sources/Integrations/IntegrationDetailView.swift b/SparkApp/Sources/Integrations/IntegrationDetailView.swift new file mode 100644 index 0000000..d32aff5 --- /dev/null +++ b/SparkApp/Sources/Integrations/IntegrationDetailView.swift @@ -0,0 +1,187 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct IntegrationDetailView: View { + let integrationId: String + @Environment(AppModel.self) private var appModel + @State private var viewModel: IntegrationDetailViewModel? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + switch viewModel?.state { + case .loaded(let detail): + content(for: detail) + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel?.load() } } + default: + LoadingShimmerCard() + LoadingShimmerCard() + } + } + .padding(SparkSpacing.lg) + } + .background(Color.sparkSurface.ignoresSafeArea()) + .navigationTitle(viewModel?.state.loadedTitle ?? "Integration") + .navigationBarTitleDisplayMode(.inline) + .task(id: integrationId) { + if viewModel == nil { + viewModel = IntegrationDetailViewModel( + integrationId: integrationId, + apiClient: appModel.apiClient + ) + } + await viewModel?.load() + } + } + + @ViewBuilder + private func content(for detail: IntegrationDetail) -> some View { + heroCard(for: detail) + actionRow(for: detail) + if let msg = viewModel?.lastActionMessage { + Text(msg) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + inspectorRows(for: detail) + if !detail.recentEvents.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + SectionLabel("Recent events") + ForEach(detail.recentEvents) { event in + eventRow(event) + } + } + } + } + + private func heroCard(for detail: IntegrationDetail) -> some View { + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack(spacing: SparkSpacing.sm) { + DomainGlyph(icon: "link", tint: .sparkAccent, size: 28) + Text(detail.integration.service.uppercased()) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + Text(detail.integration.name) + .font(SparkFonts.display(.title2, weight: .bold)) + StatusPill( + pillTone(for: detail.status), + message: detail.status.label, + trailing: detail.lastSyncAt.map { Self.relative(from: $0) } + ) + } + } + } + + @ViewBuilder + private func actionRow(for detail: IntegrationDetail) -> some View { + HStack(spacing: SparkSpacing.md) { + Button { + Task { await viewModel?.syncNow() } + } label: { + Label("Sync now", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.sparkAccent) + .disabled(viewModel?.actionInProgress == .syncing) + + Button { + guard let anchor = ASPresentationAnchorHandle.current() else { return } + Task { await viewModel?.reauthorise(presentationAnchor: anchor) } + } label: { + Label("Reauthorise", systemImage: "lock.rotation") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.sparkAccent) + .disabled(detail.oauthStartURL == nil || viewModel?.actionInProgress == .reauthing) + } + } + + private func inspectorRows(for detail: IntegrationDetail) -> some View { + GlassCard(radius: SparkRadii.md, padding: 0) { + VStack(spacing: 0) { + InspectorRow("Service", detail.integration.service) + if let domain = detail.domain { + InspectorRow("Domain", domain) + } + if let coverage = detail.coveragePercent { + InspectorRow("Coverage", "\(Int(coverage * 100))%") + } + if let last = detail.lastSyncAt { + InspectorRow("Last sync", isMono: true) { + Text(Self.fullTimeFormatter.string(from: last)) + } + } + if let instance = detail.integration.instanceType { + InspectorRow("Instance", instance) + } + } + } + } + + private func eventRow(_ event: Event) -> some View { + GlassCard(radius: SparkRadii.md, padding: SparkSpacing.md) { + HStack(spacing: SparkSpacing.md) { + 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)) + } + } + } + } + + private func pillTone(for status: IntegrationStatus) -> StatusPill.Tone { + switch status { + case .upToDate: .ok + case .syncing: .neutral + case .needsReauth, .error: .warning + } + } + + private static func relative(from date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: .now) + } + + 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" + return f + }() +} + +private extension DetailLoadState where T == IntegrationDetail { + var loadedTitle: String? { + if case .loaded(let d) = self { return d.integration.name } + return nil + } +} diff --git a/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift b/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift new file mode 100644 index 0000000..97f2f1b --- /dev/null +++ b/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift @@ -0,0 +1,76 @@ +import Foundation +import Observation +import OSLog +import Sentry +import SparkKit + +@MainActor +@Observable +final class IntegrationDetailViewModel { + let integrationId: String + private(set) var state: DetailLoadState = .loading + private(set) var actionInProgress: Action? + private(set) var lastActionMessage: String? + + enum Action: Sendable, Equatable { + case syncing + case reauthing + } + + private let apiClient: APIClient + private let reauthService = IntegrationReauthService() + private let logger = Logger(subsystem: "co.cronx.spark", category: "IntegrationDetail") + + init(integrationId: String, apiClient: APIClient) { + self.integrationId = integrationId + self.apiClient = apiClient + } + + func load() async { + state = .loading + do { + let detail = try await apiClient.request(IntegrationsEndpoint.detail(id: integrationId)) + state = .loaded(detail) + } catch APIError.notModified { + return + } catch { + SparkObservability.captureHandled(error) + let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + state = .error(msg) + } + } + + func syncNow() async { + actionInProgress = .syncing + defer { actionInProgress = nil } + do { + _ = try await apiClient.request(IntegrationsEndpoint.syncNow(id: integrationId)) + lastActionMessage = "Sync requested." + await load() + } catch { + logger.error("Sync failed: \(String(describing: error))") + lastActionMessage = "Couldn't start sync." + SentrySDK.capture(error: error) + } + } + + func reauthorise(presentationAnchor: ASPresentationAnchorHandle) async { + actionInProgress = .reauthing + defer { actionInProgress = nil } + do { + let response = try await apiClient.request(IntegrationsEndpoint.oauthStart(id: integrationId)) + try await reauthService.reauthorise( + startURL: response.url, + presentationAnchor: presentationAnchor.value + ) + lastActionMessage = "Reauthorised." + await load() + } catch IntegrationReauthError.cancelled { + // No-op โ€” user closed the sheet. + } catch { + logger.error("Reauth failed: \(String(describing: error))") + lastActionMessage = "Couldn't reauthorise." + SentrySDK.capture(error: error) + } + } +} diff --git a/SparkApp/Sources/Integrations/IntegrationsListView.swift b/SparkApp/Sources/Integrations/IntegrationsListView.swift new file mode 100644 index 0000000..d106b64 --- /dev/null +++ b/SparkApp/Sources/Integrations/IntegrationsListView.swift @@ -0,0 +1,112 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct IntegrationsListView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: IntegrationsListViewModel? + + var body: some View { + Group { + switch viewModel?.state { + case .loaded(let list): + if list.isEmpty { + EmptyState( + systemImage: "link.badge.plus", + title: "No integrations", + message: "Connect a service from your Spark dashboard to see it here." + ) + } else { + Form { + ForEach(viewModel?.grouped(list) ?? [], id: \.0) { group in + Section(group.0) { + ForEach(group.1) { integration in + NavigationLink { + IntegrationDetailView(integrationId: integration.id) + } label: { + IntegrationRow(integration: integration) + } + } + } + } + } + } + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel?.load() } } + default: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle("Integrations") + .navigationBarTitleDisplayMode(.inline) + .task { + if viewModel == nil { + viewModel = IntegrationsListViewModel(apiClient: appModel.apiClient) + } + await viewModel?.load() + } + } +} + +private struct IntegrationRow: View { + let integration: Integration + + var body: some View { + HStack(spacing: SparkSpacing.md) { + DomainGlyph(icon: glyph, tint: tint, size: 30) + VStack(alignment: .leading, spacing: 2) { + Text(integration.name) + .font(SparkTypography.body) + if let instance = integration.instanceType { + Text(instance) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + statusDot + } + } + + private var statusDot: some View { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + .accessibilityLabel(integration.status) + } + + private var statusColor: Color { + switch integration.status.lowercased() { + case "up_to_date", "ok", "active": .sparkSuccess + case "syncing", "running": .sparkInfo + case "needs_reauth", "reauth", "expired": .sparkWarning + default: .sparkError + } + } + + private var glyph: String { + switch integration.service.lowercased() { + case "apple_health", "fitbit", "oura", "whoop", "garmin", "withings": "heart.fill" + case "monzo", "starling", "plaid", "amex", "stripe": "creditcard.fill" + case "spotify", "apple_music", "lastfm", "youtube", "trakt", "letterboxd": "music.note" + case "readwise", "instapaper", "raindrop", "github", "linear", "notion", "obsidian": "book.fill" + case "google", "fastmail", "calendar", "gmail", "icloud": "envelope.fill" + default: "link" + } + } + + private var tint: Color { + switch integration.service.lowercased() { + case "apple_health", "fitbit", "oura", "whoop", "garmin", "withings": .domainHealth + case "monzo", "starling", "plaid", "amex", "stripe": .domainMoney + case "spotify", "apple_music", "lastfm", "youtube", "trakt", "letterboxd": .domainMedia + case "readwise", "instapaper", "raindrop", "github", "linear", "notion", "obsidian": .domainKnowledge + default: .sparkAccent + } + } +} diff --git a/SparkApp/Sources/Integrations/IntegrationsListViewModel.swift b/SparkApp/Sources/Integrations/IntegrationsListViewModel.swift new file mode 100644 index 0000000..59158f7 --- /dev/null +++ b/SparkApp/Sources/Integrations/IntegrationsListViewModel.swift @@ -0,0 +1,60 @@ +import Foundation +import Observation +import OSLog +import SparkKit + +@MainActor +@Observable +final class IntegrationsListViewModel { + enum LoadState: Sendable { + case loading + case loaded([Integration]) + case error(String) + } + + private(set) var state: LoadState = .loading + + private let apiClient: APIClient + private let logger = Logger(subsystem: "co.cronx.spark", category: "Integrations") + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + func load() async { + state = .loading + do { + let list = try await apiClient.request(IntegrationsEndpoint.list()) + state = .loaded(list) + } catch APIError.notModified { + return + } catch { + SparkObservability.captureHandled(error) + logger.error("Integrations list failed: \(String(describing: error))") + let msg = (error as? LocalizedError)?.errorDescription ?? "Couldn't load integrations." + state = .error(msg) + } + } + + /// Group rows by domain bucket inferred from service slug. Lets the + /// list view render `Form` sections per domain. + func grouped(_ list: [Integration]) -> [(String, [Integration])] { + let byDomain = Dictionary(grouping: list, by: { Self.domain(forService: $0.service) }) + let order = ["Health", "Money", "Media", "Knowledge", "Online", "Other"] + return order.compactMap { domain in + guard let items = byDomain[domain]?.sorted(by: { $0.name < $1.name }) else { return nil } + return (domain, items) + } + } + + private static func domain(forService service: String) -> String { + switch service.lowercased() { + case "apple_health", "fitbit", "oura", "whoop", "garmin", "withings": "Health" + case "monzo", "starling", "plaid", "amex", "stripe": "Money" + case "spotify", "apple_music", "lastfm", "youtube", "trakt", "letterboxd": "Media" + case "readwise", "instapaper", "raindrop", "github", "linear", "notion", "obsidian": "Knowledge" + case "google", "fastmail", "calendar", "gmail", "icloud": "Online" + default: "Other" + } + } +} diff --git a/SparkApp/Sources/Knowledge/KnowledgeItemDetailView.swift b/SparkApp/Sources/Knowledge/KnowledgeItemDetailView.swift new file mode 100644 index 0000000..3c2e1b7 --- /dev/null +++ b/SparkApp/Sources/Knowledge/KnowledgeItemDetailView.swift @@ -0,0 +1,190 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct KnowledgeItemDetailView: View { + let event: Event + @Environment(AppModel.self) private var appModel + @Environment(\.openURL) private var openURL + @State private var detailState: DetailLoadState = .loading + + 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 } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + heroImage + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + headerSection + switch detailState { + case .loading: + LoadingShimmerCard() + LoadingShimmerCard() + case .loaded(let detail): + contentCards(for: detail) + case .error: + 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()) + .navigationBarTitleDisplayMode(.inline) + .task(id: event.id) { + await loadDetail() + } + } + + // MARK: - Hero image + + @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 + } + } + .frame(maxWidth: .infinity) + .frame(height: 220) + .clipped() + } + } + + // MARK: - Header + + private var headerSection: some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack(spacing: SparkSpacing.xs) { + Text(source) + .font(SparkTypography.captionStrong) + .foregroundStyle(.secondary) + if let time = event.time { + Text("ยท") + .foregroundStyle(.secondary) + Text(time.formatted(date: .abbreviated, time: .omitted)) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + } + } + Text(title) + .font(SparkFonts.display(.title, weight: .bold)) + } + } + + // MARK: - Content cards + + @ViewBuilder + private func contentCards(for detail: EventDetail) -> some View { + let blocks = detail.blocks + let service = event.service + + 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 = 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) + } + } + } + + if let takeaways = blockContent(service: service, kind: "key_takeaways", blocks: blocks) { + let bullets = takeaways.components(separatedBy: "\n").filter { !$0.isEmpty } + if !bullets.isEmpty { + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + GlassCardHeader(icon: "list.bullet", tint: .domainKnowledge, title: "Key Takeaways") + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + ForEach(bullets, id: \.self) { bullet in + HStack(alignment: .top, spacing: SparkSpacing.sm) { + Text("ยท") + .font(SparkTypography.bodyStrong) + .foregroundStyle(Color.domainKnowledge) + Text(bullet) + .font(SparkTypography.body) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + } + } + + if !detail.tags.isEmpty { + TagChipRow(detail.tags) + } + } + + // 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("Read Original") + .font(SparkTypography.bodyStrong) + } + .frame(maxWidth: .infinity) + .padding(.vertical, SparkSpacing.md) + } + .sparkGlass(.capsule, tint: Color.domainKnowledge.opacity(0.15)) + .foregroundStyle(Color.domainKnowledge) + } + } + + // MARK: - Helpers + + 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 loadDetail() async { + detailState = .loading + do { + let detail = try await appModel.apiClient.request(EventsEndpoint.detail(id: event.id)) + detailState = .loaded(detail) + } catch APIError.notModified { + return + } catch { + SparkObservability.captureHandled(error) + detailState = .error(String(describing: error)) + } + } +} diff --git a/SparkApp/Sources/Knowledge/KnowledgeView.swift b/SparkApp/Sources/Knowledge/KnowledgeView.swift new file mode 100644 index 0000000..1cc6329 --- /dev/null +++ b/SparkApp/Sources/Knowledge/KnowledgeView.swift @@ -0,0 +1,230 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct KnowledgeView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: KnowledgeViewModel? + @State private var path: [Event] = [] + + var body: some View { + NavigationStack(path: $path) { + content + .navigationTitle("Knowledge") + .navigationBarTitleDisplayMode(.large) + .navigationDestination(for: Event.self) { event in + KnowledgeItemDetailView(event: event) + } + } + .task { + if viewModel == nil { + viewModel = KnowledgeViewModel(apiClient: appModel.apiClient) + } + await viewModel?.initialLoad() + } + } + + @ViewBuilder + private var content: some View { + if let viewModel { + mainContent(viewModel: viewModel) + } else { + loadingPlaceholder + } + } + + private func mainContent(viewModel: KnowledgeViewModel) -> some View { + ScrollView { + VStack(spacing: SparkSpacing.lg) { + filterRow(viewModel: viewModel) + .padding(.horizontal, SparkSpacing.lg) + + let items = viewModel.filteredItems + let isEmpty = viewModel.allItems.isEmpty + + switch viewModel.loadState { + case .idle: + shimmerStack.padding(.horizontal, SparkSpacing.lg) + case .loading where isEmpty: + shimmerStack.padding(.horizontal, SparkSpacing.lg) + + case .error(let msg) where isEmpty: + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load articles", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel.refresh() } } + .padding(.horizontal, SparkSpacing.lg) + + default: + if items.isEmpty { + EmptyState( + systemImage: "doc.richtext", + title: "Nothing here yet", + message: "Articles, newsletters and web digests will appear as they're ingested." + ) + .padding(.horizontal, SparkSpacing.lg) + } else { + LazyVStack(spacing: SparkSpacing.md) { + ForEach(items) { event in + NavigationLink(value: event) { + KnowledgeItemCard(event: event) + } + .buttonStyle(.plain) + .onAppear { + if event.id == items.last?.id { + Task { await viewModel.loadMore() } + } + } + } + if case .loading = viewModel.loadState { + LoadingShimmerCard().frame(height: 220) + } + } + .padding(.horizontal, SparkSpacing.lg) + } + } + } + .padding(.vertical, SparkSpacing.xl) + } + .refreshable { await viewModel.refresh() } + .background(Color.sparkSurface.ignoresSafeArea()) + } + + 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 var shimmerStack: some View { + VStack(spacing: SparkSpacing.md) { + ForEach(0..<3, id: \.self) { _ in + LoadingShimmerCard().frame(height: 220) + } + } + } + + private var loadingPlaceholder: some View { + ScrollView { + VStack(spacing: SparkSpacing.md) { + ForEach(0..<3, id: \.self) { _ in + LoadingShimmerCard().frame(height: 220) + } + } + .padding(SparkSpacing.lg) + } + } +} + +// MARK: - Knowledge Item Card + +private struct KnowledgeItemCard: View { + let event: Event + + 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.replacingOccurrences(of: "_", with: " ").capitalized + } + + private var source: String { + event.actor?.title ?? event.service.capitalized + } + + private var serviceLabel: String { + switch event.service { + case "newsletter": "Newsletter" + case "fetch": "Web Digest" + default: event.service.capitalized + } + } + + var body: some View { + GlassCard(padding: 0) { + VStack(alignment: .leading, spacing: 0) { + Group { + if let url = imageUrl { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + default: + imagePlaceholder + } + } + } else { + imagePlaceholder + } + } + .frame(maxWidth: .infinity) + .frame(height: 160) + .clipped() + + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack(spacing: SparkSpacing.xs) { + Text(source) + .font(SparkTypography.captionStrong) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + if let time = event.time { + Text(time.formatted(.relative(presentation: .named))) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + } + } + + Text(title) + .font(SparkTypography.bodyStrong) + .lineLimit(2) + .foregroundStyle(.primary) + + if let tldr = event.tldr { + Text(tldr) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + .italic() + .lineLimit(2) + } + + HStack { + Text(serviceLabel) + .font(SparkTypography.monoSmall) + .foregroundStyle(Color.domainKnowledge) + .padding(.horizontal, SparkSpacing.sm) + .padding(.vertical, 3) + .background(Color.domainKnowledge.opacity(0.12)) + .clipShape(.capsule) + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(SparkSpacing.lg) + } + } + } + + private var imagePlaceholder: some View { + Color.sparkElevated + .overlay( + Image(systemName: "doc.richtext") + .font(.title) + .foregroundStyle(.tertiary) + ) + } +} diff --git a/SparkApp/Sources/Knowledge/KnowledgeViewModel.swift b/SparkApp/Sources/Knowledge/KnowledgeViewModel.swift new file mode 100644 index 0000000..838a29a --- /dev/null +++ b/SparkApp/Sources/Knowledge/KnowledgeViewModel.swift @@ -0,0 +1,83 @@ +import Foundation +import Observation +import OSLog +import SparkKit + +@Observable +@MainActor +final class KnowledgeViewModel { + enum Filter: String, CaseIterable, Identifiable { + case all = "All" + case newsletters = "Newsletters" + case webDigests = "Web Digests" + var id: String { rawValue } + } + + enum LoadState { + case idle, loading, loaded, error(String) + } + + var filter: Filter = .all + private(set) var allItems: [Event] = [] + private(set) var loadState: LoadState = .idle + private var cursor: String? + private(set) var hasMore: Bool = false + + var filteredItems: [Event] { + switch filter { + case .all: allItems + case .newsletters: allItems.filter { $0.service == "newsletter" } + case .webDigests: allItems.filter { $0.service == "fetch" } + } + } + + private let apiClient: APIClient + private let logger = Logger(subsystem: "co.cronx.spark", category: "Knowledge") + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + func initialLoad() async { + guard case .idle = loadState else { return } + await fetch(appending: false) + } + + func refresh() async { + cursor = nil + hasMore = false + allItems = [] + loadState = .idle + await fetch(appending: false) + } + + func loadMore() async { + guard hasMore, case .loaded = loadState else { return } + await fetch(appending: true) + } + + private func fetch(appending: Bool) async { + loadState = .loading + do { + let page = try await apiClient.request( + FeedEndpoint.feed(cursor: appending ? cursor : nil, limit: 30, domain: "knowledge") + ) + if appending { + allItems.append(contentsOf: page.data) + } else { + allItems = page.data + } + cursor = page.nextCursor + hasMore = page.hasMore + loadState = .loaded + } catch APIError.notModified { + loadState = .loaded + } catch is CancellationError { + loadState = allItems.isEmpty ? .idle : .loaded + } catch { + SparkObservability.captureHandled(error) + logger.error("Knowledge feed failed: \(String(describing: error))") + loadState = .error((error as? LocalizedError)?.errorDescription ?? "Couldn't load articles.") + } + } +} diff --git a/SparkApp/Sources/Map/MapBottomSheet.swift b/SparkApp/Sources/Map/MapBottomSheet.swift new file mode 100644 index 0000000..b770e97 --- /dev/null +++ b/SparkApp/Sources/Map/MapBottomSheet.swift @@ -0,0 +1,103 @@ +import SparkKit +import SparkUI +import SwiftUI + +/// Bottom sheet that lists the points currently visible in the map region. +/// Tapping a row pushes a `DetailRoute` (place / event) onto the Map tab's +/// navigation stack. +struct MapBottomSheet: View { + let points: [MapDataPoint] + let onSelect: (MapDataPoint) -> Void + + var body: some View { + NavigationStack { + Group { + if points.isEmpty { + EmptyState( + systemImage: "mappin.slash", + title: "Nothing here", + message: "Pan the map or change the day to see your visits and events." + ) + } else { + List { + ForEach(points) { point in + Button { + onSelect(point) + } label: { + MapBottomSheetRow(point: point) + } + .buttonStyle(.plain) + } + } + .listStyle(.plain) + } + } + .navigationTitle("In view") + .navigationBarTitleDisplayMode(.inline) + } + } +} + +private struct MapBottomSheetRow: View { + let point: MapDataPoint + + var body: some View { + HStack(spacing: SparkSpacing.md) { + DomainGlyph(icon: glyph, tint: tint, size: 30) + VStack(alignment: .leading, spacing: SparkSpacing.xxs) { + Text(point.title) + .font(SparkTypography.bodyStrong) + .lineLimit(1) + if let subtitle = subtitle { + Text(subtitle) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: SparkSpacing.sm) + if let timeLabel { + Text(timeLabel) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + .padding(.vertical, SparkSpacing.xs) + .contentShape(Rectangle()) + } + + private var subtitle: String? { + if let s = point.subtitle, !s.isEmpty { return s } + return point.service + } + + private var timeLabel: String? { + guard let time = point.time else { return nil } + return Self.timeFormatter.string(from: time) + } + + private var glyph: String { + switch point.kind { + case .place: "mappin.and.ellipse" + case .transaction: "creditcard.fill" + case .workout: "figure.run" + case .event: "circle.dashed" + } + } + + private var tint: Color { + switch point.kind { + case .place: .sparkAccent + case .transaction: .domainMoney + case .workout: .domainActivity + case .event: .domainKnowledge + } + } + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f + }() +} diff --git a/SparkApp/Sources/Map/MapView.swift b/SparkApp/Sources/Map/MapView.swift new file mode 100644 index 0000000..b4e334f --- /dev/null +++ b/SparkApp/Sources/Map/MapView.swift @@ -0,0 +1,161 @@ +import MapKit +import SparkKit +import SparkUI +import SwiftUI + +/// Map tab โ€” full-screen MapKit view with a timeline scrubber overlay and a +/// bottom sheet listing the points in the visible region. Pins are +/// Spark-tinted and tap-routable to detail screens. +struct MapView: View { + var isEmbedded: Bool = false + + @Environment(AppModel.self) private var appModel + @State private var viewModel: MapViewModel? + @State private var path: [DetailRoute] = [] + @State private var cameraPosition: MapCameraPosition = .region(MapViewModel.defaultRegion) + + var body: some View { + NavigationStack(path: $path) { + content + .navigationDestination(for: DetailRoute.self) { route in + switch route { + case .place(let id): + PlaceDetailView(placeId: id) + 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 .integration(let service): + IntegrationDetailView(integrationId: service) + } + } + .navigationTitle("Map") + .navigationBarTitleDisplayMode(.inline) + .toolbar(isEmbedded ? .hidden : .visible, for: .navigationBar) + } + .task { + if viewModel == nil { + viewModel = MapViewModel(apiClient: appModel.apiClient) + await viewModel?.fetch() + } + } + } + + @ViewBuilder + private var content: some View { + if let viewModel { + MapViewContent(viewModel: viewModel, cameraPosition: $cameraPosition) { point in + handleSelection(point) + } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func handleSelection(_ point: MapDataPoint) { + switch point.kind { + case .place: + push(.place(id: point.id)) + case .transaction, .event, .workout: + push(.event(id: point.id)) + } + } + + private func push(_ route: DetailRoute) { + if path.last == route { return } + path.append(route) + } +} + +private struct MapViewContent: View { + @Bindable var viewModel: MapViewModel + @Binding var cameraPosition: MapCameraPosition + let onSelectPoint: (MapDataPoint) -> Void + + @State private var sheetDetent: PresentationDetent = .height(160) + + var body: some View { + Map(position: $cameraPosition) { + ForEach(viewModel.visiblePoints) { point in + Annotation(point.title, coordinate: CLLocationCoordinate2D(latitude: point.lat, longitude: point.lng)) { + MapPin(kind: point.kind) + .onTapGesture { onSelectPoint(point) } + } + } + } + .mapStyle(.standard) + .mapControls { + MapCompass() + MapScaleView() + MapUserLocationButton() + } + .ignoresSafeArea(edges: .bottom) + .overlay(alignment: .bottom) { + TimelineScrubber( + fraction: $viewModel.dayFraction, + anchorDay: viewModel.anchorDay + ) + .padding(.horizontal, SparkSpacing.lg) + .padding(.bottom, SparkSpacing.xxl + SparkSpacing.xxxl) + } + .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() + } + } +} + +private struct MapPin: View { + let kind: MapDataPoint.Kind + + var body: some View { + ZStack { + Circle() + .fill(.background) + .frame(width: 32, height: 32) + .shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 1) + Image(systemName: glyph) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(tint) + } + .accessibilityLabel(Text(accessibilityLabel)) + } + + private var glyph: String { + switch kind { + case .place: "mappin" + case .transaction: "creditcard.fill" + case .workout: "figure.run" + case .event: "sparkles" + } + } + + private var tint: Color { + switch kind { + case .place: .sparkAccent + case .transaction: .domainMoney + case .workout: .domainActivity + case .event: .domainKnowledge + } + } + + private var accessibilityLabel: String { + switch kind { + case .place: "Place" + case .transaction: "Transaction" + case .workout: "Workout" + case .event: "Event" + } + } +} diff --git a/SparkApp/Sources/Map/MapViewModel.swift b/SparkApp/Sources/Map/MapViewModel.swift new file mode 100644 index 0000000..12ed4f5 --- /dev/null +++ b/SparkApp/Sources/Map/MapViewModel.swift @@ -0,0 +1,108 @@ +import Foundation +import MapKit +import Observation +import OSLog +import SparkKit + +/// Drives the Map tab. Holds the visible region's points and the selected +/// time-of-day filter. Refetches when the region or date settle. +@MainActor +@Observable +final class MapViewModel { + private let apiClient: APIClient + private let logger = Logger(subsystem: "co.cronx.spark", category: "MapViewModel") + + // 0...1 fraction of the day โ€” the timeline scrubber binds to this. + var dayFraction: Double = 1.0 + var anchorDay: Date = .now + + var region: MKCoordinateRegion = MapViewModel.defaultRegion + private(set) var points: [MapDataPoint] = [] + private(set) var isLoading: Bool = false + private(set) var lastError: String? + + private var pendingFetch: Task? + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + /// Coordinates that the timeline scrubber maps onto โ€” the moment in the + /// anchor day the user has scrubbed to. + var selectedTime: Date { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: anchorDay) + let interval: TimeInterval = 24 * 60 * 60 + return startOfDay.addingTimeInterval(interval * dayFraction) + } + + /// Filtered subset of `points` whose `time` falls before the scrubber. + /// Points without a time always render (places, etc.). + var visiblePoints: [MapDataPoint] { + let cutoff = selectedTime + return points.filter { point in + guard let time = point.time else { return true } + return time <= cutoff + } + } + + func regionDidChange(_ new: MKCoordinateRegion) { + region = new + scheduleFetch() + } + + func dayDidChange() { + scheduleFetch() + } + + /// Cancel any in-flight fetch and start a fresh one after a short debounce + /// so panning + scrubbing don't hammer the backend. + private func scheduleFetch() { + pendingFetch?.cancel() + pendingFetch = Task { [weak self] in + try? await Task.sleep(nanoseconds: 250_000_000) + guard !Task.isCancelled else { return } + await self?.fetch() + } + } + + func fetch() async { + let bbox = boundingBox(for: region) + isLoading = true + defer { isLoading = false } + do { + let response = try await apiClient.request( + MapEndpoint.points(bbox: bbox, date: anchorDay) + ) + points = response + lastError = nil + } catch is CancellationError { + return + } catch APIError.notModified { + // Cached payload is fine; keep current points. + } catch { + SparkObservability.captureHandled(error) + logger.error("Map fetch failed: \(String(describing: error))") + lastError = "Couldnโ€™t load map data." + } + } + + private func boundingBox(for region: MKCoordinateRegion) -> BoundingBox { + let half = (lat: region.span.latitudeDelta / 2, lng: region.span.longitudeDelta / 2) + let sw = BoundingBox.Coordinate( + lat: region.center.latitude - half.lat, + lng: region.center.longitude - half.lng + ) + let ne = BoundingBox.Coordinate( + lat: region.center.latitude + half.lat, + lng: region.center.longitude + half.lng + ) + return BoundingBox(southWest: sw, northEast: ne) + } + + /// Default to a wide UK view until the user pans or location fix arrives. + static let defaultRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 51.5074, longitude: -0.1278), + span: MKCoordinateSpan(latitudeDelta: 0.4, longitudeDelta: 0.4) + ) +} diff --git a/SparkApp/Sources/Map/TimelineScrubber.swift b/SparkApp/Sources/Map/TimelineScrubber.swift new file mode 100644 index 0000000..8bcef40 --- /dev/null +++ b/SparkApp/Sources/Map/TimelineScrubber.swift @@ -0,0 +1,58 @@ +import SparkUI +import SwiftUI + +/// Bottom-overlay scrubber that maps a 0...1 slider onto a 24h window. Shows +/// the currently-selected time in PT Mono and labels the day. +struct TimelineScrubber: View { + @Binding var fraction: Double + let anchorDay: Date + + var body: some View { + VStack(spacing: SparkSpacing.sm) { + HStack(alignment: .lastTextBaseline) { + Text(anchorLabel) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + Spacer(minLength: SparkSpacing.sm) + Text(timeLabel) + .font(SparkTypography.monoBody) + .foregroundStyle(.primary) + .monospacedDigit() + } + + Slider(value: $fraction, in: 0...1) + .tint(.sparkAccent) + .accessibilityLabel("Timeline") + .accessibilityValue(timeLabel) + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.md) + .frame(maxWidth: .infinity) + .sparkGlass(.roundedRect(SparkRadii.lg)) + } + + private var timeLabel: String { + let calendar = Calendar.current + let start = calendar.startOfDay(for: anchorDay) + let date = start.addingTimeInterval(24 * 60 * 60 * fraction) + return Self.timeFormatter.string(from: date) + } + + private var anchorLabel: String { + if Calendar.current.isDateInToday(anchorDay) { return "Today" } + if Calendar.current.isDateInYesterday(anchorDay) { return "Yesterday" } + return Self.dateFormatter.string(from: anchorDay) + } + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f + }() + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEE d MMM" + return f + }() +} diff --git a/SparkApp/Sources/Notifications/NotificationsInboxView.swift b/SparkApp/Sources/Notifications/NotificationsInboxView.swift new file mode 100644 index 0000000..29d5a8a --- /dev/null +++ b/SparkApp/Sources/Notifications/NotificationsInboxView.swift @@ -0,0 +1,187 @@ +import SparkKit +import SparkUI +import SwiftData +import SwiftUI + +struct NotificationsInboxView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: NotificationsInboxViewModel? + @State private var path: [DetailRoute] = [] + + var body: some View { + NavigationStack(path: $path) { + content + .navigationTitle("Inbox") + .navigationBarTitleDisplayMode(.large) + .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) + } + } + .toolbar { + if let viewModel, !viewModel.items.isEmpty { + ToolbarItem(placement: .topBarTrailing) { + Button("Mark all read") { + Task { await viewModel.markAllRead() } + } + .font(SparkTypography.bodySmall) + } + } + } + } + .task { + if viewModel == nil { + viewModel = NotificationsInboxViewModel( + apiClient: appModel.apiClient, + container: appModel.container + ) + } + await viewModel?.refresh() + } + .refreshable { + await viewModel?.refresh() + } + } + + @ViewBuilder + private var content: some View { + if let viewModel { + 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." + ) + } else { + List { + ForEach(viewModel.items) { item in + NotificationRow(item: item) + .contentShape(Rectangle()) + .onTapGesture { handleTap(item) } + .onAppear { + if !item.isRead { + Task { await viewModel.markRead(item.id) } + } + if item.id == viewModel.items.last?.id, viewModel.hasMore { + Task { await viewModel.loadMore() } + } + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + Task { await viewModel.delete(item.id) } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + } + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel.refresh() } } + case .loading, .idle: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func handleTap(_ item: NotificationItem) { + guard let entity = item.entity else { return } + let route: DetailRoute? = switch entity.kind { + case .event: .event(id: entity.id) + case .object: .object(id: entity.id) + case .metric: .metric(identifier: entity.id) + case .place: .place(id: entity.id) + case .integration: .integration(service: entity.id) + case .anomaly: nil // No dedicated anomaly screen yet โ€” Phase 3. + } + if let route, path.last != route { + path.append(route) + } + } +} + +private struct NotificationRow: View { + let item: NotificationItem + + var body: some View { + HStack(alignment: .top, spacing: SparkSpacing.md) { + DomainGlyph(icon: glyph, tint: tint, size: 30) + VStack(alignment: .leading, spacing: SparkSpacing.xxs) { + HStack(alignment: .firstTextBaseline) { + Text(item.title) + .font(item.isRead ? SparkTypography.body : SparkTypography.bodyStrong) + .lineLimit(2) + Spacer(minLength: SparkSpacing.sm) + Text(Self.relative(from: item.receivedAt)) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + if let body = item.body { + Text(body) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + if !item.isRead { + Circle() + .fill(Color.sparkAccent) + .frame(width: 8, height: 8) + .padding(.top, 6) + } + } + .padding(.vertical, SparkSpacing.xs) + } + + private var glyph: String { + switch item.domain?.lowercased() { + case "health": "heart.fill" + case "activity": "figure.run" + case "money": "creditcard.fill" + case "media": "music.note" + case "knowledge": "book.fill" + case "anomaly": "exclamationmark.triangle.fill" + default: "bell.fill" + } + } + + private var tint: Color { + guard let domain = item.domain else { return .sparkAccent } + return Color.domainTint(for: domain) + } + + private static func relative(from date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: .now) + } +} diff --git a/SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift b/SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift new file mode 100644 index 0000000..04657b4 --- /dev/null +++ b/SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift @@ -0,0 +1,173 @@ +import Foundation +import Observation +import OSLog +import SparkKit +import SwiftData + +@MainActor +@Observable +final class NotificationsInboxViewModel { + enum LoadState: Sendable { + case idle + case loading + case loaded + case error(String) + } + + private(set) var state: LoadState = .idle + private(set) var items: [NotificationItem] = [] + private(set) var nextCursor: String? + private(set) var isLoadingMore = false + + private let apiClient: APIClient + private let container: ModelContainer + private let logger = Logger(subsystem: "co.cronx.spark", category: "Notifications") + + init(apiClient: APIClient, container: ModelContainer) { + self.apiClient = apiClient + self.container = container + } + + var hasMore: Bool { nextCursor != nil } + + func refresh() async { + state = .loading + do { + let page = try await apiClient.request(NotificationsEndpoint.list()) + items = page.data + nextCursor = page.nextCursor + await persist(page.data, replaceAll: true) + state = .loaded + } catch APIError.notModified { + state = .loaded + } catch { + SparkObservability.captureHandled(error) + logger.error("Notifications fetch failed: \(String(describing: error))") + state = .error("Couldn't load notifications.") + } + } + + func loadMore() async { + guard let cursor = nextCursor, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + do { + let page = try await apiClient.request(NotificationsEndpoint.list(cursor: cursor)) + items.append(contentsOf: page.data) + nextCursor = page.nextCursor + await persist(page.data, replaceAll: false) + } catch { + SparkObservability.captureHandled(error) + logger.error("Notifications load-more failed: \(String(describing: error))") + } + } + + func markRead(_ id: String) async { + // Optimistic update. + if let index = items.firstIndex(where: { $0.id == id }), !items[index].isRead { + items[index] = NotificationItem( + id: items[index].id, + title: items[index].title, + body: items[index].body, + domain: items[index].domain, + isRead: true, + receivedAt: items[index].receivedAt, + entity: items[index].entity + ) + } + do { + _ = try await apiClient.request(NotificationsEndpoint.markRead(id: id)) + await updateReadFlag(id: id, isRead: true) + } catch { + SparkObservability.captureHandled(error) + logger.error("markRead failed: \(String(describing: error))") + } + } + + func markAllRead() async { + for index in items.indices where !items[index].isRead { + items[index] = NotificationItem( + id: items[index].id, + title: items[index].title, + body: items[index].body, + domain: items[index].domain, + isRead: true, + receivedAt: items[index].receivedAt, + entity: items[index].entity + ) + } + do { + _ = try await apiClient.request(NotificationsEndpoint.markAllRead()) + await updateAllReadFlag(isRead: true) + } catch { + SparkObservability.captureHandled(error) + logger.error("markAllRead failed: \(String(describing: error))") + } + } + + func delete(_ id: String) async { + items.removeAll { $0.id == id } + do { + _ = try await apiClient.request(NotificationsEndpoint.delete(id: id)) + await removeCached(id: id) + } catch { + SparkObservability.captureHandled(error) + logger.error("delete failed: \(String(describing: error))") + } + } + + // MARK: - Persistence + + private func persist(_ items: [NotificationItem], replaceAll: Bool) async { + let context = ModelContext(container) + if replaceAll { + let descriptor = FetchDescriptor() + if let existing = try? context.fetch(descriptor) { + for item in existing { + context.delete(item) + } + } + } + for item in items { + let cached = CachedNotification( + id: item.id, + title: item.title, + body: item.body, + domain: item.domain, + isRead: item.isRead, + receivedAt: item.receivedAt, + entityKind: item.entity?.kind.rawValue, + entityId: item.entity?.id + ) + context.insert(cached) + } + try? context.save() + } + + private func updateReadFlag(id: String, isRead: Bool) async { + let context = ModelContext(container) + let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + if let row = (try? context.fetch(descriptor))?.first { + row.isRead = isRead + try? context.save() + } + } + + private func updateAllReadFlag(isRead: Bool) async { + let context = ModelContext(container) + let descriptor = FetchDescriptor() + if let rows = try? context.fetch(descriptor) { + for row in rows { row.isRead = isRead } + try? context.save() + } + } + + private func removeCached(id: String) async { + let context = ModelContext(container) + let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + if let row = (try? context.fetch(descriptor))?.first { + context.delete(row) + try? context.save() + } + } +} diff --git a/SparkApp/Sources/Onboarding/OnboardingFlow.swift b/SparkApp/Sources/Onboarding/OnboardingFlow.swift new file mode 100644 index 0000000..aa01d97 --- /dev/null +++ b/SparkApp/Sources/Onboarding/OnboardingFlow.swift @@ -0,0 +1,78 @@ +import SparkUI +import SwiftUI + +/// Root of the onboarding flow. Drives a NavigationStack through all steps. +/// Persists the last-completed step so the user can resume after interruption. +struct OnboardingFlow: View { + @Environment(AppModel.self) private var model + @State private var path: [Step] = [] + @Binding var isComplete: Bool + + enum Step: String, Hashable, CaseIterable { + case signIn + case healthKitEssentials + case healthKitActivity + case healthKitAdvanced + case notifications + case location + case done + } + + var body: some View { + NavigationStack(path: $path) { + HeroStep { push(.signIn) } + .navigationDestination(for: Step.self) { step in + destination(for: step) + } + } + .onChange(of: model.session) { _, new in + if new == .loggedIn, path.last == .signIn { + push(.healthKitEssentials) + } + } + .onAppear { restoreProgress() } + } + + @ViewBuilder + private func destination(for step: Step) -> some View { + switch step { + case .signIn: + SignInStep { push(.healthKitEssentials) } + case .healthKitEssentials: + HealthKitWaveStep(wave: .essentials) { push(.healthKitActivity) } + case .healthKitActivity: + HealthKitWaveStep(wave: .activity) { push(.healthKitAdvanced) } + case .healthKitAdvanced: + HealthKitWaveStep(wave: .advanced) { push(.notifications) } + case .notifications: + NotificationsStep { push(.location) } + case .location: + LocationStep { push(.done) } + case .done: + DoneStep { finish() } + } + } + + private func push(_ step: Step) { + path.append(step) + UserDefaults(suiteName: "group.co.cronx.spark")?.set(step.rawValue, forKey: "onboarding.lastStep") + } + + private func finish() { + UserDefaults(suiteName: "group.co.cronx.spark")?.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 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 + // past the sign-in screen to the first post-auth step. + let effective: Step = (saved == nil || saved == .signIn) ? .healthKitEssentials : saved! + let ordered = Step.allCases + guard let idx = ordered.firstIndex(of: effective) else { return } + path = Array(ordered[...idx]) + } +} diff --git a/SparkApp/Sources/Onboarding/Steps/DoneStep.swift b/SparkApp/Sources/Onboarding/Steps/DoneStep.swift new file mode 100644 index 0000000..4db7413 --- /dev/null +++ b/SparkApp/Sources/Onboarding/Steps/DoneStep.swift @@ -0,0 +1,40 @@ +import SparkUI +import SwiftUI + +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) + } + .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 new file mode 100644 index 0000000..f6f6aa2 --- /dev/null +++ b/SparkApp/Sources/Onboarding/Steps/HealthKitWaveStep.swift @@ -0,0 +1,130 @@ +import SparkHealth +import SparkUI +import SwiftUI + +struct HealthKitWaveStep: View { + enum Wave { + case essentials, activity, advanced + + var title: String { + switch self { + case .essentials: "Health Essentials" + case .activity: "Activity" + case .advanced: "Advanced Health" + } + } + + var icon: String { + switch self { + case .essentials: "heart.fill" + case .activity: "figure.walk" + case .advanced: "waveform.path.ecg" + } + } + + var why: String { + switch self { + case .essentials: + "Spark uses sleep, steps and heart rate to build your daily health summary." + case .activity: + "Workouts, calories and stand hours power your activity rings and trends." + case .advanced: + "HRV, VOโ‚‚ max and SpOโ‚‚ help Spark detect recovery patterns and anomalies." + } + } + + var types: [String] { + switch self { + case .essentials: ["Sleep analysis", "Step count", "Heart rate"] + case .activity: ["Workouts", "Active energy", "Distance", "Exercise time", "Stand hours"] + case .advanced: ["Heart rate variability", "VOโ‚‚ max", "Respiratory rate", "Blood oxygen", "Mindfulness"] + } + } + } + + let wave: Wave + let proceed: () -> Void + + @Environment(AppModel.self) private var appModel + + 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) + } + } + } + } + + 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) + } + } + .padding(.bottom, SparkSpacing.xxl) + } + .padding(.horizontal, SparkSpacing.xl) + } + .scrollContentBackground(.hidden) + .background(Color.sparkSurface.ignoresSafeArea()) + } + + private var currentState: HealthKitPermissionManager.AuthState { + switch wave { + case .essentials: mgr.essentialsState + case .activity: mgr.activityState + case .advanced: mgr.advancedState + } + } + + private func requestAuthorisation() async { + switch wave { + case .essentials: await mgr.requestEssentials() + case .activity: await mgr.requestActivity() + case .advanced: await mgr.requestAdvanced() + } + } +} diff --git a/SparkApp/Sources/Onboarding/Steps/HeroStep.swift b/SparkApp/Sources/Onboarding/Steps/HeroStep.swift new file mode 100644 index 0000000..ac747d5 --- /dev/null +++ b/SparkApp/Sources/Onboarding/Steps/HeroStep.swift @@ -0,0 +1,67 @@ +import SparkUI +import SwiftUI + +struct HeroStep: View { + let proceed: () -> Void + + private struct Feature: Identifiable { + let id: String + let icon: String + let title: String + let subtitle: String + } + + private let features: [Feature] = [ + Feature(id: "today", icon: "sun.max.fill", title: "Your day, unified", subtitle: "Sleep, activity, money, and events in one feed"), + Feature(id: "health", icon: "heart.fill", title: "Built on your data", subtitle: "HealthKit, integrations, and smart baselines"), + Feature(id: "intelligence", icon: "sparkles", title: "Anomalies, explained", subtitle: "Knows when something shifts and tells you why"), + ] + + 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) + } + .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) + } + .scrollContentBackground(.hidden) + .background(Color.sparkSurface.ignoresSafeArea()) + .navigationBarHidden(true) + } +} diff --git a/SparkApp/Sources/Onboarding/Steps/LocationStep.swift b/SparkApp/Sources/Onboarding/Steps/LocationStep.swift new file mode 100644 index 0000000..476204f --- /dev/null +++ b/SparkApp/Sources/Onboarding/Steps/LocationStep.swift @@ -0,0 +1,61 @@ +import CoreLocation +import SparkUI +import SwiftUI + +struct LocationStep: View { + let proceed: () -> Void + + @State private var manager = CLLocationManager() + @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.") + .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) + } + } + .padding(.bottom, SparkSpacing.xxl) + } + .padding(.horizontal, SparkSpacing.xl) + } + .scrollContentBackground(.hidden) + .background(Color.sparkSurface.ignoresSafeArea()) + .onAppear { status = manager.authorizationStatus } + .onChange(of: manager.authorizationStatus) { _, new in + status = new + if new == .authorizedWhenInUse || new == .authorizedAlways { proceed() } + } + } +} diff --git a/SparkApp/Sources/Onboarding/Steps/NotificationsStep.swift b/SparkApp/Sources/Onboarding/Steps/NotificationsStep.swift new file mode 100644 index 0000000..613e5b5 --- /dev/null +++ b/SparkApp/Sources/Onboarding/Steps/NotificationsStep.swift @@ -0,0 +1,74 @@ +import SparkUI +import SwiftUI +import UserNotifications + +struct NotificationsStep: View { + let proceed: () -> Void + + @State private var authStatus: UNAuthorizationStatus = .notDetermined + @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.") + .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) + } + } + .padding(.bottom, SparkSpacing.xxl) + } + .padding(.horizontal, SparkSpacing.xl) + } + .scrollContentBackground(.hidden) + .background(Color.sparkSurface.ignoresSafeArea()) + .task { await refreshStatus() } + } + + private func requestPermission() async { + isRequesting = true + defer { isRequesting = false } + let granted = (try? await UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .badge, .sound] + )) ?? false + authStatus = granted ? .authorized : .denied + if granted { proceed() } + } + + private func refreshStatus() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + authStatus = settings.authorizationStatus + } +} diff --git a/SparkApp/Sources/Onboarding/Steps/SignInStep.swift b/SparkApp/Sources/Onboarding/Steps/SignInStep.swift new file mode 100644 index 0000000..975f41c --- /dev/null +++ b/SparkApp/Sources/Onboarding/Steps/SignInStep.swift @@ -0,0 +1,73 @@ +import SparkUI +import SwiftUI + +struct SignInStep: View { + @Environment(AppModel.self) private var model + let proceed: () -> Void + + private struct ExplainerRow: Identifiable { + let id: Int + let number: String + let title: String + let detail: String + } + + private let rows = [ + ExplainerRow(id: 1, number: "01", title: "Open your browser", detail: "Spark uses your account on spark.cronx.co"), + ExplainerRow(id: 2, number: "02", title: "Sign in securely", detail: "OAuth โ€” no password stored on your device"), + ExplainerRow(id: 3, number: "03", title: "Return to Spark", detail: "Your data syncs automatically"), + ] + + 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) + } + } + } + .padding(.horizontal, SparkSpacing.lg) + + Spacer() + + if let err = model.lastError { + Text(err) + .font(SparkTypography.caption) + .foregroundStyle(Color.sparkError) + .multilineTextAlignment(.center) + .padding(.horizontal, SparkSpacing.xl) + } + + 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 new file mode 100644 index 0000000..d45fab2 --- /dev/null +++ b/SparkApp/Sources/Search/SearchView.swift @@ -0,0 +1,300 @@ +import SparkKit +import SparkUI +import SwiftUI + +private let recentSearchesKey = "spark.search.recents" +private let maxRecents = 8 + +struct SearchView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: SearchViewModel? + @State private var path: [DetailRoute] = [] + @State private var recentSearches: [String] = { + UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? [] + }() + + var body: some View { + NavigationStack(path: $path) { + content + .navigationTitle("Search") + .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) + } + } + } + .searchable( + text: queryBinding, + placement: .automatic, + prompt: "Search events, objects, metricsโ€ฆ" + ) + .searchToolbarBehavior(.minimize) + .task { + if viewModel == nil { + viewModel = SearchViewModel(apiClient: appModel.apiClient) + } + } + } + + private var queryBinding: Binding { + Binding( + get: { viewModel?.query ?? "" }, + set: { viewModel?.query = $0 } + ) + } + + @ViewBuilder + private var content: some View { + VStack(spacing: 0) { + modePills + .padding(.horizontal, SparkSpacing.lg) + .padding(.bottom, SparkSpacing.sm) + Divider() + results + } + } + + private var modePills: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: SparkSpacing.sm) { + ForEach(SearchEndpoint.Mode.allCases, id: \.self) { mode in + let isActive = viewModel?.mode == mode + Button { + viewModel?.setMode(mode) + } label: { + TagChip(pillLabel(for: mode), isGhost: !isActive) + } + .buttonStyle(.plain) + } + } + } + } + + private func pillLabel(for mode: SearchEndpoint.Mode) -> String { + if let symbol = mode.symbol { + return "\(symbol) \(mode.label)" + } + return mode.label + } + + @ViewBuilder + private var results: some View { + if let viewModel { + switch viewModel.state { + case .idle: + idleState(viewModel: viewModel) + case .searching: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + case .results(let items) where items.isEmpty: + EmptyState( + systemImage: "magnifyingglass", + title: "No results for \u{201C}\(viewModel.query)\u{201D}", + 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) + } + .buttonStyle(.plain) + } + } + } + } + .listStyle(.plain) + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't search", + message: msg + ) + } + } else { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private func idleState(viewModel: SearchViewModel) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.xl) { + // Suggestion chips + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + Text("Suggestions") + .font(SparkTypography.captionStrong) + .foregroundStyle(.secondary) + HStack(spacing: SparkSpacing.sm) { + ForEach(suggestions, id: \.label) { suggestion in + Button { + viewModel.setMode(suggestion.mode) + viewModel.query = suggestion.prefix + } label: { + HStack(spacing: SparkSpacing.xs) { + Image(systemName: suggestion.icon) + Text(suggestion.label) + } + .font(SparkTypography.captionStrong) + .padding(.horizontal, SparkSpacing.md) + .padding(.vertical, SparkSpacing.sm) + .sparkGlass(.capsule, tint: Color.sparkAccent.opacity(0.1)) + } + .buttonStyle(.plain) + } + } + Text("Try `>` actions ยท `#` tags ยท `$` metrics ยท `@` integrations ยท `~` semantic") + .font(SparkTypography.caption) + .foregroundStyle(.tertiary) + } + } + + // Recent searches + if !recentSearches.isEmpty { + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack { + Text("Recent") + .font(SparkTypography.captionStrong) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + Button("Clear") { clearRecents() } + .font(SparkTypography.caption) + .foregroundStyle(Color.sparkAccent) + } + ForEach(recentSearches, id: \.self) { query in + Button { + viewModel.query = query + } label: { + HStack(spacing: SparkSpacing.md) { + Image(systemName: "clock") + .font(.caption) + .foregroundStyle(.secondary) + Text(query) + .font(SparkTypography.body) + .foregroundStyle(.primary) + Spacer(minLength: 0) + Image(systemName: "arrow.up.left") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.vertical, SparkSpacing.xs) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.lg) + } + } + + private var suggestions: [(label: String, icon: String, mode: SearchEndpoint.Mode, prefix: String)] { + [ + ("People", "person.2", .default, ""), + ("Places", "mappin", .default, ""), + ("Metrics", "chart.line.uptrend.xyaxis", .metrics, "$"), + ("Tags", "tag", .tags, "#"), + ] + } + + private func saveRecent(_ query: String) { + let clean = query.trimmingCharacters(in: .whitespaces) + guard !clean.isEmpty else { return } + var updated = recentSearches.filter { $0 != clean } + updated.insert(clean, at: 0) + if updated.count > maxRecents { updated = Array(updated.prefix(maxRecents)) } + recentSearches = updated + UserDefaults.standard.set(updated, forKey: recentSearchesKey) + } + + private func clearRecents() { + recentSearches = [] + UserDefaults.standard.removeObject(forKey: recentSearchesKey) + } + + private func handleTap(_ result: SearchResult) { + let route: DetailRoute? = switch result { + case .event(let h): .event(id: h.id) + case .object(let h): .object(id: h.id) + case .block(let h): .block(id: h.id) + case .metric(let h): .metric(identifier: h.identifier) + case .integration(let h): .integration(service: h.id) + case .place(let h): .place(id: h.id) + case .intent: nil // Actions ride the App Intents pipeline (Phase 3). + } + if let route, path.last != route { + path.append(route) + } + } +} + +private struct SearchResultRow: View { + let result: SearchResult + + var body: some View { + HStack(spacing: SparkSpacing.md) { + DomainGlyph(icon: glyph, tint: tint, size: 28) + VStack(alignment: .leading, spacing: 2) { + Text(result.title) + .font(SparkTypography.body) + .lineLimit(1) + if let sub = result.subtitle { + Text(sub) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.vertical, SparkSpacing.xs) + .contentShape(Rectangle()) + } + + private var glyph: String { + switch result { + case .event: "circle.dotted" + case .object: "shippingbox" + case .block: "square.stack.3d.up" + case .metric: "chart.line.uptrend.xyaxis" + case .integration: "link" + case .place: "mappin.circle.fill" + case .intent(let h): h.symbol ?? "sparkles" + } + } + + private var tint: Color { + switch result { + case .event(let h): h.domain.map(Color.domainTint(for:)) ?? .sparkAccent + case .object: .sparkAccent + case .block: .domainKnowledge + case .metric(let h): h.domain.map(Color.domainTint(for:)) ?? .sparkAccent + case .integration: .sparkOcean + case .place: .sparkAccent + case .intent: .sparkAccent + } + } +} diff --git a/SparkApp/Sources/Search/SearchViewModel.swift b/SparkApp/Sources/Search/SearchViewModel.swift new file mode 100644 index 0000000..3f86e86 --- /dev/null +++ b/SparkApp/Sources/Search/SearchViewModel.swift @@ -0,0 +1,106 @@ +import Foundation +import Observation +import OSLog +import SparkKit + +@MainActor +@Observable +final class SearchViewModel { + enum State: Sendable { + case idle + case searching + case results([SearchResult]) + case error(String) + } + + var query: String = "" { + didSet { handleQueryChange(oldQuery: oldValue) } + } + var mode: SearchEndpoint.Mode = .default + + private(set) var state: State = .idle + + private let apiClient: APIClient + private let logger = Logger(subsystem: "co.cronx.spark", category: "Search") + private var pendingQuery: Task? + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + /// Group results by their section label, preserving server order. + var grouped: [(String, [SearchResult])] { + guard case .results(let items) = state else { return [] } + var order: [String] = [] + var byLabel: [String: [SearchResult]] = [:] + for item in items { + let label = item.sectionLabel + if byLabel[label] == nil { order.append(label) } + byLabel[label, default: []].append(item) + } + return order.map { ($0, byLabel[$0] ?? []) } + } + + private func handleQueryChange(oldQuery: String) { + // Detect prefix shortcuts and translate to a `Mode` change. + let trimmed = query.trimmingCharacters(in: .whitespaces) + if let first = trimmed.first { + for candidate in SearchEndpoint.Mode.allCases { + if let symbol = candidate.symbol, String(first) == symbol { + if mode != candidate { mode = candidate } + return + } + } + } + scheduleSearch() + } + + func setMode(_ new: SearchEndpoint.Mode) { + mode = new + scheduleSearch() + } + + func clear() { + query = "" + state = .idle + } + + private func scheduleSearch() { + pendingQuery?.cancel() + let cleanedQuery = stripPrefix(query) + guard !cleanedQuery.isEmpty else { + state = .idle + return + } + pendingQuery = Task { [weak self] in + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await self?.performSearch(text: cleanedQuery) + } + } + + private func performSearch(text: String) async { + state = .searching + do { + let response = try await apiClient.request(SearchEndpoint.query(text: text, mode: mode)) + state = .results(response.results) + } catch is CancellationError { + return + } catch { + SparkObservability.captureHandled(error) + logger.error("Search failed: \(String(describing: error))") + state = .error("Couldn't search.") + } + } + + private func stripPrefix(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard let first = trimmed.first else { return "" } + for candidate in SearchEndpoint.Mode.allCases { + if let symbol = candidate.symbol, String(first) == symbol { + return String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces) + } + } + return trimmed + } +} diff --git a/SparkApp/Sources/Settings/AboutView.swift b/SparkApp/Sources/Settings/AboutView.swift new file mode 100644 index 0000000..4d0b221 --- /dev/null +++ b/SparkApp/Sources/Settings/AboutView.swift @@ -0,0 +1,32 @@ +import SparkUI +import SwiftUI + +struct AboutView: View { + private let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "โ€”" + private let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "โ€”" + + var body: some View { + List { + Section { + LabeledContent("Version", value: version) + .font(SparkTypography.body) + LabeledContent("Build", value: build) + .font(SparkTypography.monoSmall) + } + + Section("Legal") { + Link(destination: URL(string: "https://spark.cronx.co/legal/terms")!) { + Label("Terms of Service", systemImage: "doc.text") + } + Link(destination: URL(string: "https://spark.cronx.co/legal/privacy")!) { + Label("Privacy Policy", systemImage: "hand.raised") + } + Link(destination: URL(string: "https://spark.cronx.co/legal/licenses")!) { + Label("Open Source Licenses", systemImage: "scroll") + } + } + } + .navigationTitle("About") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/SparkApp/Sources/Settings/ApiTokensView.swift b/SparkApp/Sources/Settings/ApiTokensView.swift new file mode 100644 index 0000000..df428f7 --- /dev/null +++ b/SparkApp/Sources/Settings/ApiTokensView.swift @@ -0,0 +1,187 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct ApiTokensView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: ApiTokensViewModel? + @State private var showCreateSheet = false + @State private var showCopyBanner = false + + var body: some View { + Group { + switch viewModel?.state { + case .loaded(let tokens): + tokenList(tokens) + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load tokens", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel?.load() } } + default: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle("API Tokens") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showCreateSheet = true } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showCreateSheet) { + CreateTokenSheet(viewModel: viewModel ?? ApiTokensViewModel(apiClient: appModel.apiClient)) { + showCreateSheet = false + showCopyBanner = viewModel?.createdToken != nil + } + } + .safeAreaInset(edge: .bottom) { + if showCopyBanner, let token = viewModel?.createdToken { + CopyTokenBanner(token: token.plaintext) { + showCopyBanner = false + viewModel?.createdToken = nil + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut, value: showCopyBanner) + .task { + if viewModel == nil { + viewModel = ApiTokensViewModel(apiClient: appModel.apiClient) + } + await viewModel?.load() + } + } + + private func tokenList(_ tokens: [ApiToken]) -> some View { + Group { + if tokens.isEmpty { + EmptyState( + systemImage: "key.fill", + title: "No tokens", + message: "Create an API token to access Spark from external tools." + ) + } else { + List(tokens) { token in + TokenRow(token: token) + } + } + } + } +} + +private struct TokenRow: View { + let token: ApiToken + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(token.name) + .font(SparkTypography.body) + TagChipRow(token.abilities) + .padding(.top, 2) + if let used = token.lastUsedAt { + Text("Last used \(used.formatted(.relative(presentation: .named)))") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } else { + Text("Never used") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +} + +private struct CreateTokenSheet: View { + @Bindable var viewModel: ApiTokensViewModel + let onDone: () -> Void + + private let availableAbilities = ["mcp:read", "mcp:write", "webhooks:read", "data:export"] + + var body: some View { + NavigationStack { + Form { + Section("Name") { + TextField("e.g. Claude MCP", text: $viewModel.newTokenName) + } + Section("Abilities") { + ForEach(availableAbilities, id: \.self) { ability in + Toggle(ability, isOn: Binding( + get: { viewModel.newTokenAbilities.contains(ability) }, + set: { on in + if on { viewModel.newTokenAbilities.append(ability) } + else { viewModel.newTokenAbilities.removeAll { $0 == ability } } + } + )) + .font(SparkTypography.monoSmall) + } + } + if let err = viewModel.createError { + Section { + Text(err) + .font(SparkTypography.bodySmall) + .foregroundStyle(Color.sparkError) + } + } + } + .navigationTitle("New Token") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { onDone() } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Create") { + Task { + await viewModel.create() + onDone() + } + } + .disabled(viewModel.newTokenName.isEmpty || viewModel.isCreating) + } + } + } + } +} + +private struct CopyTokenBanner: View { + let token: String + let onDismiss: () -> Void + + var body: some View { + HStack(spacing: SparkSpacing.md) { + VStack(alignment: .leading, spacing: 2) { + Text("Token created โ€” copy it now") + .font(SparkTypography.bodyStrong) + Text(token) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: SparkSpacing.sm) + Button { + UIPasteboard.general.string = token + } label: { + Image(systemName: "doc.on.doc") + .foregroundStyle(Color.sparkAccent) + } + .accessibilityLabel("Copy token") + Button(action: onDismiss) { + Image(systemName: "xmark") + .foregroundStyle(.secondary) + } + .accessibilityLabel("Dismiss") + } + .padding(SparkSpacing.lg) + .background(.regularMaterial) + .clipShape(.rect(cornerRadius: SparkRadii.lg)) + .padding(.horizontal, SparkSpacing.lg) + .padding(.bottom, SparkSpacing.md) + } +} diff --git a/SparkApp/Sources/Settings/ApiTokensViewModel.swift b/SparkApp/Sources/Settings/ApiTokensViewModel.swift new file mode 100644 index 0000000..c96c978 --- /dev/null +++ b/SparkApp/Sources/Settings/ApiTokensViewModel.swift @@ -0,0 +1,53 @@ +import Foundation +import Observation +import SparkKit + +@MainActor +@Observable +final class ApiTokensViewModel { + private(set) var state: DetailLoadState<[ApiToken]> = .loading + var newTokenName: String = "" + var newTokenAbilities: [String] = ["mcp:read"] + var createdToken: CreatedApiToken? + var isCreating: Bool = false + var createError: String? + + private let apiClient: APIClient + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + func load() async { + state = .loading + do { + let tokens = try await apiClient.request(ApiTokensEndpoint.list()) + state = .loaded(tokens) + } catch APIError.notModified { + return + } catch { + SparkObservability.captureHandled(error) + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + state = .error(message) + } + } + + func create() async { + guard !newTokenName.isEmpty else { return } + isCreating = true + createError = nil + defer { isCreating = false } + do { + let token = try await apiClient.request( + ApiTokensEndpoint.create(name: newTokenName, abilities: newTokenAbilities) + ) + createdToken = token + newTokenName = "" + newTokenAbilities = ["mcp:read"] + await load() + } catch { + SparkObservability.captureHandled(error) + createError = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } +} diff --git a/SparkApp/Sources/Settings/DebugView.swift b/SparkApp/Sources/Settings/DebugView.swift new file mode 100644 index 0000000..842dab4 --- /dev/null +++ b/SparkApp/Sources/Settings/DebugView.swift @@ -0,0 +1,86 @@ +#if DEBUG +import SparkKit +import SparkUI +import SwiftUI + +struct DebugView: View { + @Environment(AppModel.self) private var appModel + @State private var cacheResetConfirm = false + @State private var statusMessage: String? + + var body: some View { + List { + Section("Cache") { + Button("Reset SwiftData cache") { + cacheResetConfirm = true + } + .foregroundStyle(Color.sparkError) + .confirmationDialog( + "Reset cache?", + isPresented: $cacheResetConfirm, + titleVisibility: .visible + ) { + Button("Reset", role: .destructive) { resetCache() } + Button("Cancel", role: .cancel) {} + } message: { + Text("All locally cached data will be deleted. It will re-sync on next launch.") + } + } + + 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." + } + .foregroundStyle(Color.sparkWarning) + } + + 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) + } + .padding(.vertical, 4) + } + + if let msg = statusMessage { + Section { + Text(msg) + .font(SparkTypography.bodySmall) + .foregroundStyle(Color.sparkSuccess) + } + } + } + .navigationTitle("Debug") + .navigationBarTitleDisplayMode(.inline) + } + + private func resetCache() { + Task { + do { + let context = appModel.container.mainContext + try context.delete(model: CachedEvent.self) + try context.delete(model: CachedObject.self) + try context.delete(model: CachedBlock.self) + try context.delete(model: CachedIntegration.self) + try context.delete(model: CachedPlace.self) + try context.delete(model: CachedMetric.self) + try context.delete(model: CachedAnomaly.self) + try context.delete(model: CachedDaySummary.self) + try context.delete(model: CachedNotification.self) + try context.save() + await appModel.etagCache.clearAll() + statusMessage = "Cache cleared." + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + } +} +#endif diff --git a/SparkApp/Sources/Settings/DevicesView.swift b/SparkApp/Sources/Settings/DevicesView.swift new file mode 100644 index 0000000..66f3b10 --- /dev/null +++ b/SparkApp/Sources/Settings/DevicesView.swift @@ -0,0 +1,89 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct DevicesView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: DevicesViewModel? + + var body: some View { + Group { + switch viewModel?.state { + case .loaded(let devices): + if devices.isEmpty { + EmptyState(systemImage: "iphone", title: "No devices", message: "Devices appear here after sign-in.") + } else { + List { + ForEach(devices) { device in + DeviceRow(device: device) { + Task { await viewModel?.revoke(device) } + } + } + } + } + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load devices", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel?.load() } } + default: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle("Devices") + .navigationBarTitleDisplayMode(.inline) + .task { + if viewModel == nil { + viewModel = DevicesViewModel(apiClient: appModel.apiClient) + } + await viewModel?.load() + } + } +} + +private struct DeviceRow: View { + let device: RegisteredDevice + let onRevoke: () -> Void + + var body: some View { + HStack(spacing: SparkSpacing.md) { + Image(systemName: platformIcon) + .font(.system(size: 24)) + .foregroundStyle(Color.sparkAccent) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: SparkSpacing.sm) { + Text(device.name) + .font(SparkTypography.body) + if device.isCurrentDevice { + TagChip("this device") + } + } + if let lastSeen = device.lastSeenAt { + Text(lastSeen.formatted(.relative(presentation: .named))) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if !device.isCurrentDevice { + Button(role: .destructive, action: onRevoke) { + Label("Revoke", systemImage: "trash") + } + } + } + } + + private var platformIcon: String { + switch device.platform.lowercased() { + case "ios", "iphone": "iphone" + case "ipad": "ipad" + case "mac", "macos": "laptopcomputer" + default: "rectangle.on.rectangle" + } + } +} diff --git a/SparkApp/Sources/Settings/DevicesViewModel.swift b/SparkApp/Sources/Settings/DevicesViewModel.swift new file mode 100644 index 0000000..eef8d55 --- /dev/null +++ b/SparkApp/Sources/Settings/DevicesViewModel.swift @@ -0,0 +1,41 @@ +import Foundation +import Observation +import SparkKit + +@MainActor +@Observable +final class DevicesViewModel { + private(set) var state: DetailLoadState<[RegisteredDevice]> = .loading + + private let apiClient: APIClient + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + func load() async { + state = .loading + do { + let devices = try await apiClient.request(DevicesEndpoint.list()) + state = .loaded(devices) + } catch APIError.notModified { + return + } catch { + SparkObservability.captureHandled(error) + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + state = .error(message) + } + } + + func revoke(_ device: RegisteredDevice) async { + guard case .loaded(var devices) = state else { return } + devices.removeAll { $0.id == device.id } + state = .loaded(devices) + do { + _ = try await apiClient.request(DevicesEndpoint.revoke(id: device.id)) + } catch { + SparkObservability.captureHandled(error) + await load() + } + } +} diff --git a/SparkApp/Sources/Settings/HealthKitScopesView.swift b/SparkApp/Sources/Settings/HealthKitScopesView.swift new file mode 100644 index 0000000..bd3cf39 --- /dev/null +++ b/SparkApp/Sources/Settings/HealthKitScopesView.swift @@ -0,0 +1,90 @@ +import SparkHealth +import SparkUI +import SwiftUI + +struct HealthKitScopesView: View { + @Environment(AppModel.self) private var appModel + + private var mgr: HealthKitPermissionManager { appModel.healthPermissions } + + var body: some View { + List { + if !mgr.isHealthAvailable { + Section { + Text("Health data is not available on this device.") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + } else { + waveSection( + title: "Essentials", + subtitle: "Sleep, steps and heart rate", + state: mgr.essentialsState, + action: { Task { await mgr.requestEssentials() } } + ) + + waveSection( + title: "Activity", + subtitle: "Workouts, calories, distance and stand hours", + state: mgr.activityState, + action: { Task { await mgr.requestActivity() } } + ) + + waveSection( + title: "Advanced", + subtitle: "HRV, VOโ‚‚ max, respiratory rate and SpOโ‚‚", + state: mgr.advancedState, + action: { Task { await mgr.requestAdvanced() } } + ) + + Section { + Link(destination: URL(string: "x-apple-health://")!) { + Label("Manage in Health.app", systemImage: "heart.fill") + } + } + } + } + .navigationTitle("Health & Activity") + .navigationBarTitleDisplayMode(.inline) + } + + private func waveSection( + title: String, + subtitle: String, + state: HealthKitPermissionManager.AuthState, + action: @escaping () -> Void + ) -> some View { + Section { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(title).font(SparkTypography.body) + Text(subtitle) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + Spacer() + stateView(state, action: action) + } + } + } + + @ViewBuilder + private func stateView( + _ state: HealthKitPermissionManager.AuthState, + action: @escaping () -> Void + ) -> some View { + switch state { + case .granted: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.sparkSuccess) + case .denied: + Button("Denied", action: action) + .font(SparkTypography.bodySmall) + .foregroundStyle(Color.sparkWarning) + case .notDetermined: + Button("Allow", action: action) + .font(SparkTypography.bodySmall) + .foregroundStyle(Color.sparkAccent) + } + } +} diff --git a/SparkApp/Sources/Settings/NotificationsPreferencesView.swift b/SparkApp/Sources/Settings/NotificationsPreferencesView.swift new file mode 100644 index 0000000..87b3297 --- /dev/null +++ b/SparkApp/Sources/Settings/NotificationsPreferencesView.swift @@ -0,0 +1,147 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct NotificationsPreferencesView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: NotificationsPreferencesViewModel? + + var body: some View { + Group { + switch viewModel?.state { + case .loaded(let prefs): + prefsForm(prefs) + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load preferences", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel?.load() } } + default: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle("Notifications") + .navigationBarTitleDisplayMode(.inline) + .task { + if viewModel == nil { + viewModel = NotificationsPreferencesViewModel(apiClient: appModel.apiClient) + } + await viewModel?.load() + } + } + + private func prefsForm(_ prefs: NotificationPreferences) -> some View { + @Bindable var vm = viewModel! + return Form { + Section("Categories") { + ForEach(NotificationPreferences.Category.allCases, id: \.self) { category in + categoryRow(category, prefs: prefs) + } + } + + Section("Delivery") { + Picker("Mode", selection: deliveryModeBinding(prefs)) { + ForEach(NotificationPreferences.DeliveryMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + + if case .loaded(let current) = vm.state, current.deliveryMode == .dailyDigest { + digestTimePicker(current) + } + } + + if vm.saveStatus != .idle { + Section { + saveStatusRow(vm.saveStatus) + } + } + } + .safeAreaInset(edge: .bottom) { + if case .saved = vm.saveStatus { + StatusPill(.ok, message: "Saved") + .padding(.horizontal, SparkSpacing.lg) + .padding(.bottom, SparkSpacing.md) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut, value: vm.saveStatus == .saved) + } + + private func categoryRow(_ category: NotificationPreferences.Category, prefs: NotificationPreferences) -> some View { + let isOn = Binding( + get: { prefs.categories[category] ?? true }, + set: { newValue in + guard case .loaded(var current) = viewModel?.state else { return } + current.categories[category] = newValue + viewModel?.updateLocal(current) + } + ) + return Toggle(isOn: isOn) { + VStack(alignment: .leading, spacing: 2) { + Text(category.displayName) + .font(SparkTypography.body) + Text(category.subtitle) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + } + } + + private func deliveryModeBinding(_ prefs: NotificationPreferences) -> Binding { + Binding( + get: { prefs.deliveryMode }, + set: { newMode in + guard case .loaded(var current) = viewModel?.state else { return } + current.deliveryMode = newMode + viewModel?.updateLocal(current) + } + ) + } + + private func digestTimePicker(_ prefs: NotificationPreferences) -> some View { + let timeBinding = Binding( + get: { + guard let str = prefs.digestTime else { return defaultDigestTime() } + return parseHHmm(str) ?? defaultDigestTime() + }, + set: { date in + guard case .loaded(var current) = viewModel?.state else { return } + let cal = Calendar.current + let h = cal.component(.hour, from: date) + let m = cal.component(.minute, from: date) + current.digestTime = String(format: "%02d:%02d", h, m) + viewModel?.updateLocal(current) + } + ) + return DatePicker("Digest time", selection: timeBinding, displayedComponents: .hourAndMinute) + } + + @ViewBuilder + private func saveStatusRow(_ status: NotificationsPreferencesViewModel.SaveStatus) -> some View { + switch status { + case .saving: + HStack { + ProgressView().controlSize(.small) + Text("Savingโ€ฆ").font(SparkTypography.bodySmall).foregroundStyle(.secondary) + } + case .error(let msg): + Text(msg).font(SparkTypography.bodySmall).foregroundStyle(Color.sparkError) + default: + EmptyView() + } + } + + private func defaultDigestTime() -> Date { + Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now + } + + private func parseHHmm(_ s: String) -> Date? { + let parts = s.split(separator: ":").compactMap { Int($0) } + guard parts.count == 2 else { return nil } + return Calendar.current.date(bySettingHour: parts[0], minute: parts[1], second: 0, of: .now) + } +} diff --git a/SparkApp/Sources/Settings/NotificationsPreferencesViewModel.swift b/SparkApp/Sources/Settings/NotificationsPreferencesViewModel.swift new file mode 100644 index 0000000..dd224fe --- /dev/null +++ b/SparkApp/Sources/Settings/NotificationsPreferencesViewModel.swift @@ -0,0 +1,64 @@ +import Foundation +import Observation +import SparkKit + +@MainActor +@Observable +final class NotificationsPreferencesViewModel { + private(set) var state: DetailLoadState = .loading + var saveStatus: SaveStatus = .idle + + enum SaveStatus: Equatable { + case idle + case saving + case saved + case error(String) + } + + private let apiClient: APIClient + private var debounceTask: Task? + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + func load() async { + state = .loading + do { + let prefs = try await apiClient.request(NotificationsPreferencesEndpoint.get()) + state = .loaded(prefs) + } catch APIError.notModified { + return + } catch { + SparkObservability.captureHandled(error) + state = .error((error as? LocalizedError)?.errorDescription ?? String(describing: error)) + } + } + + func updateLocal(_ prefs: NotificationPreferences) { + state = .loaded(prefs) + scheduleUpdate(prefs) + } + + func scheduleUpdate(_ prefs: NotificationPreferences) { + debounceTask?.cancel() + debounceTask = Task { + try? await Task.sleep(for: .milliseconds(500)) + guard !Task.isCancelled else { return } + await save(prefs) + } + } + + private func save(_ prefs: NotificationPreferences) async { + saveStatus = .saving + do { + _ = try await apiClient.request(NotificationsPreferencesEndpoint.update(prefs)) + saveStatus = .saved + try? await Task.sleep(for: .seconds(2)) + if case .saved = saveStatus { saveStatus = .idle } + } catch { + SparkObservability.captureHandled(error) + saveStatus = .error((error as? LocalizedError)?.errorDescription ?? String(describing: error)) + } + } +} diff --git a/SparkApp/Sources/Settings/ProfileView.swift b/SparkApp/Sources/Settings/ProfileView.swift new file mode 100644 index 0000000..f06653f --- /dev/null +++ b/SparkApp/Sources/Settings/ProfileView.swift @@ -0,0 +1,76 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct ProfileView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: ProfileViewModel? + + var body: some View { + Group { + switch viewModel?.state { + case .loaded(let profile): + profileContent(profile) + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load profile", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel?.load() } } + default: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle("Profile") + .navigationBarTitleDisplayMode(.inline) + .background(Color.sparkSurface.ignoresSafeArea()) + .task { + if viewModel == nil { + viewModel = ProfileViewModel(apiClient: appModel.apiClient) + } + await viewModel?.load() + } + } + + private func profileContent(_ profile: UserProfile) -> some View { + ScrollView { + VStack(spacing: SparkSpacing.lg) { + GlassCard { + VStack(spacing: SparkSpacing.md) { + AsyncImage(url: profile.avatarURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Image(systemName: "person.circle.fill") + .font(.system(size: 56)) + .foregroundStyle(Color.sparkAccent) + } + .frame(width: 72, height: 72) + .clipShape(.circle) + .frame(maxWidth: .infinity) + + Text(profile.name) + .font(SparkFonts.display(.title2, weight: .bold)) + .multilineTextAlignment(.center) + + Text(profile.email) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + + if let timezone = profile.timezone { + Text(timezone) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.xl) + } + .scrollContentBackground(.hidden) + } +} diff --git a/SparkApp/Sources/Settings/ProfileViewModel.swift b/SparkApp/Sources/Settings/ProfileViewModel.swift new file mode 100644 index 0000000..1e0554a --- /dev/null +++ b/SparkApp/Sources/Settings/ProfileViewModel.swift @@ -0,0 +1,29 @@ +import Foundation +import Observation +import SparkKit + +@MainActor +@Observable +final class ProfileViewModel { + private(set) var state: DetailLoadState = .loading + + private let apiClient: APIClient + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + func load() async { + state = .loading + do { + let profile = try await apiClient.request(MeEndpoint.get()) + state = .loaded(profile) + } catch APIError.notModified { + return + } catch { + SparkObservability.captureHandled(error) + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + state = .error(message) + } + } +} diff --git a/SparkApp/Sources/Settings/SettingsRootView.swift b/SparkApp/Sources/Settings/SettingsRootView.swift new file mode 100644 index 0000000..74dc6f4 --- /dev/null +++ b/SparkApp/Sources/Settings/SettingsRootView.swift @@ -0,0 +1,80 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct SettingsRootView: View { + @Environment(AppModel.self) private var appModel + + var body: some View { + NavigationStack { + Form { + Section("Account") { + NavigationLink { + ProfileView() + } label: { + Label("Profile", systemImage: "person.circle") + } + + Button(role: .destructive) { + Task { await appModel.signOut() } + } label: { + Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") + } + } + + Section("Preferences") { + NavigationLink { + NotificationsPreferencesView() + } label: { + Label("Notifications", systemImage: "bell") + } + + NavigationLink { + HealthKitScopesView() + } label: { + Label("Health & Activity", systemImage: "heart.fill") + } + } + + Section("Connections") { + NavigationLink { + IntegrationsListView() + } label: { + Label("Integrations", systemImage: "link") + } + } + + Section("Security") { + NavigationLink { + DevicesView() + } label: { + Label("Devices", systemImage: "iphone") + } + + NavigationLink { + ApiTokensView() + } label: { + Label("API Tokens", systemImage: "key.fill") + } + } + + Section { + NavigationLink { + AboutView() + } label: { + Label("About", systemImage: "info.circle") + } + + #if DEBUG + NavigationLink { + DebugView() + } label: { + Label("Debug", systemImage: "ladybug") + } + #endif + } + } + .navigationTitle("Settings") + } + } +} diff --git a/SparkApp/Sources/SparkApp.swift b/SparkApp/Sources/SparkApp.swift index 256bdf4..a2bc57d 100644 --- a/SparkApp/Sources/SparkApp.swift +++ b/SparkApp/Sources/SparkApp.swift @@ -1,14 +1,22 @@ +import CoreSpotlight import Sentry +import SparkHealth +import SparkIntelligence import SparkKit +import SparkSync import SparkUI import SwiftData import SwiftUI +import UserNotifications @main struct SparkApp: App { + @UIApplicationDelegateAdaptor(SparkAppDelegate.self) var appDelegate @State private var model = AppModel.shared + @Environment(\.scenePhase) private var scenePhase init() { + SparkFonts.registerBundledFonts() SparkObservability.start() } @@ -19,10 +27,187 @@ struct SparkApp: App { .modelContainer(model.container) .tint(.sparkAccent) .sparkDynamicTypeClamp() + .task(id: model.session) { + if model.session == .loggedIn { + HealthKitObserver.shared.startObserving() + } + } + .onContinueUserActivity(CSSearchableItemActionType, perform: handle(spotlightActivity:)) + } + .onChange(of: scenePhase) { _, phase in + Task { @MainActor in + switch phase { + case .active: + await model.reverbConnect() + case .background, .inactive: + await model.reverbDisconnect() + @unknown default: + break + } + } + } + } + + /// Spotlight tap handler. Identifiers have the form: + /// `co.cronx.spark.{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." + guard identifier.hasPrefix(prefix) else { return } + let rest = identifier.dropFirst(prefix.count) + guard let dotRange = rest.firstIndex(of: ".") else { return } + let kind = String(rest[.. Bool { + UNUserNotificationCenter.current().delegate = self + registerNotificationCategories() + registerBackgroundTasks() + return true + } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + Task { @MainActor in + SilentPushHandler.handle( + userInfo: userInfo, + apiClient: AppModel.shared.apiClient, + container: AppModel.shared.container, + completion: completionHandler + ) + } + } + + func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping @Sendable () -> Void + ) { + HealthSampleUploader.shared.addCompletionHandler(completionHandler, for: identifier) + } + + // MARK: - UNUserNotificationCenterDelegate + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound, .badge, .list]) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + if let urlString = userInfo["spark.url"] as? String, + let url = URL(string: urlString) { + Task { @MainActor in + UIApplication.shared.open(url) + } + } + completionHandler() + } + + // MARK: - Background tasks + + private func registerBackgroundTasks() { + // BGTasks run in a separate process context โ€” create fresh API client + // and container rather than accessing AppModel (which is @MainActor). + BGTaskCoordinator.register( + apiClient: { @Sendable in + APIClient(tokenStore: KeychainTokenStore(), etagCache: ETagCache()) + }, + container: { @Sendable in try SparkDataStore.makeContainer() }, + onPrefetch: { @Sendable in + guard let container = try? SparkDataStore.makeContainer() else { return } + await SpotlightIndexer.indexBatch(container: container) + await SpotlightIndexer.purgeStaleItems(container: container) + } + ) + BGTaskCoordinator.scheduleAppRefresh() + BGTaskCoordinator.scheduleProcessingTask() + } + + // MARK: - Notification categories + + private func registerNotificationCategories() { + let acknowledge = UNNotificationAction( + identifier: "ACKNOWLEDGE", + title: "Acknowledge", + options: .destructive + ) + let view = UNNotificationAction( + identifier: "VIEW", + title: "View", + options: .foreground + ) + let reauth = UNNotificationAction( + identifier: "REAUTH", + title: "Reconnect", + options: .foreground + ) + let snooze = UNNotificationAction( + identifier: "SNOOZE", + title: "Snooze", + options: [] + ) + + UNUserNotificationCenter.current().setNotificationCategories([ + UNNotificationCategory( + identifier: "ANOMALY", + actions: [acknowledge, view], + intentIdentifiers: [], + options: .customDismissAction + ), + UNNotificationCategory( + identifier: "DIGEST", + actions: [view], + intentIdentifiers: [], + options: [] + ), + UNNotificationCategory( + identifier: "INTEGRATION_FAILED", + actions: [reauth], + intentIdentifiers: [], + options: [] + ), + UNNotificationCategory( + identifier: "NEW_BOOKMARK", + actions: [view], + intentIdentifiers: [], + options: [] + ), + UNNotificationCategory( + identifier: "CALENDAR_EVENT", + actions: [view, snooze], + intentIdentifiers: [], + options: [] + ), + ]) + } +} + enum SparkObservability { static let dsn = "https://1583f3671989ff49f2e578e5cef8ace9@sentry.cronx.co/5" @@ -59,6 +244,12 @@ enum SparkObservability { } } + static func captureHandled(_ error: Error) { + SentrySDK.capture(error: error) { scope in + scope.setTag(value: "handled", key: "error_type") + } + } + private static func releaseName() -> String { let info = Bundle.main.infoDictionary let short = info?["CFBundleShortVersionString"] as? String ?? "0.0.0" diff --git a/SparkApp/Sources/Today/Cards/ActivityCard.swift b/SparkApp/Sources/Today/Cards/ActivityCard.swift new file mode 100644 index 0000000..c7bf18e --- /dev/null +++ b/SparkApp/Sources/Today/Cards/ActivityCard.swift @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..f41527c --- /dev/null +++ b/SparkApp/Sources/Today/Cards/CheckInCard.swift @@ -0,0 +1,67 @@ +import SparkUI +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 + + 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) + } + } + } + .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 trailing: String? { + switch status { + case .pending: return "tap to log" + case .logged: return "logged" + } + } + + 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 + } + } + + 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)." + } + } +} diff --git a/SparkApp/Sources/Today/Cards/FeedSection.swift b/SparkApp/Sources/Today/Cards/FeedSection.swift new file mode 100644 index 0000000..2155b4b --- /dev/null +++ b/SparkApp/Sources/Today/Cards/FeedSection.swift @@ -0,0 +1,65 @@ +import SparkKit +import SparkUI +import SwiftData +import SwiftUI + +struct FeedSection: View { + let date: Date + @Query private var allEvents: [CachedEvent] + + private var dayEvents: [CachedEvent] { + let cal = Calendar.current + let start = cal.startOfDay(for: date) + guard let end = cal.date(byAdding: .day, value: 1, to: start) else { return [] } + return allEvents + .filter { e in + guard let t = e.time else { return false } + return t >= start && t < end + } + .sorted { ($0.time ?? .distantPast) > ($1.time ?? .distantPast) } + .prefix(15) + .map { $0 } + } + + 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) + ) + } + .buttonStyle(.plain) + } + } + } + } + + 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 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 + } + } +} diff --git a/SparkApp/Sources/Today/Cards/HeatmapSection.swift b/SparkApp/Sources/Today/Cards/HeatmapSection.swift new file mode 100644 index 0000000..f79ea68 --- /dev/null +++ b/SparkApp/Sources/Today/Cards/HeatmapSection.swift @@ -0,0 +1,25 @@ +import SparkUI +import SwiftUI + +struct HeatmapSection: View { + let rows: [DomainHeatmapRow] + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + HStack { + Text("Last 45 days") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Text("โ† older") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + + GlassCard(radius: SparkRadii.lg, padding: SparkSpacing.lg) { + Heatmap45(rows: rows) + } + } + } +} diff --git a/SparkApp/Sources/Today/Cards/MediaCard.swift b/SparkApp/Sources/Today/Cards/MediaCard.swift new file mode 100644 index 0000000..f76a6b6 --- /dev/null +++ b/SparkApp/Sources/Today/Cards/MediaCard.swift @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000..f404c20 --- /dev/null +++ b/SparkApp/Sources/Today/Cards/MoneyCard.swift @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..8684309 --- /dev/null +++ b/SparkApp/Sources/Today/Cards/SleepCard.swift @@ -0,0 +1,64 @@ +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/UpNextCard.swift b/SparkApp/Sources/Today/Cards/UpNextCard.swift new file mode 100644 index 0000000..673d588 --- /dev/null +++ b/SparkApp/Sources/Today/Cards/UpNextCard.swift @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..2c255e3 --- /dev/null +++ b/SparkApp/Sources/Today/CheckInPlaceholderView.swift @@ -0,0 +1,39 @@ +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 6b29993..ef0daee 100644 --- a/SparkApp/Sources/Today/DayPagerView.swift +++ b/SparkApp/Sources/Today/DayPagerView.swift @@ -1,12 +1,13 @@ import SparkKit import SparkUI +import SwiftData import SwiftUI struct DayPagerView: View { @Environment(AppModel.self) private var appModel @State private var selectedOffset: Int = 0 @State private var dates: [DayKey] = DayKey.defaultWindow() - @State private var path: [EventRoute] = [] + @State private var path: [DetailRoute] = [] var body: some View { @Bindable var appModel = appModel @@ -18,20 +19,25 @@ struct DayPagerView: View { } } .tabViewStyle(.page(indexDisplayMode: .never)) - .navigationTitle(dates.first(where: { $0.offset == selectedOffset })?.label ?? "Today") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - Task { await appModel.signOut() } - } label: { - Image(systemName: "rectangle.portrait.and.arrow.right") - } + .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) } } - .navigationDestination(for: EventRoute.self) { route in - EventDetailPlaceholderView(eventId: route.id) - } } .onChange(of: appModel.pendingRoute) { _, route in apply(route: route) @@ -49,38 +55,44 @@ struct DayPagerView: View { case .day(let date): jump(to: date) case .event(let id): - path.append(EventRoute(id: id)) + push(.event(id: id)) + case .object(let id): + push(.object(id: id)) + case .block(let id): + push(.block(id: id)) + case .metric(let identifier): + push(.metric(identifier: identifier)) + case .place(let id): + push(.place(id: id)) + case .integration(let service): + push(.integration(service: service)) } appModel.pendingRoute = nil } + private func push(_ route: DetailRoute) { + if path.last == route { return } + path.append(route) + } + private func jump(to date: Date) { if let match = dates.first(where: { Calendar.current.isDate($0.date, inSameDayAs: date) }) { selectedOffset = match.offset return } - // Outside the default window โ€” rebuild anchored on the requested date. dates = DayKey.window(anchor: date) selectedOffset = 0 } } -struct EventRoute: Hashable { - let id: String -} - -struct EventDetailPlaceholderView: View { - let eventId: String - - var body: some View { - EmptyState( - systemImage: "sparkles", - title: "Event detail", - message: "Event \(eventId) โ€” detail view lands in Phase 2." - ) - .navigationTitle("Event") - .navigationBarTitleDisplayMode(.inline) - } +/// Detail destinations pushed onto the Day tab's `NavigationStack`. +enum DetailRoute: Hashable { + case event(id: String) + case object(id: String) + case block(id: String) + case metric(identifier: String) + case place(id: String) + case integration(service: String) } private struct DayKey: Identifiable, Hashable { @@ -91,7 +103,7 @@ private struct DayKey: Identifiable, Hashable { var id: Int { offset } static func defaultWindow(anchor: Date = .now, calendar: Calendar = .current) -> [DayKey] { - (-7 ... 0).compactMap { offset in + (-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)) } @@ -106,6 +118,7 @@ private struct DayKey: Identifiable, Hashable { } private static func label(for date: Date, offset: Int) -> String { + if offset == 1 { return "Tomorrow" } if offset == 0 { return "Today" } if offset == -1 { return "Yesterday" } let formatter = DateFormatter() diff --git a/SparkApp/Sources/Today/TodaySnapshot.swift b/SparkApp/Sources/Today/TodaySnapshot.swift new file mode 100644 index 0000000..33dbb8b --- /dev/null +++ b/SparkApp/Sources/Today/TodaySnapshot.swift @@ -0,0 +1,258 @@ +import Foundation +import SparkKit +import SparkUI + + +/// Fully typed projection of `DaySummary` for Today rendering. Each domain +/// is optional; cards opt out when their snapshot is `nil` or empty. +/// +/// We keep `DaySummary.sections` as `[String: AnyCodable]` upstream so the +/// API contract stays loose, and decode into these typed views at the +/// presentation layer. +struct TodaySnapshot { + let date: Date + let timeOfDay: SparkTimeOfDay + let dateLabel: String + let health: HealthSnapshot? + let activity: ActivitySnapshot? + let money: MoneySnapshot? + let media: MediaSnapshot? + let knowledge: KnowledgeSnapshot? + let anomalies: [Anomaly] + let heatmapRows: [DomainHeatmapRow] + let checkInStatus: CheckInStatus + + init(summary: DaySummary?, date: Date, now: Date = .now) { + self.date = date + self.timeOfDay = SparkTimeOfDay.from(date: now) + self.dateLabel = Self.dateFormatter.string(from: date) + self.health = HealthSnapshot(summary?.sections.health?.objectValue) + self.activity = ActivitySnapshot(summary?.sections.activity?.objectValue) + self.money = MoneySnapshot(summary?.sections.money?.objectValue) + self.media = MediaSnapshot(summary?.sections.media?.objectValue) + 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) + } + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEEE ยท d MMMM" + 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 + // when the endpoint goes live. + 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), + ] + } +} + +// MARK: - Domain snapshots + +struct HealthSnapshot { + let sleepScore: Int? + let sleepDurationMinutes: Int? + let bedtime: String? + let wakeTime: String? + let restingHeartRate: Int? + let hrvOvernight: Int? + let deepMinutes: Int? + let remMinutes: Int? + + var hasSleep: Bool { sleepScore != nil || sleepDurationMinutes != nil } + + init?(_ payload: [String: AnyCodable]?) { + guard let payload, !payload.isEmpty else { return nil } + sleepScore = payload["sleep_score"]?.objectValue?["score"]?.intValue + let durSec = payload["sleep_duration"]?.objectValue?["duration_seconds"]?.intValue + sleepDurationMinutes = durSec.map { $0 / 60 } + bedtime = nil + wakeTime = nil + restingHeartRate = nil + hrvOvernight = payload["hrv"]?.objectValue?["value"]?.intValue + let stages = payload["sleep_duration"]?.objectValue?["stages"]?.objectValue + deepMinutes = stages?["Deep Sleep Duration"]?.doubleValue.map { Int($0) / 60 } + remMinutes = stages?["REM Sleep Duration"]?.doubleValue.map { Int($0) / 60 } + } +} + +struct ActivitySnapshot { + let steps: Int? + let stepsGoal: Int + let activeCalories: Int? + let activeCaloriesGoal: Int + let exerciseMinutes: Int? + let exerciseGoal: Int + let standHours: Int? + let standGoal: Int + let lastWorkout: String? + + var hasAny: Bool { + steps != nil || activeCalories != nil || exerciseMinutes != nil || standHours != nil + } + + var moveProgress: Double { + guard let activeCalories else { return 0 } + return Double(activeCalories) / Double(activeCaloriesGoal) + } + + var exerciseProgress: Double { + guard let exerciseMinutes else { return 0 } + return Double(exerciseMinutes) / Double(exerciseGoal) + } + + var standProgress: Double { + guard let standHours else { return 0 } + return Double(standHours) / Double(standGoal) + } + + init?(_ payload: [String: AnyCodable]?) { + guard let payload, !payload.isEmpty else { return nil } + let stepsObj = payload["steps"]?.objectValue + steps = stepsObj?["value"]?.intValue + stepsGoal = stepsObj?["goal"]?.intValue ?? 10_000 + let kcalObj = payload["active_energy_kcal"]?.objectValue + activeCalories = kcalObj?["value"]?.intValue + activeCaloriesGoal = kcalObj?["goal"]?.intValue ?? 600 + let exObj = payload["exercise_minutes"]?.objectValue + exerciseMinutes = exObj?["value"]?.intValue + exerciseGoal = exObj?["goal"]?.intValue ?? 30 + let standObj = payload["stand_hours"]?.objectValue + standHours = standObj?["value"]?.intValue + standGoal = standObj?["goal"]?.intValue ?? 12 + lastWorkout = payload["workouts"]?.arrayValue?.first?.objectValue?["name"]?.stringValue + } +} + +struct MoneySnapshot { + struct Transaction: Identifiable { + let id: String + let merchant: String + let amount: Double + let currency: String + let category: String? + let time: String? + } + + let spentToday: Double? + let currency: String + let recent: [Transaction] + + var hasAny: Bool { spentToday != nil || !recent.isEmpty } + + var spentTodayDisplay: String? { + guard let spentToday else { return nil } + return Self.format(amount: abs(spentToday), currency: currency) + } + + static func format(amount: Double, currency: String) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currency + formatter.maximumFractionDigits = 2 + return formatter.string(from: NSNumber(value: amount)) ?? "\(amount)" + } + + init?(_ payload: [String: AnyCodable]?) { + guard let payload, !payload.isEmpty else { return nil } + spentToday = payload["total_spend"]?.doubleValue + let array = payload["transactions"]?.arrayValue ?? [] + recent = array.enumerated().compactMap { idx, item -> Transaction? in + guard let obj = item.objectValue, + let merchant = obj["merchant"]?.stringValue, + let amount = obj["amount"]?.doubleValue + else { return nil } + let txId = obj["id"]?.stringValue ?? "tx_\(idx)_\(merchant)" + return Transaction( + id: txId, + merchant: merchant, + amount: amount, + currency: obj["currency"]?.stringValue ?? "GBP", + category: obj["category"]?.stringValue, + time: obj["time"]?.stringValue + ) + } + currency = recent.first?.currency ?? "GBP" + } +} + +struct MediaSnapshot { + let spotifyMinutes: Int? + let topTrack: String? + let topArtist: String? + let lastSongAt: String? + let untappdToday: String? + + var hasAny: Bool { topTrack != nil || spotifyMinutes != nil || untappdToday != nil } + + init?(_ payload: [String: AnyCodable]?) { + guard let payload, !payload.isEmpty else { return nil } + spotifyMinutes = payload["spotify_minutes"]?.intValue + topTrack = payload["top_track"]?.stringValue + topArtist = payload["top_artist"]?.stringValue + lastSongAt = payload["last_song_at"]?.stringValue + untappdToday = payload["untappd_today"]?.stringValue + } +} + +struct KnowledgeSnapshot { + struct CalendarEvent { + let title: String + let start: String + let end: String + let location: String? + } + + let bookmarksToday: Int? + let newsletterStatus: String? + let nextCalendarEvent: CalendarEvent? + + var hasAny: Bool { nextCalendarEvent != nil || (bookmarksToday ?? 0) > 0 } + + init?(_ payload: [String: AnyCodable]?) { + guard let payload, !payload.isEmpty else { return nil } + let bookmarkArray = payload["bookmarks"]?.arrayValue + bookmarksToday = bookmarkArray.map { $0.count } + let newsletterArray = payload["newsletters"]?.arrayValue ?? [] + newsletterStatus = newsletterArray.isEmpty ? nil : "\(newsletterArray.count) newsletters" + nextCalendarEvent = nil + } +} + +/// 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?) +} diff --git a/SparkApp/Sources/Today/TodayView.swift b/SparkApp/Sources/Today/TodayView.swift index bbfb9fa..91bf2bc 100644 --- a/SparkApp/Sources/Today/TodayView.swift +++ b/SparkApp/Sources/Today/TodayView.swift @@ -2,44 +2,98 @@ import SparkKit import SparkUI import SwiftData import SwiftUI +import UIKit struct TodayView: View { let date: Date @Environment(AppModel.self) private var appModel - @Environment(\.modelContext) private var modelContext @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) } + } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: SparkSpacing.lg) { - header - - if let summary = viewModel?.cached { - content(for: summary) - } else if viewModel?.networkState == .loading { - loadingPlaceholders - } else if case .error(let message) = viewModel?.networkState { - EmptyState( - systemImage: "exclamationmark.triangle.fill", - title: "Couldn't load today", - message: message, - actionTitle: "Retry", - action: { Task { await viewModel?.refresh() } } - ) - } else { - loadingPlaceholders + let snapshot = TodaySnapshot(summary: viewModel?.cached, date: date) + + ZStack { + TodayBackground(snapshot.timeOfDay) + .ignoresSafeArea() + + 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) + } + } + } + + if let media = snapshot.media, media.hasAny { + MediaCard(media: media) + } + + if let next = snapshot.knowledge?.nextCalendarEvent { + UpNextCard(event: next) + } + + CheckInCard(status: snapshot.checkInStatus) { + showCheckIn = true + } + + 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(.bottom, deviceSafeAreaBottom + 66) + } + .scrollContentBackground(.hidden) + .refreshable { await viewModel?.refresh() } - Text("History heatmap coming soon") - .font(SparkTypography.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .padding(.vertical, SparkSpacing.xl) + headerButtons + } + .environment(\.colorScheme, snapshot.timeOfDay.prefersDarkTreatment ? .dark : .light) + .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) } - .padding(SparkSpacing.lg) } - .background(Color.sparkSurface.ignoresSafeArea()) - .refreshable { await viewModel?.refresh() } + .sheet(isPresented: $showSettings) { + SettingsRootView() + } + .sheet(isPresented: $showNotifications) { + NotificationsInboxView() + } .task(id: date) { if viewModel == nil { viewModel = TodayViewModel( @@ -52,133 +106,182 @@ struct TodayView: View { } } - private var header: some View { - VStack(alignment: .leading, spacing: SparkSpacing.xs) { - Text(Greeting.for(date: date)) - .font(SparkTypography.titleStrong) - Text(Self.dateLabel.string(from: date)) - .font(SparkTypography.bodyStrong) - .foregroundStyle(.secondary) - } - } + // MARK: - Header buttons - @ViewBuilder - private func content(for summary: DaySummary) -> some View { - if !summary.sections.hasAnyContent { - EmptyState( - systemImage: "sparkles", - title: "Nothing yet for today", - message: "We'll fill this in as integrations sync." - ) - } else { - LazyVStack(spacing: SparkSpacing.md) { - ForEach(domainRows(from: summary.sections)) { row in - MetricCard( - title: row.title, - value: row.value, - unit: row.unit, - caption: row.caption - ) + 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") - if !summary.anomalies.isEmpty { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - Text("Anomalies") - .font(SparkTypography.titleStrong) - ForEach(summary.anomalies) { anomaly in - EventRow( - title: anomaly.metric ?? "Anomaly", - subtitle: anomaly.description, - timestamp: anomaly.detectedAt ?? .now, - iconSystemName: "exclamationmark.triangle.fill", - tintColor: .sparkWarning - ) + 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) + } } } - .padding(.top, SparkSpacing.md) + .accessibilityLabel( + unreadNotifications.isEmpty + ? "Notifications" + : "Notifications, \(unreadNotifications.count) unread" + ) } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.top, deviceSafeAreaTop + SparkSpacing.xl) + .padding(.trailing, SparkSpacing.lg) } - private var loadingPlaceholders: some View { - VStack(spacing: SparkSpacing.md) { - LoadingShimmerCard() - LoadingShimmerCard() - LoadingShimmerCard() + // MARK: - Hero + + private func hero(snapshot: TodaySnapshot) -> some View { + let isDark = snapshot.timeOfDay.prefersDarkTreatment + 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) + + if let subtitle = heroSubtitle(snapshot: snapshot) { + Text(subtitle) + .font(SparkTypography.body) + .foregroundStyle(isDark ? Color.white.opacity(0.7) : Color.secondary) + } } + .frame(maxWidth: .infinity, alignment: .leading) } - private func domainRows(from sections: DaySummary.Sections) -> [DomainRow] { - let all: [(String, [String: AnyCodable]?)] = [ - ("Health", sections.health), - ("Activity", sections.activity), - ("Money", sections.money), - ("Media", sections.media), - ("Knowledge", sections.knowledge), - ] - return all.compactMap { (title, payload) -> DomainRow? in - guard let payload, !payload.isEmpty else { return nil } - let summaryLine = payload.compactMap { key, value -> String? in - guard let rendered = value.renderForCard() else { return nil } - return "\(key.replacingOccurrences(of: "_", with: " ")): \(rendered)" - }.prefix(3).joined(separator: " ยท ") - return DomainRow( - id: title, - title: title, - value: payload.count.description, - unit: payload.count == 1 ? "signal" : "signals", - caption: summaryLine.isEmpty ? nil : summaryLine - ) + private func heroTitle(snapshot: TodaySnapshot) -> String { + if Calendar.current.isDateInToday(date) { + return "\(snapshot.timeOfDay.greeting),\n\(firstName)." + } else if Calendar.current.isDateInYesterday(date) { + return "Yesterday." + } else if let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: .now), + Calendar.current.isDate(date, inSameDayAs: tomorrow) { + return "Tomorrow." + } else { + return snapshot.dateLabel } } - private struct DomainRow: Identifiable { - let id: String - let title: String - let value: String - let unit: String? - let caption: String? + private var deviceSafeAreaTop: CGFloat { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene }.first? + .keyWindow?.safeAreaInsets.top ?? 59 } - private static let dateLabel: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "EEEE, d MMM" - return f - }() -} + private var deviceSafeAreaBottom: CGFloat { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene }.first? + .keyWindow?.safeAreaInsets.bottom ?? 34 + } + + private var firstName: String { + // TODO: source from /me endpoint when Settings โ†’ Profile lands. + "Will" + } -private enum Greeting { - static func `for`(date: Date) -> String { - let hour = Calendar.current.component(.hour, from: date) - switch hour { - case 5 ..< 12: return "Good morning" - case 12 ..< 18: return "Good afternoon" - case 18 ..< 23: return "Good evening" - default: return "Hello" + 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 extension DaySummary.Sections { - var hasAnyContent: Bool { - [health, activity, money, media, knowledge] - .contains { ($0?.isEmpty == false) } + private func formatSteps(_ count: Int) -> String { + if count >= 1_000 { + return String(format: "%.1fk", Double(count) / 1_000) + } + return String(count) } -} -private extension AnyCodable { - /// Lightweight renderer so we can pull a short display string out of the - /// dynamic-shape sections payload without a full typed model (Phase 2). - func renderForCard() -> String? { - switch value { - case .null: return nil - case .bool(let v): return v ? "yes" : "no" - case .int(let v): return String(v) - case .double(let v): return String(format: "%.1f", v) - case .string(let v): return v - case .array(let v): return v.isEmpty ? nil : "\(v.count) items" - case .object: return nil + // MARK: - Anomaly pill + + @ViewBuilder + private func anomalyPill(for snapshot: TodaySnapshot) -> some View { + if snapshot.anomalies.isEmpty { + StatusPill(.ok, message: "Baselines holding", trailing: "0 anomalies") + } else { + StatusPill( + .warning, + message: snapshot.anomalies.first?.displayName + ?? snapshot.anomalies.first?.metric + ?? "Anomaly detected", + trailing: "\(snapshot.anomalies.count) anomal\(snapshot.anomalies.count == 1 ? "y" : "ies")" + ) } } + + private func shouldShowActivityMoneyRow(_ snapshot: TodaySnapshot) -> Bool { + (snapshot.activity?.hasAny ?? false) || (snapshot.money?.hasAny ?? false) + } + + // MARK: - Loading / empty + + @ViewBuilder + private var loadingOrEmptyState: some View { + switch viewModel?.networkState { + case .loading: + VStack(spacing: SparkSpacing.md) { + LoadingShimmerCard() + LoadingShimmerCard() + } + case .error(let msg): + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load today", + message: msg, + actionTitle: "Retry" + ) { Task { await viewModel?.refresh() } } + default: + EmptyState( + systemImage: "sparkles", + title: "Nothing yet for today", + message: "We'll fill this in as integrations sync." + ) + } + } +} + +private extension TodaySnapshot { + var hasAnyDomainData: Bool { + (health?.hasSleep ?? false) + || (activity?.hasAny ?? false) + || (money?.hasAny ?? false) + || (media?.hasAny ?? false) + || (knowledge?.hasAny ?? false) + } } diff --git a/SparkApp/Sources/Today/TodayViewModel.swift b/SparkApp/Sources/Today/TodayViewModel.swift index d50df90..b0bd8a6 100644 --- a/SparkApp/Sources/Today/TodayViewModel.swift +++ b/SparkApp/Sources/Today/TodayViewModel.swift @@ -28,6 +28,7 @@ final class TodayViewModel { func load() async { loadCached() await revalidate() + await loadFeed() } func refresh() async { @@ -55,12 +56,47 @@ final class TodayViewModel { networkState = .idle } catch APIError.notModified { networkState = .idle + } catch APIError.transport(let underlying) + where (underlying as? URLError)?.code == .cancelled { + // Task cancelled (e.g. page swiped away) โ€” not a user-visible error + networkState = .idle + } catch is CancellationError { + networkState = .idle } catch { + SparkObservability.captureHandled(error) let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) networkState = force ? .error(message) : (cached == nil ? .error(message) : .idle) } } + private func loadFeed() async { + guard Calendar.current.isDateInToday(date) else { return } + do { + let page = try await apiClient.request(FeedEndpoint.feed(limit: 50)) + 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 + )) + } + try? context.save() + } catch APIError.notModified { + // feed unchanged โ€” no action needed + } catch is CancellationError { + } catch APIError.transport(let underlying) + where (underlying as? URLError)?.code == .cancelled { + } catch { /* non-fatal */ } + } + private func persist(_ summary: DaySummary) async throws { let context = ModelContext(container) let data = try JSONEncoder().encode(summary) diff --git a/SparkApp/SparkApp.entitlements b/SparkApp/SparkApp.entitlements index e522aba..913d363 100644 --- a/SparkApp/SparkApp.entitlements +++ b/SparkApp/SparkApp.entitlements @@ -12,6 +12,8 @@ com.apple.developer.healthkit.access + com.apple.developer.healthkit.background-delivery + com.apple.security.application-groups group.co.cronx.spark