From d4ff919c2eb7f871b72b26f2da8341784fef3ae7 Mon Sep 17 00:00:00 2001 From: Will Date: Sat, 25 Apr 2026 18:44:15 +0100 Subject: [PATCH 01/10] :sparkles: Phase 2: Initial Views --- .../API/Endpoints/BlocksEndpoint.swift | 8 + .../API/Endpoints/EventsEndpoint.swift | 8 + .../API/Endpoints/MetricsEndpoint.swift | 28 ++ .../API/Endpoints/ObjectsEndpoint.swift | 8 + .../Sources/SparkKit/Models/BlockDetail.swift | 23 ++ .../Sources/SparkKit/Models/EventDetail.swift | 84 ++++++ .../SparkKit/Models/MetricDetail.swift | 104 +++++++ .../SparkKit/Models/ObjectDetail.swift | 48 +++ Packages/SparkUI/Package.swift | 5 +- .../SparkUI/Charts/MetricTrendChart.swift | 154 ++++++++++ .../SparkUI/Components/ActivityRings.swift | 98 ++++++ .../SparkUI/Components/DomainGlyph.swift | 25 ++ .../SparkUI/Components/GlassCard.swift | 61 ++++ .../SparkUI/Components/Heatmap45.swift | 93 ++++++ .../SparkUI/Components/InspectorRow.swift | 49 +++ .../SparkUI/Components/SectionLabel.swift | 18 ++ .../SparkUI/Components/SleepHypnogram.swift | 65 ++++ .../SparkUI/Components/StatusPill.swift | 67 +++++ .../Sources/SparkUI/Components/TagChip.swift | 115 ++++++++ .../Fonts/Comfortaa-VariableFont_wght.ttf | Bin 0 -> 203916 bytes .../Resources/Fonts/PTMono-Regular.ttf | Bin 0 -> 169480 bytes .../Sources/SparkUI/Theme/Color+Spark.swift | 99 ++++++- .../SparkUI/Sources/SparkUI/Theme/Radii.swift | 1 + .../Sources/SparkUI/Theme/SparkFonts.swift | 66 +++++ .../Sources/SparkUI/Theme/TimeOfDay.swift | 138 +++++++++ .../Sources/SparkUI/Theme/Typography.swift | 27 +- Project.swift | 11 +- SparkApp/Sources/App/MainTabView.swift | 22 +- .../Sources/App/SettingsPlaceholderView.swift | 31 ++ SparkApp/Sources/Detail/BlockDetailView.swift | 162 ++++++++++ SparkApp/Sources/Detail/EventDetailView.swift | 260 ++++++++++++++++ .../Sources/Detail/EventDetailViewModel.swift | 41 +++ .../Sources/Detail/MetricDetailView.swift | 279 ++++++++++++++++++ .../Sources/Detail/ObjectDetailView.swift | 202 +++++++++++++ SparkApp/Sources/SparkApp.swift | 1 + .../Sources/Today/Cards/ActivityCard.swift | 63 ++++ .../Sources/Today/Cards/CheckInCard.swift | 67 +++++ .../Sources/Today/Cards/HeatmapSection.swift | 25 ++ SparkApp/Sources/Today/Cards/MediaCard.swift | 64 ++++ SparkApp/Sources/Today/Cards/MoneyCard.swift | 47 +++ SparkApp/Sources/Today/Cards/SleepCard.swift | 64 ++++ SparkApp/Sources/Today/Cards/UpNextCard.swift | 44 +++ .../Today/CheckInPlaceholderView.swift | 39 +++ SparkApp/Sources/Today/DayPagerView.swift | 29 +- SparkApp/Sources/Today/TodaySnapshot.swift | 238 +++++++++++++++ SparkApp/Sources/Today/TodayView.swift | 263 +++++++++-------- 46 files changed, 3168 insertions(+), 176 deletions(-) create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/BlocksEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/EventsEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/MetricsEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/ObjectsEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/BlockDetail.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/EventDetail.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/ObjectDetail.swift create mode 100644 Packages/SparkUI/Sources/SparkUI/Charts/MetricTrendChart.swift create mode 100644 Packages/SparkUI/Sources/SparkUI/Components/ActivityRings.swift create mode 100644 Packages/SparkUI/Sources/SparkUI/Components/DomainGlyph.swift create mode 100644 Packages/SparkUI/Sources/SparkUI/Components/GlassCard.swift create mode 100644 Packages/SparkUI/Sources/SparkUI/Components/Heatmap45.swift create mode 100644 Packages/SparkUI/Sources/SparkUI/Components/InspectorRow.swift create mode 100644 Packages/SparkUI/Sources/SparkUI/Components/SectionLabel.swift create mode 100644 Packages/SparkUI/Sources/SparkUI/Components/SleepHypnogram.swift create mode 100644 Packages/SparkUI/Sources/SparkUI/Components/StatusPill.swift create mode 100644 Packages/SparkUI/Sources/SparkUI/Components/TagChip.swift create mode 100644 Packages/SparkUI/Sources/SparkUI/Resources/Fonts/Comfortaa-VariableFont_wght.ttf create mode 100644 Packages/SparkUI/Sources/SparkUI/Resources/Fonts/PTMono-Regular.ttf create mode 100644 Packages/SparkUI/Sources/SparkUI/Theme/SparkFonts.swift create mode 100644 Packages/SparkUI/Sources/SparkUI/Theme/TimeOfDay.swift create mode 100644 SparkApp/Sources/App/SettingsPlaceholderView.swift create mode 100644 SparkApp/Sources/Detail/BlockDetailView.swift create mode 100644 SparkApp/Sources/Detail/EventDetailView.swift create mode 100644 SparkApp/Sources/Detail/EventDetailViewModel.swift create mode 100644 SparkApp/Sources/Detail/MetricDetailView.swift create mode 100644 SparkApp/Sources/Detail/ObjectDetailView.swift create mode 100644 SparkApp/Sources/Today/Cards/ActivityCard.swift create mode 100644 SparkApp/Sources/Today/Cards/CheckInCard.swift create mode 100644 SparkApp/Sources/Today/Cards/HeatmapSection.swift create mode 100644 SparkApp/Sources/Today/Cards/MediaCard.swift create mode 100644 SparkApp/Sources/Today/Cards/MoneyCard.swift create mode 100644 SparkApp/Sources/Today/Cards/SleepCard.swift create mode 100644 SparkApp/Sources/Today/Cards/UpNextCard.swift create mode 100644 SparkApp/Sources/Today/CheckInPlaceholderView.swift create mode 100644 SparkApp/Sources/Today/TodaySnapshot.swift 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/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/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/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/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/EventDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/EventDetail.swift new file mode 100644 index 0000000..ee526ab --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/EventDetail.swift @@ -0,0 +1,84 @@ +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 + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift new file mode 100644 index 0000000..07e7503 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift @@ -0,0 +1,104 @@ +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: Codable, 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: Codable, 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: Codable, 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: Codable, 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: Codable, 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 + } + } + + enum CodingKeys: String, CodingKey { + case id, title, domain, unit, today, yesterday, baseline, series, anomalies, compares + case average30d = "average_30d" + } + + 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 + } +} + +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/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/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 0000000000000000000000000000000000000000..9acab52ba57097df63b2ca351b4bc21afd45e8ff GIT binary patch literal 203916 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

f3?sF)=vk}cc%ExRGn;Hw1RHeJPU=o|W>CzKUe z`Xc{!&gd0Sb~>xL$2T86LgD55&>nYsy4#bQ>QUdRIEb>id(z!>&7F=wA-5+j&Ermk z6co|C%>D%bY{DKnydLMZqwGmqS^5uU&q1OaMMZ*70B(Ls9(9FJ4Er`gJZJ~u+f;xU zZcaXjVGk*B1*k^aqB%71wdD`f+fOS^>8Rkrij|9H`}xHRJ>2UC<86GOX%OE_gA>N6 zgFz45O*3cTwF3odM3Vqbn;E*HBZc9SdOV%K{-GmIim zpt62c_@M-4({w;~`4r1?qLmzaS|Q*DeHAqVcKuRpv&X}(Mhx>;? zKoz>K_%v|95*~&;0oQCAE!S?5*gIZS3)dZ#O@@O;IA3iqykG;* z&C{%a=j|{ka3PgXaD=X*5ZcZEkHDR1*StS&}}hx*tQeOpmQD{B5Zb$T}{7P86u_EmL7&VXG)vaGC?i%>MzqTS$&l*95xGZ;swb{I|6ayQpsjB$5zpja{SN;|3X$m3hg-exezp> zVm6>MP6nBHz-QTw{8X!KxRNb}2r`AHQWe`Sd036nB6}^;m%^)73q=788o{BclCP4H z7eF2^76_C(wM8?0YV0W1)nd)W;Jj4J1M;s0gp^n5nw8 zwaa=63wxH;u9$1|H3G)N0s2*D23=ETer3bKkuEis>UGdd=8)A z7`fgN$)B;1c|63{3W>uBZUR!7cCVE`JS?SxW?%2H6C;EM!K z=_%hx+sdW0AlA8sa#j@nrbf^MP(+Zf(PU+W69H1&Iq#2>m$*|vQM(vrF{@~9F{)ceqiN) z^x+FGna%qo#Hd;N1}6hO&~~n-M|%T%o7yG_g;P7uK@t)dVWUA5eg&~={$LMbnegm< zE=cXOSoT?ZfOXP*LcRGGJA|x#7Q1ZOOT0Ti?hG0?cbNnCfLK6v+JD$NIR>Kf&uo0O zveZYkqffLU_y`T{M>`NJEc1nD2{;CO=Ices8PZ-$(#X+zRBc`1UNqb|DdCLlfNa@k z)`|Npv@~dokQ;4Tygg{iZnWYKR?0k|KyE&*AB|lz8SC;6+0E6~)y73MK5A(=YGL7& z&Kzc17Bf(rS^9)I>PlhZmF@?>-zRw|OM4|7My2$dST{Q^)BNVox7xYnoHKL*cK&4= z#fTL=3j`!&WAQUPZyXD%0t7b2yq~(W^z~UVHp_ktXfI&FC-n)lhoOTHvTyJQeG-VB z1q)4-g~Q*=+y|314p319iK~<6JF76bNCaCO-`SC(kvc4cT|CFQ6uU_+k%16q0RRxi zKK7TbWx2t!w9aerd{@`z z-p7NP`2C_i8=K?pxM6-tLH!d}Q#Q9Q(GXy?7!}OF5AR;H%6~?N+fxEMya;MSI&Fy8 z1D}MNN{uP1eg???2r^q#**k4!VPQ>0aeW|#y(H+rB~<9D69^2 zw^ht4zF+bpUEO+rnv%j^4NfjV@<`F7ak;Ja z87*1O4Vl%gmE#_k^b!s_dAJtNVT>QZWdpr~G)6?nq1v-J=!l8BCX^@5d=4*WUO6{< zT;UQxRFc1zu&TL~Gy);O%LcjeS9oCXVqh@zEX2({9ft(aO=1DOX_YxEsbX{$4r+xp zD&i55!CVs2)Gz>k6~5}R0OE`4K6Ea7!6Pz>M+Cw6@*?@URVZ!IR|RxSy3<#c7m4KI z4)GM8$`YzR0A5KmLds9DDwJ6j1b-;$%?wT{LBb0@(>;pVtwP#MCVpEyJw1I|F=ote zz>k%uf|PN<>lgA<48xGTCnd|?aX0nwjMSY+NOjzsO87zPOlq|g6%p7^JJvKetZZM~ zVZqrksphhYrCoW2lgirirBpMH25MbCeb=gn##Otf_3m2Lg!k#~n~((8x2X-sAtM=X z(=K>@yVtkQwx*ekI;p0(w+fc-9J64O(#?dmL^(RyRrnDz}5d&p&DkJ+7(_Tr+B$O8yce#P~yJM(yHQPH@; zpM3MYv>J)MDf||`kPx>~YapVKbO@3f#^xx|m_)3i2DoY>R4$Zg7Z)V81fL1vm2H+} zU#j8O^qa&q1oBF<>q=vm@p6~rQ%SCoucuIgFUeQP-n3ho-D=ZXzxoQr;H|MQ>DCAA zZM$H9LwKLQu)Nq`EOzF!naz-(&7{#eQ3?G%NFy>|n`ggGFiKF}bW_M0TI59}V1eQI*Sw-)mwBBF+hqSwf|+37~nBst`8QBFfR+>qlA z!v$tF8Vo5}+3YhCMLC|~HC!K7|JA{oXf_LPSz%N(4we`U?3SuNsxo7=daROPv{+Na z^*K59S(3$K5d#*jQrdK z3gQAqtLFmK6&wv_R+Cg%tw?{kH-)gBXh0@#n9F!VyJiol6++9<1Ib~-(ow6~DHP~D z7N=L+WE9%r1lHeZfsNi^=!c+)@d@p$8qYe(UdEs1#9gJM!z^0eHG}xB8IkQpS~tx4 zOlUOyV7UE8GqtZ3wY&qZLD!F{h2-v-W;f!94`4N{sx+!fb8hivB32L&5s;unA1e{V zf@TVy*bN;lVeyJffczB2B&`T>10+ZCR_L8fU2HLlYq5uA-{1;R{P|ov_%4^xh{B(S z#)Dmf{*@^2a7Mj(VjyUaJO7hxGCo9*3lC6duTj6RQP+bPb88wIA(vr}g~=KVF#yuq zp}&(pucoz8U5|2QWyJ&KZEfXQ(U8M>z?^Bd=uKaUMuZ~yys|BTZmQbKh3xV+5OZ(7 zCw8r@GaHdB*>6fCW0hm@eVmSp)RizeV|-?aR->6EEU`&3BD~9lNj4q{POiz}xsIN( z`W^G;c3*bG$jQg9qVKO@1$1O7(ft@}OVJI4`@@=>j}31hOhk-US0PEN&>i^(41s0E z>u_1v-ypL2;YzZ`GJL}GbdjH0;@AVuSv3#pW+l*tl2iyu!2h6kyn+HbgZB#42}TAR z9A2xiY7nW)j7+}}7aiW~Knl3}S%pTX5SOJVNxvdI0kTYlRXIqXhBag*j5*}NKE~x% zQ5&iP9W$`nnx&)^FRh8<9l6lA*7bH*;p{J;H0dyN)=urME1a~nhW}{NQi!a}Yl`9Z za!9SLW_e8wcCthWY2HOV8Fb%~cnv*56lXa-x;}%;3WuC9-{2NPUK>s=mL)IF=FtC- z4Cx!N5c^Q`7U%+6;HgIR95Dk$+WVo?tM3!t5^`>Y1qL^4acToPv;ISwP7Hqu(a`m3 zH1zFiH1z*Pd^JXtBdlTzh3Av(`3k8C_t3uJ)Nbt5Dy#y4n#<&~g&Wwt!cm_0rqUG`n{o$m%b|R=y{QFi{^FV#OOSzd%VGr*Aof~w*-BG z^z?u)h~E|p*J%Ewts{SsD@pq#EXGml>&@_mNbAgA;Tp?2t_H0)Di)&>*sKqr%4-rO zPEb-9vP|nqMbeAw%mQMaWb=Ahg$y?RdUA*(CU_n4e`~Q<(B;oYiHC_Zw(|WmNHJpt z$mjvBZ!`((&Y;C{(kvQ@_S0Tm%@zVrq0GnO3!{x&1t2>ZP>RLp+GMoAV0*a>wtI`X zaG^!G8U{w|6unlMV)fYFI$cYPk+*goTJur9P<+jNKCTO4s4rQ?O|IO)!i8yJ*X0J` zI{g$Y%#@DiCKnE=LtFg$E7&5)3&;+_Xb496(gCwTF8ODh@KQlnp%-;LOTxM2EcURX zPT`8o%8=}L%h-R!*!kpeRXSEPN0_GhOzSxlZj$ZBH{{pWS$A-p!;@llhU}&^ zt5t6bjK|TNvO55ZWwQ9JHqo3XZ1kC}W`~o+k#uV??4=s}h~lTC5zElyO5qOnyzrMK zEK%v>9fT!JORvHm2Cn~CFmTq+O^sHJoZ3Iqn=cJf%g zCcV{QbxE$ml=8NQe4|I0?Xjl?4SK!D>GhioI=#d0z%i!vrKP1PR0m+esU(2aa=@xg zQUgn1NCI}0D1O>BKoemN^E%CqTCY=b>k0(t!!A>!fXcIyTZ<}lPGOy^-(a-)t(F^+ z`(bzW>kK;n4FGH7{-APU6I%sHRbT_)Kuz-ku|x$(YD6D%Wp6=uVSZ;3f+0{aDJQ!G zr5b$>s5t3WVWiFhLPB2IO*>jH+^BgF^9m`a_L_t{EoL=;H!}zMyV^TO zCGe&>Jumw1``=Ie5Fp6)G+ILxL1xtt*DA*oL8RE0CQ0>tqUe$tVi=deBGsF#MlJn>XZS zsY8FISfPYY%Pkgs2{CbwaN@%$H4)_BxEuj*dU`0^<1el0>^kTOA|ya^NM4WAXS1b8 z3rnKZX6BHOg`jBx4iaNi@BxjIw4*?sLDhjDsBZV#EpP%N6*|L*^c}MdFmt(#_R9S1 z#?MO9>|Tr6Vs$%FeogO7&9}*^&TMOGD6JGmMv`s(%mH5!pj?>&TIYNh5DhR_>@nz) zPXuIVH{|3po2*0fiHBuo8sP}gOS&uzgjZ``#;3R&%{RNuZjZy0?-QLy8UCt(5#?^J zDX@zJ^M@R4(a;lW#^QO?q(jiJ9RN6QXqY6Sweq0_Z2Qo6;?ggOnu^u)xB$bk`a6Ws zgxL6f6AQ4{*upcZz)}i@U@iPL5D##m@lt-*bT;qIC#KPFh4;wj&(en@Gz85oVGX-S zc>hc~nG9XGs&MqY`Yx;q`iQU6SLL17O1K1>ARiJ;b8e!-^b($29101SP^L7cR^zvY zLNE9$X+;TMLDt<7 zE_BCu)VlI?w3H!ih5Hxv@7DXP;}st?I>#67_&%qcxW0xahitSH9QS}FzClPg`F6_lspZ7*{j~hJ1Ang za0Mi)_dpj4_&mnuZBWPKw;umY>b|tu&jf^#o31wqMq8bPLG}u`GDO!ThB0?a4Xt51n4n`!)59;ujDi7 zYzE0qGIhoH`U0aHTFMzB!3_|zGmVOvZ5H)91n9%lmJqX%4~IQkgDMUIMn~RQWBw1H&1wU7T10)oPS|NT z`K?wJJ86BdW-n;phejE%;xv-?ah(za9tl)^VclqOsKk=U84O5 z`i6yuW9FEBj|8f*x_)gKs(fSh{KH|WB0sn?>nC+0dRF!+KD#)_Pt5uofokmGBx!0k z5t^E_Fjdvm##oo@RpkxUQ=z;ORE<#Ha5f+IXub9cj20(voPC{0c&AXK(RywIs4f^| z*S;Ny*lccy*F@kIr~-jkpekUMIaG}?<~UR((|iT0!kM7B4Z7yJZ0C)~97t zigDtHyRM+Hmfnk}2$zKFC?Ld@mX(!;`m%C%T?o6ChfI>EVW(&$PZuntL<3+}Y0@gl z9-lGKnFZZRc6&nC;H(UFR~5dZ%I|C`P9F%4FW*$!8gVY}Eov>c8-q6FTY1|0qR2+@ z6*c8uJFYq7;;G|$>+C$U%IN7`9s3~}9_bKT90xjXL2E%*t;cB2u4v3^X$$1}l{~9( zZC2U5rlOwuh|y*%Z45WJ1xnJ9Ri#VA(ULiAE}31)`ply}h(8HPP5D&265loA7kIf_ z^Lx!-HGkI})SO}>>icA{2qFQSaY)J>wv??!{kI#~-E1HGA^Qn?nZ3b2WSg=7y<$L&h*e^nI89tEJ}mx7{JHp{p}{cGFxPOA;WEQE!ySgbhQ|%h8(uTK zWBAx`#8_=?H{NP|(D)nU+s1zwzcdb-W||%`ePlXl8Zev8o6OtHe=vVw{=|I9eA;3_ zb^J_Ap(Sc*v&^t8wXC&lw%lO3+wyJ8e^_3&yk+@^<&fpHWRP6aGHHXfMcQSxS!Y`J z+l;m)wl%hEY&YBPw>@glvX8Sj*?a7L_7(P{@_hML`9b+H`C0i_4gp1aG8{#YI!Cu- zwqu!NgJY}Xc2r4u!tsLRb;n;F|8g94>YPqz&^g2Tedlw|UpRl~e8-jNy4ZEOYrE@C z*Iw7-uIF8^x!!R*-9dMWyTN^{`$6|(?jO5*-#v%Dd%chQG``usWxfr*t-f8p-M%CK?fyIcu|Qv7MPO54 zN8ql&{=km{KM(va@Rz_Rfv;196gedW$;@>r-6^wEmZiLyDyQC@dVlJpsn4eVD)r5@ z=CsLa^V3$Q-JZ5P?Rff>^abfproWK>dir0}|CKR5V|vCt8INW>oAIlRCEceH` zujKwQ_oLi{k)@IKku8y1BM(Hrmsgm#DDQ{)_Wbnx!u;C&JM#DDKc4@5{%iT~Ilb zebI!XIYrBhHWu|4{jBJ>MSm{(XVF*1+G1z%4JFo+1tkxZJW_I`Bvx8jT3fol^rh0@ zm%dl}*|;U+z9`EtiWOM|^_uFdtG|w}iC!JOF?vspu4Y}$?`y}`K3bPsS6a8e?z*~9>(?|` z8&);^rLn5KdB5dA%ZXO8)!iCw zy`pt{>+P)%wSK?#tMP2SdAw_U=J;F3A8ot1?XtGV+y1ldm9{^&ebhdq{k`^owIA)! zcJy{E>bS4t@12p(iq7WFNu6^#mv;WL^Y>kqT`gUAb;Y{3Oz=#YHR0h2gA@BEJ~_!b zDPz*@lLmV%J%OG`&+U`t$(Kz2VoK+f|D5v5RB7tWsW0@l^?o?5aoS_k4bxXne{M$l zjQkl_&G^^Mc{7*KTtD-=nP1F2HOn~5Ju7S0mRYyXdT`cbvwl45mD#4*zS%jmD`vOO z{`Ks)W`8*Q^Ev)Gp*h8K`sQ3TXTzK=b8epV<($E}rnwDsJLgWH`}o}F<{p@PY~J{J zQ|9%}`}e$)eXIKZ-FI@nVZLX6cz)^p#`%-x&zpbo{I?d&Sn!X9RSUnfD7xseMFWec zExuv#-HZRaq-M$53rrVyE?9BFWf#1AVa$i_~Pb^@4EQkD{@ynyyEoAs+HfqM1M)qB?~Y4 z>8f$7u3PoUs;5`Ixay5n|Fi1As*{)MFO6J!!KIg6dgY}rUpln9X7%H1GS}3vS+eHc zHUC(9-P)ViKE3w6wf|a|yKd>apRPBrZ(P4&{kPWdU;pd%pKP#faBqli=-jYr!;TFf zY^>aP(ZBC88)rCLVLyCS7u-NqpNzZ zdf=*qSKqg}X7i)h%)jPO*H&HIcI|hzWNzud&VJpa*L}8i+SXV48~QKqzo&ms|DkQ= z+h%V2?zYdj7i@oU`+seZ?Xc|d@3?Bm_8qtGcz(y%*I#)3zUz=bNB6c|Mc#E+*5haCHFjV&tLAf z+&ll?ZTG%)U-o?$-gp0fzrOF2Z>4-|?zet;zvlkt`Ic7L&_XU`Azmhb(^K6&3o`~JEABK+O^ z@c0)sEt;*!T-MwQ?sgkEUge3?41Z3a&}mNN52EumI-O1v8$5OD)WBCJ(^oNu?^+EL zjYgv|aO&%?zdmtlaGQN7HZV9Ci^T>{51cxA^5nqwe(mYwCjEA;-gNx*_ME}fCyyOF zcIxz?=ExD7ZE*0^iDSo)og5g_=+KOoX$=O0jPkd!HZqL@?C~O;aX1&Ja`Qi(`9tVM<;=4hsIfaH!$4=2uvDK*DD`WDzHla69cI2A!<&YU>d#2;&2XW4u5$}b4oY3y5`oxgQ64RVIa_C?G5{CR<+mPwB9*>w^C$96tQnCx83f-#+>5@L~1J$w5H-@k3u9 zB%uGezh5_S{3}7&?$#L*lVm@B{P@6-03`hK%P)_f7}#z)ecWt5tr;B532iTxOe_Xm zffyam&(9C1!Br9|E-sG1XQEy)>jwu1ZGJ}zXoMyojq7c$*x+{esiTKJ|NL_#IT(b& z6DLl1AiP_|?Ixr3$PuGPuYdhbb$$lS!$9*FKEc?o--@*H{C;&0=iuHP++(K)x96Td ze)8zyLx&E1ap3bWzWCzM*T)YYKY1E`pFVl~;6czE6eT-(Q1eB9zxLE|kOi}#H-heH z1_xq8{VvSy*y({G5%EX^0|R;^YQNaGGoyZcuF;CnkU$`ik(rT}mKK0Z!)k$3Pj9dw z$kHT&Wh5NJ;vK71IDoIH92Q-1Y^H| z=7rJ>8j!l7q5iBvtso2?|MJjr9STEfV#mKcc<|tt#|AYz*ti*{qG8&8%}{?o5jy0Z zY!Bh7lgCeMZ4Rp)RLvp^n%If2kKoVIBS(&%JdHMXjKjo}tiTASBz9r*f~ zc1UmYq@<*HZ2Fw!VNMq{n7l*J{g21+gP{15VXAOSwuYc2`9;UHapl5m9 zlqPL93QWI)Q^(<5LO--igJ?(HrQv%q<(_t|dphObsj=>z>p4+*?!@qOaB*o+zTIxq zXu%J(I%L@hTKv---)263`~=`aXVM)%zEvPF5DyaXqM$Sh87wI_ zk$BuG(UzjIVt>I#PTp$vLe~^Erw<=Ea6sKj#7AnT2bm8MuUIqsKf2$3{qe!Cz8W0a zzr~;%0Jqc`G$Y1gw;8cxMw`y!a=9$JZy1&J=+V;}JqB#iYfc|MI@TaZ?ayh=aoQgd zQyN7H`*CMSKd$dkC{hUX8Xsh`9qXuNI+_=j694f#>A3ck_Mqmt<{;irZDq$z{U8>{ zj}L)LoapC^nP#*OXoAiAhYNl~w8jOu!xg$|4XIj3mUIy`vBy^|V#&-DN1 zJuvD~{lSfd%z5Ce_9Vm2om-J1jUH8gnhfKkpB%n7ptLHEHBv#jH!#*ch!->(+gSI$ zQtr9Nx<>=Wb2QE~R_$E()Ha=CJvXGZ$;P^O=Bf?P{gm>Yarn9Tif+RlVzk?EYpdq# zGwA+l2sV%ZKh(Wx zmm_I*-xoU&KwwYgo>f^@T~%G(J<~JY)12W9shZ(vC{h$9QCvj%$dV{=Gij3cv20m7 zl$Zh3h&H9;H(FlkWB60>jj2Ogb7l_7UaPArD_86RAOaEo-6sNx%mli+x=H!iG1FO@ z2n6t~_qoge{_j-V@km8YlAIUnD4u$_wUx;Bu@gjm4P7xOLcDtIa4O|eDfch=-hXvf zX21PuGCR>tZB{DjcA-$Pb;m@CUy|To`g94dO-7s@9QvrHqcCazl~n>g{3L-sORSTV zc*6NMiN`VO{AKa}rA)jZf1-GYWu(mM9GlR|R4O?=)rBw5s*?8ZLi+y2@0qDY4_AF= zcDlridtex}Z|(!QWC~rR4?Mz#TN+hkLE!*?9s5WHZ z{R@1?FR&&J%QjnEVIa>B6U)i7-b8C)_Qq2ULkzu5?w$3uvG3Ot-O{nBbc?eeT_KEI zyJgvKl;|yGy|t9}_Q;rE;c+ZV3#$4A4TftHTaumX8XQiG)_Hs?LQG>@Y13SNZEdZb zcNj2h(szucrH;>Z^s<&OMkdtGWMp#ma7j*a#K=7emOk6PLHb;M)pMQn-ukM0NqW!t ztoOPv_{>>)&tCPJFY5a^ea>4vcX_2v&vXm}cUvD^Uun7YxnS|!kFrYs3adnql6JO8 z`qL;8p)_A4f=j`0kU&h02m4$dW+}eVHUfx z0IutaI^V{;COnqS`L}4}x1^0M*4;1|uvYoP*%(FkESyeenw^{S+CZAAbodq})p$!w zNV1IHVA`^xk`YWFi7JQoA!~LV8CJ1UaJKTr0yg`|RU3(xDnT!6b7wMZIpeeAS+ka7 z2b{zR`|IO5OWSHK+Io++N?mJdYdLMJq2c_%ET8b<_&=Oh&T-M1d!y-hbL zn9s|XJ`6E26U+5%q(yzwzez2Fd-wK0{e?1HrF_=f@6g(xRAWAEyVIb5t|c|fnzpNs zTOx>19YwB=2Md$;=t!~VT{YK`RD``|S)=@0)1^o7^>!#})OD~qe5f3e`rZ~N4Ebhsw5X-ui-oc$Uf9hAb^XuHX zHyrf(!%;Bo9zQ=G1OsLgXWnoqcIj!acYOGq>pVSMWV^CApZornp~vB{citTiFvpJ% zy<$m}hf2lN*#QpPENx8enD<~}@32NR`D1OZ?%7&2JZBZ04|%g!U(0mid8@B` z<(B<`%ddRWv-8(KyJzsfjz4bU=bGraw}SS8?k4==Wn-|opO^b1?hoaDWoeB5V*b2K z{<#SdBUg*k(_r!a6RUlonU84SlwYFkw77IXKfga*eP13k<#~7YeP@1uy!yT|zaL+^ zKc7!Nx{Ztv=Q4ir`e*h}Vk~H(mT4E@o3x+bhexcrVuzS(tG39qbdPvf_K5cHUwz3< zqTTiX!%J?HYd#K3dnR&l$|q!Ua4`yM^{())8nUYTtg2X6RU=zfKS{4!`}%#(dev=y zqO0?tm>TliHP^_&-E<)EDn@aohl^oyAg-+96`hL@4QF4;hCzwT$Ynm2wYAL4vbL7K!a0DOWj=zBs!t9tNtU&> zt)#6qkWX^Ks4*3t?62&ndd?2#G@ara;ZZ#soCT3CmT456T_`rAAYE%?fM$+`t5yvcLxhlJP|m_ zmd}u#itA}J!fmTIiZdf}H0-9U;@X+=&T;STtQTljgz`Q{=TY9=Pd{ObR%{f7-nUuH zTkZKO_>|*D&H2yJpTEOVC%Ww`VGyEwPbSkalsTV-i8guO?+(vGP2y{KLeI2OrtvZ`G10Z}di2)_~~cufX-8Ow&T%5gn`G>Phi-W4*HND0^b8JKI`0(|Ou1K}B_8 za@P!d7_U_{)p5EJBb?-R6cXQRfE!tE$&;r?{lRcFKvC*;2gA;Q-Psw5 zIp}Z1;dIpPbWpj(#2QA#7z$qAGTK|jOTF3}NfBjn`R7cujM;?1sG&dldOoicg4ICv zH0z{jn+ChnD>)L-sITwt-MD@4)w{RWgv5Al3w0dGcOBDXy`0BR^xrXwjBadf)JwG{ zrd!2Mf`0dWFv0hZHZvP_o_+lO)$i>BT|C;CiwOYSX`$w^4PCtHvankQ3(`YbcGja8UGo`yefA;wB zs2kwqkU&u|4(V$+J|c)D5hMu~S<%=ohhiC<`U?`xnuX{?Zm-u3i?aNjWZGqipLWmt!(b-0 z#eoOV6@+WL>&d>-Uf!tsdGS07Oj+fHt>x>=DEA)%Wt-+nZHgckzd8ZXe_=4l=8{X7zouZ|T_?wrJbu z-M=r|qyII0ai7=UmbJ6IkQehf#DR&A`lwLZyUNl?F-b4A!0;}x6P|qoi$Jy4C>uY*h zrY>5O$>~e-_6<+g6+-JDCPdrBY< zP8&krV=Zy4*bC()z( z68s+ZQDyUPqV)&Bo{C;#j6<7LV)!HUXf)9EqHFSTmfM-MO4DF#3ooa~dAzlSQ)tv- z1KN%o;SS5%I+p;SZm%!i`AO)Mt0)w(*5Ivm?M@KIab$!X{sAtU!L&C3*;EwFR)W0f zg@aiX`2@~KEhmnOxw2=sY<)OGwRm&~X>B<(2e1`CFAhuFh2p?VT6Wkw9v+)59yt$=&&Mr0sam*Cv)+q7EW8W56H#|! zJrQ5s*(8vob=2+l0Yi+mE+01ySn(Du%XK=2(X#Dg*~B%sTd+Bsz?l`wjm@26$uJ=} zZLT*sTC3VR&*e(dNiU=sqxJP@*0QJFljy<|w^*q&zJz^a^UTt|nWXoMtM2vEduxk( zGE1U~w9-4t;?CmR`b$qO?#1anW7Tt6OWalWrs;DKw$FP2nIrtdy0*iLjlvQrmmuh! z5#~;m!P4Bs@?iuO5cyaGd`tmn2+yqCmuG?}f zb2cNFIM9uCOaqNq@=m#~4>|*hC>TcGu+T?!-7f*Vvns`Cq>ZgED$3>tZx-h(ACvu0 zJ_lQDqpyRWi3a1-r>9PBvvv#)>SMIxcmutI58G^3Q8v0xm9X%%si9O|^CwU_L;5_? z;^jW;>Ahstz2)Z?rwOqeLu7t3;K?PKzx?E4HNEIwE^Wjm?ZZQ`^$4NP4DGb-IL+wIT`Jxb&LHZ_f|fajcBmyx#f{r%#x9Q zCJ?Oo_g&T}(N>AwP2%O)UH05;@f?}pWP@{RW6<%T3Ik})WHByKfJ@(tc{2arU*-A# zb6@`<017P7&XH8N-K&hqbe{Qz;hnmSq<~fYnQG5d`3> zUF!Y*j;`eh7ia)%3wAW>_lM(P#J8>9z1qX>`4Q2C`y;97GPc& zjtAXmAAj`ptb?I^-suN3+1R+vWm^vh{e-Q(@$~8GIAMQpZq}U6e2)*cb6+~z#x>73 zClMeDkioiyD|%B6(L&@1uvams%_MN3`*-DyT-s)hvI$MVUdj^b^5$w&kRwV&B-ecp zt^@~V&7RN4{Y#8HxUfyibE#2J#~TsFFXFcn6rbOS0-_{1Rxs>UiI^d0p-Je#ZIxZL z5_;JkYIdPguzk%SpwPaRSAoQxnhS7`YZeE`Po6wE4q^(00!|}_;A4ydd;^D^n+!Y& z!X-X*WAvty1GJXCDA7rQ_v>Pg?Gy>H<_D;YiWb-km!xg zA>ydX@%NSKXxRER7zPvC*NbGd!GxBoaON^nnFYI8=W1igIdoxnE$&Ta@gErOs(Wf< ziB|9#p95McpBXQndy8@VIW@oHLzFF~W;k>IvBxM)th+c|u)d2fya&sP2-JA>r>bQE2 z|A4ms8_{4MGQ;TG4o^qXOhEXcpHQNuVVyzb3eq&3f*3}P48~Kw-GoL)b{b1ff?#|z zpCQW&?}weoxmz$3HYpGx)^DSnH{(&aG@it^>#{oV29vQKj!U=&4Z@aA-gR>r>FxW{ zPomA^p{TMB@6;oS7Y@+G>#UP{e$eAn9oNU_<$C2J{#z#s#}n~A}beB;2Z$-l+cEk~0jY{*>D;St0!yueRlVB3(yweFinexYQ&>!gmK^Dn^Sgf96 z>hbF=Jbq%G{}226jXjN4(u+@+?>(pLl#J3R&AR8}GSb{nockXNx(L8-K#Vww24^q7 z5^4kdvgl5^_j zj;G-i=fxk~z}y4qBk~>pJpx`62`?fZCmQ?U(HkzlNhJV^1p(U48jT((W78EQqfzx- zE1JeE9~?q?EvKYF`wlI5k%-P~vv>)Crl-D?m=9Sx*JtTH>o4=(^7k#Yp&1}t(dn!S z-?*gHEkC)iE0hn319Q0#c-Tem1gq}JR46&O;<+HbS6=m;G?V9~l}lsxqI=nAmR3C{ z@fSW5494>u&x)8<=h&CL)XNGeUziAIrT6P~5;oMiMflz{XfUoq4n`;Ht@Flz6BZWwZ!!J$D39 zG1g4w&-~xgKK*st{S&nNu1ZZpfM&=%Ic($DPq*x2O8f5f-pi$*ITDrP`nq1WQ@+jm_|1L&_ANn}(k~fZBA{aGD!pCw zj_hSUi2OCBQ4SEx$@yS3Cg>DJyzHbeM`o>-cUu)VU(?2VS@ah9#IdiB-IneOcHhgl zZ>KuuR(&!!EDZ7&u2y0YF{v&jsdB1-lS2Aqklr z?EDFaIP3#jxHe`m33;w<*t|@B26&!L%I4H*RF~Sj z0UWzOo{hzL;h%no6?WD+?3}J>?P8W?_L$}3V*CXU@H65+63yGx8Aw^nvYO@py0H!v zkA$Y_s83Y&Vi}ABJ!kmjZ-InE?_fE!3=AzF@K!7;uBI>zEc1LG!&iy!>jSf5xOcHCRmxi&k^Y7d zm`X;PO7s%2_w^axs$xPEVdcyE07aPQO4;Ste)O$p8HVD=fuY#sB^EK8!F_ zW7W02sT%ib5;5>mvec83Eykv7kSH;2`%3Heg45b`3V>cpw0)_;B|cEkN#r(%Z3Z~= z($-OPx$wi7F~g2ayv>vu486Z1Yo(aZOlL7Oi&-nC84g`J2GagX&f1su^>RtfKPAqA zxaf8XRRr5iY&7zp6TcV*=jZ(}0nw7crDkh%RI`&IcGEfF0)b9)-s2-GAe)u3Z6MAN zw`_>aI}clp_Dh0R$<7Wrs?~R1{@xvE#~qGQLo*AW8D4Egsq7&P5CyIh1J@e$rVL&X z4W|)NTS+Vxr++7Molk7+C(DCR?uDF*-)21&o)5eNAPCJMGqoO#@t%;-r(4C6_*Hq? z&kvph>C)*3i(#A>*`FUrK5mJmVhn@0UAR?0@68fGtK^^79ZF4!7bOlmlE^`_+sx0` zZp-bkcjlW;aX4+6(_zu^b8)O6MYWQ-2~LXxQeFI-YAxdM*T$_iHFJDBU(L^v7WRfmgM%X9uVr5!`9%GuW1{9| zOT*irHX!{e%FsM+APuU|8&oWo6D-c!{%+lGE}Ev9ZZXmZXU8EqK-VPoq4ro#n%j#! z9sQ3xm-h8E}#dLiDDA&3BP;SN_cal;#z1{x+n&giy1Hk!;is{ zt5#DOARm_Z-WBT|BvL+kc8czQ`fMX=6()nu@lZT0-W*x~&uHiG($39tT>vaq*O-QE zaAro#8K)RT?LDPF(gN^Da=6C`q5;~*NcjswJkh@IMtEmX4xn!E1;c_-HI7>BIs&Ao ze_2R>i4FW3GryNa=zrK+xX3iq;e;#|NTck_pbwb->U{cB zpI|hL+uOxrrC6|u(^@9O3m*mXTa|>KO*)}>VE~bN2JLFO#r7VtJS%fhqG_N`9mxI;Elf^sGch8AXaMK-CNZza@qmDNe!{j}dn=X4%f zzmD|3M~}(WsMo}b;!=W0Oeo!~;q??T;-CVRW(cIAQNt68Z(1h{-@*rvn_Ihhl5Wkf z8ipzmKeFd1PoGZg%}qPz!{p-_)TRxDHME=8<;;tsM@VInGoRV&T>i|3mihT3j{on( z>K6LdRDxhC2f!2_1b&@o0W99*rtcvSz#OFSnP&nlcFD3Wy}0+;Vt$vcRL5W$>R|4y zbVe6#LdP-u18w%`z$BY4=2t7zx-Pv(8kN0Af{`oVBaO@6BSFcP?-4yOdyfPsSH4Ha zD0`0t1Q+jFjDy8nSZOL`@>y1B7N=BUnDn{JV}y7IFGmn5!^{0ucHM97>$mphpypE2 zf3blsZNh__Q`Z3e4d7VMiw!fwxJG>7>MDjLu=b;UBP__-$7Q)-j?gL4M$A6JKZw?Bol#uYUoIRi-`(y3ZlsHyy?+!CZQwj#gUWV`bb^VF7WuNes70ptI@h?d;VM?+%DZ zxc@b94e4DR56cNcqgpe5``gq;MhzeE;1;CXOQ==(G3y!}lK_opyVtC#L{x2=RHiE8Y13~XMajN1P<|MOl2WVhRiWRUWO&H|m zgX9spSZHWP7EZEOg^-wA+u`gY0DEk&M}9r;v0!;X{6`8@!k$oT;2+X1vS+~5NA1^B zPaK*b2-}%V5Gl85Ym8h4W4ZzmSJdI*Of%;mE7ztJcp1eILyov2ZWd`|SMtP!q=%$M zLac4cK}av@wk&Xkh|zU;aTy7Y*k+!pf^`%{*9zVG_SV++6MNmBrPi+Bm<+yu*q^FC zM1hyrjvd&oRSzWE30h}!Lw!EunyTCKEh%beL-09GK?BG=fI1m$$2eHW$&!M0I9$`Q zRk)q(v)FfH?5YN<;RUfH;rqwO;C2W}MbT)E+=&j>#VW@sj9iFl-yFG7oNXw~n}Ws0&(wDNEZ=9(yOCx4rtOh=Y=)>HnXF&P)*j!5 zY^8AC+#*=q6DlI^33X6rBQEYu(tCwf_m0zhH&@+T{=VO4M1GqQc_#!Q%^HYULP{%%kC$RKF~u-9cs%n-U3>(k(f$bE=x< zzs*vZ>0WuQQQ0K;56K)Kf@&qZ(zNj;41&pkz{XUvg$p?#33+=N82}hTMs~snW+Q7e z3Mqb1-9Z7J-&u?~EFJO`u8#S=mw8Iu13U#&-u$WG<-3F<#8r$Rbf2t^TpWs>$Eth9 zWcXy<@w$#lp}}w?vW=>nLnQ`LDyAKa)&B-|sjPfXQ2rf{pv)T%NT-sJkRWRjT^#@y z-41w*$#fD7VCGU;UwB`_M}=g9CwC% z<3M6l!>K8z9V}zt01K}yKY{k-i)9S1Dpmgq;uuEUNM`OP#qFCL73?tl!IZSkITBpb zoC_SLup+(AIOyZBUBlWXo^;w9c0}lsS#f$1#lpF85yg6>7xV3!TdS;*`LI^m+>uRJ zXQiiss=}o}RkqgzdyH=(lhPtaE)uX=@M{G?1Gde#z3mY3BwfEUasL(orJnj%qsH8 z`h8z;NH5Wk%UT_9qz?3tiN2Z2+snI0Pj?S=FxT&iDx&tt#Xa!|ft1me`}hKoA+xI+G%E7xhMB$wz^55Qzi-RZt4t6fIB*Ym~$u z3H4<6OeDAJWH3o&HISy`Z_0ESgDF3!145IWTvY|q(ZzF`y-o;dNq_q+`Spv@SrNy3-W|4U2= zmsh_V31+ir&oP^e^Wpkaaz}VOulpWEJN*)`uanPbN}wVbYF4=f|EW+Ia)zuW$+YNN zm;y)?EE2NpiqoXMPPjfI@?n)K+T{35^6wHy02gS?`ObiYWj5K_!B~pI!8z&i=?364Q0VgW3o?>Brt>ij61BjkitLp~bQiAa}qiGR(BfAJp(rQ_zb8=F| zWnP?;G7(MJ)~3;;2bfqvGSk3&t-DdL4?C3Wo_F7Wd^Q*lXET!F4xhyl$%*1Z#@pT* zL7sG8F5?!m!tw6zIsWqwZe`G8j~`#;cPw@U!3~)OyvWP5C;V01o2&TH<_6;#!L)qAs}y0Tb-mc#YV1jpmhQl~BO{HMbP$7S zAdkVJyg(i=Z>g@Ip}p`uErX2qWT&OdX`?Q4a}T4CI(jXe|{FXBg3MK{Hx&N}XyW#1Mi&(7F4 zs?~hkAU(Zp<&2NIUaRJv5sEu=_~9I)2p#g_=A;oG`?f?Ph=mx3hl92;I6Pb$(|p>V z7mXP3k5@FS0>AZ-rF61>{)Sk#(yclb9 znfnv0K@gCy+(BuOqm#orv#e2Q5mWM_yefB%J3_JS1kauwlUhseGwUmlKJ2pS;+U*O z(FD&AYJmfZEJ97<&ftUs6fq>+<&gZ>#zL5S9F%uoxkIKhwf{=QbCeo-0cINWnd4ri zh*E79D@_!~-6jzje^2y1`DhLEW{i@INfXb;*o>ETwdE-90X_XacH37S=>^aS;IY6L zq3z27U6qvQ3rLof=`NJ(_{$|~tP;`25?U0_HIfM;-_M1CGtuCJTMb-d zQREN|wLP|nRV)}$*i`-ooa+{6%y3AJhnVrPe6Sik#^dU9_{=P3XK_6Fb)rqH|3Mn%-*{$Z|^H6 zV%#^Av$Jp_>27^tFvDkzh@Qch(BW8A9K@V}F6*I9o_NwKV9o`c55lzA#(|aYCR)b+ z;-YKvKOv}Sz(}#aUfj+#)*HF)oP-&2>%iTi&8R{#%%5lw&1T6=`bSxd=NgHR_u!F_ zMU}OClXldBO$a3vfmbPmLOL=` z8EY54XTe!+S>p8Nl+dk~wSw$S5QVv9Iu-!rMO%c$%xNkTE2;4CiBC(aka)K;R{zN<_cH6t@8g_YOtD!dlf3_RY#AnU1 zX&VDmCuPsbL+!pXu0!iSnLwZ|guaa;)M*%*hT|LjlAQ&M*_q1hE~50@ zyuEktc4K#)X){fJtnc22AN(s{;m7UUH!7v=YU9qm2lpxCdiTEemS2Hiv|$~eql4;j zQtFAH%R#WI2sbx{coVWs)E-4z378o2535nhXQS7qx4%zst>IXpUF$KfE6F3_ELQ&@ zyCKP*wQhwV{xN|q(jSOlD<{fu8jeP@uqVi*9(mUOYY;~CPft(IdmW}7G6bP8hA3PB z!jljnU{dc~C_CqWh>*o&mS zvQm4U=a2C~iZ4`RjKtL=DTC0g*p8X)?c0nCkxp20QzLusW~J7F@8R$XNwgWyX6N1G zgR*esJV&`kDcU6bpy}m&doy2NV=2QoLJeoCIM+lg+S;gTH`r}LhAo76x5Jdvvt{K= z@7d;ci#gPeQ{C>yvi@?O-rMB!GTrXSY4MLUleOd1V1jOaa&-6{ZrY8y>oa^*ySYR4hEjEJpC$Lo&W%@~pMh>rXd(CamGFAQ z$H9*B63Rg>YpR;hdIl?_T6A+1=wc@hwGX97HSLXgtLORe>%_Vfyb%y)umEr(#qoJZ zoT!)TLfC0th)3(Cd}0hIdI55*XxKY?PQl!bik#Ssf)JbzQV=4WO*wPKrhN1WIqaw7 zypS8GA~$yCa%1^?60FY`d0IGqAe2z#nn-ty4~A3d8B)&5HmZ!gh}u%Zk0^va1)vWy zWJ1K)vemG$LR03b3ohK#(+f7jMVltlrsBL!i`Nr*-Cce?l-Hg4>kn!1H)-)+OjsTN z&~yY$iM?y<;c!A43W$Sn)E%|`hG{@bNOB41M;`+S@K+e4c00z*3fg;kEG5$^Um**N zZ8wf{HF6dKpv;`=dbzzPscyMqrBPesIB8TSxMn+!m1yTdY)-waSOP_zd8zt^Aou^7 zXvLyI;KWjgwK}Kew3d@wu2fUD?x-3mqk(X*=yU^EoUr=?ke2Ex-IKF3TvT^R$ ztpcVG!mdov5v+cYj7vrKXk{pXrEgH{L_#bW*B(`f)m8TL;yg}xC8hPRib@QK2er6L z1?oNI$==>N>DMM13MB8(1SUyFgQ#6=Yb6{leVk;l6k4S<>WG(&;t7fi8HD|_<4SJD z{Hn9+Vjd-v=x3O(S57w1O+TDdZ6j#aj}i8??&fE*1rX zTfoY>Xta=57ybA#+WTX)*BJ^#oo&IQm!!~EM&~-jcL;kc`Vxbf0Ao^|uJAnc`@or8 z`8U-H+D@g&T(DIjg_fPtLPe5)l^1(gfPw8`MaquD_p_5+818f@N|HEileVraBof0 zUHeV?Arb_c2fNXbybr33*^J7{@1bU>J1^+oL3KU_=wRUsfjXNy2jUD0fy|c6N+6F~aE0tPv z<26wo$WQ_ePR27NzL(m^7A{|H-`}Twzqrr*F=7wW5ld#m?-$IHGgLv8Dg@=3+Q0E+ z2+KQ(1rk|WF@Bs4X+v{Yh0ETq*jdtTF#zX%M^RG&X@jas8_*}|wUWL~ZY421vBkv6 z(A+)Pl0nv_4<^=D+IU41SjuO}*Po{cQlI9v9C=}Q4$Dy9Dlxk&; zNbt6CyQ1(TAc%4j;=o#L|F8oQfDDoYiypDqv0ym6a6Ou}dU0~v8B_46fInOOb(WpW zzx_oP#24SjaR{vVmaQA8)2il>REZL@fez0fpFTU8QJx7+s73V$67*4?u;#F_(A}?U zZaymR<@sOe^UJjW7MkiTiMZrr)E zyS=`B^B!9S6S^sG>Fj{Ny^GK-;CfuMP? zS5gs7Y6zs~=~zj``hVWnl}OBv0sfIbA^{BM3Xy9X(_zhkmht_VnzG z+5nyRIQD}KaH|}hpvR<&*LNh4)1$S9$f$q-jwAJ+4%8NO#knuXoAp!l-Oyn1z zF~ZJ2#jyw+&x^Vwo3eI8MS~E^DT0|Ks}Zo5sb_XkZPYU;kpYV=(^3FV69w(p%Pt>> z0ncZnyBp;-sUWgeL{B}liLuF{;!<09z2-_W5zHw8*Q7X^R7n8t5DjbtQeS*lU8;8F z%9->M^v>@fr5?nnC^p$Nk_hBdLT1GB>SGuQL*q<`NR8DX)nU+duw;Q&Bba<4<=u^- zhr)wQb{_zr9QYfO`IR?3p)i&Y--a))9B2@silY99SF5!j7?O<;+p__u=3r)1C#+Vh zlQAbJ7{E&yOou}TAji7P0QfdXiaP0z>$fYh}l1BI4v|){G|3v=YvIJtxg4Aq=+} zmC&x9J6Dw|wbuSQ{rKn1*pEQj8&Ht$yz}g+KZ0rk<_Va&h6G2Co{t8d)1#x4Kl{^n zKYDU>`0VJUH=dlFKK)2ID^9d`aliQ+AwITpa~-E~90onYagM_@H|v$6C&{LX)~HmPn>TNl>zg~bZtcDE)wf@N z_2%tY_V=1zad-FL7ZA4lyZYPOn|!epwmJFmeL~Vg=wVK#6nLO|j^5sd$gO+qI^on1 zmJi_V)`uURsB^b)QPs|%|KL6FQp`Q$-{6&R@X8*8Nv%MV=DQKTxhS5BP8Q(GJHtr= z9UMMjK$Vttd_0Q%0&z?rQRPBHHO@WIjlvdKXf*9Az*Wm-J5@@R?VDShH5{!HWQH`b zHob9!%plz$ya~l_-^kBBVV9%$)j*KRhvhjk`5)81e?t4j7ySksDBxHN6xXq^>Jk)H z8^?rbkM?$OAh51SQ_kMGle(SD>LgBEI~ThRHWaCms{E~lp4ez4JlEZ-=jwd^ys0g z=Q^IuW=G=XRc-0x1itJP7Eo^L3O<@UfX~K)y8)whm|4DdJQIH}q>QY|8IHG0u3_a8 zZivfm?g+*#X4~TFe?DI{i7XnN!%_;kb+JOkS9{?&sVMzsZ>wIZ$=Q!Cu$k%t z)w$F5*6uCQcW!B4$&75ZI&ZIfQ+}1U|0-?2g|5N{jg{chQN~`v zw|rAhN|oD9a<_?_>QjBqw>H)|LLlbe*k(HSwl`kF_gn?f8A>WO@;VnzhQpWiMT?&H z_cCOmQa$BtLa<{onujKmW-e{g*%fqYs}w`|x{z{Jrn} z@pjGD$WBtu^UQaiDxRty8i)G*ocHA~fBTzneet!o-ubcb{Iwtd_D_EE+duKv8+W`fXL&cm2NXytKuaE4fwnw$Lfd<-ObD?7GcrdU0Jh<+bs1b0=KpB3xbOO|||W zt^dh1b4=O9$~#7zX*8B_S(TbICPrFoF21NW2Mhn@VsrcH66di(WhUjUt=ZLzbJi70 zmuiG_>5u#UO4Yf!Jy%oNrL6JkXSHD?|B@*COg5|~l@wU;C}W?^hIKAWsn2f1E^M=| z*kU_Gc4^w~UZ!vGbg(Uc)R41F99iwPHIevLRwek-#!I{JNrM%WZ zv?=GZ>u&DJw%L1mA9lJdCBm*k#h<~WJ&*c+EhRD}aZ2%`MJx}o7GK6AZ^LcxS!UZa zEw56ozEn@YMzS&&$y4HiD&(w}@9X;W=cnhPko=(Ko}WH{{uz2Z=Yzi0IU(POGE~6b zxgeb$;0f$uoMzrJF#DCF4I+8T-&TxS+zG_b|!byO|1|PlBAD5`_gfk z`Pf{Ktu0<(&5Z49ABzNdFqZ%ekIr(1+FwQj{4x^YOi~{rlOal_GQgW~yY%MHzGCswB$3-EQ^6%bZeVU)a07Pip0f*8(7{?i7DNG6;=Rpy-4yW_)!5%+KBk<)#1b*>c7M%kxY-j zpVsA3MmvXj`hO{|V;Rl!vJB<*`f2(Yns(eU*UH_zzO{97!hq6EMHTzvGHT`kU+}^? z^x0~u=~5>vS+0|{dc9q*tF;)ZLCVryLnvf>ch2@vIqhQEjmM`)cu+q+LJ|>Nykub# zLQBt<-27}waZd}A@=;|6`Xy?T@9e+IwZ960wWP>gwHY%Rv}kV+?-sJ=QdZis)xP}7 zeD-}HrR=+SzVE&%r|Z6c^9Jl5l72&yrRa0wIg~3?Om*W%1pQpdndIf+8YPn^Um5fO zc$}6roRq`ajRasMiIrWv4(0r4Y# z{d3bhO>M`2LeG8)X(2hAUm*U3QH*085awVwnVwG*Ot)luetOy&v5zOX)dqcP_H?=( zRiJdvGZsZH8H1KYb#SK1ISfP$Is}weBDTIUGfj%bF?l{L^5{68BAhButDr&KvOsjq?1wN zlTZdE3hqThSYEYU5SW9^8mx_#8elSGHpOo1PlEn{Dp-o(BZ*-+!y76L!FARH9MsT4 zrL!jMG|ly!$|~o3=DTu%I*i)TE`xp`Awq1zo^Xo{6f9HC+6C%RpqN4QNb+VId5@{9 zHa5gL(X22}0`beH-~x3x#DjZ=*5!R3^fk4gNs*XHsu&AO)N_pVdyBj;5rZ;s#-vgn^_=LMh(sD|`?I z$)pj6BHe^m05HNd={% zTEebD1ABBr_7PY@(1<-$l3)sTw(8huFe14pqE{sSFV=awx+0Os_VOyqvbjiToUfF{ z8IWaCqrA@-Ay z4<#)knhYJiy&+X(SfJ1(9>-R*K@F6c+DicSp?D9+wIa|0BlqM9nQbQdpyT-}{0`su z9llTSBscJZbtZIE^g{8)b0C<2>1iReJg`S)^3NjYgd9(}i@qswXU7zq(!*udDZSGnIym*HJYt;Q|-0 z0~LfLX8wM`TKobt^FV5bvw%tFLW{?X?%*^6=myyGQqELB&J;OVck#<3H-Ib<&FmcU zB&6bQFEB18P<8uZ`vG;tZR*F#xd}Ocq>U!J5O`LKvx{^p*Mke4{IdI0%L`#YFA?Xg z*J~R$L3%*2+HZQvrR1t+jViC#dB;_5TX&J8CYfLVU5dhxSXh>2BPv#^Sg8r#>BL6d zf$F#`QB5**LCT3Bk*V(Obce9z@;a6Va=Zf8v^$@3O?!J|qiP{wEU8=9#O1KIk(wFk z>v2{|fps9}J$qM@ovSFhcl72ul%$l1A=N;I`c8TdlEcTFKm@`itbkFtqV1v!{+hIr6BGiE zv0_~8p@TrT37mG?xj!IAx{+&{end3852|U8%iCnhpz7*wIp~ z*W3bVzs1QZ&r%Q^LAlu|S~K?LU<49uh`O6@(lnPDLSrsAF>xU@?TduwT$a!*^#By~ zuqp}ctcfX-^$O-tYCrtmzJBkn=%})sQgn~f-qSzjgD>z<6*8ELqD#b;jrC@o6SFQO z(YHKU-dP?VTBYaQvGQcEgF2h`#BRkDp8v_oKVN;m_VDoNR77P)TnWL>@q6!m@X?b) zfWDUoV69+n}`^?|k1W>I@-wIhqQTj5(VCl;g=R!+e zF|Se)@jvYAU;M%g`_FJ(1fu1Q#_~99FUA35q>_#W$bI-!OJ7(Fz>4t@pLWV}n0dB8 z$G|*$|NZxmKE4wigGWI3`@w&**ZCD5##w#hYIz? z#)Hkx0kze}94;-RNvenXeqoW4`~YJFW91IX9ju~f2pL%cu zgv7ezG7vpj0A{GC{XljBX4-k%sJKRJH?z66w*lDPxw%o6U`z#8oUP3@TuY3dS*+2J z^~nHP4mzb^>ugEF(n-=FcOyIV?s z$t4nZB%)?IqJ`VGi<1B&3v!h%cyR1Nb2#XNr9?1j)%GoU7@b#lgh;+XicPIX3M8q@ zBy;l$Njj9DW9Ok2Ihbs2nGFH|AhrwKB7nuPcZ-siRIN7;Vdw0REdS7`S?$e1}n zfXdS5#1xsrFY0i?>}!VSQj4|&N9yQ&bj*-HmW6h(F<^!{H-W&NhkE~fzOexS$h&Y#2;EUuI}4cVX4p+}ywW+LyUr)4t4P*(I?7C}ENga}F*_hFNGE8RixH z_NQt2Pc!0oLy~B!$%VovYM>y&=FrlEbjg_c!#PGgl<_hm#sD`32(rjocse?nRZ&@6H*~$+&;*d7RFl2X$Eo-^W@ z3EFvw7WXL&#gV{HVl->0Yj;w0?f5v;;UqhN1OiDBCow>Y9R@p&FEltj293tW z;Lm%pp?bmKU$-`9qIFqwt?Sq6AKoAB9ZD;}T@Xms7!G75zdyZ_5MTmf8I{_|qN{Nr zep+3jJGXH*FpP|*_?j3`HK^idQc8$=`F1&P-Juef`#c`dw=F0-2oY27EWIvMt6 zCTc*@98+!pF9-MzV2QCK|C?c3;8`KlQP?CUbdO<)%Gt^#He+Dg+U;Awk=5ZVXRi7d z`N4nkBK_fOZlTJMEmw1NTu))4QG*H5G_iN6OJfG|Kl>JhWfU-%I_D7?)WDCX5USu* zW$>ii(GtJ@GCTRg>dN@Xt1ri7EivBTkr*%dvUHeK4;Ju9y0GnPuzC`s=|g0IA3mbg zZx<$eNqpLCqD=yE`ex+c`;PkB>XJAi5pe4Soohz^rrn z@%!(8eA-nd7xw9$2}>`Dl!|H;4PHx9woyel+SOD+hOcJ2Y<;W23DwxT`O53Bzy8Wi z9D#CHt*>vaV?C~KQvD0nk~&~}ufF-_o3HMnt^^K8I-K0ufY-i@lRhn5);Z3dE-*1muyh z@gm%C>9&g!U=tpzA-R%1X$>4<9&y6~7>oPo9jV zEHoh-flhjO{RH$lowYfUVX!jnQiZ(7MDskHot=p_=Lr>d4`1|JR=muLnk?pu8dS7N z{a?MB{xI&cS0gL~zFf+qb=e?e7A5QP_yo@pnOP^j$@Jtd&Z_ec2tvED1yO`HW?iyA zWob$zAHSNoiZpGfDwJMz>Z)26a$MFbi&8C7R_4CrT`bjXiQc%ew^na%fgD-G{kiMk zNwl1Y4pP*{y(jO!`|d}l^R>!Bk*-lBHe)bfs~ufz8zIg{mKe0rQwM$ z^EX**o{H5EsoE|%@whW>e9Pm}WOROZ_~hC9fA)hPeE);@-~Zr4cqNaIpGr9Yc=S|z z-}l#MQz&g5yxF+_CV|6r#096lnJeJc$~mx6NI)+pOV;2Vjf=&$x3kVESy>~Kreqra zJ<_vh_@s;Ft?gItzw+igU;oC}zxbsuzxjnb+uJv9-X*1F|3>AW_9ec`c7f23A&!f| z?v<%7q+8vfy*cXQZVkp|fa!P&ObW2X*sthz*}N?5;Hw^zZ_@W4qwhEIGQf71grkAf z{3w+RvV5^2fasIvE3n?}P_YE**3+i}D_mJvq8VbFMUQR*jg4?mI+eN+4Z3H?J&tBV zo>oJWNkJG5 zkB)E)w{ifE&jS!vxts;&AhL2PiwC+Hx{D+ew+duLZ`{0jlPuyq90CL~P+AmAdtKY~ zOR(ucWGR9i(K}2PK7cROk~w@n=i&M#+Ve|{&y6zy*LNj%Qb_fpFvgqH5BevFfWsq# zHD#J3(V78GBGT0HclSu8u3_|3(4sD0nYF4y-h0jM=Eh#?IcVb)N0p?(k8bP;F0skF z5Ilp!T48}Syv80j!kzu)_HX#R-~ax5kDv3Er_Y}~BTWDF@lhz)?9NAj_ThWqU+PY} z)(_~<0sZ+AYN{YS&>C`b8dCJdb*BWR;k}rNJ*&loe#~odEOOtdlzA!9#%yN+JP6D? z4I(;tZN&N7`b2*|Fua97A9WIy+H;LmzdPCl(X4D%d???$o zX2+-K8m8vWCh&7WCWjv_O@O-zb%!|YKiS6*z39Tnf6Wu|?-`B%$Y?wi$B#?LlF#Yr z&o4avw=RI1e?gZ&MZyOIiD7(B-+zAL{C{`>()^rJ0B0TsNLP_yG=GtCtB`)hMTj$$ zGXk&uX6m&cDqnuLchu>f3sB}%dzLPk7P^93FRAk$^gqsf-%~#Qi^WmdYO?rY?6h-i z*eX`WX6nYTg)`;c-(PQT-{#uY?q=)2yR;5CrI_?aWA5d@m~1oV{pjh(XdJW7`-mIS1qr|&QAJt85jgcFPK7&#L%DCF z+Qa++{8`g#cW<-*OV#~5_g=mG21zvZV=wK;n{P_L6rE*reRFe1;(I%r+WseoCpM)& z*+K{GktPTL`na(9evw}NJ!S;v%hr7{8;?&h{KLs9(e9(CN9ZX+xk*J0sft4;FNJcn zk9;@|so>UfsCx4$u@xbIyzf9Ob#u2|q}tLp1P3?o?ccexPJ9@_y1t8;dhmcByO0#5 z)Re9DQ{&~LErQp_C!n%`MC{9~@Z(xc;h-j!t3=yNmZJ;1mtdMJ;tYGldyqZQYYM`O z#KupFIxOKE=3(y(6ZTRNLanRU=>tg3CiHmOX{FArv^%gZLzB}XBx29l0W_ceq90Hn zO;(NT7u2Bz8?-vQSSb6cS*=1NDmD_2(d%N>Zn3<anTp%_fBD(N^{{z}3 zIHWJ(RImUtqrr zvi-DbybDbRhk%c-D2v_5_*0pS@fS^7O@g#TT7!GZHuDo>Qfd|ZU9AGsLJ!k8IA{bh z{2pGx)vS*#t>~SdjpAT?3#)5x=%-8PrUi?y`iG|E*TA6)0c}sq=)SV8uTTi5mj4? zV`1MpOiI~(x!45ESCagIvcl9ON?tZnG1s8$5@g^a*9=93kon8?ZrwoNfMS>nivZ2< zCHhx~Pl5|SnTx_iFe6(+vuPRYv+l*;Q^fGb~OnA9p? z^cq81;^r&sO~NB7_oU23HX@5rB=yy&p82D6q!yn?@|-+#sn)BUdH;W`y}IaA#zJW? zR>tDq1k?!qErQ+1#%OVGnKO2A??oKF%j5olF?_hMuQfE3esw8>UD?>CzoIxz!$PgX zbA6>e&85eYl=I79QK6QR&JM9~kR+ z(zDV0m-PH!u?Op_Ko+Vtl(MovJ`+GK_P@asvrcEG5oh}BNVOl$ODG)ARPpmb)=d8! zBCyxjk=5uN`Ok%2Ta3|D?L8Tzy97s;cI7AGB%tD<5aE2cO18>9##GRY*9_uw^cA!n z`;y5kP?ROB{1fcf7hyxMvJ1}JP*<6VB+*h*3d7D4+q}I5b*-mGoN}b{p%?0*0AP66 zN;Ip;D2LwpAa))B@>^!aQW%2^CcTAB>t8t;7O!7r(0Yf@hwl{qBlVP_xGesk;+nUH z@vvw@oQS%f`B6=`?#_agMIa0Ux=`LJh?p=6 zgo?>7lYFqfUbfpMN$?Rh}M2cHx3e4Pbh(fg}R2bUPl48>?R))VT!)@m`4r;(80 z8xu$p4F~gXPDirtktK(^<$4fv^*e{p3|xE_Cgy_CtRRffFU@Fn6(uCT%)hJVLur9B zDWX=YKSZs>T->5?_o#19vHK$ED*#!{UlxOsR6THqncK@$+my0n^INP0IOS9sDu zoQo2TpBUB>>PYi8){8zf#?}nBRS>N5Kz;ms>v0Go?-+u+b{{4G* zrCgu?b8;ZdLVuU%1$IY9!Z<{z#Uh^LYsG-HYmW;usidqc?r<9YX zxPitWrpe3WM9Y(0Mm|XZbxd($M}VhXu$27@@s=c5@-$yXT>S>4Cphqf<^Z&xy5LU| zGrTi|svv6brgTdTTz@h?QM?&|6@<7Mmdq1a{aQA)=;8Kll^mD)Q4Eiik>YO%(xgOn z3omn;`kRut9W?8WqAfrO%d4$RIAo(jS&>|OH`_Kl6eaR*sMSqkWJcqB!*|cwAC&x~ z9hCX&pFxMr#Xl*7+F2J`1zkVqq{{dxFZkX51$~s7mJd~4*r*3%t;EJi?@{(p|M|xs zTZF)uAM@8Za9sYniBuj)oIePm3 z$3tj40FcGQkKccKbi%FgK5Ud(E9C~k2`~vbQ^_}DoYro?`YM&~1);yU!D^{BcbhtM znm83)Pu`fEpY&)b`Mw7QC)uG}dmA3X)RqTuz}wi{;-xi3&{?YQ-MaVIgPIvIcY#@Z z@YcOsd-V$mL?}ulmWC3bHApo2SPN4f&f#@VPi0D1rZ{u;qrATA-l@8`v+~}Vx(6** z`k62DU1nFFiPq=OTy6)2G`ZI)&F?LAp=E}i#l2pdd$R@>Ka(WCLf?KxIt=3mvppl; zx$%mlc!im+jCoBkqC}J6VxBS9)1i=r6Jo3)j%O^7aAs6?&5{A>fs)28sQk|;pi>4* zY4pd$3TG2A^+vlv@nIoZj7Deg{a<6nMZCy61~WBhxMcr?GMJ8U4f%1bv5X{O@wZ5K--(}rcghDun~a^C}U85 zgeS&~D53yl2@b!i@S6UxiZu)DF`;JmOk&JEs?n4M6u+@|r$^iaJH&OGo`>xn9{~p< zvO^U9voJzX_Ekc~P|PsP#3;yH5y>zhPF5sBo<}y5WFr!$x^#b`Cn|YRhY#s0`itAQ=Mkx+9Y@2jEhgb z1qQrD^%($5ROXoZg03@W&`wKzGg1K|S%aLIix|3q6Ks8=c-#*U`@T_JYo_D#Q;d(G zXkVd*`EqR$&L6HEkTW^6v`dvn^vjh-By2xW@qb`lk6xi3m;@3naLtRdBT-rdL7g~2 zPo`NB5{{=UMM%;TGHx(})Yf#us!uYxP(UTTsSA~5) z4>U+%%oM^)d|1UqL1xyBqzvfRgxgiICro)6i#ZWk4eOmL1HKiFy6v}hfY>bAsQ=`> zF(u3>Y^Q2Fx#s>jW{g>q)Wu*W5@&S;-duGUnJ@DMH^K1D^y4#5L;jZ9Da@VAX-s7^ zGRVqM#44tk#InoT4E!5<_kYm4gNL(-^@3v0PqrcdsalDAB~k1sJU`;>=ycVx(yY^t zoFt^vQ^+zw4B@Iv#7qLz%VurMmW#;wcw1<2S^eTNhn$raZX&d}I1*4IaNkJnHbNp@ z{(&Etev=aR5bzqy5(aOU4LPU$uz@M$C^0#mgie0*rkpO#sq7Seva`uwjigjIME{W$ zL)9=SCt0Q>k zok?oRNx}O~bwjL_3o|7}Nbr(jyr7zNu&5>-D14Caj-f4l*=o{0xH{06sDpbkBPe!K{M&7@k|uj!E(lj)gnW7A- zb$*Ybb7e*9BkKF${qno4i6VfJlisNK$9ps)F zK>>oenAxNuNx{#Goj*D`Iqoqqqkf}Alq;9_Dh>DWf(u+?zAD1VS$IFqOa-G8C#r5Y z`Fo;35({NpP-YwcH_k=jZp7&Wq}bztX7V^rNT#)^?UV<>f!ueAHYDxNI#MK?0WvriaBD+bzOgL%u~3k9+m9)CwrBt&`G9wJC4+COi!H@85o# zy!fs98s>l`iQReq9;QN(%9|)xaNdCYzPXTA4#^Hl%xR*BF#QE$wTaBS4Lj!m%Y77w zs?HV3*vu~3u(7u3S_fnmkfy7ynJ;H z;@^6GJhl%EDeps32QZdEoxf`)B}VeV#+60kRDr~u_Bi@_r|g9B(P@X5j~^c%KL<*q zJSKUgp0YSAf)Mx zS!%|z^YIRC|5jSJhc>6{D3#~*1kyKc9QNVhCu1|$M^qaLt)o`8tQ>@TE*0u5nsvuh zSXNJXznwsXh&LApTEqAAYCWu6E18w}E!yx`838>?9&Kk5v=3g}R=bqx=0e|?hz{l( zaVj=36xlF9MfsC*PO*=M1@CFnrbdPmmj6#}ZyF;>n%?(CL}o-r9+7utU42wnAJa3l zGkeUAcC;LC6BKo=30a^C$udP*2WShDE$bizSs$p%jGmP&+K?!S5GfIm00I7HeKg?D zhHY3fB@!~NrIx!pGdtbYT}Ni+8F@rz`uBe$vZ{Kfn>)1OoasC(Gv4!gpX2}hpE^@K zHJfT>N_BwcEV47XOeU3x=%4K1YQarklMj{ZMgKW1`t!8towKv<2qk?4==3=*s*~r> z@Gh|+gl372Hcat3lo7BkiO;5QowT*)z$>^o7??k!sfk*H=a3Ll8=CU5FC@slbMxkn zI`Xg%G>*iV+qdr?0+i&>rC)m$bfF4G=~VDT{u*$pR1Ua9<{v2~V0?#oE4Z;#3_wHy zc(m^!nSbSKCB;Hyu1Wka(zl;Du(w-qL?ADWz(BE0SFY`}W>jJ)eQmG~it%c+n3Ks) zMVa`hJRVwsi;@f?Ai$qXah@t(+Xu;sIgkmM&DWcD_X56BGH>dJqtr(E&u*z|Tbshd zwT4Y0gK5W^-GFr;X@8b&)GI&|o!m(-siUDR2I-`&#V~?DPYynrBUIc~t`7d$YfA*x znk|5#mSp{M8syf4KAeXv3Qleu_>vC{&RVMmP+^SMb1-1^mFUN5OZt2(R}XAywCij^ z9w5-r9vP0;7E-|_gWImoQLekzzCuMQlq)r?ux7b{!5|GOZz?;eeh&D5yeV$JdHdd-JGXWP{I5{HFjv9n zh|F%bnGzc+8FKm~LKV(@+H1E8f_hf7qam!+mwOH^SxQ}S>ft4 zon<*@u9JMSzWU^_xYj?Or2+j*h||Z%jew#yBwIt zuvxUusgj7LJ6hl_T52pOM_$yd)^niOMKfSoyVQ({BBC>LJjp+m$*6!Dl|!MFWi&o{ zrt1tQ@v<%TXEwOh&6U0L(@DM>9ZuB=<-VE$NCv^#$5B-&7P8T>hx-dh!UB=l^PsB8 zcnk6Z2U+A=x#SeY??id9LY|=-Q=epu*d@5469G48IKJdRq}X7m>3DvzfJ%YKWG7m3 z=qw`*(QAIGv9%z9YOZ1|7m?xMrG7na)^P{~Z)kpD6LEV?>px%~9Kua$U~Ps&OGJ zdZrm7`;-hk==h6-8z14WDHIKNc(JPTM83OH05dr?fGGgK$1^y&AeKcn6B#pjL&;un zDSjh!B#~!2eZOf=U;3WRBwC`LF|i!rE&S2cMVlk>$73PYobmC6yNugS_Kt(CY|XOn;&YzOpqlsm#VYF-Xf~CRNz#dVmcO?>o2e3PoAi5@=F-ax+qj;>)LMcO znYTE3AhcRMOIr6eTKD^vFD9s<8l;t${4>pC6=vHzqaGA$Rk8yVNpWW0%JKTeo@+{*KUBGU7RfJyiS??G&^l^*`uZ%1UB!V zkW4(7uGi5qUr+N=lmn#zC6A zbL{pGdOgnf#1HA9<`-%8de%P+Qt5GudS^s$B9V-DvNcdd6Vk$b*OCe734=W z8rEtaMF|6R?A^0x)bRQEFHR|+0lI(g`;3U@GLljCP9nK<5Z<(t9e|?b8>V^ zUr$$u&?0|K=!EpLYCe}|I=RC^%L}r4kqx$BTqL9RV|<%QfFvO-9j2W^F)B!pX!?kue2f%Mh_F zFFJp+7SuzD5$Aa56uDLW-G+PIO=@gTaCNp?4EyA7SS0b%JyDkJ?<9Lar!Z?`4&{l5VLjK!I*@ zt?=5<$+*t|`F{s-!$DGjm?8(0e1M=P;*2Uy9*{9&OAIB_H_snI8pH<`$_C5%5A=-O z7x2$o06cmm^D%|0_#n@kgTyOA)MG&t3D9e^*M6G5{j`w~mfd-c$wo#JtuT5to_lW4 zNlEUn#ETTm(Z58g+ck1TQOG5wkzs`9_qh=o%7g*3i^l6a`ff;2V%>QSoGnHwGr&xP zQ828N8BeXImFyi;j2KixdzW)xAt`Rg%z)pHi|VQ|j84*&BR*p0Gud=a)ak?Ffn6-H zCTMULEqRZmuQp9AA5rlp$aL> z=aaMX1mmlF%3<0Y0wQQv7Ew8uFHj@~ca`r$h=k_PPd>a^EeDP5-P@e)I|k=c@k^)C zkoXspBRjeBByM;Y-XI}KCvZ;H?if(cFos#%j}l3=eL3td@rB{wQ8@n|F6a4FNmMF{ zK`yu?j!@6&!VJFA1!nOFZzLrm6k_DCHaS}4s!N{Bk%*hFu}7?t7zws1Kgw?vOR}Ss z0nc!mdy_ebQ8KHr{7@HCjbGJ*f9}A(duT+25l~upY(Je>$uU3X_Ux zn9*aofj+^=cO*~Kmfo6QhqT{yE2ShSir0nZh{ zo*R+I^^^A&i(+_aCdNdBB8JS+4tt7L<+L%}5^lF<`#|`0?J9tviboUmLL#wiw40W5 z-@i!Lyf9feXkF=}51&4v6vD~LC&%CYFl~-r8Z>pzXeOjM!g!1We0bbQ_CHv&+(`pQ1 z3!gsoVDtaXr+8?F`t;Z5__)$fQ`7}ZCSwPKwte1AYGYshW@%*oFZx!uZv7`O`_?Ys z`fmEIeR{D^FKo;jpDItl0!(QRJ!hq{k00B`T*pr=u8If{NduRff+3cU#5?@PI~oaN zSF6H1#d>mt4Wx^sTMXMRHXU(!RluE1BXf}mPe?92BA=GP1xq;NbPAb#t*$cDKsnki zvNIOA(1kQYQI^_V7E~vlLsqv%dXx15w@+KLtXx^Li0h3dE4f5=kbu&R8~@A^O&n3L z5D=~++!ONDC%DZmoNWCgYVPvZ5=ay8wX=G$ukLui(d2t2o3=yLUm{r@L;x0VPX9#mz7TswnKvG$yF<@9MyhW!SpP!?cnTU zYV}VB<793Amt65LFQyBv@i@#o?Za7P+x?W$wVjh^XO|*K=HP#ASwH z`5*&JDwlawAOoI9J8%FgX$41C#9`59;PjJPw(lAOqREm9Hm9dUJLiju5Y7}vNNM2` zIe4rPZb8ntC-7D&?hsN(v(l(HTD5|^o(j*=pDl%dfYk^0Dhco$zwmufjC!*ZSPAKj z{sH>=U#C)DV`N4u9b;anFKre(A(+*42JZ_TZZ3UOH6Nl6GIrt^7f(B$(q)Le)q ztp%z$2f!E$rxUPf5%9nuvQ?(UdYAczJ0s(1iv-qeD$@|c0ItS{GowmbFm@tPhz!D# zN~%)u!#%3NoORDndJ{bRHR(crih<2AsIq7g>60OairPNlN%2#TXNm8$OfOppw#S;& zT5xR-^CohO5E;^V(PPtP+~U)xy!TD3n=x;8X8U(-{8yp0D%BR$B~Z;;wK+urlaB1dfX5JnUZ%VrzHZ80u{wxg4X7m?V4ymb9;$;2cH4E8 z+JGg$U^G*;h*FO3bj;(T^Kii<8j;ji{)EccNN>guXH$?q@q?I;#^IyxfOXd;j?^7- zZVtMf`O$!c{ZC3&kW;AAQ#=jxg#%d;B6vC_g#*Sm7_X|$Q)uD_`3Y1Cr%%dFRAx$% z!7z_RL~Im@si70(Iz-cX^g0i7JnE?6L;i$-JjV&(xW!_4#*;BmMrSS`RnoH~Jwefr z!pDH^(!G1YXdW<{!EEKgR18N^H0uv0=*?_WBQA0Q>SP!cvn$GaVQB^V)>b(Tvf1jE zaVt%TM{e}$CcpOZz|Q+@5Y2SP_VW~1SWMQdtiPTC>G#->R&6pmJ%P)B3U%PU1+$35 zoQ>0hD$4m{4xBuA@6qyoh_Wk!rw0}AH~TpDW`2zRHlenOp^2kH#T! zD(GrbGqU`XjWd{XDbh7y9_nyLtI9PVV>gGI&ts9gm%1dW8oCs8%MGreo^i>{YycGc z^0c{gVEZ`?ayrCmGqYzzDkv7p%p-3=&;?XJ6)Xq$D(Aua(cQ$iZg~BQ~nxkiU^HHLU=`20aS*lioE1o-Z za?)M35bMGRR7*fGPtpx=n$I-vQ~gvY`HdVY&g%u4?wCJGM=jXx_#G#g=CXh0hmy8@ zwx76ut=nb{wWFD?J&8nQH#>Bthsu%5Q>qo9MkmR&1j7&T)ko&D2qg|7+kiu;eLN6I2w2a_d5Y763F zXQ>&N0qO{Tg!|ju)iMY!1Q7C)YZakK`s`cjT2zbwBSz~rE-9ae*l^&?QTyq_t2q%~3^V*jq?ZyoSz-&0Bxj$RU8t zMK!%N1j^q+mc4}6VH!UvF*1}dSTIh-dUB1;>Z0P$0Gh&(yyh(geL7xhfmT-&oUm#l zxuo>ST}n=fY--jQuv$owL>j^h@bX2iFGfORAjF_kWCBMcDobVy`6tEa-Ld5WwVz%* zPuJJaaD|^?4ZV$k3X~0>4@w{?QJ|-2(M$XycsV2j@b7Vma#QlscpkQMOX7Ke_uY=q zswi0yo?!a%CJ>SI-6HQ&hvxCy(@F2#Fs7aNrizwjsMkcyjC>Z=UuN|}!6X{{$`0tT zYJnd|4zQTqS;q&B(6ee~P=!8k!cdsLd%?SC3h8P3v-IO<>Bj*i^>hLrTOJ^Uz@Ht5gzr*Ei|{(pK4{RS>ht7Ej98H2P(=sgRh-oTI4dF z>+k#K{UvW-)9b!sA|fPmZ<-m&D(k0i6&fwy9Ce9?X-SywPv}o@M`?U-^61`tSV8i|>D6 zt^dKV5bXU2>H8Z!{xQ4$u{BF~{6^j%A!0u52MTH9w-~2Ac^6jVty>8g$m9QuLVEaw zJ?STTd^D)=>|N>1oFSj9Cj27?W@%#dn0%(Xv|uul6FP=SP+4Zmt(_nqO)~K9Y>`B` zRfY|2G?)U{!sQy1f|&gWcy`~~AdHR1Np!%>5kVXeO< zWY7;`sKLe8{vyL;Lh7HJq{bWp-!j{^ePAGQgcx5?YXf}QLXC=NJ+Qap z(=AjmtBD}^?cddGms}xR2wd5IvAhS7`mNUXmhxLZ=Zq7pQw1Hby+u-&m*2UVjidmK zPM&{Kn&R{V)d6-B1+a4m9V#FA%d_M2Q@mIBFsUBRS-sV)HMgn-ABRmU8I@o4-!f7* z_!xUhX>e!Rd3R2h1C9qMT4#|wXWiq|(`RBF?&C@TV=^3^_3$ciUl;C@8v z4yAxF8$7%bCR)HBO^p78D~l7+wejZo1Sf-Htz4#t4@(EBZ_qL(UcV0QTA^dRcpf=G@1jAQrY&WYU-j(29L**qJif5Ba4dsjq z(U>1?H-s>t1OYWxpwpDYtkwtwlFE@(dT{vQjW-|O-BAk8E=uIq_Kod3ckbNSYPOPE zwXNLV4Q|qaJKJ`~uR?uc%r-oURAjm61Al~Qm8`jEpM3a(=j6PKa*`IKd}c^5wL26DqdkSphr_s1GRjC+^3c07f8=2QicFW6sb(6 z5*PV$_%3pOF*S)72DQ9^3>_ICyT#i6!NI{k*h>b~D>-;rDQJq3+ulaYiK+RAJwL`v zr*#S%lfP=`@iog!jJvsglkwt8rc^CC4#gVL5>pk7#X-;@T{rT}{W24Ggsz;*n){za z)?oaqB4q*@z5ZOuiyL9{4J6krT4c(#z<`D@>?th7Vj2Lb#O;k76I_C%R&GdJ(wo5o z7`2lRSxR5}{4&Rg98Q!1=+dN=#0xJtU9Y6hH-5O(vB*C$(Mo}n2LqT0ePO9;z+PG1 zMs`1B;z%PteTvb)r@Chy9$^b?})>8y+GF?Uf-!g^nlaH zHi-GBZt*nnf|$2&E*2oBpr*j2_Irz714sV2VUuT`v+;P`^eK=oJs+gUV)5)_*1B~r zeLp*$g55A<_|ItRKWFv*jyldeKWsf>;95(-QL+Va@B{SYBy{8-N8>3-;~|^oO5^#` z=UA4ri81Y^bxjr^fj^%Dw0M;Qw5+bPXJ@3o^6S;?g+{eR4O{wm{ncQyx^0_jA5u6{@$%@>plz&*j9L-6bNEiZYz z_}~UogufgA>;Lh8`!4TI0byh)JVdct>S<9E7Gn6-WR=Y@wUwY;ZBk9Vlq2i2ss+Z4 zl_BRkno20#Q{3KmOI2?=p7})N*X(!Bm$NgVfV0PU3kA`1@7!!b8#c301M!*6#)Hw6 zg5Q)UFt=}c>5~s`sQ%w{TK$86M}LFj$XWs0@I>H)Xwi(_~<-X5E9rBO^9tlJ`m~>F1qC7s|Lf2_ehg%*W?5f)_@xVRUR?J zBxHi3O40|+P;q1wW@BaWKL%lM*tvmez!q_$b0d9yf1iZ=3`1G+_i^;VITRIm zuZgp?rgSV^P5eIS9bt0fgl=V%rtc|Df2)@3WL@R(r+xbu^zC2Jw?|}9%5wrt4Rwa; zTSn*437-+=1EHxZiro6=RAZ9s2tNdL1Q9HuAU0%;EOT2b{FcFymb^?}(f#&23-Hnt zt60f{q@aZYCK;=^iu!#uu(4>NKK4)VKDlTcF@@#eriimH9815r!VT{dmjN zQW$SuCa>gje#fuYTNT*;$MjB&f~%9WQv|7qP)|ajhnY32yTuq#-ka^?1D-U&#%3*?i*h?p`6NWCq zCmNQ{Mq4n4>=?$IMW?E~U5psW)K)8{aCVROB8(Q7qZ9CfDvahL;OYcL3&GH`{9yWbjm5(ivNb2FOW*nStoc7c&Z8?^B$+Ts!rBD~A9Kr3Ifq`(=gtg?ydP6V46;(8jwIsXqWOg%Ppi zs7rPObst2L&5dj~>`GH}kGg-zO}UZK0hv?+=sAL1mGH(s0Sz6lY)*6Rn$2mRqgjAo zTzNhvq5Vz;FaRYq7>U4N&HSKNg!=^xk+3OLGo8|6z6g9~uedlpO?`i2ocT-i;O2`x zc=6Q_?a%&;{SnGeZN!uUYWG8Wckk||cdUh1_OiO^<>v7vy=`oIJCxsh2J-h+y?&ow z|BT@aW<8q!CGIrB()rCZ(#vm9SHO;J?zdq6g%g?6s`vElX%A0p?;?Oz0rLE;h*%U< zDgmq@{$u8?f||4m+DOMr=S?%H9x}@}%T{^qmAy5OA2o4hkz>Ic-G!=u*WGPZW+oT` z*37DcjAQZ&phV5=Elv|H>8I$!FCN&hKQx-A_7R(j-&Ys(+}t#xNf-I(;WE1nsbFzw(jnOD;k=k4q~dv^Ntlc%R=dM2FW?98GGwIZx$q*_=W)c=DP z238Bln)~{5iGp}=jsZWnfY$c9r_ZUOakgmUNQF{~{Q}3*DS(l-bCHhCf6CbW@`3%< z7i86`K|0qk;?y)VP#UgOIXCS!ZysPLN_CmqQsNw>Y$PhPbNwXyEf(jk+oK=dK&|?R~lX0bLj>Rpt%?7t+B?wR%CM6|fFs2Y8G0 zoEm{T7z;jg@{~KBR5P2;(GmFDn0%cbPAoJgm|t?7St;&JHp_n?n?*xm#^q|w)H%y# zeGuDRD7wl0;;1x_n$i5hZnJskLCtMrgj%2aGQ-4>*YUPs9+j-p+?yx&-cczRd>rKd z<##^*_&5IL@u=;Lj+6DQJ^AN1ds4CGWEpA4Y+A)n-XPN!JWLkevxRwby?u+L78ojY z(i}KD3OeOssiDpDw2j8D!W2x2tWd2RmoKtPKBBGp#6X-It}E7JdtWSq<$9wGx(FxB zxUZxd-+`8Zyma7VK^y0=b$kq4TL@D{_xLzH`TijN`LBeeqWxyM8P`3}%`K4$bP;*r zIEQ|oYJ>BcC9*$@x(O)G%gFK9s2{QC#juHI%Bwjbwrs9P9qCoC=XI*N;>?-@Zm|dl zW2Y0UeV1B|!w3(EQvkRMH$+5n>ODVg_1)4_FqY@$6j4T7y%i<>ROxnx8LNEV{G#crS;B%&?x;Yx-3$J4O zi06>pre1f3*L|TH#tJ(MAGlAwQ7!NUHN zbt;gs98CbxkT{eamn#T(dj)75*et0~q)VaqCOXCge)TnewQB0|+6ZVuz;H4la3+2_XR(%DmSI5tL@InC`uaNg6CJW-P z110lU(DR)(!G3)_1dLYJ>H-k2|EV3?G&>v1p{YUO7$dPG^?D& zO@vC~l!qN~mBpz$Gd3idmM_?7cl^#A26-fDE~fX+gsWm zP?t)4oBylu?;`?o>nKUxiz4`smO*X-t0~i(3xcI(DD>5>M1p98Nr*Hl=SVJ_SAypN z?--??B3xumHhHgyldaECMt$F&^f-k~D-;g`BO&|+7v~3aQ%aGkSY&Q927G2TT9O!w z>mnyX9OX=xoLf zG~Fi>?f_whw6uzu3NqPDyC&a!+e|=%#%2o)G5k7_S0+vboqXeR)_YT;w9m$*5Rc7h z9nuy~B5=W)F*9qdBOcL*(nV<&cx);Zb$P?-k-X4&ZH9TIC;i%oZr09r=+H0e_UdF zHPdKBWHU$0PG;F^Ej!+f%w~YAVT0)mObv;! z0YW3|SHM|50b917dl?azC8zkC={4*kq<~8UPO^`W%{6jd1eUWkkOADL4aWhwhhsi$ z^vyN;(&Scm@0x3jxk!H!XT14(+o@P<~5XA zRO#R+lI3Y;FzK}AuTfy?$U{0zix`E&?ot*l;1)1y;;;zzcSq zJh}e$Xoy~&wmdCu8?qTwAyGSvKEy-4Mk|%tnT~~?SUC0ySpR;hoaCNEO31m0}=_T2RxaD_X%}H!hjJT#Ou@_ zi^w9V)LZCYH*X){Dcxx*&*47Qp2{e}$^`J~5NjPx7Yba=2`%=|*;~b8rCJK_yObT5 z;a(n_m?#b~-zx7+c^?(1{-}uen{LSxv5?0 zUVMdK*uaY_5u&?0F;;H=0@~tK9+Mu=fz>bQVWj+~QbmKaH zT$0K;8H4PIf!JOU{51IO^%E((Md$#wad9jj?}43{s~}&l?(BNU&$8$@*M&(Z*KwXCSc{1rVK3&&7OSRYzDn@T?h|L%f znclHO^oJBD)ON_>A;o>MtUdWtyma6M zC80fJhqe9gAX6-t=fm#7ZPIQpZ<2B5BDyN5CUx{SBQ*T@`%j|`_7|FsvM=<(+L}G< zt}Cs|mK>P}#qLLBqksG{b}uMSf_)utm%6=!xM5DFEqVoNk!+YG(8z`I7=EKJ%7tGf zcOQM-pw;UnFLA@JKwyDgQLaMRrmRD6%&d)iY!iGQsi)$P@vHyvz<%R(aWZK}xM_Cu zjJ}w65X?l4Nf4F`%eG%%WNfNqU`33_Mp~=O8NWer+!|wU68p;4fxUz4KMJt2fTmz$ zS)4O({nV9`qA4Pdk$mH66+7FB@urLHMd;=zpC8z z%WU;^wrIxvI(CfWI7g(#WHwmgOHE(nrC~C+WEdSnw7KEZ!JGkS3$KPp)x||)H9jm; z2}*pdoM>X5akes^srJdWyrG1Wg6~uf;s_MV2X0TTw_$^9HQAY zMeZuy^iz7)0P^#1sHOdH#Em$Qp)Q=n^Ty1s3 zlG1{5>>4?k&SXe*3k@%y%gp1bwgN`lAAShWSm{IJVKWHIZ{Z6TUICyF;}ER~gj6bG zYE-nv#btMkh?&A8c>3yZcTGM<_w9N^jz2UeXkZgrAPeNjkodPamj&He#R8I}MT|Zo z;z1-M_(({JD7;0S4=kbuodA~==$BCeEy(32f+&Qy@K%WpKI@UNC^oLzTkO;s4yw15 zDi?&eh){x&B9%bf8I`k#gih*cf=;qGSj4Z>BJ}1P`wU3Bk~K6&B$+q#{{9+Dx)^@W z;rOFF6m->kVY$@GcRm^q_B}!r6jII=l>OkXESxWX2Fk)hA(~llkx4IbjSE!^&1}@Z zz=|>He_AxG8V*ajGJpLGNwZO%2~iOk6NYdJ;H^+lu!t8HM`1KpYBq#;<$j-(&-KQ} z{YKIYobu|0S#ild`!iWO5+C+W7-W;_Hamghz3E1PgK?yIVj8^S=Nd>UjOln zi|(iazlUH#hIyUTOEK#V!bdZE84}W+7iZ zf8qDHceZadSYT^@PM;<}?^PPUWD@gh?Wo~1uy`vL!&F|6C(4}BPF0rK-B28;3UXB zqoy(z3A-6&^9+yy9@n)S>OAfI2*iWRH1vy7b4j|}-6ekFn7xg+3?c~7fF^CmKKn$@`z!9V~sOKkt|*qh9gzKCa0C5bd0l>+2*8~;ac}_p)1$0M52MS z0&Jg-i)6}=(o|@jm>CMj2iUJ-9DuI2N2W)|MLk_m$eE1y$Rl|bNfWBtV5KV9(Sm3L zc8f=fvH=qXxZstPFN_P2#*QkK=0sLNi~>+6o2N=6=CjHY(3fckSWJR<)AS^>TcYN1EZh(A|v{b4p{YvW>GF;62s9#sMA0-#1V+Ck;1Y?v8ZJ;e;f83kVP%i zWDoy7o_#?N#iv3pS9q6mKr0Jf9PkPhJ=Dz&$YWm$LyLt9E_u1^@NEN<4KNjtfywg^rrMtO}>Wm0DTz02TUydz(qIm>f1HgM`5QB zZ3D~7ZU>SZlimc5xFX@gFDWJ67)|5_OhGXXpa2G>T!N{Gzc>GLS1D0Dqd$t4K_yv- zX-xW!*VPi!3|g0*uoxG#iRssx(+Y&ULj3?5TIZBSL}fBSJYf4JEDggLkmfd7OsvJC zPx{9+-k*-eibNu)V~?j%&KZv8t9-x?7)T>si*6mzrxL;<)4hp z^%1%SZjDMH#B$>0B8V<}PIKYw@alOxkD2%Dy-pVDo3#*vwlW(U0*pm zvh@C&1N#p1k&I5#Bn=e*%=#zgPV;s^B@@hfU}Q?pqo#PTKb_4l7C?*P{G5$u7na-3 zArnMWx@b4Rqmtt9aOj|n>MdM**4LF6pU;=KTD#JaZI1G413yWoNVG zoc=lb`ad7ozxO>&d9niNqqenK7kboU)k4_3qZj-y5vBj$E072dnGHuUl?vccDJ&>5 zfpdnK0}MckkGQnSg;W|=`3f&AvL>y)J1D&x+9Z(ag}rJfx-&6M_H_D`_%$cBsS(F~L$f8S`s4mDc=5 z9ZUxNs4k_5Ypc*&GKA^p&VCfgDI!3ESUkG8agUJkOdXo8R0Q@)ZPH5cZ zx^{d{s|EuDD`l^X=YG;V{fHi%CULJF=Kq0MxzgFy5-8vmA-fB=;FJn8Bo6|4zyX)F zsvcAuQ_}3gNO9N}YeDG1_R)3-^LMCE-S5pe3~-8G{)dWQYW>hOvy+nbWtLehlZUmu z2=Ehn2C!+w#0XqZ2Al~LE8u#hD`c-<`>riM`_YJ61frlqzEH~t${M~7082R9a-)_s zhqXw1U<@8!(4fyW(3~5J8QxaR(Da*G$QdvsvI3t6Iq;%74NSgNnmbj=hixzkDeCv~ zL>GtZ`-Vf6Uh0^nk31YfOU!_bMleM15L64fX0e$oSg2S0+lOnC8>7lTu8Dc_+N+RS zdl!>WPK-y55RopM;!L=#Y5f6L#S#|xqCv-?KbTXb?D&9UWWnTCFlsK3f~e+NAj1Qz zRKwo1ZkueoG|Yi!7$*^sB;LV-b3<~&{1PS&)>_9i;O}9#;{zAqJ(c$`*B5T*CpmTB zTnoIavO3ZT$9?Jqs$_s+mLp81`C3SoFhVG7KU~g5R__-mXkyP>yF1%0)DI75CeH9? zc|M`bTJE#>F~uk_~aK!-A0m zBlkVs7lNp>A3un!I;vz{_M#@!QaCU@q-#SjCxLf&Rprx(;BsFzkpCBM{VJ$;$GE)Li|_x~vQPsjd>*4aKA3v}6bBf?2gIb408FP9N1rSXvkd12ibhk9w#> zu|@?m9*~Q|wzsI;WEHolAR?Z}Ky1`+>}{bd z)S-$hSGFM0aVZu}k`UBHVbVuMY~Pd?l?PJkz}AO0Oy+bRvNBy{fB)9K`>${B5#!kd z6<v86jG`W}0 zOVz<$+ro++2V9P~y!n~4B zWF=2|;#&6*O1cfGm<5bX6aa-1*@o!oXvCfXMzvNWyr+yRj4HJhhX+ZZNs`soTY4w$ z^$l7G8*xxd*#{~~VRZ;6+Ji9ngPCwx=AF>mVsvJ;=3#$DY*ywt(3~OXR01CoUy2?V# zK>}euJgTl{s*UHCtpO-O56<_nM#LGK$9O#@s=ZuH`r}RR!6s^VMEMndTpwbV5^+5oP3b_Or1Omc?QmKWzxT3ij!A^9dl08?}5`Gl2U$f!v)2GkQ zfzjc3!VD8BxeC?-1w46rZS}Bs&V~!|D7#^0+uC_0h%=` z)$}3A9gaJ8Gz>7eVTQ{A;en8UX4M*`^K94g4ua&z`&Eoq!^C;y}L5j;m#+NfT){cp#;}pi7u~@PW zU6Dt6OnRG@U>)t7Z~_iIHPDbXL(E7fptwrh@CLJvYmo(lS-%8gZG7VbG=Z9llJQs6 z<|i0`mwk-+-v+R|gT!T55aS~_2%XRz&=81PNcTf{O~r{c9I9{=Vc|77+_+QW{z3q@ z)i`Tyhj$60iO3PW5Gz}Bc@F0p;W6P`aYC|G>p^%I@;uT8xm8uBjXnbc@ruMK0c(*Y z%RhXV$$9)oXyqTFmG|HpVpA)H5Im|xL?kd1UVG%H(Aq@d=5ctB2_TzNxePP}N=81! zSI6>2Ddg`bVC9jI_v8yj_9E0tH$_TR$_nNo@`)M5VCj(Oc|W`-0!>eB0&)VlO0xt2 zSYpB9yXWtTyt5|KmVVyT49*}rLip`>d77O;0jARoMx&RUW~xzeTT%m0KHmMMsWr3no4PmeBGLa9B;`C=D)r@a9+4av320PZHe4E536 zU;ZA2R2Vi!*HOP|D#jCwf zuT)vDG}h+l_dW>`_htIqGOT`hyXErJ-lKXJit03M5tpI*6h1PgedmGjahy?xCxp&W z{b$M$sTzc`D;*XJKIU(M_FCpNBlbP!?&SF7;}bI;R~oB=&87m5;rm-JRxfC*(4_c@ z#!5_xedVFCE_K9$e7LT$Qk84`GL4lKZLH^)X{>A}yaxtmM#($KE?^%Ml`1J0Xskx3 zB8~DT8Y`|>f{L^OcdA64iN2z^Lspy2wQMLfR+%!42(t^X9=aKkRN2E>_)(#wz(pjB2 zmsc2{nz+eWev`43UVfOIe(3vD@G{s_tT-41+)o{TM7lUWE)PF|aU6axoPBy<9SS@b6%c zhle-zcN<$fdpB+$+#(o7O`fgRjXSsZsbamgd;8vN5AWT%6&^}+kRAm32Gs&%iyJ%; z+{2+jSuHAYr7e7m5&5aae{kKUiyER4cefm*90&{r7ht=9h`bnqW6l&gQ9ET6pdve( zcLxL>F=D;{VMs#{JwRWM>h z+6Oiyo^UrAB&%Ou4kD7)mht1oKr@!Jsgxw>%LinRHBjE5bDUjy^6+egTgH>;!RzG~ zqy)9LFt>c(Xloq9St#MD)sJW-(B0 zSZ4)DI3^L+uxm#-ey5A@4ZIew(DUGK?w_BkjKhp%TRWl}3{Z{EJ$%r#k;-@U$Iz}4 zk0)NDgFnf<{1f4ljO_&P`7mvp$!*q_`ds#0NqI(Z&P;&6fr2;-^OgFh?pw+F{8$~#jis3h2 z-E}-0_0m>r8_`N2SvTq9Bu;Lz!zg!Csobh<=JMwmkDq5e-eQ!RY|)x!bhzkG4Hjw#Ycbymg{Sz`RLh!osQ z7!mIiW8hEn<(j}?Dp9b&DngFesB4SiR4P{+%{mnJkfH)E+GirNu!ZbHQpj+q%ziYeChfADkA~Ug$QNRVyZ3jUW<|b7m z*=ii#3Ln18b9!<@n%Jtx20uIg+-l(%fR!fe*0U+`D~OzL{G1+N)ao$tTC({`Vg~0Z{OK zIwG&($~W-yLUg{+Rul#`SDMwe@LM|4w=WycQ8Ol+dZYyiTwx%+4A8>87kU9P2oe+r znH*R0PEy_y4(hz=($`+nrE&pT=n_t)Uk!K{>!3m~i%3STHg`xYY6N(N!uMa*Pr@9- zbL!I}<5d?B7Dqf^;wG4|z$u%1aj_n3X6kP;Q-70P_VxZ)q5uhjG|52) zzE%NQs5Eq3%Dqu4QBDfBKRS@ERe2Ne16InKZ#i+9bv?0GoeU0un3OMYKch1K_vug~ zf_pHRRY;7D){}gV<$?^5f3oCuvH=-d^$K|oL9q-SZZT+J-=>@||Ag!PQ%3v|egT$3 z{qXgVpHaGzOM*%P(Uiw0OR+7Cf590~Km3jVJt^}Bt35J~EdD`P5Sac5Lx7Me#6>43 zC^Fr4dyiZoKz}i{k`R)jreGf@#9+?j*DhjDjP-5rl2vAJ{ bcT=+#Af#GlA@5MREQ&73ZU7~Ow)Ou2_ncVJw_@&D{zlkQYby-RnJPTi93q*HNKcemwUKww$LmMu$0#m0tE0=BUsbc5+3 zA)%xo9|{lxfzU!rPzqfR7`_Q1kdR^s1T6hN^Y*4dM3p!qDJKB3s1rRb{^4nx1-{NW6gh zO&vShci#H?&L0w4BhW!f$F3n)Nla@*S&Mgjwj8tkD#s-W#8J;KDd)oC>+;l zPtGm9d%Cxt|K@Lqw!T1e#;mPf?VX>T@suC+*P^~^D+;V7^cURE#(nPA9YbdiI(PIF z*=vAvZEt@^yXo0AHxeb?OcZg)j`p*6TJ~GJ&^{gQU4894x{mDl4A*mBB`W&s&i=un zoUEk}qpLcilJ4Fp?BYVsaAKV2i0hxU;3P_lkV0<$c9@hGx-szzNF#es^j;zuFzQ%h|B zR1yZM)`QyI;8WpLgu66K!U(t@dKAc((>$u6Y=Ww2T}xVgN{Q)|#~uZNr=!&@lqai> z9wIy0$i_o(HGicix#=M)2V2D8Lv(?jyEq?u29jowfYZ9=uh4U?J?dZ7@1r>f{(H3?1eYv+kiuKn9~I*A4G)UR zeGk&sj2}XCO=q4l<3SQ7E?09;(}>uF8(D&aA~&u~C9akclc#0n`kW1}L#{&$I}f>9 zTwB{aN6a2AM@8464P~woTD86h>FV|Fk-80O!ON}<8)l*(Gxvi>(EQK_^sr6u0l8LG zb`p&&C5v4nro7hmE7p%((3Cb(*R&zc?RGVfJl48? z7NI^&^|EUHNL|_pZ8&s@`&_j?$31evp+jkhFb@6xL3-?z0zs!1)aeD_1o{9cnjaJw zw4yU)Iqozr$Z_YmLFa}h;0`ZYylQn)U=`YJP|J9M3%@G#lkD9#ayvKEEQd1 zP@FHW79YtpSuG!yKb6N!!gQ_aZqvi2$IKDt<>u4PXPP_A!{*1#pPElv;w%}K{7i4= zyv#M3x4L3n@vc-?rYqZ(=PGy2bTzpyc74}1>UzNSqC3u==+1WMxl7%L-FIcnY-@IO zc5HS+c4~H3c5(KC?Dp)g9GTN<5XG*`8j{1)hsNmv}Do zT;sXj^IcEC^N{CZ&y$|#JuiA*^ZYJvc3xdxL*Aymj=b)??Il}Jh!asK4xYH}#7|HB z?8GY{E&aIcWBYdgE&{RE8XSNcu)%- zT&VHje%BAD@ZicI4`MVPWM&tF2b;1xH6FOWga@rb9$e}9y62uC51s=LUIhO+Gkz z|KtxQ@0%Q%{Ql%Uli!}acCunJX5!Kqo=RZ2R_vFlzwI^#%R-g2r z%s&}-GUjCT=O;h^=<^TXdH0<+-udY}=f55KcEsD^Z->2Yd)s{cv*Uj|{)gk=KYrl& z1;@`ne%|p}$7_$5z7=;o=dH)z8hLBoo4&UezqRPC1#dOIRsUAaTj_6E-ZH(p<;{(6 zE=YuCAeFw4O@f z0nMRiENgR6ew=1trJq4nSR|ZS=j*AJw$m{A;VaFi)p%wj@)q>dslj3@z=(R^9z+mJvy z`iTU6NuZ({ZJCp+F&~|vX$>@xInfJhTHrfwhh$r|7HhN-&IZLTsH;c+nb5g-+^vLL zRs_sG+J%-mxL%BrolV`qG!Gu&a`d_e{$>Gu&-JJ|UDKsXa6K8Y5ua*Eqy&!9PCV&F zt9hWjTce7lHw%5lA>D!2omfFV%(cmP&|fn8<@*)ru?0`m;pvsM88ll#HRIiYUKim$ z8NO*8`fb4|5^$A)84#{XxqvEhRjffgFz_1f0!<46OEC+=C`NYnQv-vJdeFqrGZ<3md^X0L37iJ3M2{`lDfvJ()6#&s9tM{;8v4}& zzEyx8)~eZLk9-j;8Vtm^1xqb2jJbw>9nV4rh@7h4=nZWqpe)a)l z60ix)-)EoyA$%6X;w%IQGQp`5l(BqQYSyV4^lSjohdoj~=zuIGpSZ$AazrM%3gv|9Dn_bkmbomk&tz5Z$sf(&suUzcH zeC55TUDNj@$hHvc$~@@f3Q)u~4)EEDS%`HfGQ=?BZ38861xTW)HYGOPf_|k;;Zu3tUvxxL}P77*$IXHfCwq z7Z!t)GpUi5K-Q{8g0fW2@~{+?1?8G>$lVTWmknOmfWwQxZ!;^7MicM)W5G2yBwYD>{J&0zHxJ69!T&nlIVf`nhyS}& zn_nDzQ&Aa#m4)|wR?H19f&AHHh_L@~p$Bw6ZVK+EZ)P!22Mu3tRaVOwQu!0S#Aq z>XfontxRH8Sxvdf zI2w}E_tEn<*iYtpGN=fn3&n?T6SRA7&G+o# zqF#Fn_sA3Q^h}J)csfjbDh}AVwU(n*oGd0YaL4IaT%(n79X_lLaiBgCIgg9=%n8|7 zp=XZYW#ZnV^hJ}Tl)r?0rTm2nGLT;Ala%GqE0ZZjmO;ZpI#ob1noi|QpYWLScr)S<`~+C(WrlaMK%S*FbEz;nC1YH1wO9rGa?7(qhlda z1lF5~Un0Dm4M3dP328c80k;svpf2_{qPP}>cbbV328j}vAa?#0#KIBtOu3XOeKAo6 z+GhNKu#p4YK$L~Pv%W{oWTfL~~ytsymCQejiao4ghpD zd_>f^0^usyr>1%U;@vH%n_oe+pp9tZ?L>?Igh2Ng(b8O^W#HLzl(+tzXeIcuDwSw8 zc(vwDqO~s*tuG_mkV3Q(^Xjyhh)y3RY6G2TJWR9+{MrmW9kT!!Ul+#H)lby@6wwyY zr2**aIgM!B2SmLwL_0v+j=PBZ|4y{?Ric5{iH7bW8a@obxyCNwKO6Gc13S70GTeJ1 z0KDA$8qqo6(YfINxz7U7?^iw{IuBzwA6E7Jw}>tT{a^hK(Z%53{+R&qYyY>24!|-W z@Bwgt=x(A*-zT~pv|hdq@KpfDbNN4ruE+zd1>pHBCWx-O4)7cRbX=Vc*af&8@Ho*m z7{_7MAI9}z)L*vb0QUmk zA-WyoxV;*1CIE7~19aa3ymz4QJ2nFj0loovndsXnzZ3o5Wd>9NwgPSjpzNMRz(&B; zfad@o6MZL&=(~{Xy>WnIKqKHbz)t~?^$6rRg0d0NGXmTJ;0^#s0DKIr1Z*R^??s{? zd<*ah;I~BgHv{$nt^)i7@JFHtAh!pgPY*z54<04@;R&LL3IL}Q{RsN<2xRogS%AM0 z{dhj$e88Om^!+Ht|L7{fIe>2ho+o+?bUpSi(c?LQ#efdL6@VuI9}+#01VBGe8~|WE zPdq^MR6Nm73ju2Y;Pp>$0YE2z`YF*fF@Rb?7XagU<~^JafuBDI9Y4Pw@F>yP2BKf4 z0U80@0pQ0kQT|KN{43D$E70&O(C{nJ@T*Cpmv#}oat7d5!2JNsrB}f7S2F=s0MxxU zkLdM5z)gVr0B-=$-y2zg(}>2)0j)&GJOI%7o9__4xt8cHjOX}nqPHI>dS?p&*Z<=u z`dtRmyZHe0`}^|%*AcxpLG%a6`47PR2jKieAJGSiM1TA`(Vs2{ppQTOiReS{;=`AS z{tP^SSqgyM{yGYHlIX*GMFz zkCfSf{~?izU18eOBr@RpWPC^>YZVC>%Cmg{;DK!xxsfD1=-&f8p6>!)1^k{wemjYR zM@ST+?;_Arv>fm$i4yn?GrmTm478QsLc)8TL%FSe!y)$ry=cy(FLxV#QVxD<1=( zY}H{Bs~bqHfoHY0kHoqsNNn6g;`DJ6XCOcG1rnQ%kZA7!43gN~NTLJ$=tN)Lz`5mq z5?e9uo;VWQu%d3yAkhn2cU(xKuL}U5Z)XgNfg~J7{S3!fZvp;HV)x}F_EeGBdw|3_ zzaw!jJkGB?PU5@=NSyy85*L6^7k-PxMbDAgR}Huu0KV);U;9@8z_0z_)&B1Rj*&PJ zN#c?-NgTWefWy9n;PIhiKtJFIUiCe+f+ads;kobBS0DXLYJ>UYs5x|oG;QGc%65qtQzPT815HLaFjsd{m zaEy=sz70C>1fTD+0KmJu&jGwc;(Idy-zV|?JOFrjFXTIN9f_lVA`yVRMj^ZVXOnnf zIRHF+0DO7ie83fe+W;8H1K{BUF9O~KdPO0KP`zu_(aBBz^)}`~>>&_`@WgtO5Lx#8bF_3S)fQ z1bBtSvrPc#>2u)abMVQZ?*d$f*B{X4XTbgQvq-$yNn#9g9K+lg`wNL*Bmg!7E(5$o z;+L5JzZ?WSLE=}50O;kfenH~b;Qg<^L*gaK;iVM-(E2jw=F8WRctrp(S6=xIiC5RyA+zV=5FuWttYoWvX8?;DWqIP_v1ayfP;;MXL6gL&|q*Gas&9Pl)Ww?Gd} zoj86siMM+Ikl)+r`<-OKLcqBse(NUj`?VzAzX*V5K7bB?0NwcDb^vhv5o7%0`2h6) zryLR=Lgs%4|Ne3w0OS5E#`3o;ye@#gKLRa(e~QGXktF_c4~fsv|K|ruoWye{aX$f^ zlaL3Z&k|7~DG~rr0bU|02LTrWzDd&5LehLcNozL12iOSsDj)!Oj->4;fM1Xd`!h*9 zb}IHpz;3{8fad{k0Nw|DM$+K`U~lC(0C)iKCdtSYz*3S?ACQc$0c-+X0vILf#M=kX zN#Tgc3k60PPZf4fvd7VlH4dU^U=el1Yb1 zCf`6Zbr;F>901NQ(m{9n6@VWB{zfvR8qfg%KQb;NnF)F_lK}+)(2;or@B-ij$t?7r z1=_Q&1&jfJ(}gluB}q4E%60*k0Cob+UCAW(zBgpUM2wY z$iE8kGRcDdBnzWS7Nz24iM4&zObXq4sa`2U=WHsJl>Vg13ZqQ<90yWWnCe zhP_rec0T+@M+zX&X$J=T>)Zy zMTn%8AdXjx=vX=S$v(v9DiPJILNu%fakQC;$IV6@Z!Thhb%>rdAm-MD*j)>vehUyG zScE9&5=17KAQ)fA!gl%z4n>3iQ0vvZz2M_j~=1x5Ut*i zsK%v;&fZOT2ooJbeDfllQ~Z_wMqi`r=@NQ^euKA`Zbc0HBRWAJ(>LgQ^ep|9zK>XX z2TnCQ>3Qm+XS8#T7wBj7bDS!5)30cZet}mew$Psu6MdO}O)t?_dLOaVgV9WetZ;$_&+dWBC^;B4G4su1HoiZ=yna85o`%o4N3 z9K1C!Pt?(8^f~Wg<_Fdj1%>xVj2BNEXT?E3bB%YNAHSN z^lheYZh>hYj#KMo$8#GSG=q-AU-V~>cHgN{sB+e9@M7!85Iz%VlPv{m~ z#8%ORGy3hKSL_gdqF?M3XNdv4xiBP##V)ZMr}ulrUU7~%SA0dBhxZvS5EqKCii^a> zVxQPA4v0&{L2*c2DlQXW!*2ZwaizEl=l$1+YsF!4ow!~c5jTh%#ZBU7af`TB+$O#* zZWrGW-xPOfJNrAuUE*$Wj}{yHp7_4FSB!|GA|UP)qv8kRe(`{KQ2bClBz`0w=7@uM zR6HhrA|4k{h$qEU;%V_y@r-y@JSUzPFNmKZ#`dBZ6Tc9@6u%O`7B7jHv1Gg|UK6j2 zH^jI&CVnH{6mN;+;%)Jc_#g3G#O2-TyUC{L58%Qkt2JX3Cx?Q*m1 zpvz>Z?2_FQ@od>6x5@1|^SzO7pbup)eT%M^J7k~ir<>_cx8cy zqaVue%X{UBJSqe7J~=9XAn%tC$Oq*QVcIWCXM-^e%RTk^PkTfQUz zNB&m+PQELDFW-}YknhV6_5EA=`?|K<+Ewmo?C2Tj7~av{+jX|1BXn(T>S#wN)=rf*p~v<(WP>0v+AQu%N+AVNE};2*AeqO`giPTSG`4SnR4S;5NhSv z8oD+w*xWu~-ipMwP^YU$&llxPflpE_3HMU-rEmKeRsi&4rX&BKr<;J>9HSg0h+j5=iew9Zq-#Xm4rF~#{M{oP^ zP-OqqJL^h?0VhcyFJk4CP9p}U+*nsC3#)@wU7bC>z3pK` z`h|71dVEOHxkjgRSmo9=O0kBu%(7;nr*Dg8nA6BLQ)!MIo_c3nqswMk*N%;fw!Kq-V9B-(Vq2HREM{B_w@LyYkjrh z8eTNntkqT2s{SkLQG+(#@^W8=apm_0uYAF)P;F(f%pbg}3SKFk=+9phEDPeODmTii zf;g%S9Nx-cf8Jm(-e51@U@yL4FTP+eKBJd%Uywduus>grK3}kBU$AH9j^aT@u;+?k z&lSO*D}p?z2;!&+;;0DX(D@D?R0MHU1aVXbaa0CzR0eTW260pdafJ9%8N^W;97kmk zM`aL4RgkKx;1gBBC#r%^R0W@?4pLPeq@_AYOLdTz>R`{+!JeywJy!>Nt`7EG6YRMr zINq8dj+!8jnjnsvAdZ?Kj+!8jnjnsvAdcD~j@lrO+8~bFAdcD~j@lrO+8~bFAdc!# zF9r@sQ`Zu2xl!vaH*k2%4IJKb1BbWVz~L=7aCplN9Nuzc9NzLE4sQ@gkY;aC=H4Ln z-k|io!Etzldf=_C(&l(&O}(Bs=y{`_H|cq^p0^nK?>F$5*Vd@ITA!X*=((~Rm9>8T zUdL5it=}8Z)#~>;4YfKAwK@&8It{ft4Ye(*y}rO#)_e7P9e=%!zh1{*uj8-R@z?A4 z>vjC~I{tbcf4z>sUdLarq z<8RRMH|Y2qbo>oE{stX?gO0yZ$KR;qYt->I>i8OUe2qH3Mjcl8HW9B9@#(5&Nc*6}y%_?vb7%{u;O9e<0C zzeUI2qT_GT@#`ybWs8o#MaSQw<8RUNx9Ip=bbKuepWmzc_j^_Uey{4^?^XT#y{f(6 ztGD;5@%nuVpWmnO`F$EbZ*zs#A9AffHP*YPVg$GwhUsX6YIX{+-3^u?meTjBSG zcMT4~haT$c3~wJ8=-=Jj)jed>u7-EowS1sw%hn-P+1bClPhD;9AKI#}hIe-MMd%N7 z_H7>Q(zxdH`tAJ#LtEM7YwwNd=^N@A80_j8>gn&ZfUEs|j;^zYdv>+=cJ+01S+@2M z4|YXj&(hn!rKh94x37Q5&W~&v!24V~gO{6!!dA6l@xed7(5`S%9=xc~tIB=GMWu03 zV_ei47gfeZwQ=FsFDi|Ge8%I@T>JwrztMAr(T~q4@fmG=Mn784PgNR?D-1Gx!3zVs z&!DFwcwtmk8YsL*KUx9z>@!HM3^q1;t~BVWH0Y@`de(-e4JSCjU_V-kZFuKqygjzv z28nWRQFp!FDjyiq?}rBQw@6(UEwy!S*Yfag>?pcAH}{`yT>$1(S6CN#Yy6ce^Ly>8 zdH~8`8ye_o-!i;Y(7?Y8nxrMF9a9u8+u->%KW=z55+RF%DdXIGzIIJlz+ z%G=)21#R0EyfF<9_gTBKd+qHqb7C3X35v}fy~CR=_~S|-?d)ma(cjnU*fBh)>t0vX zl$?sJ(`n_sQ zeyZ1z_bT4#3jm%|WAJ+wZ~R^zzfUvxn1zb>exHutr{h-(2I_VEYQez0j^C%_ z_v!e3I)0yy-=}2b_v!e3I)0yyUoAK-ezo8r*YQ{A_$&1BSLpaF^zm2d_$ze$6}6H2 zHp}nVB%$u$I`f?_Bj1$kDdkFDs!hEnEhVc;oi~*_Zz^@#Ds|c_b=oR*+LV*h;;+^QKbgO{LD8O2wNhomW*luc~xjRq4E{(s@x;;g3Z4sT41ub`7)-^$%#& zR;kslrAn=0$Q8CKwTj_halT49XDwCAIYX{SSJhM*!LQZwg2HUFY4M8E-mtx01N~f} zoxeomEp-##Q@}ecbaCORek~}f@Z0*e#Pt#V+C-Iwo%*$TN*fF17S`$IR!T3-)vs-| zw&3Ubbr>ZV;C*ck7ykaW;4=N%ZXU~dSig2qY0l;Pbp$QRY0$4DbF4V2QP1*U#^8^^ zai%eZ^NpD}+bP2tM+Z(idT>94>ry}uPC-g>1~Pz?k}{m%wBRJ?Oq_vq;dG@D z!cT0`dUD~sr~@ZPE}RkZNl`h@ja)eMX-AuOocZiQ`!19$LI0iTm)lRLU^n{SjhdbM zGei0_rRcXGXEJ4|83yiN;ABkP|886lp&u7cmbyT57qIO@+KG}DJk^I<7fzqr@jTbN zaKhCJYFs#nYDeuL?zx{(DfiHWQ=;kROSQiGQR>p37&aA^^rJpV^|HUdNhn7 zI#!{VZcwyadycuGTBs3qY4j-ChjHDhjmD*okJ~QSIM|1)P>)N{LkHUNNV>FG9}YkVGk9Iz`j*8B{Qrcj7rk!Di&WDA}#`%Dmz+D|*{eGl)9o#V!DAO1mZ*rZXgy zcHFh#RB(mXatNB{(kPiO_y0`u48G35Y2^R?4`W}2^UNg}0iR^@UnlU`mLq3vT7tSp zt#lPq7tS|Xk4}|>3zYNxsM4P0c{BixW*yq2mGj@NRcjpjzdu68Xz;idv#A+!TZ;4C zCAc!Qho!p(a#!+q;i?-YJpcYP{p2~X>SQmQc`T?ykgDdUmyIrGp2U>QZFJlhz?CS#`hmosM zbFH#*JpMkEbMI`gm@?xzMWLd77wQ#1c#d^zH1QaXw#*y03aU<7KgG@MC}aFQPuW6s zXx7shA6v*B=!Nx>Er1I&Y{fl~ndiUJKTFD>MAfK1mF*rv|4RQE!%jRGvI+e_E?a->sL0#-+xo z%CgP#kHj45XBcHnt64g+JK){|N%dNfPSdZV=6khanx)@GVZ?% zJmI=ddzR_iqWtK#5^9*v`cZ;phWSPsb}7@jws!kx}F=H z3C#rNfZ-o0J2qX@r_#%Pp9T5#Xgp$$DEp@@&{VzQUU`k?CwNY)89J2`=KB|UAZ%s( z@f6QJV`S}^$2&19rMqhFH}tO`x;d;#N6`|pk;dqGB~)6g^nN;>N{dttk4ss3o`39J z7&<-GUZ{7B7Gm{Xh#9j`TZdO^>s0m>?`YL2y2sudbE6x;_Q&9mK@&@hwNS}`tpiJu z+x@ddr_N&67y82s&=1de##Mk`c+LCIxUXgxuL(+vcnxH}vlIvLev;y;51%d`I@U;N8tVa$%H z^L-GS$7}U;i_LN|M$Z(om0?~hd&1*W9_{p93aEICy>4IhBL4_-gH6^~KLnb#|({nS1*Lyjf;lw`ekD^T4<~&ywg+O5wG4x z3-+MdhvxP!Xa&y)HDg)ss+9d2o_UO2wKix~5|}C__F|@UdwQFHrwMA_@Z97%$as|2 zut%UQGS5UMM>R4fFT=wOaYXsh|4KWQHD`a1wPQDO))Dpu*_-VP>ho0T)@!_Dehg@P z*Dtbu26tE*EcGo~|7vw(3~EH|`KU6res94YugE-$R5{Nn<^ub#yejg%*^N5(X4Sk@ z9GJT9@w{eB%=9adlvfs>Kdk+y>bl~9A-^x@Rfq$;Q(K9B%tGwmc>l)xH5cA)WsA+% zP5-ohv}zQw&pxPeLs6!zD$9sDsAQ-(z;qfOQ^*fjTBLM><0oowZb(qAo72bMk6zg_ zu|^nL!Ll}Nn6WDT`_Z#bDon~R;(Nmf-lO|0ygyLaN`3}~AwQ6x3i-*ZEsy<+>=3tQ zEmA87&*|Q2Pa81>qYbZup}vh;HBzqe$E)Z!6}}H#%{vktkN(Z3tJYJ z@gSlT#{So+1zsLKZ%TBEmY(2khU-D%d z$C{?J>3_2aVebB`T?l*bJa?zgKBY0n3}tB=`oNK|er-n(YOiF*aW2DyR&hFmYW1|T zDqrU7vp32;@;D6+u}&EKK;;Ea*HObKWI9>bW*}F4RPKu{)!7WpHu@&=&HKh;j znd7`#hvIo`^;9H7`OHfHly-)Az`FxphdAv)d&Z^Y%beuaU0>Kc+aIQuF>=o9Cffkk zQ|7X+-F{FPhK^KqcUq!yB?myI?hPlJMf zty62%7q5oX*FVG4Gcq7o? zpDrI`Z)&U^)BOack*wc_mMHInXQqlN7}~-u6)%ldow>|%G)84ed+J`aQj<5+%UWgh zX>feHKl$%Q9(ro(K4z*H7iuvz_WG~ZBo)C^YqGJMSN2woMnzzFlxha^%FOa-%fMQ| zu>hX=JUTV&c($qt2unlNu=U`%^6xZW^?Eimt_%2B=M1l?13O!$Z#SgMGmU+c5a$j1 z8}bR29(*xB{!>0HD;dJ_#hhkY7hVxqxRR3vX$+FWR#6CWVH*m z9HV7TVp|j9|5VF!s`sNjT*FFp&un*(Y^rJ zQ%~7e!5ePD_|~B%pHR89C;60lC2H2H!6s8M~a zL_0pg+kg`8+oj#}-D0%o{UK{Fe`B3G6XRVZw_J&oX>HX`{un2><0sU2j`5h5;cp;a zI6+hR_%9$CFP{|hew1a#l&(g32j=6dwaCz9OP1`U6UcD+n{ zil5{2Nv5SgGanQO^{p^G?Mp z-b3?uyKxKtdry2*xsHiP$1VAOHR|ipD?iUPEY?aDWy}+nGSd?3h1Y+^t>|F>uhskG zb9)~D5`4I4r6K=JhYK~!(T1^d-wntW@0n|Sra6^6%QX3iG?1xo0v4v1#~qUSRC<~7 z|15(q(gEfw>lF9J(qVoq(YUY*{A-3pxF=P@&$IThemCkIRDCM#X094-6%SP;gXOmZ z(o}p`{hc}r9@jdJXCbaDJ~M?Zea2`+NkTGaoJ_Mp59=)Jk}(rPT;W;9lrSciHQzA? zo(Zf;%fVBwGiIT}VaRcf_N>t>KR2Df#%wX#8PA2z;s4n}C>|NOx$n@}Sv#1Qib9sM z;>N%4S6Q#=?=k%Q_gC0M?1k4;2CK?eHxz4QA8qQ}IRCCM^$rXBTD*=LaZY9Fc@M?D z0@twb&3jq3bLrMf*#=MdN>y*Iu+yPaagG`BoP{*=j{bBxb z>{+esytiWyRh{QeJ)<+IR`IQ==O`f^*#pXk@vIR;n!YDi5nk?--~Ca$eb(r`z-Wv~ z`HRM`i#0{<>X~CqA)jaO0L99?4spcT>xXz8(gcoSasPc9SC=CX;WK2++OPCfy{Et$ zpduoyyNahgOBClra%O3)Lw}o*^H_LPAqiz`^s6%+6>;Z%vw9;&$tM(p=l9BZR4gr} z>l{N+eKIxNE1wtebF57~CKbKt#g+2%)hVUoVaT6liqs6`epnxQPBdwhhU^JjUnP6x z&2g-reHwm-`Mw<~pRFp+GvAmmp%WDq5n$V4cyb1}8Z<7j%sGb6W8hiWu2Hd4tD8EX zRji~H_0!)149#<|_J&$Lp8sd+SWgzBW-6Y4?n}K6uV?CW$jff0qZKixk>nz5q!@Z{`Q~W zIgO@!>x8{{eL%)IV`6JZWLdx|MO(n+jNw~TSs+rH_$sn25N?uu6I^&?c4c9at5@-6Np?xY=eDNzoKJh=>UMBu*W^P})k2sjO{`Q-J!XpLrCEoQtU96rt@m3u`+j4dbMR9RKwOH7Ej=49sy)2bM6OiWg4n#+}znk65e z_}0kC#Lv>QveHsrE|72pUx@Lj#&jFaRZJ&~sW^Zy18M^qM}_S~Aj^Vli}R@2cEX^> z4E$#1oY`bE)0lI7jIra-{sCgk`mEX)0l@YPQ8t0^_`!F<%)~cDd==jelO+~uXduOr zwDCZB8sz#Il98D4z>6`snTKS=IUab-i5p7O6u{&hk*J0ajNmd7myz6(6CqjLBN2EY z0=H&U1mBvSBbKodOIg6C-3P)VSYJl%VX0xVEl?PSj>4QH;bW{f4y_LA!{QA@JJYy> zH0MYv(4?Zv*qBrW1roITKw_$^T;pE0>(Kgzhxr@cr z<;L|c_kK674;7;FND-35UL=t%FAi#=NY-RUW>D_+(Wb-GAQ&dW@8{4K0T+)`YX_=a^R%uV{_U-4xvKgUH{iSGG zh6Iuh+?I%vY17MBvM$l|im=*3x!p#_V^0!yis%a+2lFU6xvf`)Dp zguW;!bPMh*!Mh6FRj>+Z273wamhfGKy}58!MPY^8k`!%mj*mn3+DD1ImY0qqB%=;| z!wp8V7}xh(!VsU~D}0$UAwEjxWS7e7_{0j| zT+$%WFAak4%YHYaoP1eS+q z;ONxR5?P~kF=M(eUcoC;hVy796v|1NtUOLAlrzf-g|dzZOjceh0&z|&XYNc^xqw$| z7`U0|ZnE3UMfqMgXH}*+WUSD&mJ=gZ=h1r0i9l1F=2keJN9TtH*E$E(%n?+<65N#p zRnUVw58rw0i-cpYDDfue=X%`v$@wLYjEoXTvZExfsmfH<)Z{3sDscegDgSjx!pSSR z^Mvz+QrVdT^xn*Ro65Gz!AU9>tw1p+OE{T_WYiv>8V=1#V8d+XB#)DBPJB^Fnm9?| z#LCH9B%{T#vsjO>O+dkADXf-R30XV=TnR3eFLI%Le-4uMoZR9;vYL(Ho>ETAkZkAV z5Li0e>pRznXWD!ySX{wLC6Y@y*z+A>W^b4~t=C}D%uO~}iSGVwgIl$!FRL_&NfW=|EH-MM%u4^t*{QA09* zk*qquuqHdYzQtKw?n`mRI4tu`+3u*^6n|5!r*u|+;j+1TVlbnoqBy#$IHSa$;%j^) zCEj0{m6x0SV#C>mxeJPtOLG$wvWpUnHWX&enLR7TS?cp8%==1ljN7>;wK3M6>P)Lz z8K2SAG%vYub$NbmUhE8UJabk?c1?n-AQxX$Yk^^uJsD~@lGY^aOwju(gH#Ep^SrU=m!q00xHW^q&}MPaealu6>x z>#yjnuIjjSwPW$XvI6hg;ihqWfA*x#`fR_OEU7hff>EIcgXQ+A!D| zO>mAl#zq`v`gs1CE$q*0gPJ;wDjq=*M&>h>pU<7l72`kR?6eAW8=MmM*%MaP>UY4tRV<2z-l8+Wnl|$jJ!n)y16Lfn>DVw#J4=!(7nK$#q5O|K~A3K^mKH}P8kFc&O@ zR@}caa$DryNE1vM>&SOGd4!XDcu(>pP7cQ4x$ngMC`Ll}bMB52FGPszBkqil_eY3l z?Bbbl@j{sRR+zZUEbcamJE6`~j8z;qiMHy5{3Prc^Q&$Cr2LmQtZm-&{8K}XZR;28 zdHU%=`P{uDp1Y%0IB(Ct@4o!oohxGSEwWf1SKyoYRzZTZ)r>F;Q!$H@#i`z4!BDB7>mCD(62l8)I60gS z>4-6zPC!Mj0($eS7&^(>l^zqm z+mdE3K@%Dd0>ha=^n_K{AdBgFFE`Vbo9oKVt#;+)xH9tc(AnC_A$+0YGax{*B_Ke6 z#`-bdl1i`}U%E(T)1nH>eBQqSVY)p8ZMuis+w4ukU1UHFPYsNEJG1-`X2Upz5y z;+lE#ne@+}Ne7~8I7Kd?@U!UGmB#VNXg;l4VIEDJeSFo+uLKm2PpqTe#i1si`75S6J97;fdYW zc0!xrP-ERmS;*#8TNPTY7)^_9Ezj9~?3Xl0u8G9;LQImtX`JjzLTO%dRWeF*;dBHN zIN6hqh+5!9yUo)6w_F{VWBu<&4D&= z`RNDNaqi!=uhm~Z@t4}Bs}HA?CW@%4I()4&j=`X3Sdr~?u2LtPMVKKAh*O_}n22WV zfY_czv(d5$8#u@N+pyrgm@bW=5{B7Lrf>_s;`IdEO@wlIpL#v_bP&mgI_m+mIBaGk zQjHi-K9Y(A_xZPcJ%7CB#))$m$f%vQx179e0mxhiGVRcsRQit6m?#@G<~DeKBT>$y z1Sj`4(8ffW#sVEI!mBtjlSZo<;SHO%SqnJ_$(Sk0LFYgl0MDCfdyGhSBwK8jWY`!> zGU#SS!GFVxy_7BVrEG7vvdO%ZlWR1OGbJH~?WXmdXtB%2qY~H&WA^&zT9hM(W;cev z9+Q3&QgYMtYWS8+41YTh8NN>Q_73*4K(xAwF2h0@_zLJ8z07elHi3cdOmjqL zBE|xX$1ACGEN~6THyn*fup~0bk~G|YRb%$A7j;>EsraUf3YwW@WaTHDp-6$^j0+wV@W zg*JV899;OMpwXA!zOwGAiI>EsPdQ7M)MOPcXs@nVoE4wiQk#%hYn$%)M@m>*Dbgv~4* znl$#vVOTfsUmejIF~l3^uSQ&r#gx~hkP~48hipN91p}>VJr1$gA#QO9Xo zcJ1e_Bx_^Y7?_6~|9SD`a2J*=w;y573WunWO$U}uJR+7i9C+#7cVB<~^_k+XiA^GB zf>*IsP=kM9U|BS#t3f){U^$PY9|`M1EOa3}J(kB5j)+yblXp@PvFTg@p@%z}rM@`N+>{bJvP^Hmk z=Ni6V&E~OLOW2^V<)n%CW^0f@7n(D~>I|_iUaX5zy5Lc|kYkI%gh{C8NUz%cvnB}2 ztDBLsWy2X_-NXrB{{{I?ZPl4oC2pH5re$Va{>=OZi!EZqg6Cy#XU*DKnc3h>OsWtj#j-3rSXQ0|Qk&+n%yZU*_l^Po*+kype_l)HAh=z zg7ThXGRlM{TO_1fQd2F_&S-7d%RAuU#&;Li!aye*q+OisWsj_fJ+Hl-RPa>ELo(VK zKZL0gXkl)}^D1INGTN0soQ~4r3>0*8a$N~ZTV|}Cfzl0L=4Zyb8RBq>K<$=vVM`ZV z2c(r?+D`i^N?DaFN*4O1!cN|<9 zHLqux*A|vl?9cMAo|Uz7*@_HbL1vhYFPvMHT~nC&tK!zVdD-($uRY_g3tGeK!e*}B zS{0d|5S8IyT-CBDDk(EM%IV0QQ=XYzPz5z^CVbC6)YwX^bv2flolstVcGmLN0dvth zHoDJx*oskh!MGUuYJ!HEf>H>EPt6t~OqyA$5Nu8GrP;oT>%~J8FUp|>^(Xhi^;isT zh=ev|@U-98R{frk=wIM?glqqjp7sO9yZrNI=LpFw99eC)aUuKr7>g zs+*IRM84vr1rAJLHz!?uVzG;pE>^r>Ppj+i)XrP&&-d`F7js=Ev(GU^fi=b%*f4*&ROkAnUNju z%qkO?q?hDmMCG&;rR1ka$7Xt_S+g>FR%vfR&~bJafVZujlp`4_fcFfA3|X@REjR*o z&9VkHbGq9WbfV+zGjxNtDRpP6)U29z7RRn~P%oQdyGHj#9)?RiS{PRw2aPzW*?QKK z1;t1T}WNT~Z$IbQhFom9CtVyK>!v+Ep!Zf>54fyx%F+eJMY=mV4G+2 zE$f~clN4o5_b;wquplQjD#nr1ROMcVpDookQ}BQDaa4)l$$>>ej&g0gi1IxCp-IH3 z@Y8sxEmu5$(JIag+-4O6fqU8Kd6TcAIXRyzaN8ExgcG)MFMj3<)@}y5@fGts(Dzx| z^N5y=n8pJa@JfJ#G0n|3k0GA18ox);2^a!g3^2p5gqzG8F$>n9OouhiDi>SD&3tNW zzCem=`0Mb^!c5Veq?9GM@m6>Yn;I;uZJoci*2oaXbTER$+w* z#|n{46T5&rVOi zmbg&m!BUB;i!n*zMO;!W_NNJ3s_0JLl`7AP6?njS&W)@u(I@eE}EXL0#WZ@@NmWs!fd|Ik_{cgdivXG3l zIFFjJ9^>RBh9@E+K^dI;(deoyoJZY%Mb&Lp_g0zOMyi}gb8zSqpVI;!kHnX8vy`zw zVjdnVwN`OqX&FZ$7PG;W`1uwNg9MGFc2=kD09c&bofpFOa%eWyK3^o)iRc;L=mO!* z6tlvklarI9XW{owlw36dtCL*Ao{rja01x`?v8k~|vDi?-cWWC>O)N@;d?I+UzJwQM zc;r|@Ik7^1f%%;5VvAeNNfnmozTUwJ@EavB}E%wc*UmTvE79a1abXPRx zS{=THWl2RVh8Ha#THQo36CpqxxKI(PV6`r zz@xQs*jVYMh3y8vW1{WPGGs`u)`VH&oK$aMfsY&M8o(5UuevtI8M`v}XF$B# z31k~j*V8c}H`Em$t`r;c1SatA4BogMNZ0pnhttI{HgRcUSK9ux>(WeH4M)kCIf4&v zwD&BqLoPx@Fh7RFN|};p1{>vKfm)NVs#+cHAYf=qkaj1$(9G(h)ENuU@W$ZaF3z)g zX8wwX;@`Vwm!`S>4eoWe)J#`#Ot~|;$Q_qbRF&?oj*{sql{2!#&6eb%*@acheaR+^ zMIN<=MS2=e{@Lj&cNhB7?VllrEW`pS!;)MD$?lR=g#GaIdQ^|qltaCR^nsFSA%`oX z!IqJ9=g}}ow7d}fXhkwo=sa40bu@NtB)05mOlb4uhS1zW5eB0Z(e}bZcT!ADk~>l) znS|Sj=duu$`o>}EjdodvdC?rU0?igqhI!9~eL`Cxj+JznlPyR_3sY(lywi@31DEj1 zc>^a~_|>LMIJp7IXkk_@&Xl1*7iWs$IMEp=b~{A3L+rMT9zCW7A!#w%D7?O)9mMjx zF{fBCws_*Xn8J+0<#RmlS*t2iXT&WMnWs0*+rF&0eC3Y0*}lAtD6vTHwAnJ7`dSzC zF31fJ>pUQnvWnLqJY)00bp@92sPJ21cv>g_j7w*_uP zGUC?Q&70N4koP}*BdrDhcy`$A=^`?Y_tJXAb9C6UFUZB~n2Xmj1GPP95w1pu9s4-G zc!XJ$npwmz8*S6BbBl8MIv1^vU_CKgm6{5U9UOeUfd zN|jslR%M)a&SH1wqOY70SyWPBzcH#f$`ThJXI*%jFV^WjO+IWfRknR~+5C&T_*_!r zSC335$ME}MqI^Y9jklw<0*bgD=Gg)B?4V(#h<1mmcyyC}C%*wy$2!2r$N_0*n~LK~ zoFpF&gH17y1+1#fS{A^|l0mx~JU-McsTM(F)$e36QHWS+6sV#WyJ)O8POrS6(F3Kj$%=66BY{t*_8RYS)2Olu;CnjfDTs85y zD4KYA6$txq(Zo+dm>-ik0h2gSgek(DX&@{MmJe?eY75#b~Gm6`b6OxJzV8W9)f(u0%eEO?26Dcq3fK^wwCa=65_V zmqVO%BQa)rH!niFIk}0Gc-ZdIOOtQn1De^%D44-XQ3{evIk|}^`4Nt(UCK!@&-B?y zHq^x*NfrgkVoTWWFnN<%Y&DDB+Cc^Dtk9xm@IESh)&3lRMNG(fL3BR}FTaT&WP6L2 z&dS-brEbQRuQ+E^RHrs>TU6j%xvggQvNX)>-L{OTz7^MAmhwd7#0wGeQMTd@2iw{Y ztt*IhnE6;0-#Uw5Et{&bd@_ig7HHL3 zVz)-~W_VX*84qCZ+{W9u<-Cw)Awd)xXGei(NC;;XhOaVRZA0FP!!EBwL3i*_G^AvSmAw z65G1$L`p2lmhF5>lTDjVw=o4HOq+O{wCOhKX{Oz6yZh-&(r(jVuWXXWZMRKAegFSE zz>u_^{&sE6gTZiSF!R38``ib74?tQxAbcx$z(MidZ+v4R$!O$>pZ)p7gwl$K+j{q( z#rH9S0o_>@ZxxCYC5li}0J92$4enHiIfRP)wht?+Bu&$**0%g%G*hqL=EuqrMjOJY zm&Z1(r|!CyZ$3tF2w-MdKcNSeLliZmmSI@xC1LC7_M{Go7oU7I)>lqjTI|tOJ~Fv! z;Tss(!4ExW^Z2c6&2E?N{-feJUW5KQtVW$foKRoGP7iGj2jI!r1LDAUL0~oF7R~dO zE0ioHIfE>^w|dTg6|@-y4W5n4BQQKyuQ+ZoEMO0f(31eOmifwVE|yya#|Xl5W~en) zUgxA;;WFB7Jx*Ujm=lPfvdvcxkqv|ht`Tejs88nqITFe^G_LxL%?9~dlwh$@B)Dr9 zaO!>4{I*#cGK;s(!fdV~=%bH53bO)dmBysZR}6I>&BmW#$n&`q$7;3HfvYi@DWb1;0)zoDljBbD3Wx^ib#K}w(j}1&aYmt!NTOylmJf%I zcNiV_Zu%wVQ;>{?Mb+NS>Y;0V!n1rJ`l%~r06MGfxX5Z}=0@c?Vy4Y0r^=;%yarQ!eO@GLXfv^V zmgEBCOvrh0VqTp57O>fzjt^L$wZ3H4KZCxYuLGk@r0RP)A?i%L!qswy0D{Nf3L0QT zw?n52O6@uC&tg$qv2e^rOLQL|~6_7QL^&g!oeH7z*5QrOwV&F%6;!mjK zYViYgPm(sCgq@z2Ez*qeJaR^Er+?n4%47v%mfaRt8UjM!&>*9UOPYF6+0KF6KmmF~ z3IHK4d#f8VJ2TXjJR37J8RbSwjHSeSyO^m-{maCDRfSEflE1RDF8UwL?kslf+q|OV zo(H;&p>U&d%dbSrPGhh!Xl!2BlS*_BD_^vlyydMU!#fd8qlkrnM#EFX!ndJ|HZ-p( z=X+C5iC{-0g2i<0qGrI)z6-&!7!9O-P$3Olq*NU)(cmx}b|bR`%R*< zxX~i6rvz@@7Zo!Ram^w6r1R`up0h=~M5HmK-I^ks>(IY1jvju*x*}U_4mwvQqx~y0 zrvA+~~tyRrwf59T|z3}vIa zg6r20eD;G|strw%ka_dit_kIfW{baLqB}p--eg*+imrt}z_L)3g@w@#J>wwJ^n!;# zaRtQ52^N;(T6Q&x>t&@p7xcy$*TB1<%VK<+Mi}^oWTDam*oJwF^Albs?T3cYB}z z5S~(_Sa68YLLChC20Ebwb~98@nt=?Obpytr8}Z3(YM>bIy>I`0y{W-%U4swAK(Qvm zgUzY#j9)#xdZsTs+TY$FCLVML8e(nnd{=9>YjPmJwj=Cu3Pt~^<0gwc+Tzc3w`?`d(TustU4PK{H-{3IVZ6*QDO{dpOsw!EA zRNvs9R5iG6O@|AlTNTU1$A4krTfcA>62g}rQ5CsI@IHgEaZwiz=q{)TtAof}IZN3< znz^R2MxFwJuD%?8;DW`pC&#I=AkBR@eFxtJb-9K{}FXP1I z;QgR1d{KQFMyAc3HC{2^GQvu%Qzb}MsHFa^pO+ehjE14Lr9Sdflh;<>a!FB>4GlJ( zfq6sfO0#mrW(e{WzK;^*DRLhrI6{$ohMZ`I;&qmy-w}ct0A%Ez(mfAux;I082nn&G z!~Ppv{{FxZcOOx8FIfMWtW1)9g9ya6yb?%VD= z>;o#DC%VosBI6YrHhM#yy-W-ggvx zvtk|`1dO=?bqr!S!C`>vbIxnz(;pyrR_3eEq4_+%LM{f|rj)c*ImiS1IZ&`zVpi1L z<_h9_a0fAS{>Uko>1-}C=|@4zRckx%Uv2gT0s zmrCDXcw^!EPgbt{2@ns>wUjALl2!ND>M}Gl`CigX&XB;!kh9}6+AEqFiHN|1cNpl491Jxa5eD{N@xsC>@pp*C z`RGD$QprtB{_MX)1=n4_8>tzPy47Vx1Ckp1f(IiDX8eIk#vf?NYc$d%D%A@b@EJCH zU3?I3h0?;a%3I?<{kw78`99=#m>}!vb-UF&D_F297Vz!$ka-oH#=wTfm#L-4Jx@+? zh7kA(d<%N0ip}!v22l-BE(XR9=-@tf$ zT;4l8YbZ5hBFSSwYyg#c_;BbX;TO~VESxtdfH=2}5Mv8V5LOckOg00`Lm20^Xq(Cr zzVZdW=mdeJKqX8b=yT+WMl8#SN+Nsph9tVqJAk(nn7WSnSjy$3-U0z+v?0=`TS1^NNGU;t&Kx^H)gF%0d)o$|PBnti4(vPNW`w)b&g~j>d_{RxxVbg_V%q$Oc>4K_Eh_Q z$)}piEsdtGox?5d;t#{YWamg~NnFW2Yh_eP$LbbWxMAU!mq(8%GWy{yM3JO)|FJe7 z(8;KIQKl`Ecenw_NZoh1eh_$u89`oDo%YH_q)mc`*m4py!&rl@QP@4gACt{x@?LP!ios8^`fTvC-cT-=TOmQ%+PnyLxgBVqD9ahv%I&amIEICiw$T$N{ z8&Z@{86)?V?|kgpH3vgmMDO5)Cp4UH9q9H5`y;#80WRqnAH$mFPz>V2-sd6%nCEl_q_Q^nN zZAhf1O5N0DOQ++0r_&#|D}KF*a|Cb)#_QK22gW%PFw=oYa^$yyVOP7YV|0FygI6zD zU&cNP>Rf|0&nlj`UbiY^mTeYgw^2+O#YW9fs73;q>VW{r7G8{FQr(1K3Ux;w*x%H( zv8^-{Pdf{}>vAIxu5C`Qy?nx1kjSx{a#& zIKa%XWk;KsXx-Jy(jru<5N)kSgF$tci;-5*P0puF*LjQGj$C8GDn1>Gcyq@30fy~ei@RblX)Ql@D>a!OsGo` zP7anfbehl5YfB>@N4R1!qpMLE0|6tgE2CX7J4E}24Be zB*3mL084Ys3@0{ZcV?AcDX}pnHYCORkk}d&hrOr0$`Ox%5o6I_ky(m*$oEL8B{h2W z%=$=m2*u-FF))-I+_NT0+$-ES)NEY%;DLLi-Q|p}np)K!Gl*W}>f~ouFhM#99ROL4lleEMY2Z@6_|M3tYEW3feDW7$GZ~ ztRf)O0+1u@fyW*-Yow=MW>Ye07_Q`al-32ib+!a)|TLoM=aP%HEpD}AieT=F_L-kRwQC5pI!!EH1c(AE{Jb|$mdShg5wTh-z=gf{d)cKqV`H9K0qvwGawHZ{G+o$e2p)^~&& zed{f*28+A7C)by1IeY%xvz~^9nW2&I2kt*mT;D~4Z-98-05aC3tEeODpg3{@;z;#M z=H>yNU_ZCa7i$|CY}ASMFH1@Ll044i|&IRH+U4-DT{SiAmC zT|s7}0`(2i>@ar$P;M7UiJzAluQDYUN&?9}pnIOxV`kl7qm?`97LPKAk4 zg2WO&al$9Q;}b5QxaE7pr%d_w`INVP;tikpflrit;;TM!#V0IgxFMkxVYm^zfz*J# zst5nE$J}?2B~V^bTb5XbLgJe$Qyl%m7ZyIh@b@iEv0N_J)WYSsFttwkNHmv=Mp{}h zT&wQ3iO)+vK|(lH)G;`~|ELPUWJDo;9{|4J_5pox7?rsqrOMTacx={xM~;dgqvFS? z_%SMejEWzl;>W1?F)DtHiXWp=Dfz{t9GaLM8Y!=9LnG~nT(bkV*^qc6BoatGk9{>J zz7rFrnD{|Vyn(EXn79%XCt~95n3#&~iz&BaZ^RT=Ot=-Z5DGDoqk_B%hBnm4h1r$Q znXb`nWTff~Ck<2(0vTfqjKqYwB6g9ti&Zw!Y7>@v9HKx@>0;ao$s6y=_cmbd~fB+eUyj%5QeI7Z)MCunP ze97V&6WF5?Y{WqdV-T&dHUW6h3;`JbN^?_|eU=j_cvZQARZGT#3MvRqDPoM^Y=^j9 z=PSzz#B$`;+~>>1oRSTavPD=;4#i=3TltQnl$0Sw`GIm*QT8d~igHUq)Ut2^;6Eqh zvPOGG(N6=^qNWiUgpgD?%OOIexScBVfC;4=Ki&IZ?*IShMvnYx@1|`a z6c7CA_kVlcU;ZUXgV2rN{jqo(Lm$w+pb`ef=mTp7Ik4WTB*E-x`3(G=_toCeWD5uc zWYr0u8Yc4;S>=O7t8oH^z@VU#XBE;Bst!++EC%8^ZW9x>T{flPCeB(#Up>hVuRvL% zhqZ8|bn8iv|5CoSX`r#ioo#LPWCn8noe;(TVfD(OzuOsfS$u`DPVp_gP#(Bcg5cFI zhU5#O@=bNvKFv_lleFN&N5KF9TJmY$b*hn=Fe0?$Tew}C1Yyuh<1~u?&5NSH43*H< zrxg9QZ%wcUH)-EAvLi->;9{{;sjX>Wu`)b5T09# zX@?-I!F*)IVhLc&VC%*Pv+g!3tdGNoiE<-G2<-uE8EoCy#;_Tod@+Pzd;OFIPD$XD z1Wrldlmt)NE&~iKPsGI`&nXWXcuHkEh@6EW6Y5lxdXcv%V+!g_34awPy*=P*E;;V$ zav5yCaC5|!jQaH=o5YwkmWbO& z?)9XiZht43_FoA1maT#2_VB{*fCKeIdq@C@Js5;*>LAc4Ub#w1f=YX3nxIVT1k|Oc z8Jc@!Q5sxsKGCAEO}2#4hZfhV%n9Gh1c-5hjl`r~1REK+x{F{VQD-Z41S$%kG03{t z?Uf~97+RvH(=Yei_kZZr(C)!la?S36HG2kQTRhp-ZPWL9a)8tScIKn`@%_Dn2Szje zOpo_9Ke)eTtgrb2ymsJjpHjeUH|dJH|DnFNo5B6lYH&a29wGiw%std3O#eB`sBQwr z2s1o*i3TcPT0?~8Xd$amkh#hQDwPAGg&P`ZAf_}#0u9!NKpi!f3CIU&RDX^@D-~DG z=lZFJq6`~`s|aw0U>5*%il>TV2NG0s!t4woj00vTlp;jbriwLKOp7%MP~MW7leBiw zAHQEB(DOE9bj#rJhcl!5p^ru*u1$X(@6Jc`)JXTE7MzMnXJ!XCiadg$u&{mSNt(eKmm^7m4!HRWzjo?qqRe~id zxuV}(R{>TXZv6HW;o#~VwD6YxZwg&1O`+d!U)c4*AK9&Y9;^xdYe78P!~#(0W(|R8 z*OZ0AR1p^tN+W`EBY1Imaq5BP$PS_GAJtgha%L{gT^cq?a?def_y&sJne9y|Jf=jY zb)NWwf~u05%#Ai|Z8(VPSWp*RtYYO-brD2ErKR;k2@q7*UbyfTx3PKaKyyB184srt zD>D((*C?ZoB-$b?*SCw|L2o2tUHI?oKqu|L?TFbpn(Nas`LNXoj#ydN*J-~C{kT+%Z#sZNhpC{SV zF}BO$4>ycB!hVOv(1v7Q)3;Ua=&iTdOt{U#Gf5H7HhOCAX%P^+KgW5@_c8}~*DNQ^2U2cYV|jb^G*fa(X7XX(J7 z<{P>RP7>%*VFS1zHTddA^9%#`Sp~BKAF|9uk@+XjP-K?rD6%N<48IxyXdrlzE9xGC z82}o+jPq5FGwx*75#j1+AYJ9!vRF=ewV1EvdY{{d>hxBF`MO@5hcDHvMb6{4lKxho zFkK)cRhOUs38}^BIz!Oned(Mz{j=XZc=g(@-~7Y5S!{mPw+IUa$7@e*pi~C<^a{4+{=Km-t05?zF-d(HVibxni@@L5fE;) zOh(uJ@vcqcmluZ8Gn;!%R)=YW(PlQDmE>nDkk)`bt4<9llTjO$6GX935}-&4NUAKV zCiB6~3ULb=kz&5eC=P{cI0GzpVLOKHDQq%|15SuaH(qM)py_ebb1+aS@>1Nn|vNsI;SZCN>_nEa&APiDE}G0Iki^BYQcz%!k{fP;e9UO_39kuBH+d%8 zlvEj^F7}08c_wApNLvR0s?P_m2ViCEj*LaNMPy`4HFM!UogCwJc7z!6{cQ zv<F8PEa1ocHfB=SNMD@im_0> zzxm_8Ftj3GXmd8S6%t*mANlzBwq$Xr;2-zo)^r?h8EgxW53V|pA1;Owr4lU-=k~82 z7~gU)3Ps!Qen_ZYQxYoY5k^_%^xf4+Stc5M+ct`@w4eSKrgw47@5l>@7%pOX8Gy`c z2&hd>+=`Kg^$5f7Y9;evs2Hk8gG<3gj6XEX5EX+QeyN12!ykcs+KNK;t*wSADrKtD znd+HQ1+-?e0s{dYQ)(<*|(;%DjwL#D7^x~DuUevewJtHA}|tU zq`k+w!6!C2#17S_Vf33&HW79=IU7*K^w`PDLvaPOfzW3>7h|n?(lY zA^w9dq}!)bF9+BMOPHa+Dx>0srGyd^cD+TfE1@87=~2pQGRl`>8|032HW3j81XiK5 zhO~bLP@FM?tsC1Iwr$vqduM8a8LCB5imohQu(>!MYbv)DLcZ2eb9Qw&+MdrhhPsmC zXe#0fc-;QgkpMme5opl=c(+d+7I$U)8CBVO6Tw)pl!9o&_lT)oY(FE(q((F~?HQ?f zI+x3;7V;~vfacrV8CqsN-^K(p$y`#S0p?L?)yqVBKM5tBj7 zGCbL&lWFFr(rq&;9>8S-Ziv;72LnwHmMSq=>VS_hd!`ZZ&}v@F(_}i1_P`ox3fuBc z&CM2HtW{W=gfA5GS=czlY%x-Yz(z8`Igxc(Yq~`BNpTT0vMSBGV8f&U=W_tp3fu!g zeWeRIfj^*>%fW#7K;YTHO9B0hLLedx^^_5Vr0HWI_cG+25i~4$z&z*<Z<`E;Y_aARw>z6nSQftGNq4oZ*&MSvZN}ZZY%iSX-@P_x z+C3Apj~Wq4zVKlqxWlJX!(C~EIAcLlJlaj^kkQcafrp7I2z^p)*B!G;1pc(@7=u zL_)x)eF@761}4Z`*KNXP)|CQ70VNPf5i`)M3Hg-_j>5)|x^9SE1@d6zQP8!(mH|t> z+^8N5M|T`mbLjI&<}{`TrmI|f(^`l7zI$^asYR>Rdk-AX^>r5>UU4iEIkw_hzOTRa z=!&CZr{mc5{`U5Pf%f)(W$UqM)A1D#=exV}50{TdqQ}a|^F5Jr;P~&g4-U4suU-wT zZ35P=f*&WO``>bi-n5&7P^zZz8nbA-1R)wF4=~NQ_0XfHh?It4EtyxA?u8_7o|j6N z#a<&62)kyG9*XDV1}LEdEqqZhl+uibZ&EwdzG)?K!Zw%B;CJ}#A@>_xC^b)KgoqAp+|0h5sbBF1$Fne(=(z!S$$I zu_zjtbt}{nl)?e&yFrpsHM&$STdu7(J&Y=-rk;VgfXwbriVXPgj}~Ud{}R){-G@`OMSj5JSaCK<6$Vl`4Eabis3hcpO0#;rxl}2r=N3w{ z&FA@WI5ymek2+OYo|2LSRmhPlq--efQ!M7}iO#W3Wo#^G z_X<07)0{E1I;5;#ZO`EzG7XKNi_(btO_fMqK zClQXMQju^%d7ht*sZ?X_6T!$uT~293;Y=UGh}VcM@)a32oIqR@>;^Jb3?UXI@bNpa z9{Yikvz?{c*7-`ipW#f0edoDVNjXLLr<^8^f@@P>96;L}G5(RWbQL(y&la z254CLGAR(6P~d=mfaS?r%w4o5e2HYIixz_n!Dk8J2*g)_>SrBar09*RQ8)@vEqDfK z0RKD<;J-&t?ndtn8VOYk-U0R#`aHw%{s+NV2yTRM#^va%tN`|E3J1<-0Cu+l+(WRj z1h5NWX1KhyB-WM0M4Nb37O%aW6t6_Z`JnhUuh`%fUv3b;?hs!zi(l4@IlcI`<@w-p zDJ(6FN*k`~x>M^SF|*JJPF0W3GQ*WA3)1xT7W3nKySwfm%jd`L?^<>LXiImZcd}TV z=uISgCyK?%-h^mw?pn2GZEqxn(4*i`Ch2W*h}_`5wdwTQeS`JSm7BYx(eBMFS8nQV zYU~%ej)&Z{2kpu=EG=TfkN}L~;giHmmoh4SFa1DG@dC7K_J)Aj1T<61E|1 zQ`n5m+)}CFqNTWtvY?RwVOwgsph}2)URpUmLXakq3E3aOUcE#BI3XsN5ui^K+ygKd z^SAp~`$7L6;UOnM5lB%PBxW>(VsM>7YCh^07Js83QzmvA#Ra1X8nT8SgC1pJ3`<-Z zQrY1O#MFgY1W}E+lUbyltbB`EHJL+{p;7Qq%_a@L5x#Hbnhw3**xO)eC>Oid#5>1Y z%?)PJ*xb6-w=0kcIt_NeJ&=ugV#;S5yj?4@)Aqgt)(qrn1Z@E_8?I#2s5M%j<=z_}V#y-T*-p_0~)Ql(#|tNo?Q zxqkeitAn=d*{;%T=lpD!d$#+|Y|nhl9cbg1C!iY-5RjFHDGsZ5M#GSXW*ZyrPk6s@+9pnl~DOS zfy_5*rTj8L@OjewjJcb{{U{(ol^>df!VY zw2VP8lUM}Oy-c|1T-n`~E^OT2(|ce;o}YdDH{^$sy%YK3L{B2wyRpzV(G!2rJrF4j z+TA|u_SKsUT{CvSsd=^d`tXD6(&=>%4%a`sHg!hAotwJ(8R^`#*=S4`-4Je+ryqLg zyFx!vOatd%QO3mAlxHCW#A-M%Q<&jlJR=B3@E5-zkpM#nC3r#dYV4xCkdIl5^x%K9>Fh24`uQE?Z_wK zrusFjo1d)(gu_8)vB^fsP=go4LzagZy-$9D<|omDMWk0qbDcVDQPJc<{%?cM!1QqZ z)7;Qw#K?*N9q}qK#?kVOsME)RF;mKNK7%PMM}yY+G0>v-M?ip(d9K7r>9q} z?!Ct~ZB&NOO{6>B!M)ET{PTNL(<9xeH}*!Z=pF$gJO-=yU&C~uN4VJRq47jQ6B|`dh$_zt#OOaY8)K%wVV~|Mo6ClJcB}8-IScEU453bzfF-BZ$z> z;515E6GbpPhC9_E!xTKFR1{!1LvetBjpmKY3A&U%2vEmRsljVgq`9(!wxNJH5jYF8 zm~fh>#6EU}3##@pDi5`k;_A4v9fYF#ocgWrv(PJ2J*M znoybv7jW`YQq!c6o$C$O$`a*z#fHX0W_Z9JbXZL`V=&yH-(D_SS`xWVMac<^9eoC@ zyD(hmuqd_$7PwC+Rdyvmlvw@qhWHCQ+)q1k?pAoq7O_g4iP>z}SkfG^1v6=yxA7N+@>sf9Cap zuP=SRe zb;-6>Sf6wtNX=Y+sv~)WG?4m1#mYCkteTa#T`nqrAqB4~~H3=yl{VzAOme@D6r$-L|0 z%;LaZmnaBnwz@TX5Y~mcQ&AQgC!*_LX410kqzK~|*hcpn^lX)yp=>9YsSW6VkOTU~ zwVfaS9rRI%guLEnRDWFIJ+ScLl93f%>&}k$m4kz06D_fn-CA}!e?uK%gRV_O<+v^n zuZkM9TGD2_StB5?y^+HBH2o4A9j@9e3OK`Yq|rX`zB@*en8D-N>J22W_y z;vXbM)TUW;rbxKI6!qK zev~E1pO!XjtnbQof+Fp6$Ed*-J2=!$hug))W^p(!w#5Zg7>LSPdr(z>B-5RgWGx{vgwKVotlsM=wj0o ze=*zWYK%E+O;1W|Wz!Q#5VEO+{;-loBD@Z%8}UEW>Edz@DkujG48piqr`-FP>vsh% z%Q71dz0Y+~TppI!RTJ(K&pt~B=T!ZY#cPh#e+x3EPN$0X^1MnJC|zDLH{P(l;V@#P zt2>jqj_xIM?4-e{^ zmH(n%X@#7uzKX0=&Jm!zEV%$PjgZrXdlA4`zL&uuvH95;OqdWsWHh3iqQO;yX#m-e zf{d;ba5H+O;y_{zJ}zNmcwZMf1YvqAw+Ipdx1I1lpT`hY8l3V>UqF_Cl~tPj0qNko z4B|k!9fo?u{Aej#jkcsdV!aYT3bWIV%CYk>?OXi`gvjI|7-tO51POHnYUa%Fp?dxv#RlYL7a(vsr83uwL?Gite@sS0{b=n(~)TjHMn`pVvw zeXThmc5GN!7{3oaGqd84j)>MkD9HbVdR?YmEF_DjpWTbzD{q*J3k8wQj4R>Mu5?vU&E>zZpJ3lduA4C;e5D|dcVd<@B!xPIpm5we3^Reet z;~kn{n-HtUY^r9aQq30cRN9+6a68AtVWS221??|DQ z{5EVG8R_q64`&nMaH6BJ!{h1bZ%{h)iYzO@K~smW?t4?mPp)m5(kn}Fora!NJ!Ct@ zWq6tdFhnrQO10epbK4{I9)mF4;Gt*fJ?>=w$QFW8fNH+nU&i9Qxbl^ixW_B7qE=q% z#_@|iuh1yn+KU7I07$65&?|Diy}e35Y6g|X&Y0+n(Sy7_B6=d?dPE$GoQf!TfJ1gR z6bae{BIVH`EnF}2vuqW;PhS?3+2lfV?OF+&Uc@lUpcq7dx+qHD0ieAkU zV6;g~Kz?O=_vU}Ug^Sx}NWRNlFs1hdm-cl5uaq52B=1KxPxAin>2y&Qsodnb`<*Aj zbE{?NFh0s){O`U-XVOPkf^llc$=l_OB<&IEv(VmO%roIDZkyiD0D2r4n zi2!GpOCpt%HE{&29Hq9#;Uf(q%h{)1XQPjlt*mAPGzeFxZTq>FC|d9cilM+$8ixoS zorl_gI}frEi|4UAA&%6wQqABa7r0b?sKbYBEs851h(<+7F$N4JR1Snu%q0`kWi4uY z)ikSD3v_HQGjY=TWcGxIoie{F6P_B zLv5n5O@y+!ta3OjUT6?!9O4Mv%80qLh*K8vgjt+7-&m|SqApFSEVb0ST7GH{rby}5 zfWdUJ4UlzL_CR6lsg23_#;10>%PWhvU7{h<=e%^u*cc5P$7fb}-KDMKqJT)Uv>TAv zBsvuR_XijLl`h75MJLg5$>uSc*imnAWJx4GNP+Pa^eTmERzOei1bOS1`Os9C(-1^jx)R7di zws~jsL(N>C&6k>Qa_4mReD+3`SexCEJ%Sxo2Tg}X>N~{nTTL~5 z=G;yZBrvI!sg>#j3goKX65obh&92*{o@qovD$1})xz}b@+tC?hbtKsA^z#+7O~yY$ zZf18EpG}xe76Ym-5YE)T8Ka{J=8Ol!O) z-WrX!Mq1->#e~k$UX#P*Me%x_mu_e6A7mD7A3G}l9nfJm`YCw#Q_7ZQFi>esty5D$ zAqFs5F3>HMVn+$O3Hm8t<$=G|Gto;jUWEvKlUco&39d74HA`@sz>opBMi3x4MUW$S znHW*X1H29}Gu(T+Ag*VHEd#@zxRDXBB*c>m@j}yUO$tIU(ehxIUkvyrd#(`cipU zX5mmc`ccw#)|8fOxldio-`458Dt2Fi&|lF(cq!NP>35$SU9-5JDdS8Nlxr7g_vU(* z_g3D5{bWeJH-kJYx1_Q=w?4EZbR@(@-MF*yp++w1#!HPixpTVdeA5jW&gRxP?PxlJ z9m(I-=iu5^pE(AaRbRWD(OU!w+B$9l;OX%!vKXvwMx`8>>v0p1pDp`_N2GRwVc6HUUuGklUF9ed4T(g=Y-~z4 zX-y^Gf>tbQZ6%N?1)p^rs=KhQgwl|%wUrRM45-jb6gnFdzp4&bhJMLuu9&g}9Hwc4 za7MZ$!=;o9H*%B@< z!S9SA!L-9Xu)OsC^6D8{#YZ_81!w~WiU&d4sK2nf;O`cNRKXwh7f^81$bu$yvQz(5 z3D2_>5TewGR}*}aAWy1d1E@Y5da(Gj5b{(tg#0WT@Di7T3>)1%q3C zhF;XGprHkeOfyFjt9Xm2Dc5M9-n@C=*8J#-Xtq5Y@964?H@1%!L+MzgA>{A|BECQ_ z?vcmwcctWs`R&eRcW%KczAzp-usx9LPGvh&-UHh{8vK* z4EIm>LC;ru< zxe?7WrJICBZxR;0NyJV~LIGQ(o>6d=YI|@s)?&p;?joBTMnPgK4uXpv07nVBp=ejf zJGZmlvl(@8aKR;L*_9iZmWr9+0zu1afNp~81m_8EjPmOkt{=T;>pi&e@q2LK5J2_( zJvVTmx^>gRO}ycKI6l4u;4r}rf^9pwNALu|%<$&dc8aHWid{R!#13(Dhd8=JT)t0? zjf!id;_*?jbyPe!DqdbK@XO~1ZwxBN!N8ybL%?{a*w!hG2zgO%bcjZ@LMk^4eFWBS!_Q7Nw%;PE$HNy|%cj#U+F@VvprQHlJ%C z7_hiQZg1FQb`>`C`+GwBHyh11W1lBr^TmAP8E>{P=UulJvh^S=P^_HWBQ~lF##Kak zNKvhvOMk(Vl>3wX+UlYtKHCc4Cx|baGR(G81+5GLm0*E_CvP@$r;=->|E_YJM7mqO zqPujv=jW{rP4t!QMBLOoqAp{15maV`y$CZMWA6*3r}U9Kn4dAZur69l6xg zc#oOk*b%=#d@jyI9bbsivX1Y|QethvvefWMl|>he_`*K8#A2kyg)mt`2JXuKpUVsT zMh=d7G*eM6Xr%Di9i(vpQHwJ6@aHMp37&J=Zeyg zHD%icsY;&4^JpmZDSY0)g>q>^nz#16H2+ji5=gQ8Bv6`{DY{;;mLfFBjBbtit=c+4 zh3U!^38ssn1W=uJ?WaRQE*m!&%Q*rae8gBW$aF};CyKx0RS@`~mZ7$mDrmrfzgP6? z0XW5AJXrlQvCC{$3sMj`8Hp<9-R=q~|rCF2$FE3V3TV6p{ z#;IwxUyb?1Z6I?+;cv-UY?h4KlF=jdJY%s~4Dn1RPKGDf8$Rn|pWLmRhh#ax2l;Tj zDaE#Mf{X+^p!%8La555X&GfIS2|`3l!4&uyXV$eOg(Li+#=*OguE zIPfaLJp?b1H*F#3Sr0HX+;Y#LINu?*w~K7MK*a6KtzvhJcr7h%Hj8nO*oNLr<|V6> z3IMFN$ONMKAg-@VaP`HA^eMBx=CLKcN9!`i(f^=7n@xNB;-it3jot0>!umZcM)nRi zC06elY`-Vs8Hn9;F4Dd_4f!mFW)6z8wb<@!L*}}&H4(D*m>UC`_GoDJy@!YIeS9)q z*s!ms>;CnHh(A3V$uIoziVfvZq;KaMTR0rDxPmS-BJ_c(UZ84&Ldoe~E?WUrOrpA4 zLXOMgKDjK`{iTNL`!T9zt_Y&~vh%R)PY*3U&ZQ!+7l=30)%>b;E7Owe`TIDJzYj97 zcwa6d_4kiq`PP>_XYd4pi_>%h0FQ%b8P)Ak{K^dS1@_=3IYd4Q@f|Ebk9bHF)FqYB zfQmlZ#h)nSKxQ$A(h$AY}m4Pq8f;s)`<2C=_E%r|_u;fD=+ zSAzgWIM*POJcKz%dDOj+K_2=VQpM-7eHYsgu^AqP8x-#hS+PNv#8$>OjBOg*er#qG zRYm3#l17pUKQ3b%#x{*@KQ<$3cK-Z7K8CBV;;c_$o5l7uZ1dQRyrs&bT%-Ue0IF}G zUM!@cVKL2anrG>68dj$1Z#t{s92bY=1VHr-{Z~O0II()n>yJ=EP_JXqO}8+sqvvGq zA$qK$cnZNZL7BjeFf$MloB9#bNiT9lexTs#?G_LVL<|<-ZEkHT@XWmYsG9OYQu>ZU z3lEm$M_rd(9-pDLe2m*lovDsgXFk>0+L=PJqm+pF^dX<#ruUikY}=&$(^N?Hh&nD4 zqzRHlyxf7khX~G;04@`}22dUC+S)~()^WbXRez{MT&sz6G9wL+OBx018GpH~%*@So zh>#kjjRX3eA7}YscZMj(dWP*5bEv1$5?8AlcG&HWT@giz$3B-pX+x{o^Jg0^X6Jv^ z8{%q>!+_q9$fT@kwbG&AXll-6(8+QC(;ieiY)(cN{@bsl)3HX8R}{|!zS~>@duFPo)KCgTd^*H2v*L`c@jh;if1y4ZvTJt62RFu447Mj^UcxDt6<6Dkpe_Jn$i8Kdaf-9Ts=YMW137&lQlM>hxD8Ypd0$37XIrSUK6>(4YbFM>_6Q3st5}XF8#Sm2UseaDTYn&n3nUtu9R<;)`;;=>FbSd`vU>J~Y z37Krc)Vth!3O-K068ib29+js@7XC50^5>O##0;w=&)HH({um5(D&4H^i=05k^U5un zQrrO5ec2OP9-y7f4Irli_fX@5CUFlnJ}3#gt;PpcC!M<)k|UcBzecLj1yH|E)!?7S zb*fe2QS_u!GdVz8-6&O+#8$>OjLlG+dF}UT*Ob4fd>Q;hExa&y)_28s3r)vVEWx?U zED+!|EASg&e>(Tw)ygi|8Kb&{IHY2akwqJ11HvgIa04y`)1BK<^E-2A4Oa}eP%3P( zp?oE-w#U;&WdbSmdHo%>$HN(_7ebMB6QhXhL2GwN{D>f~T!jq7UIU&xl_?s=n-U7p zr4p0(V7QH6hTANcqO^!Xx9wvIkuXG^ep3{MRZxTm70hLf8eW!}@Eb||FApD{(#1(|J0&-Iou4$=}5Qa)E=U;6w##D+ZEuW<6QiOuDprfO!NGo^FLjDHIt3J&T$?`Q3dLX# z?L>PU*kWorD8>+xyRgJyN1bk&DJokKp#am~xjo*8z0WWf?I@!SZvs>&6T2}nl^5t$ zc?Mu^PxHgg&%jq#iIM?22-*muEBKY*!Z-z_=kVfAzBmSlX3Ad66U=EVMFGn z-jiB^99k)4DZ}@hSEiCe%*7lsKGYTx9cYmVGYBc>{^fy|L8v)m(wJGZV z`TdSS97dpjC8bjWc1Z>*JfgBNweJrF;c*_9?1%!YdOisc&WE%1t-MZGD?dM7zyQ12 z5^be6QlGi?#T~^XMLJd5cD6l)V{>QPF16j{>^`@{KZ`D&-yJBiN}qRoH8YtQNmX z-T0q1-StZD7TggeY1%U|7*l=brj<*ddGY%465Y4B4=#+__0w=TwaRzS@ptr1f1}ei zt4pTbChLVyBg9}owv*V-VXIMI{5HG%{36^)Ssgsd!mQ7VLHu}Dz1r0a1o{C%382nX zd$9(3!BfjCaD6QZ0ja&8z;%mVgIaW*y1!BHl3;=wN_HYU2SsW9VBGn6x^h{Ak#S#* zh?eJ{7?c3B5EyuAk%2FFK1h4da(1pQM(HHtw+Kp&xiZ2W4@RS*APS9y&3>0zZ-yvc zbAieX?=T`8!-#AQBeF3JJ7`$V@J4VK79}DX>MVa7I7juk%-$R-(5)37${XQcW)8$1u47?H{eh XaW%?U znRlg~|J(KR#Y!KJ!}xa`)Hn;?niN8vRBIBnct4F~!=R{@{RE0NNh*s(=OjYXpe#T- zGpWbWCT|!ttrAVrf-xH{p_@Quij2t~h{_`}uJx)5M-ov6v62EeN(#K(`=`~>_t+9P zZ=+SQnG~bZCKLtT{cs(F^GIPONd-gP1s&}ewx_UN#s-GCfo2$#31K$pF=X~Xh3zu7 zSFy{|8N~Y_Qn)mr5*7yZHHStF)y_>aH%$CRjZeml~-%mL|AE z8B4QfT5-dqRjw;v-Js9`Jvmf?=P7T&vbjNBgi{>LlNiQ1S3Z$1&-gl)uTH)9vTmZ?uz5weM&@f*mS^vgwrMQWNqyN)Am?b3uTpURiN#1!RsX z?7#mr=}gL({ zdx93dAx)uF@BKaX-a3~)qC6yi@-^rQxDR+1LM}K)MRkn8DQjU)?CnhE{eKgj$9vwr zLz9}k=OpfFMi}5>yrf!;gg1=uh#!e_m=ik_4<$IviA#x_+&P^-pT3diFsFBJe4 z#jFf)LxGacGrphMWisd)L4F{rGBG@*CWph#ouaGT*qsX4rnU&(mZ_;NcLBwx@9LCE z^kI5*_-FqlMi#!R*w39i2R5STm|_f?b>CG-zzg~YDtrQZ;aO}~u-(FjLaLC>k%qm+;&tqMTbFjE2s-2%inByEqfW9Ofu%PrA%Rz`%YLL2@?ctqfwubGQfMY z8)TK2T0Wwb0&2C1@ z^-6)akAtS4-t?KzyiX6WY2`_9`Sb7F#cNPQb#i(&YBUL?49mf+Yv#+9axRaf7Xbr%9S7y=_g~`!(%X66Z_0-QMIny>Yr^5#Rs+ zo-;=puycE_&3Wd`XqI!{R6#qMUN`X zG?r=EQm)LcGjis=E??MQW2wa^uZN*8tK?#K^rVw9>Jj&o(9}hy8x)HtA}^pYt+ZMX zlnqu0ZZ_llaG}lSeFNexl34uPH&`Vpn{j%z9CtSQ9qQ`kKz?4A^MPYSyyg}Nsx_=;1qczNtFfKkB z7u)0Fq>&#t*S76L=WBk&RN2dI{Pt$f>cDur5^m;Qk71d!4^8Jo5WHF)G zMJbEHzvM=eRip3GHpRRKdZ<1js}^Yu^!#?r;sglGaX5@@#gCW-WJ1sHVmH5OoxC~y z7I3BOZ~cq-fs{(^nxuoGuS>c@T$6PFN7z5IhXd$zcY_*$Xh$Vu*r^s%8D3L}4ibrV zA$pY{5V~Nc-w*?W&uxJ8o|3jBO2EVJOaelr$7cQL-)GpTal8+W-7vaqwfFt|-1}lF zM9n$A_`a=RQ&rNAWmyB*?thOyy>gr(B8n)rGK6Ci$4*&xrF7bJ(E$0&4rcPR6WVA0 zm&t@=7=ZRmrepw4PlR!b(C>$EOybyy;}DM1I4x zwOSGVK4slT_Q;oJ!d1O0X0(Qi2bGf``hvG$oi%qduu`jHUsNUT;QT%={fzAbq zIHJ=H|7ezJ<^oQN3m8>Jos!;&9ZlGg{}i-WjZN*_C#uJ1$HqjePwbh!IW>9rEz_I- zCS1Mv(#3jrx?0osv|Z=40@TFJB~3$s!h=E&fP~v_V5{*2lBYg6=dq~tzZzveYFst7 z4zGwh9kmV=>9U)k`Uq6UL5;;)CP0=iTaN+Ai>F)m1il(8vKLX;l{hxxxDN*`%YJM@ z*)?1phk1FOt;1Urm@jv-8cPta3B0^GE*4jDTJMc*gY>tZx9tzAdE0P zdeJ?-``r>@U%YGcXI_5!whnl^e!f<84A1`Yk;zA2s%V6_aShk0v8NVj`(ngce?=H( z#j=%AB{ef@?lOREI@$>xL0jkge$YOj#LHz3J(?m@xQdmDufcn(uy?#DPb9yC`~a4Y zrkbdbB;X?Xt9g9DKi|5!{rb>E)ue-^CC1_^;=A%9`Tg=5X))m%Z?l--!!_wbVg11Y4 z$q{ANa%m#A1Kg3wLmMmt__U^pogQy)lzTjomr+ zSlJCj8X1OKWyH7TXfDLqE~JuG=js*tZe;IwEKIeEh53AHVNC)>)DvFj6NdC*#U$dN zj?J9&tUSt~zFuPp<(~kGA(08h3@Rfa+0~OJDMJnR&aT5~eHG1T?7`Y-Q_GjD7B#J2 zyKbzdHxTG+ju#r^iuqm9))Q?<-SClg%i>)t?H7L1w6eQ?=dPxeJ*geD|Jc^}KdsFU>ji`Tpg2KYGyN{rDbo z!w(G3ds^o`@_RSlc5OB989{&T3)LdrJf+E|opTWb?Hb7mXLvn1%<|X$p!O4jw&n$V3Cr6i^TXtm`PF614wCp~dNNrEM2Hu0b z1{C-+=47H1H+-SL5Lghd^dl-JI#XInx`MSt9A^O?7O^D!fbW8N*-i z4qRI|x92M}B35Bvuj0OzyIjJxi6h9l)~#C=?i&+hUCa8ry82gju4-8oZeA5mE8*JO z@G5tBm04LOIVgTc+vhl$ON=H;yPz@FR~hiVpXj)8=yB@PP&u?31G1oi?5PLs0eqI^ zWk^?b1IZ@HR`pd9q*CFGzCh1+=yz8M_HhC9c%1ZcO*LCVJQHT-Tc( z*w8g_Z!Pj$#}fk$>F!)udwEHFZRU==?3U_y!=iSt7~d6$cx&70n!ECiU90<>mvd)vpld`@%v_B3r_u~mib=3l|xuE%0$KgU(#JxzN3;(m@= zgU2S6G6I{JS%lg~H)omxGwqW0+*JD(aTBOLeVD01t@-1ip&3*<%w(p&v94uq57%|f zwL&cF%PwfiW?L3z7F91o<2;YqLt&+<#?n8?%>HuB>;*s*)WsB2&yjqJq=UpwRiT!I zEh5#YnK!tX;Dbx>1Ziy{kMgXSq=D|Fdr7)LN@waWvG&da0&L9b5WzNGVBYFN>Se>Z zDe7grajqp8KGldb*?DM~+o79eBgx|&hzCgkheP7PaxUJ@N0y&mu3TC!B16J5B<}AM z+ndEevsm3MR_4UUoao7kJvs3xPO_peEzZ}8D|O-(_L>yvG}%4ZZ1S~g1zjd%&R^JK zQeVm+*=h0@uU0L0`4=>x>}_9v=Q52%>i^?SDcf`QJ1PlaiO%a(X-Eb}HXs?G_trqq zrsTVwvlqeHbtzQN{KXrEDUXF-gx%UGIyAd3V@uM-NOV#PB2tYeGgxEjTf-_k!X&GC z0Ri^W1s+dD@Tmtt=u1PX3s86&2f3IIfn2MBY{;;Aq*_EI?OIxUnls!pm8!9kXeimd z1dnk$RJ$5u*?6Wpovy8o)m1BXaV5q&kwVyI3L-ZGyvu_?-%Q}xf#V>KQ#hR4Hq}E`TLM%19~$9)Qx6v z10&;r5hDHZYO|ka{!BB<^CJWWa|QJ_!=YyR8o^TR!~*{>ES7vNUsZb`sLJkpX_*_^ zvtfP60hd+_2fW@@v^Q7f!|7x=oUB?K5KGa{^dq{N%2tXfWQ+gHqpKb#zmbK}n6pvZ z5ZMFQu=->auf9yOfn+(gWLfeH6yVH@mqo-=!ZBk^t zVs^95D)t~^jY?5!g1u4`tig^eYl7gs#j+--b(-a3>u2!*&oD!K?t!(je+~vQ5@GQ{jSVX)1>Q5Td!3Mx+r< zvYQ!i1G-*91?r*v)*J2v11jKlc|*49#zv*uRL{T}zCn-ATz|mvxZ`Q{g;0_QXacV4y%&K}l-QtfY(*v#5!TC{tU)cHh6H}4q!IryMpgKnT;P%Bn ze?@%X?>Y2{_}=VD_p0{rmc1DE1mZp05d`QK>-1r7#IV!-FZ)Mgz!;{)uyq2AXt#D^ zBuWPxPZ4JuQyMw4h>cZ(N2@z!5^1Nb4A&Sy=fIgpU^T#F@lrR$Q#n4?jKOa<_@xZu z7|)D$Uy^$o&5lDZBx4js8qFQ+(xw`lFN}uAWRIL7>Cw?-&}!#4Ccb@w%<=`0(n04b zzVI|BZA)Zd1(lAJrb&(yOWAV{mk z1SZaR2%JGepNu>g`9#FLEh1J$1RO|Z9lzW~`NKdRI>@Sn9j77dv{~&%Q5UvWz7uNr z(Y=;$4+VWl?`3RF*tyDtnL#1I3t+%A`B4YP$vS^@O={&1Pf2b?k^)aDlDFdXV7y9N zvfw4)jc__pOTz2x@w!Di7^eBU>1ea|y7qql9^Mb2$NV7Fe3q?!o{k879&XTuaI=;} z6!YJA`|sDl0WtUcG`zLX7w3XqEWjWyA72c`#e`J0FJs%kp{ZwntHSD>Sm<2iP|29S z23wm>cf1fZ;0Bc?geq650X}dc)gQGctxhYs&78X{33r#;N^%#;5|YcXfe|c3dB00` zao8?UK48o&dX3gjkhc~6=onz%adhYA_Nlts4Vm;kIe_y_t)9o1?OAjVu?S)W0;I`P0i#KPBxxqrU&$Z0u9G(!L866&9>2nW- z-0{Ym=20wO4UI&dAI@xg((W@B-)Z<6w8ew>RUm=5$lS(^LQOL1764SrdREj;WbK;D`}BRTn65o!h4~`;=P-+ z_a@4+710~NsU9KndH_J{p{f&dQo2xo6(h@AV9cpaya6U3GQFfRc?)WAlnRzX&WUWl zegP9h5QMm?L3l3ES;PEMyf`1BBH{kV>>E~u-4#$^G9baEJmF_-ml&0^QtMC9r`tk; zijbIWXVNX2-2!cs$tc18(!sFAxRv{qiOK&ZmfyFny0b0qNLR1O4RnfY!?UJ6yL_Rr zZCSW(^>Pfzr^P=Y*XX-uo6>N-JnUGqsP7472RD5YA>jY2+{AQ!R^D_1{deM})0T_4 zX#(zfw~{I-*Mi=+3fhR76a z!7Pxd_Lf;(FpCKk9F+|la9e;7-~;8F$K%-erN_+A!Z0drGasOR^a;Fl{jl|n^%AOs zUSDC|VBLcglz}}?6uam|>k^b!Oy$n6y?f&*>b*bsAa3eK+|)PZO{sG?rA~78?z|=R z04(2ViSbci|g*uAk9F8kE5IbB2yRY<&^))L}wzgXLTOZ@Cp&}<6fQx?po*K1#dKn_{ ztI7?eMjn>;#JcUskDR__Z_`d|u= zAA10mm_4H45m>t(y>|u{rH*?-M@#qsLCA1~K*3Hdzz|Cq(1DLc03an<>jQ<-q?a4PRQ#X+Zd z$0;s21?ud+=M?W7UFoJD;eQXy=5!L2Vw5|Da`;bWjEDgS!D#%E(kxz3zKu(b3C+Qe zOcU&v0JsFM@;zL$dXZ!Z1Z54>3-G~~{8cGb>tjt{2m=pPwoaGdY4%6N`w_4qev6^be{>DHsIDu{iXTq@> z$2J^bS`VNX1hxiqM@klJ7^?@qhc5If7{Rd~$8H>laiIBM#*R1bvOj9auv+Y4JY4Ux zkJ#5^eUx18q#G|UcCW^RDg#SxIWqBzxaxOrEkS=Q7vj06wbkpFt+6h0zoB;_^e+5| z3^a^4KpZ##2Lyo1i9xlxY#x9==GykX4s8TCyc+Yi4aWf-CviN712_CS^oG(&-*b$L zp}Ow0x>|Bm+bbxx%dFq&oDW7h@;!~rB_u9An90xOD)km!fdzBdSn=LUNeoRgMwi4eGNb2jpmlKNUn7G=adw{-8MS zdD^3V#t8Z88AI9tHh~$i=E+O7OT)q1>Vz*E&m`Nc12xsvK5unUc`;q>^?TxNwGn|w zUl4Qj8RU3<8H^0&q|1u|WerT`0N6djpAfDWaL1f}glg6Wt2o}m@eU3w4$Dng zT&11XLsLDQzb7{)zKNv*B}f^B*1JIL z(l~LwU1D%k7|Q~VK^zluT-Af%7wQEN4l0DD9m(Hzlnv7o5KuO(SQtV;>6!vV*Uj4i zRyT+@SQvn=u4jrkwByoX^#qCpUW>wPWWI&v6&Y2y|&R#4Ef) zvW^DrOSEzKM2dPsVt10si75wu&CeG&1=u-M=g8(rd)7ID=q za1Hy+C(O#c^aQf<-p=k0wA5hr7OfN*x~joH%QhR_EHXd^_i-liM(aRRRo(LY$Jg#3 zs)@D?H2%sD>Ux^vo~G4u1V1G}zj5(LGZT5?9JBNGQOm(fGy`xaO6!axI`u zPV8psvG}ubZa!9tF55HG6>I3QMhcE}Gz4MY1UYX$SFJx5OS=430ydnBiY@h?0`_bX znD-$ZlQ?$bK$R(7zmT2LUz6g+`kea{Ze~+Sfl}tkH06TjN=k5Ti~mvOb0wUiK%s77 z{TC;Fk)*FX)YmPpO&zX@T9h8UzrPD@t=5S*W6}D?cG@flu;Sg)kk>LWWD#MEQ0&&f zDXS5G$eI3|wi5!x(Y4f?eK6z4$d&M=9;{Jqkfq zviML*4(JVX=sh5%T~&|riY!n=Afyo%woBQT#<(#U5HXM!(0H1X&z?cYZ7lKwPS}w-8RZ$1{uE$|K|Be_;uRigzzP@vuv|?BZ z-D;$NL)-GX*$oxHZJ>?>{ zE27~fEp+|vCwY$KBz=z$ki1I2>8ojw{UD{2>E{?`Y-!*NAaFrm_K6KXfwINu5xTXU zJgb-gwj(c$!8zpmbw(t-WiAQHsPx@<*I+B0HnV?hZd$s&m~0P?i@NR02Oqk-(7EB^ zr40)kkt#f{9J1RJi?%NxUw-ehw9~Qsyb?>b-d4w30yAJr+h|H-KH#MyVjs@|VlF?I z%U*(-Wv`38w$T6g^`-H72{o4A2dFW`p1^k5$Z(yhOSrY~iLi)2bX*`(BZCa8u=cci z8idn|I+W@VdF)OQ*7g@*GD!H8PLfr4lf=>Rf36gx`ZB>-S%n)L?=w&{{_A4R~ETnL9rE|FvHXH9*B?__tYC!USFleQ}5 zk+0AGc&ymw8aMlDtEZp;{nN8Qv{<<*AA4Ast(GM#r&{~!(E@z-?`@c;0On~7^HhVn z;D}|jZn=|P!mh%p3tsGp)`Hvs$a*Jay_5QrQ;X-J=@JB)_MS>@R4U*U^D{Q7z#wOV z=uw$Pf2|(V2Fz`XF>RIZKiSF3*uzHg1IY=E2y2z=YEINDG4wPMip`Q!{Fc_%x%sGElfsbkK5{M9V2*dwI89r&a_LirG zIXQJg1g}j_UYj%rLsizy(vIXko1OK!obTy{n2Ai(76|&x&a_xP`#A&({#rEQFq@S? z+nOaO?h|I4WndY)pVm0R#>MQ<;k(j+*`t_=TGMOVJcJ-%K3ulUgun*p(w-}a&=4PC zS8dXiRJ>u6!)x+-O;!cTuez88$J6638s7upWxCYp?_r&>FkRJFkS#-QdqBun@plf+ zoRk3LszVuyOnIdWuaphmRJN_AWl2+L$`;P0ic6Ce3jbga_#C13yZS!(yG&<8FcAU2 z02ED`lKyTj9iSS?d9_S&jl^=*Pn~XpRq;qj1QM=+Xl=qagkb?Ff6bg4B3F*F`R1u=X@C_(&~6W!&_7y}oO4RY zG1qyv4cN?hkXc{iIma-6T zirH@l7sfibk2bfAPIm@d3+^kwXj&N zi-OsnY)5~}tte(O`vXN$FlM_kMk(d1W*c<3G8(ZwVU;xkjBtZzAW;{vHV-uq4FwY1 zD_XNW&Jex=n^mea7Nz=$RqVCZVgMpBxd{SUBt~hc2U_UPD=~ z9CO(Omr~2?^;q048(O_G4-x@5>c_A|p$IcEO+q%14-w3aitmhn?^ClgJ9jE?OF)K} zX6Cr{sATJY=+BR9SLkJ3=m{z`sAMLSJ^ZLy15gXfK1&5(-HFu<&GniYFCqmbh>OMo zsRAR1Er;6kyPw2r&p$Nt^u6_%I;o7yWUUj z2=sB(C+82nk5}dasJ{kB8;$`S@XP!*?9CZJ@^dl#xC-fA*#dbs6Mz;>hPaw!3rPZm zCNg>zCLq}G5q9AW-6-Wk-AJb@?Fk*h1Cx3phOn(%ZIxz+#M2nN-{4P@TUFNaHY(Xf zTtRO*Z1&omX1GwzgJ#8SmVT_lG}UXYS5QsBNYncq73(WxoaafN;cYxaX?+<50MKwb z*cs|Hhcaeq#hEi9GoB0c+!qZ^-kc+-x>5UF1i{jybmf3j&7K?-Q2rzSDVLMQya@z1>->01CXD z6^3-Y{+!*Rjm|a>%odKxHZJ8ojUbOSfylz-tb$NF*K`GtZuKm5OsyD#EM@m-flo&+l`1 z#>NJhSVzr~`j&XQy~gXX?Ybmv9+xfHG`wKpwy?#uact34PXbXZ%$IJ5K+)9Yw9kh5 zy2{;WgMUkkDOq%oTf$F{WGrU6M2!V27c+<7iN4#E|H)W8d`&qJjOYAp5%4dmQ`e%PO zF=e$a5*DPl-`IUIk~|KY5JtodEr*&* z-1Iq(amT2+4DxcQugJt7nen3zA_WNDs%6r2q#z9@WdFqH)RD^WyOmp7Mx8lcc9$2i zMI~ZLbhSs*>n-qEhH+$Zbm2h!fKfECmS_F1Q&DUN+kuYQR3*;&ulSWdEy8YCIP6w! zm^c~nyI*jn)6s!URnp^jxr6C|FA?&X#XVEBSpIdq*k-kknWJ^pTc3UB^xS~Sfq`4U zJb>~!5)klQW^PH77SJgS9Az>y8|~%xFg9t(kjJwxIaE3>EA`ae+A444N=9f1G|Bo5 zXp9*^3`#V?G6p!rJG1}ql=x#@J|q@Oli`Y(Ig&uuhYf;;S19X6$t!G>&n3=I-T~s# zxMHc$;)2Lz@TYnbYH0T!={kb-rso}~8)-foDnGhDcy z2-7LS#Aq3Zjg3<28m?RvM(bdoD-7LEIFBP{V{)=EOhMvLH3l_~Un*@A2N*rt!W}eD za)2btUv^SLzC;GLp5z>fn>H6AK;Z1)>lLtrCUd7~9eQlTi{YmBo=mzamcTCsC+((PX1wk<~&jQ#Z#m|1J2T2@q7O zq379D;7RW0^;E8q8_XfBu`_ol2d6S?Vc;)Xk84>TX99LRt4DaC3$qdtOS~O~5_>(6 ztWOrvJ?>f+K7-d|s5@l<;iWhb^(hd%ugR(5W!*CdtyNsY7 zBz*-zsL(lLoPzKY3#Dzt^5({&jm4?f^&>6K!yDSBeh_W$&t@0rqqC2N8W%S;ENYG| zi!M2|_3lSj){l)PmL1%*;gOM=4dU_ssjkHANTh31-@v-gn0PzVy%{SxjFmkiSMFkM zuR*_Dhn4F#O=~OHg}p|V%dP2@*l^T&QJxDd{KL1I)OC$PG&Y@$YOf-o@lx5P`ly>k zoyxk=+5RaJnwomy1>Gom5d(~LNyU##37dXXyA%igOsD8^(3?flJFA|eBF3U9>Y!A~ zKoDb*5{Av8Fi|LbP^lkP3K(Oj*j+y8IkISW0sMeTzI(Y5WZ&guI4d~=2{imfZ3(Ds zP}A;XhfQ%txuoc*-%un>#z;F4Ss%24mWW?}ys$1gHC4TNQOoE;m^T0TOd=wNe?B5Q z{B^XQ{}s0iu7Vct9QAYBts)B<%b&{45J-tetn`ve<0FMlSUO}s4J}n2!a22g3zT_G z5dhicTmD5RYo#|v4h|)T#etUUa(XmBiB8v^!T;rnp~Z?TZoxXC!tOUcd;1li*3~iE zNxW3`^c~koQ^|Alnt#s6`+sq@4xh>6)~^^giEd;YR}N#UW$jbA@+DNv5r`$_iE;kX(E?)n2Y;yzjQ!l(?Z#fjz|RE>;&pkduF zKmVS)Bi6fhD3e~cW6+st=?MMuy1S+(V$CjlI2wUlX7-1QEzvpI)4g#?13XgJMeD~# z$Ke+I9-?S8Iv3*R8*%eyXd=y~KhtjChh9vjRJ?$okHi3@B@<$;>JZmI4PSL9Nj-@P zL=F(THqqT+lU5D-36=+`(mi6tIi%E;RRy@#0cpI`A?=!UvGNDMOkPJ519CeibQ1$*@v4q$UQ_2OZd=*b zGQO+dmv3(my_p>>R@W^UYH%i#QLC>Dqwzyjt5@iD9@yMpBiC1`X>k+dLDJ{>J+m3? zfrU0}V6C+Zhc+Hg^6p8R=ZFAA;D>-YPF#5%R3K-V%1x@EwrXdH{gz4Ogzy3TOAuP6C8iCbv`>#wNt9iA=PcDL9MK~j%yT3F*I{3hv|x#q z5;e`pI?y(1bB2E+xjzeX0tDG0pUjH;v&XYaBGa5vVl@I;Y>hSI6}vcUKWA6a$<{E> zrbBuYm~#JsCJ1{PY)D&#xz+&?6-~9fL~2{t(1A6r3pPA5l;~{FxyGZt4NZ3q^4S2r>gy z1H9A;d<6cl)ZDI@UVKCBSGFoNifu?9V9cQxt=nuhsH6u>byQ|efpwObJ&}LvHj-zc zh?e$S9)lOBwA!+baEUV%fh+7AC<6CV#;m98UqNz|WSBmg^(3pwbhnedMz6#;$o2h! z#{$m=;5tRx4qk$z0v^q=fEe?OF)w=x%2^A+hSuWsTKI;}i%#?ogoH1L?T=0=vG&no z=gRi*j=hEDZ4t3=&_xe&zW~ zPro{_Z*6;ioKPiVXg>fdE`*9`Kh~zo1h3=u0wOa&00CByv@uQm3o1OEra=;$hAryR z4Ic>H(mC@Lf@WYioTK{Q1p<3}1`e;ssmaT>j!8PaFnI|`H^jD6hIEr|Qez4wgCeUA zxxyk~QxLm_2k;fMEV8MY5m0$?Ql2pve_<b>Ex!ez~?QA8TH@qq|{E?$z`_p{BZH zNoK@XhZfoIi4J>Raar4vyB9=k&PCq(p614$Oh81;W~jKbQn>gTjJFY}xQbqoPilbNqDV4H0ze`xBAe`+hg2H4Fq*map-Oy6eP~0Y47kum zy-_&PLn~4hF_~HRR5umMgSLl@+ir<*Rl7M@C%9H+UMyB5xD9397*&NRZHX1x4OzT% zRTEy2IWep9{CgXpf`BEBW<|1Azyuku5s;Rv0wNv|NBjaFkcdU>g5Ozp81X?Ow}8jP z3J-QIwE!?qJf|K1VtTra7O*_qxNYRVhfY8GqfoYM`P%mWrD2ad+1Qy`a94NA>IyXt zv`kJ08+voo>&0se^N&Ar<^{2M<74X=)_N=!UvP!f)#R3`zc1T0mk2#wAt{}Sf1oC93pXO)l3}))$P#hno1NA()(+m^+MOr;{l0G z1!Eyg4a7jI7zu?DSF5l_MU}gu&>#wh!GcmKR59m9b88yBr)0K=$XT-FEZt-^XI+;W z3t{Wb)tAztO6LnpfoiMtm}MY=FI{tCya-O!blS?nGK>MY zTevI^t5M!fPo>ldxwV5Rqe~K4vPmze(%`q%fr}THMgEtdT=GThbRgdJbJCQ z=!A@9K_${d6n>2I{164N5FrX&FGCb_R!wE9x}i@nhjp&D3}NZEfPH_ zN7Y@rn+HJ+>Fm)twT*m~QHj!&br;-nvZaL#gU}F1alke%zCnw!3EF75Tv4IY#M3B8_z0--H;4Y zoEuRC`OHxFqb^_29WlO7T-&pM-O)2!4h*dQ&Ub|AgTKOm|2X@>cfRu-?5_!w zSNDloH1yKC#;akbS-QhAxzb%w+qJWHbWzo6)&|&?*iA7B^_`++p;2&N+oQ8_KtQ@=osu!-tBm=LwT#?oet&w zj$0jyza!P5baW(`Z&5(i)oB^4;S(}8K5EKv3=+DOb#?)FQ?fau%PGaj}DImm4SPD22s3jsq8gz&P}c zEJ(>>4wG{n=bB&T+`?IU%=s*5iAk9ZQ0VaqY3F{j4Lo_pE#o;Jk;y!EELRDB6${CbUCPIxrSKk=4e6UcSk$B_@hosdx3y35W>#&x!@?svrpfBBa``06m;mcR8g@oyN^u<5t7 zK@G8sy~LOlXg&E!f=+g|Vx~@j@zEg=#&=3B-hLoFCH`kl*h!~=!4ZsrMJ1oLyWEga zB4}tY>RxL8Q)H1ZQ;6)b9wFmAk8=cdPFkO{D*LTsiw0d25Q?5lh{ZZ?jdy?S<$bdr-0u(;Q5^RhEaS@pyx$^u?IZ~s zBN7jbcsD0g1X-W1bisL*YuOy2F05Ld0S*VDXOLZPh(@<8!{#y5lrK-abjus#U|=b# zTpqLA>TsLQLAUS)gASh?%C#OFPv zB9#p1G7U>TLsQF2av7ww*L{>JG6_#JQz!Z+^Z;M#5>bkJhXa-nDDL{cSpfB_mk` zZ$b)*m^N9&kl7|C@iSbx-E@z3<KT5}gZN5By4vkRk}#Wng(7vwd5)@t=YU1N5$ zp?o5Jd5~LvlDj1U!ZPYeHz`63VdrN)1-=JrDyk$fR7>ZucTQ$YO~_c8b9SFk@mOpw z;k2Mr2EsN1g^{^Gu{s&Eu0;AoGF{?_BQKnoefxSdDF9OQV-mAmVMbq2`OH9;1W!krSXX&xT zv#dEV*N}SeVlp~E{GP|-!VwWqyHNK_*HJ>95(lqYyp2uJ{bcz;nKzDZEivh1XIdjY zJZ6S+SU^m+Yx$kmt7^R4HcxMLX54oLbN=-kcHUDHUi&pq2$=}}MP06B+kf@MeV&NF zXOSn_ti1gPX64bdAAiYg`N~&q%IR~bpS1j^cZ6jYV1@m=TSf|r*~g)eoJ8x!T`gnX zwVZ7%*cLh4+vEb?3PWm@WDobwu?G$1sB;57DmQQA0pcVj_S|$f><)Bz*5h$p zlNABERU@+-)(B|Hu*xlFxD zdJ_+lZPKNvUI3x`OWV_D@GrU6jQt~dl<~|^r0fVB27_Lrcgk@&c%-xnms=2WlXX1+ zwnw5vIq3Eq_GmucGa6jGwrbU2%hKkE_~2}H2U=%)zcuUmZ|+2}b!E?UxFuN~K@PDF zreol(G=vltYB-1kLGw98rDw=KNu`I8HVUen>LJ_@g;rTcRdRyLL6>~NxG%%P{_t>H z@Ax_Vk6}mhiaaQ5N3ayY_rS|uowpPMR8$C$lT(iCf^CR&LX16#ZfEOhbfqElBpri{ z-|~$}!I-=DN{;-CEI(uUIQ+hO@!WY4T_gUcOpeUVV@*4^6Jqga<& z%&~lTY8%RX&5VGx7ceBva!Am*(Db9ZLHTT*H3j}&**dyBJ_QbqzdT44MokPtTLQ#?j*tUbh6p!7Q`R zS7HQpyH`T<)r}m;FxfV|Z{*F6sK6ki+48G)I+Qj0@uNo*tzma-D<;L($;naMg7$ouVqMN5?Uh3c4FSvS5XQ6$W9l=dO%H1$8bcNm6OgdD zSW%h%sj!;-2180?9i@$tF>;NZF1M(vN~TdXXC4=lC3q#CEW(?UsRt1Xrpq8(q}2;S za>w$3MI7w>f;&}T=i~}W4@Rn&=ptZdtv}OfS&bKWSAQKevL$CrS{ICd3 zv*~|ox8w|A=g2NkgtDsv5=Pq8B1nmiGr^57GIRqqJB9nMxuMo+W=I`Sks&JLL`CL5 ziXj;Bnm$)PUuVe*ORadf?!7vtP&cR(e_g6hd8_W7Iz|5B{kmIqN?o1L5)-~~cz#$8 z8SSvTV0O)ONc3~p=NY(s9XQ@P*&Zdc zbty8YviY%(ElT$HcKFk^8+(}5_Ji-6YZk0%tz8%%5r0z^%8h(%VClWX8CNjjUm9r) z<(D)sZ&=(KcRH9vz5rv4mTm~1wTUWil%o!eG6QDnX^(xKAkGi)8XH4Vv8Q*yi^@XG*JhYTUxZf=59r-40+gOo$MwY zy+I|Vb}pg%%|ufd~!Hva78e-JwTEeT>WF6VxJH&Scq< zBdZdqPs!1joY{XaC0_B0SK%{qir34)-W-fd6!Mvij0J(_)pgQnOuT;l`+Z=-tGL zjL0DO07DB`5OQXVlmL??7eUy)sSbmkfDkvZca{AnDix?axN@G(P`M3^md(Hbz$@xW zh={|J_jvL)+17~xHckZ&X=rF73w|aG)Tc0xERHT5faROH8L}i1kn3IHk?{I3BCbz2 zyb8!3a{j=Do@o%MIuEB%&L!5nM36A#EKJKA3^-J@QBxaAy4n?W~%Aszl%-zIQVvxpzydRn%g)TX3f zb%@);p}C?R1LuUCs{o?=?jfvlIxH}rhmRCm`ia?C^dy+99 z+0)>?%QMM<`8iC9R^XQYhKjOdw#d1}ds%$yw@z9IO6!>2K>pWxH;1hkQx3BK*Qc((<$Bj&&&^WkuhBR;vE6jSW7EQNx1q5WtVhhv6-$29O;tYcamUc%D)23e$*+83K7F#%jn=h@1 zZU8<7Q~PREAXleXcT>*Ot{b6V`>dMNlEDPH+B8gtRJ6q5U^d*?o%T=Po$ju_SFyRO zQtjzbmD}TSSJg%X<{w2HS~ID(V7*IpmlZ~{S-jb_W9PQfR*QAm(y-$<&(Gd%TPT%E z415R!PvgsjrT9Z_;B!zTO9ykOa=7-190nPQtEm2yOW_4}=g49jA~#Euk)6EB(~*mK zkiglT)C008t8ha_g;B2WgV6WqH|~P_8wx7F z0Cu&ap#eIk2n6C;Gf)7U>dKkYyxrv@29gO%{?{E^5!!$*>#CWGP6T92j!M>tX|Z5) zx(d+UDYiH>!M;LAPq5MH=?E8>H3dI*AUzWO%;e-}CRYdx^>Zsw-d0UNH=EykPkiw1 zyU=EVI@TM^{e>=87*@LotEE(5S48S3d&FsC1x&0KgQAHQG@UcViIgt4bp))GACauq!1y0@gY`wH}YH_73p@NkZQUEFR306(S>oBZs3~ijUIMu~$*_0eIkNVz0#% zAU(TX0^yTvSw!3^AdI})JH*1|hl+zM6;eRPZKM=c7E#g1kax$*EgWZz1|q0y%w~@R3u7GS1_n(loh>=(638-0!IK3S2vN9ucsSfBPHy5+J@t_ zY-rvs_#qjNb z%?I}E89gZ>n}s>ZrjnXXYN*Y64Ym2VKy50f92i{$?&Ty}65Y@SGyq&MvB26E1md@v z0!C_f*n&mPw@lnrdD1I=NtGMVzy?&gvcg>_LJ7Ou4X6!=wyd?yA5_*mQF9~`c+-Qk zlGPVs-IFUcYAhV%OC%9_M*Z@6`Z}J2G=vCwCL><)iC$R;WlmX}CrW6EXS6zYx}BbT zaPo^^jCZwkjkSj#d^Fh7)*5{93*nCN>cJ%|;$3hieEsXopD2qBR4GspF1chjYYqsw zarE3Ju!p!QEf#Bo=)exT?gyJkT`$NB1{_ih!cRJOQc9o3Ib|^l#~=rYTP=*U8iV;) z4x-^$54sYHIjj_F25Xd>8nZi~NCrYT3`hAK<-<{Wew#?nle_{8zI3em zJgPjYCpZhw!l2wT)C{}w>3VTIC0Cul3Bu_rMV&_2er*}_SW`9?yYFyH`7MA#^ zIn+2H?s@a=t{rFBN&2TxJk>qDqFEU_@kHmA;U>(I9?nAL!~}q6vvaegUZm|FAekg7 za%wms@DpK%2$V(S1l3FGDrd(K&4$x@2z6bfFZ-U`2ic?%DQ zmG{H9!pcN=M_7^XldpxtUbr94x}pqCnRN>vd1Q{)tf0NJkpxIefd6R5Zzb7Atl%n| zlpATwkCNwXk~}AjhfVcjMGe{kY>q2qF)@a^yV^kamIqqnHoXlTVa7f%-itndWN_O+ zZT-@1{Y$qG)cy#S;+C4Djfu={{!GAzbQzFnESWylv7pkZ!{Ny+2e@pa|Bk-(2%UnEwEEy7GCY(FI5V>q70 z@j4FJhSJBGU@YB565)E-LUKPh4eWG0C86e2vV_B7ta~~+5oEvY6dRm-oXX1@+A^JC z!m|brjM=62ZWm2!|7Gh}?f1r-TiTKtI5oo=Url2eDF=Tf_O9L3>~y#rO%j#lJ`x|<^B2uYR%g@31|BMRAras*|7KrLWnm)W-DF~%yc z47eL(S>>uy+%H+gQx>t?a@e9+OiW9}Dk_iaS+KU!DC{8&#Zj`kDk5!+@mn8PcAEDBUY!6Tia0Y+J>IA*H77q8Zm3JYwUUessV+ZR)L>oX#hX$&=igWjzJs~ zIIM8C`Yu2?F}f1MX8^@}hVUZd?+`FzfEL;Q_ftDiC^!+MMcvS=)xtU{B`PRu$0WOmPo`;FyMZg{>L-CbqU5h$+O2PQ$CJ6FP)8CrWJUJ zQUxu5u^#9WmKB-2;ADsxoJ)u+aq&u2oI^l2ptG*qlC@d5F=LkYie5LaN&I?6`u6=R z$J;mklcku?7pyDY6XPqlIc`hne)j86n)l}N`E0Ofuy+8fX9HHxgUS~nD$-)Rwt75P z8h_SPbp2k16s232aaW+jc5`50O~P}=nFp|p=ocMWMi_yY^&xh$ya_w6%KnUneTRm6}Gig-HOb4LG&m>TGG?7Xa z&}&q^$N{WMv&M-UPn4HUja)XBVOF{GxojF-@hVjDiqxPbMxjPb)QE#M!XNaApu_9} zn86hca+PR+DfABDBAK^DB#ccW*!F_slNWLr_K*sc2`R-;3bL9~fSfM1 zBU{6{=m19D)4?(KY7f2+IY+Sf!Ik6kiJH*<)GnV@@L$6QG4f?{u8w$1drLZzZi&ZR z(rICCQ3Aez8NGeI-l*Aa4g>;G*=iR*ihnxliMqnvGn{D(;Y-|@VG^o*8tU^U2Io&> z&tYAzhB~wj#{nECaX<)eVf;|9`LnG~;OteGh?i^r7^<{AW^b}(THt}BLLFNw%H^Y2 z^#}fg-ln@7H@9}|Si4|w;Yi1tVxKjZh+C)rbY*=$)j5QWY@an6k6MHE!6D^yPFq!R z{j$|NllP_{^1``3`!C{8?+Xt0H20=`3Ucn?ZdWYR*qb^62p+LM(WlKI6mh1;w1>F% zP$=DZAQD@!U_pB}t9aW* zO{~Ty?3hbgC8%OP%TuaXgH+E_N4Jrn;2^Y-P?$l#SG#dO-nX?+`Y0}Ui~D@S=8O82 z5x1B^l@9J;=-CYBbOW=-*_6o(HaVSksdxh;6Qa>6bJcpAdSgAk^akxjPdjD!tjk(G zHE`g8T!YiOxHx397W*UV>ZE_o-`90FC(Y5`_Quqbj$wy*z!b3wZ+&{1~v<kzn6abG+q?4U5H>^c)N20bJ0u&K+*gv0%Ut@Cu+T{^L0o_K(W307xBq3lZj6088-` znPhSNZ6;Z0;7isA^P29}E=5*>O6h}wNY{)NM!H0=B~-=w73)?r&0e`zb0+Qv}=N62-nn|hT3=Y-~WAb z>sE1Cy!pz&-~7$Mw{Su0Zk+}{e;WaxU(*K3N8{rv70%O|@lmetijjimY(wco?x6^I z0`q#Py1|Vr3${zO4hiAqL9Dbs3?13yiYq~rMNv{f|1lT*re=*_?{p8lr%)06dZ%aD zGv(n)U^p;^G(Y zr!l{}KeJCCp3$K=Kq37@Zt=mWs@Uz;Sofx7Pn{IXs3NvawGTFj0#=NU2?(nLSStzf zjvSrOYOu-_^;VgSMh%uKJCk*^@?&P06lMq?GwL_6c>|4vW@y0@WF=R-8l{tEUV*Vj zx=yhn9R-=JW0v_juoF^TaZ)l-9&y|e8;xtV#NwzW1|R~C0UVIY=OC?TkiCO?F*(?t zaL+Mxtv*9jZwmJ}O#zX`!WPj` zAlO>n$kExzpirZ|2k<&|Bi+l#NH)^zx0kB}6+EFg@+P`?rK2FFy|JV4yQ^3D!d}`7 zunoA4@LeY6|I2b?U|HQCT5{}QakTlvijMv5myh33cFcxlu^XEvA+Bo6B4&*PjWx+% zJ`Dm85tc(a-HVuQH40M7nY#hEGqS!^3-5Bb?J&Y%%$#7p6MU+roajd79V{5y@xt_f!Rc81efE&%7LAaW^Ysl624~oOZ$1g z&vydndbXH)5ifBSst-^Fl)WC1^9Npi1Le^03Zd;}fZiK=c7p1}r0^Bgu|o=3pir6! z?101jT8NNHsp7sie+_ccl^0cIJVr(aXmey;QBzgeFT!!*vza^&7g9Lp=Zgs|>-5QW zMTM5jNncRf>e7}Kx{%+I+QmeT?hi@t+SNJIRFUBIjo&-2Wq3{A+9@l{4TyG)Kf+t9 zRhXl)FN@0+aQ|$3360-V&@BNqubm-cuGN*J4nCvTm236zncA(rrMocGXCM|cjHw&y zNgtl|aYSldy?xz%tt>Q`>5GdD3(fVRuLqpDeN<8_%NuB8qBjUxW!u6i>lW(vM{^+e zkyrudQICL>&NW_X#NY3eFErs*)}K?)ksRsZQ;^cRjw>B_(~$*u;XV*F{@u4g;7@on z%Gd499nC5D1%z^S_aTbgOWlxK;q8UzP~l$p;p+KdKw={X-`&{M*z_MOKG%^*j&?N` zKcfEJL3Vbi~8Ga9CK;0{Y=A31vqGhxovLL}oXc!S ztPQ{n5{1sePNlQ6R;K95^gOI*mLM(ThBO?+b+a?%%s64j%ap07B?oN7guF&_faEz4 zOwE05;!2x1+9uAliL;HD8cBR}8tF)bdklqmn_Oa#OB{2Em00ry@!n{?C^@~-Ex~}0uDG;tl?10i6 zEQhib5*&YoJJ>}J>3$MfD;7glpgt;H$xuZDH%cE5yvSuh7h!2S@F31h{ef{jU_90B z9L(oP#zCmOLYANOKF6pYnvMd?|1BV`IT-3q1MzQfcYUZ#-6 zo?tn(kJvD2q-9iL7T{&*LmM%)X+lXhbgL_8e`}eS*eJ+`f*hCe7(AATUiM_^!Oy7Y zahI5cmBe+0^i|B1ZM}+YB=b8xQ&T<56JOc+*Z*_hpHwd|FXkghjag}-(Y#k_Li2ZOV4w@Xm;W&_n>OdN*gPOL*!AKO~p|W%` zje80N7}gTS162*05WWM-5GIS!Q9R=e%DT7e8HVT-YUn8C3&xD3d=TV#Qb9i>HS!xX zk-~Mh0=tTqGv(2(^v@dDhy@s3Q%La#U4FOMZ9yulnh*X3Qk?YDT50Hsk!-aW-_tipDL$X2YeCA`6L1~#>Z-9i#`Lp8Bel0uLxn@o0VD{zp zzx?ci@18!*EZW0|^*ma-VyEO@+N@2Rw+hp?gF{zU1|k5)!7A2ZD;A)tZ*tATBmOu| zG%%UN)KlR*$1-lka=Lzx?;+pE=~Q3M#l?6wCBp&gNgk4WNMu`}R-PqE7|GywV9%lJ zBDfpLT_hfm(l+-2S#p3cbc1knVaKh&KD)l&v)gkR27Y-yrNwNB7o*Zw+7+@N^CNN9 z6L18C!)4ZCl0mbEH8BUN7-+^6N_Lj5Dx7Ir?{j3OpC*w3mrwJmWy!Q|?)ak^gVKw> zPjl-&N2c*b-cMOu?U#A_fCYzXsBJe7EAurjbZ)YfmH%tK)_2x}5@lj|&7v5WIk zwl@X^N=N^jIA+#0x88m% z9R|(fGWtVv7D{F2AUX_`a9fABX>BXZ5T3vp`XnjgkZgUKC}OsyF{8U!AxTPhF`gQRnIp6%icBXmEx-5=LclYIVBM01kDnZ^_UZ%DC+x#vZsEY zumO)l1XkLXCbpj&DhD{3(~nBF~Huq;@jt_Qh(*mK5n zY2N;kTDCDqk~ODm~%)m8`YCGy; z?oY-&$ zF8Eite$dCbGh)>kO$3-9=ps8BXQeuuq2FoheKR& zTy-b_D@k;p!+{PdSjc-`D})KmJT|A+zbh7C#3Q&^j=0zKtJ=j%Md=hh!|22_U78R(pbe-6oKwN^ zV2t6Tl4(v%cq&aecEF0ce#*hsf^`T*$%*$^p3dva|w|&d? zj`aESp2WO+Vo4kAw&tM6%J6Sbes^;6yFX*LtTqMQtML4fWB9XWq`V0+`~RQ4H;)B46*ovJMo7nO2u%De6+o<%u@0?q=tA$9M-+Jr)^^^MU zx^=7S-gEZZXP|+0FM{r*@F1_L@IXYfz_e^E;IIP}y<~2~1i4`KMSYDvtbG(#MH z82OOf4rZG>MsN?nh3(-3;bUR^fDx&1IX>tY_}Xp0dwe>6hkiFaS2xB?p?zZrR)r@KyS=1D>*ma(@k~y)o^1;k?zM>il9l=38c@3-x z6QGs)qAR?_EG^`D`Zt11_K}=d0RWfGZE|6i?Iznk)G^Lwi0q=r1Tz*(WamzCRZ$p! zlDumhz)Ts^6;P}o(r1ki66q%Om}77N`xeSwUaY0tHlLZLQ-|ah! zolu_RfM%bPm;6U5Fg-<4mNCHjyTeCWRgYz+HWg7@T1wAEtqMN4arH-5i$$JfZR1dD z*|u9!z-(}^KbsBKcw{v9FMeusD*J;)njbb_@3g@X8MyLus6O5R{zk+*Y6@oCL9qMU zG4P4@O#4W?^im5TWnltG2FC~v?AEfu^M3lqA=n_g4co}P77fx9ElZ8L)-DxXvz2Mv z!Fiy(bor&aD;|~JM$(;e2C<-xF1k18`rlf$lCWsRB64(e< zVAZ^^!o9`47sYIIE4U0YlV|QQ!Cr#9IAq&t+c-$T3Si1hbvsq{2MG=}0&GYD+)J>$ z1>kXl=UNHcc&!uQEWuuatpNFhJ;!^1P?*o*u+k5(aX!Fqf~jFn#q`Euv3b7OHD7G# z7sva=(|uyHQ>^b4_cw~2Wnv;IkWS{lqJJw^vK6qwZNae@$Nf00Qe8s;|5j8Yrn4&} z{`%uA)?s(R%q0^uLU-4cRw)jcu_A)Z2eYmg?@V4leVwPuUDw?Z*?G&Y+lG2*!B`;CRo~I+DQ~VGT%f=21Hbpw)3D-1Mptd_eBmV_P91;X zqaBzux_=RJ@*!zYAkcf`i8ZghUO3yZa_PcI>k|h$t{+O?3#xS?N_G{ZWP7y<@q(n< zSNrUKSVs(Tex?g`d%Nh=NskJkZ>BE@qgHN|Hj4)df&?CaIjOFuToNPlmoy+MxrUiG zbG?0Cc$w+V;*VgK|CZ4OMgND|4mM6S|lh;89vG z5hpvH-&KDIp@X^IJ-Bd~;4y;J0{|z7@cG`M`-kxPbEEu-;N&8J17iR?0XB^;*gYm5 zStRg=F0$h~qut`m{JzFAmg;TkFNyY8JRV9u|{!F=ZEMXi@WP zQk0}B*0)vxah>qY8J;aeE9-c`WrWdGCSeUA$!)w?KgO@WIX)I?p5NlBaW{5{S|<8B z7qvv9$+ogUJlYzn2rq1`9t_tmts7WBl-PI2ct=w#RUPt_CF8YCaG8BgOy8WI=u24b zW!p#EH%_+K4xao&cfcGvy;l zZ+^ZC8n%w$`OaD4unDx%Wb!sMo+{$*>})EpDz7LHh02?Jdbve!D)KPuQgCl0rQWUN zQE`HM32ue4FBc;?NN_9nNYK!NtC4ZtL-0KPs-CX{c%*@60Qr{2LF9Ht3fFX_=x-D! z8pO$%h~~f#y?tq;DmohUSWcg@`O8X6k795ZU~p6>g1=X5R=|ig zh489u=2dd|3fFXIQ*at`&ALMB{8Mjs%N7~>*JMUO?6#Y1(a^x-acN8CUw}v>1V8-NJ&g7 zP}+>WPZn}(z^E1Zs?E-vRY$n5GMsz3Z5V-cmEQ&*QZSMuq__kCBv_`dqJHzL{8hOl z*j$`p&@ zsxz9Z%Blr1GSnw{(P!X*ry-m|@pwKsJAY+AOwMoi!;ku9NFC(1A;8KKg0~ksAp1ZK zT1hWt+A|{y*9XQ5KQ_~Fy?)5Z!{x#TX_c&!f?`kTS{w3kb)ai5?W3w|Hk+L}S~wKU zA|}(GsT9(hjY@}{uruo*rCj}Deh7OLE&;OV5Xa~iL0K58%QK+Ie7|b~ssfssi@PSd zVKsX=H$#uy+S2JJ0xiYe+EGpKMW$;#rHSnz6aK@-l?o0RUQwN=)AOMX-&p;vtv8FO z3%1Yyxp)WtG5QCx`(LU*9-XgDWOcFdPqnwXx>>XFMeG;J`#HFMBdx*l(_ zdjdo`MEovbTD#}!KnW>c$|{h)-0H(t+GimR;y`COh)RgR zkCgr#!A1&?r+`J}Z!Gisoh6;^CD?`5$@~ks)8qg!kzux5kR=hug&rB10SL_-==qMv ze|qy9;tQL8`hhcR&xjgvuJBQ@xp4FFcfM0H>H+P`29|7;c7lG~6xOFAPIAwJ_s~QD zfA9&msgK|^zn0>cOP)fZK5D=6|oQ}h;x zjYTuQ2W=K`ae%A=H08vok%oLwP-e)yav?6E8iC76rZi`An9H}B?Yq|P^}BR`*cpVa zM6(8iVI6@h2dv^0s7en@1QJ4>*ctKnIKGc=#bxH=m5azg~t*{OwF znIVG11T9cw=N<*fZ;tGtTklz3m{0Hk01{H|bcr1@(g=kW6JkW5)}r)&$%uex_|4b0 zKVDIOdh53?3{SLvsH*bA(_*{W_>)ZLCxurE-}#F}zv=G&4Mw&TBRdS177&AKWTDk= z%X3`u#31k?2bWw5h88c|KLtSk0LupqEz$#7TJWg1AeS41RvN#(n+V&_d1!Kej-DD=;UPYK?x^0`ZBbkpIwkOL>^`Dp9esQ z=_3L0yiZ_qQ(RzbX+zx!*CR*6<+fKf7!!d+ztQAfxo3{n<}H|N5aXe;N4J z%mPaH8#BoV=VnxAr4mZNl&^yv0IX2SMtvIHoSj%9E}i#c8OJg>%UY!t#3X;I>^0v> zV+YrCk6-tAZB7&*L^4Y3d1wv<8->HEbP<4?F$;1vh{M14rL}7(3P(@Fm-O=AtAa`T zU1+c4%0v1Mqz~<0)m|UcC##0A*lbQSm>x9P@X)6E4{0dsoLMLa*(>M_i-R*)9X;1U z-c-!GJoGJeLMhFHVUp#`425WAvBmlgV}%bqE%rVBmDNj+91$;1Ocb7_k_C;e12MC{ zQ@Qp<1GxxemUWGC8E!cP0NhKWewYmAazd-(gjU4~t%?)+K_}^r=3a$$_a#bAP|Je$ zi@lgDJDE#Z03mXS&T#2Igw0!2&O(~*fNn$eL{^eFLS!E1+^C|a#w+cnVR&r9a}()m zQCbWd5im$&LpNJlOwMexy4c;ay4aS9U02r^`yiYdODc>pm*gOZuS)xv!ic8|7Fh{Zx0W5h7mGTd|< zPBPEnQr=x&iJjS|QKz%Kyj(w{c-icZMpEZBk~`iow&mUF9ie|ON$|J~lA9ltP^o=W z^*hWmq$A|3M+ju}-8oL}BLv>C+L5VaP1>0&z?&<0$bD0VsHlopxgC%Os;a_VH8Q`D zUd8YsQBvRuNkH(hgLbB+b`U8;^6uJ7yns79YXv?&t)g(5DOAMEh$F3MVyXe_GzzCv4+ zekP?g3K=_A(`9<`Q`w6%{0WhZIld9o`vNZ zw>9IunR}ZVSBA1eE5TK8weBLmbiPv=VERomnd>BLu4ZNK9?=l5 z>lav#oKA0pP4{Y=m%f|Wrb@^qqsVgejh0?+S!Bj{ z5e%wg76S*$e-$GYeqxQL7iK!fP*Fh}9jWB9w+&c= zH7&+yUn4JPL5W1~V6*Lt)<*Jl~~e z?w~ny=jv3=nOo`ZM^4X%L7|1UfY;~6>}^uBw@J=hHG36@pPj+EUT&dN1f0LJ_;79y zV+|G%Y$Z6&hHd7IT|saZd?$CP7ho?e4_FJ%4hZbhijulHXX%cPsMY`4w5`0ZZ(`dH z`)@mMtD0@6pYea_h2LuKZj*C2s||>|)u^RdR-q1k%CAmsW5O|6QbOi>Me$7Rh*Q#B zr)6>$O`cgYGRHGkU8EhrhHmiy%(9KtZ#NE*Fgn7n_x_;6gZPB z++CtBYp?4N_Nv}UueI5Y6tMO>o85v9#Cy$W4~J+TGHmj04?CLqIw)4Xt)Q+F@HVdj&umQ2|C23&{$)N1$IF#jZx9rG@16i@IO&o6!ry7K#UYw~H$Lqzt3Gqlo?D8G*=}14*ta1ZUNv8f` z7Mm3D+t1_5k`bE|rh#u5{=q#HrPYC5k|T z5NT;A$8t+4m0Lw<5`96nJv*=9RCG(^E>h82 zQW2`Qf*?YDRbq2QK-G_gQoHs0iY1#6y>7RV$py@mRdaUq+@Y35xQE{)V3r-K(9amcQC^lO4VqA?5Hyo1HLUJC( z9zzZh&T<%z0OZ~7N`@XtCs`!F217;5@?+j9riY@xTh2{z zlw(BKiQGB@B;W_1rVJ>H9ryEWk|0HS)rL)OemuI4{t0)JXERTYkpdFZ)y9D64G7!< ztw2H;we!XJIOt0T=SZklYAxpYlp&yISQmeO=k9h{j^}GjZfFnG4Xw&LQ!ZPz4LOP; z!QI=$-wPD*`jIU=enV=}=AlY`Tm(}KF-{ib_1KMBR9(!q9(QwbqzD*`Q@aSyYHKtIwd9<4#+sce9V@jE=?x* zdX9LPuD?_$Vsm{wa5|uGlwyl$uADXXp8eDZS-UPjIoAT$buUJt@A7Xd~1vi#9L z&cG)KRuNn^WHVwTJ@8+5paF>Mp8n7h|AM zN(3p}3=Y}{M>Os_a5p3vdoa2nR1|7A7j>@z@ zQ5|I%808k!wx5AAhQUQ81y7X!C#4%022qO&gkBP(Vsgs4T+~!E%8@rsCKZUc@d+XQ zKV|%obO)V{@VXOIP!hofn-i!IiywB4$d5`xnxwn;T40L+_OL@8u;{z2Z08`5x^QS=inhbZz~)q9h4%-)yz0uC>t|CGP*T-s1*kW8Y45)>X@Gzx0%1_NDd z6xC9b`Z{Q3-5B_C?b8YePU@+TP(^)iRz;l~;XWNs*cf_(a{xtcQUHPjM2J~i_*?~~ zX$wct5ERpX-Hoa(77H{>4&}c>I{>I!>6b3-)bG_F*1=w#)JZ=t0eCEOmOdX7&`m!V zrB-?ea{CvT=jmC+G!3XdSiO}p?ZZ+ry?bKo)r#q{Lx1r-L$3tVhhAFN6sppmQ5Z1w z(z$)tR8H@sEb=Z5HL@WDrINl#z;iW<=JwN%8SeFa-Kd-pi+Png5d>V+6y=~QoYh8K zq~efjLpV$+h2fkonni|g8e8{nJ+$}Gr%%lm8fpf$@5e~T#owxahFQKq?!^<7T5lp4 z0idWD$Bc&~Kay)h4#^>;3jRZIEevvmXyQP!~%NU3KX&(07$#1$C1bD$9=SWj>d!EP?dn+bMPr$0onnV^c`egav! zWiwTQ_d6bA<_WYDH=_tE_ZRdC$~{UjL5a2s0DBso!&muH$8!#S&?1l@680OzK7;IK zu(f8OmE?MdWRLK-YioKmS?+w>|RR8b_#SH)FP3u4LC+lu3AeIW*t=tAn z90id)1eO>RDMci*qN2=Dm8JZ=-&nL+r~)Qe5u@Sqx2>pXrOBWBy3nNy`$G?goz*o&bKim9olM%>{oT5&)N&iLaW~@Gc1!ZI zrq!x}MvN-vIKh(u`MR(BPH@BRDnq8hJj19)B>T2GJGto?&(b4?1IHj%9wnbYwdKrv!&h>tKB}l zV0uyg{SWkSSWpMY`o$<8-m-G2@%Yh+BlkMJe#eq&mKC=zsT{1u_F)JZ zt$;P5N^8!%CJnXI);cDT7c@I?w zEa9+n_A=}^MTtANjNAxy{H8Aq#g@l5a}T(bO2N$pQyAUcH~^URWNJf7-;fX!34vrt z%`veQVbe;v7b)V%$aI`Ea2H{Abt>ntZ@y!6!PenAmH}y9x2WEYRJYSNRQ0qqxIb6f z*H&Hla;$ksyjItgLgML>TNXFePV8ShvB?)Ma}{2Tq!+Z*54DwJ7Dy_zUs;=1qW$XW0GLn z=W_(B2@)iy)d2bZ^$)_rf%$=U+41`|I-j*qJ?qy$(OT8-spHQf;iqvE77a0enjfuS zt?nRa@M_RQShiucY6G~7agW3(e-4meJ-TfajN#k_&nLD`;5@N%wjZ*Xx@^aO)sBBN z3lIgzagY53!4*6M;M}Gw_%C#Z9;ZZ&YO#w{s4H`Y4i|47U*AWtil9z?P?w<_-Wb*y zQX@n2mt*d6JBg?wGWUAK4x+EQR9NcHQUTR&V+dRk%|qO4Fcg$%G{#EOFf* ziTL5};g

IRa&ByecA=TgKcbw(0-{7b(XaC2xSwKB@-T57Lk9C?$|7iX=N62Pu+F zQPA5#z&fY7X|yu`JN56Qx#}Fwp)l)1akL77d|5l~;@T|;sm5}WRO$od5Y!N z2jD-mM11CR?(V4t$(E(J46V7lYI*gFYX77zd7YgsdjkoWoRozQF`qvKCw#HvP z;EP66eaVT%1OGy|-dnIguY!=$R7|<)ipg<^p}#Z6IKZifnU_a!AlME$m364abU4-I zk}-*9B(2RPN0RUj!sr|$Qtl!+NE|SRekK+)j228im%9i3}2u-ddhmgvQ3U^bJ=5G{Qy``=WW=i2NEBjA}@F@$PB3rwI zqj!w0-AS;4qqH9F&Y?~kBqy{6d#2;%;^}gRA#e7G9deM0d}z)PF%ge3NX((sCi<9n zUB4-8GZHpD1@=}`<2W#5hS{2pQ#f6 zQ?yxwUxL|Q`H94VQMLh!r|0B*v);8hJNvn0diWgptSNbJGoMSaTAp?Cj3Ci_jpy=y zCC_EKu4;K-7Wl$_|5nrLjQ*h6`H}8b-N0R99?$2kLLyg%2UP@;xH}=oY|sCmW#m~r z`$>TOXm)iLKMqo6dlDc&+P}IeDgl?ZSAL@pNgCNIpIwKR8C9!}kl~yIDAI^3Bm){z zg=Dll6Q<~T6UQ|Okt+XS&69A#$z^d~{GECR=|nxF#xXlyG|rzR7$Hb-2A?As0Vw`n zJ(uGpf3NLv7yFsNscv5#$%+IL1jygiw6BR5IQl_=;yqLw)|hQbw3+lRGpvt74$CEY zn^BX_)N@g8O+8oiqfXJ792c-Q4OMG{IO=d@ag5>sYkLsgke?>;X%e3%@o5sDCgrDa zIY5hmcTk=lkYm10jX8?)^r|sGC!0cW1Ry_BwTi_w&e282~Y&;_I8RkOs5^>Z!sH2EJP0OU10HsBxTIjrk7$Ya_v~ zj2?kEw)ZN({<$lpsSEJ=94@y5K_mhA*%tHcLd^m_)760&6XKtifA>QZg@50MWVIo=Ph$_S( zKzYoa2=N8Kdg-seopQ!e$J3NETs|3^qU?`SMcRv%j60)qT-F{1pm8#E33f2(2Ur29 zh~K%&#<_-J>FsFR4NTvlpu1c|eJ-Hg<#NFJnx2p5U(q0WQp1Dc{0gav9ijM^TTjqR z!1xN4K(v)-hY6mw@k|1AH8;jp`8a`0DY}du*+L+#%LmcF+zBFY7r}b2Y-a#2=#g-w zp7qa`&`u?t5@el~v*b`ioJ%JGKtK=s1pF{js{$IJhcQrcf00T<1!TJ5tJqy-2*J9} z1ojj3zW;vFAOG=0`{GGhM7o>&usG5Uf7UVo+g&?`maNprQgzkc?M(qZp%Y?PEoMWl zcuJ81%_8|HEa&Li79?6$6YQs1El9AMK(i>15E*VE{YjyQLoW!%2^iDBsEpe<{Z9~# z6PyO1z-7HSgODX8jhP~9ZzG^>F@L~zjEb43c>$>>NPw`wIN^uy+`@!*Hmax{NH(Ul zYu9lkwh-(A;8&ry4rFqxV4{S+5e|T?i<{emrKCt&bEmE(G*gP38}~2GRU|YR?t}|r z3z{9V1ezdKn}3G$^&=pT{7%=sjLB$#HZ2FW6?#Ch1fq?qDAmWaDN>I^yt zdje&Z?k|5?g#`R=G}F^le*H?+{jBeZcCjwYMgn2zTsy-ggoL@A{7l+{OGh`|^(s(C>`ODpl z77eAd;ZS{jdZ<=Uhearq9%pO?r+7V+X?RH z{`Gc(O%#Z>U?-c~O+n=T{>R7^_aG>G(`fCEezCDftm_oJI>h=a0e`r$DsjfclB=hR zmGmT{Wi+M=b;azOFJkm$Em4K^#Qf6wKs8R#;%fe zI=N}fwbWN~v`}sv6+?3HB%IFqD(iX42S;*nBNpjI%@CA5Q7LZ**tQ_souA zJ4UVDoDyc$_EC3FkGH+a=dGwnv_}QfzJJC09j~78j(Bx>w#s{xSAWZU#jC&K{hU`1 zcoW8_UT>n^BHGnhqx?&0q3$_jejJlKcY@mpqYu9EdU$E*?y%9FJ{5TQ_ z0h@hbPKI|kjGR{ZO-6Av;^@VJVz}esb#!)pmSUq!L(nSMDA&VP;&hc5PY(*ice1{0enUgt2k(#ipue2nA5$>agFgl8`_n}i5Vz?80#w$c zSnaQ{JSDX6DpX4Ek6BN}Ra}jv_eX$B>nehfDnbwMk5O=3dVl1s(%gXhw6Ntx*l`T=xyrVMAh8mQ3L)0LLX!=4I9|!*fHaU#2J}NT-c^aRxbk3W?LU<2Zoh7>-jo zP{OIzKSZapwa_%?dt+lP-x`ah@PatICcoGzV+m$GIwZ{|LnYW1fArrhp2WONq#7k> z{n5H|EY538^S^j`^0^1s6sydZxB)<-3}9ohOzO^uXkT4Yly$^x`L{f%} zkf zQTd!u45?1oMg=k}!oy=o`aKsz>low(|2y~*#V#=|L6O?tBRs>0Jor)UY7n5I87Ve7 zg^h5g!xXBd7u{Z-JqBP*Uc}%%uG6(`kA7G=A6_dIgZLIN&I!lZI&pPC#wY*!A&`p| z=-*0m(LXK8g?gN|(zMbkHzZZqS1MPmd?J)Va7Y%r3j9vxaOB?hdbt6)XRugG96b+d zSlB^r_@2@s(Oi{P_sbN&Cv8T&oBUo2o||D&S`7+e=r-=DRoAgC(koLOnLFYO` znN9?f-!%dHjQ@dte`F#brg1#i(}>D-ZJq_5XFbY zV-9iHAogo?rTEoClb#ZaY$WJH8aGY)AOnoaSTtG21F zti7wux;E6gdUWl*?VqW-b?>1&Ty>502%D^_Ly4dje=@9Zar!;>WdB4{VUhTDcKt}I ztNz=&H{E_vw>l6e@386DFMf1Bi1Z=tH{u&&96T~831*}A8}N*AYyuh;sp-lU0*Yyi zRqk^ws2^#yARt_8lgfzXM)C2&Mlrq|-E!jBtN-&~Yr4h!wNqbu`P1sxZ|Y&OA2FX1 zty?~O#-2H^x#*KkwFw|&c5QA;$P4xOrAvlCq4HwhB;*o8k(H4|F~~h-Vt-?0Lu)+Q z=&!AAu{z@^{kp2U*4F0v&2d|cza5#3(BvP8?~A+jf1Z7)2GJ4^Eq$WL8?wwkQKo*D zc~m#a8mlPzr0j=%6cK)K^)o+qjb~oji9{2(;F+VUqwjjAa=|l$Ky7X|0SSl=F3IH6 zxyJxD$>O8`aSPH{H$c}O0;az%F`d?Kd@o!Rn!n9 zT>|595d%TJkMe-ZAM>B$@_!UuAcr(d@G*GzxWwOm39kQhhw6>1-oI<;D&O6!V;IVB z$e~PY+pmdO3Zv0W=PS{r(*C4Eaflps#wcGb=}+!4>Ne=!mOINXJ;pAL+8*{lcpST`EAQ&s)f95Au7P5{At@%U?fmV#b`=@<+^JBVH{n5V4RVqyJ9F?J_r1V` zevc<#fP(Z|r1k%ycUM#2yE-Sdo3G3hzl6}?)23wCUbWf;JV~H=(=JK%2}9zc4dJ=h zbfuA!jwy;sVT0nU6B!C%OmXX6I4;WCWJa>)kfQ;`^=^J?^-C`Wx&tqXfB(zBTv}N3 z#$Sm~f(;bg5YyhL+TgNjXah7YP?UmdEDZgDCXnaOOO$u;7O6Gx7QS<6*sS~S+?2?& z{s9{n@kNk;JQ4|7UBV7uE?u|MJq?Q+@Gno~I0!lc8kvldQw44A!xdqDiS2&rrC>Jr z(o5pyrD*%7m%jYQUwv{Z8vhaPPsE>!zW_hEPc}a1M(zo4X{e7a4!PKITkO<{0vLjQc!=AiDx7Z`_2RnI zD;!RCMXqL%lC)@CwLVgo>Zz&eNtG2}#XzDb6^%6ZB$GXj5xicxa^=SmPwvnT>uDr8 zDq#I-!mB16h`>bB8pB|ayT}FpErKA|{wK8$W9`qchKDk(U)+b^hrxL zA@(OkqNTzTIpPx|hz+u^h$z$2X{8x!4|Kly6*XIU?xp^BAwdrqXnw4I3Rd_Fw)$9os-CwdYwc;` zCrwkZLFHpjDVm~CF9)}4ZG}=KU)k2$#=Amv&97+N!gDXqa|?N)aEl%Miw_`EyIng@7 z=T5dx^462Q_2d?wdvTsy2!MHTW`n4l5}T&PfKkxLEW0kf2L`B>p;a21w)g%dURpuC zY0hurwsdr1CZ4Vdg=*6A%)$j#v9ijFc)X(FnpZ!2&8zDog}R8|y=pShFx1*Q*brE} zw!gJ$XkJT8q3s$M=UwBP7|VP$16K3LDADmUq(z&yt+)&!+Y0B41#Xd+2Q5!pu!mNf zZ!1VW=(gG5*`e(hA}jDu(<(CM7c=x*#Yym@p$3%(3mHa*oD5iNvHjf?8Hl5f<{y7y z=!KUCg|m?T(igktG+?7@fDIlhSSG-PGc@Apmo6O9pU}@i1H`9itztjY6=`l_WX+h)!AT4YUz&9~zdQKS3qxOsc75?n*#e$h3syC#{}lw|n-z~m zp~toys zIno1k%1oa4z2B|+=)8vuA4!YI#K5hE=NJoi7aI3J#Uzw!KUBEU7;<}%BYBXWx(UiF zu9vWbFgY!$#g6DbSp*naQF2u-$t0^x)>sE-SjRSJ+?AD%x|p@jSLbj!>~^cgQ3ut` zR~!N<`QDQ76^95o#5wuafyUo;*Ijqsc^6yGO(Xu4LRR5ErT0mlAX{}CK%SY>FbcF| zccUPV;v4FD9V1jPyCL|R+OUD&w{7JHkFq^ZUSVzF7cU` z#jDf*Uie6`duV>PHdL7i#M8-2XPv9AeO_InKOO%7^C#xE0*kq~FTf&~5m+4vb{_lwe4=uUw#!cn@ z-L2k6M|HY8)0C=fZaTc654p3J^eOabf0Xh#8B>TEtrx zQD+e_xLb;I3e<=KkH5G`c~23f%@LywnO@llcSDXWZQ3R?>GjbGI_>BY|G0L^4}ZL9 z?U@%|ux{HnPk0NzJbxba(F}f+g-FvU3>Rou7wBVO7w|elkN}wJVyAK=)Ey@{2fKpj z%b~4<#ENLk2n^E5P9b5f>~IQE?U~%d7WK=vi~N1xrI~h9=9av~6j_=vOPeXOw7C1C z9o6kMjm`F?-drQ9XR1XOE~&cRZEr@dyC{-rzY-PEYD*idda(DwU+_#$O|=J6ocJUs zCi1QzsG5GMrw9(#0X&xikjbVWmxhtrhg-zilsMQZjx~zyjpEsaKq;@sVex{quaSII zsaGOBETRXEis7u9bR_N3Sha}5*}WD%zq6S~26~!$`xd|{erUwovutxu&+@8Re7HS1 z+=JR+m7&7_5~2|Lm+;OcQ4lPoSBk1km8eF-D-K?98xM(t?>Hz(7prdooKh5CN^3Xr?j4rMe!U z**eAXR&lCToNf_ETg0*EQ_cGEv?z~=lYz7FgfaDH(=c87VJW6?Wh9P~nxBS+dJogY zP~3t?H^-g%zk6ch?Khe0W8UJ-Fsg*F8sB!KHyp8V{c%Ng=V0?dyux3b>P)O#Ut6|G zEL`lVu1f|Qo5J2vf7#@saJs(Qxp;DM#iB$*UD&h4;c?l9N17%o7Gpf2`qgVu@xB_{ zis#gLx>+MRLHU{yhghMdH#bULMK5x>u0kv`!wt`;XS0*ewJMZk>#Y&CXdI+h8LSdX zByhqKz!Bho4uoPMQ;zLmA^9oic5dUOadC<>{r~};U^ju4%zAFOi5QgRG)rKkiQ44I zWke$k*Ra9KrVVYKo5ow4m*2Lq|DzvWJ2^S|v&xKrWzB+YZAI&#SPicY6qjE!x^P`z z`Q-59j}K3ZJ1myMcf85@S(cZFM$teC)ardwz5V~cmn8E41|J~F<6tpg1&gWCT10~) z$y$WwljNAj1mYrMU9rFcx-QMs%5?y>r_v^qml_L`H<*q`VRzCRthAP;9d1WD9ke(s zK`<2a&osgwCFvg^I7P4(0$=WNf;$M_M=;M14&XxU1n5;}_LLzcD{yr-|CTHtksE`& zlRE}*Ar?-B`xta1lT=7O{`1XTG`pT@7N^qU;jnOpPx&oYzj(ML3`Fu1nRlpZp@4*|93CXgFnEiFti+G9-@`(DwQRCsWU#woU9=%n zl^tHarM|pkL3Cbq*jpBI&+Do6S1(+&tG#`Bp?#z|GJnDHk8XUtJsIi0k2kc4ao3T#Xw0b;^8lRq3}hkZNRZd5_IO)kLtEVGh__*q$HkOB3YDn?*2tG738Ba$Rh3YbB94C>(1)xJxqV2- zL9fHu#hk000R}lwoK^8K>a0}4XvN?qL>lF8(OC)-bLy6qCuN?*E&b&0Ya{y5V3lMB>^!`_qLvxxD}9mZI0QdO1u zdTav?P;NHBHzmZZub~EES+`{B#{y#X4S_Wl$Na@>RxL(>jl$oF*5S|ZeE#{{J}!1W z`DEc)bid`wTZmj4rlH}R7T4={j!dmbsmM>wKNMR$-nM+l(EJ_CTiaJ` zA8dST!GfnC;0vu=-ymYpSx%|}4rXRT83v$og942QU3ckVh(W||8lr^@eewBicAwqm zvJ0CXmB8EBzT#L!kW|l+j3vwB4anHl*MF=$60Q)xDmaq070nA0g92QtHd#N?k@W}4 z@%-1W91<^zkD~?i6pr19xt>Ad1K?AK6$rFqfr|_12y2NQmLZ4B>j-#tScPDo12F(G z$mEBlk+CLd-jQ?F@!Aa){y?U4w7#ykwn?N`%u6h2uBc5GI>gt?Bau11R_@}clFnXJ z{f_=#V)v1R37a)9kU%#`bUEO0xX} zmydLaU*VA=c0+lG%6PvIFhy1KAFVvHL(KUqLD#rIE`yBAAL=L@qJMg(1iwpkkv%55^HR1Z>(Jl6cGFA-kgwt8!zY zYIT1{`@pK|fG^WI(8l(OuPhj^C~GdSEotvB0CGQ^GxWM7502Wu+-qvlJi?R)otsq?Sdi>=@N?tjpc z%YVe+(q+&B1Rze-!pG!isxQcWBr170+2&CidqwN&xsp=Ot& zo4~fd2d)#X!Z9F|gi4(|rMNh~DT)horw~)E%^{m5Jv32o^VkAocdTqqkKa7FZCm}4 z?F$+gbya(-+v~a)HwVMrq7d+RjzE-LTr+WC!|msG_3W5laBzBPB0afn5bJkz_~t`{ zu?ooPqh6XSt|#8P}SMLV+{L6k*AZAY8OqdToMSsI!?s_?13 zH`1Qu55NHsBRN4e`lAr=7lojww!zqFgR#*DW1|hVq;2rRvmyJJ4Jk8hniHySZUyeb z9yzz2;1~gmx927RHpQ_}^+haD`z0gi#J3ifQ&l))xM<|hTYX1E_ny9c~lx7E^Wb)__Ky){s2 zjadRNugii6i4luHQYmOVAt7a8ilpuxe_RN`EO@3~l!{N*1)HR8U?;&O`g@_m+F~8F zqN6f_E%RSqsBpG82T=?&KjB`_M0z_(8Ed(zIZAMv0JT?PnWHbvbp$sO*r;@t%4GO( zaTl?cz)ldOkZYHKn~J7rzuZkQW!<9$%Lxt>oF&)-kYC=oS?&(DZ)gP#whxKpJz9QE#$2?C|=ENRL5AdkWm zs?#Ex%g0;0mn<3RSrn@oZ|zw)Hdx-Ata_j(VMp1xs_wWWR$2QCe>~t!)&;7{%UsS_ zMXjivKYGi$%{xcC7WCb?@y6}Z`u4_UgYowI*ig2ruchLhSVL!J&$4K;C05vs3 zp%dRyLA9U7E_sjQZH^Fl+dfbR?*&0fZe6ZD<)i|GQ_kx4TAUDj6yZsS(kar&ChQAf zyPft6=p44X1w-G2m{!G7d-;fHpn)Hec%W}gs{Dk$o@Oriu44`6 zPG~;CbpZMDKr_ts$Z?6PPN+ZkMEik_}!%ABuKf~=^`nmUo6y-@p zx;-YQ7Ed+|mzQ;RG*vBLT0dMKi+7YaRSjLg)bAb*XXooT$TOF#lYWNRYhTq}@bYQW zZr0W!OT*3DC$)$~4wk#XlW7#Vgk^-TvqIabEru)g8W;{56)^=eGsuR4Ea$YyAa75E znOSq6AbSQgMj;&OHEgxZ?Q0&o1g><5;&Z4gPLxo<_y2&FQI9%5LH6+n09*%Q;W(N?NTFN&P4N&vA1%DYKlFVgka0zS8P9ebb)a%CZn3bZ zlTYN>>0lZ#W(K*+f{oyc6OP6hzx;%AJ+<7oax9+&*fiShKI9h5+@jSz#G=Pel$f#G zoi^toY|5M(tut_c84?g*K8F>`f~rDJdI3zs34wkHK(EZ9h5fvIt9XSJYtK>+8BLsU ziIWa-!g9s}$MciAj z!qZNoq@P2aVW&@uv%+#1>CTF#n3OG@8i?7_9b!OSF8m(SqBIpwG>MMF-!%R38^hoD zBXs$~d3G7;RuCy<2YtH?J4ppZwQ|+XoCZDxDOu57?nzEygkby=nLVyLFIJ#r5x$E) zU&_Hz0S!}((~M*6{+3gG!uh&W|B_SKtTC(p2iCk*zX8F?y7&WKM0N41F4iD=0E2IN z3(6dx8;R}*G z=^MoZn57-6qhJV_{g{a$QIMs@+&joYP>Nu5I|P9pKg_ONiT>L0pf%{PMJrb~!5xS# zNahfB&^zQhg^onnSN_Pl?KdA*zmSi$X)Wp(nEe)T9Ow@CBdo5O@r~dORp)j2NQw_O zN)Nb+SX21*kMx}@FTaU_()eh!|0;n5H?qsgg>|F!j#6fqchuD#!O4WZ@N2Q=M|#!D z%g?cCq6@i$4&sjOX47P6I2mnHxY1{Y8+k7q=>Q!}#?VE1gRz1Whym~Kl8Cvgb!Zx} z%h`Wv#wPm?XZDRzzKL1o6Fvhbl9-b;#JX6uN4 zg*~(n=(BDsI7$#GZ#krHiFE;Y#Vw(H0(ZjZ2g?MY0}ZtRE9&;wLL^5RsKh7(m*Q6( zXpWB@XoogXdV8!f7$Wrq3=x%z#X&dk4AF{;EDSq0hw|R~Mh<<|N~7r_D@V$qDHR^L z2b+$&0=rmLBzD#6!rNbkV+#yKGSLD^xn%6JXgP3GY&tN5LTjq&(5=Ar2AF|>SBe=A z(2w+@WYyP1kYk^5#4xY}#ZwAh7z4Cbl8;LpQE2dFawUz0PzOX{B3+Y!usAw2YZ31aKnHRS%3g>UUqd$} z55dk^qD11`hQXdt4?>tREBhH(k%P-;Vd{D$u|oM>I?Eg`rxn329x7l}$N0QtL_U;U z;l;wsn0l}MNPiV<27@iTr??j}s1}MDcyj@Xz#vqPCW&F7?FKeyr-`)JaDD@sxm zN%kp`$xgip;w3VTZlO+C2{JK#@E~UiG8Ma*+9`I)?sDSmR;gnrjbPCFh)UV9m9GJv zg_p&PKSIwgzsW&F=djhllmS({3?qhtN68l{{6mZ?W!_Raa%oFc-xM8!9e^l+ISd!LG?_NUH2Fl;KXXRg z5OEE*ql_^eDEYLcLLx^Cf`a(u<$Zh>9E8b!N~sVgEIKZbZ`&l_#x!%u=UC@1j5Y^i zM##3QG7TaBjQUS3;t|B4_|@gtfkgI*PbrzvAVNXI7Wx1$S`eW^B4|T($t@pcBGKWn z+fZ{(J`5crJ0%`bKMpTq&C0?pz$FIm^1f%$wSuE$dG=@vi%f*$rPc@FP;n8GA#%{u zdIk=1EkZO_YlAHsq^MTyDfG0EEwU`h#YRe`CUS9XO^s17NBx@Iv?$mp;YKo4@kxj`ATwhvo!wreA{0vU0w0*182eID0V}t9 zsspo!15q-4>`q5fLO^EZS;Bse15uQxr0{GpA8NxK3TTk#;aLLvDJsL_v6w%2EbLZT zKIAN-oPc3Km%Va!sbTONa-58(;xITpZinBFh)u~gB$r}CNb~5vY|v}uyprw}>t%Dm zenI?Bg$lWb5*5Z8s>T5gA;+R(gb7|aO|<^dc!F%_=G zuiXL7uX}A|I3#~L57-X5!a{ z)OcUT&>gN4mOhuM%URFG(ctS`9f5nZuO&Q4IuGRB9CaWY1P9!!USr77Yb-53*>Ms- zO1{nQIP$K0oNiNao<%=VV6a5+Ad1l>2DV7C{1OHvWsitck~d;f$e}4NuUsJ13^5kk zqS%LtLXySIr0~M&Qk*N`-VsC9&L&1PIX@KcY$OH@Z3_cU@&ru^MCM5w2jIb>+(m47wW!py@3)g4*}3%96m4!1ly$HQJp~@ zo&jw>^58B($mR-Y5fQejZ9zWVTeY1iZ@y2vTe}w}&W~tEwTDsW{0Yr25et=gtYN@7 zY$fMZk*AG=*Nf-8Ui^OiJ+8mkefa(2Z|N;qJWrLJcXxNcA^!?rFP?2Lo`_!*&&*5j zKDoy4%r6!87jIDT7SHt0WcaUB!VU$q{AZlM`%1~DmcN!CKi0{A%p3oId61sgp3$C1 zjj5Nkk7=LKUeR9F{#1Ki`<(U#?Tgx9YhTg6rhP;Emi8U(yW01)A8BuCKi7V#{YJYY zEYPcaL;#hg;-X3U;dfp7?Y{-uHg8XA6n{~2L4ES>M*KEcNYLh;)yXv)Y7o05T7%@( zxgvJsToSkfbFQn8CG2>w@4Ux#W1^@#sOci-OsBTcT&(`99PIznpBeLaoOc<_6r-_@Hyjm3YLKVCfJtG=-~H-240i)XsK z@mAa@FX3CIM0z##8=Rl>;z`M$)t7M@|8OI`$sh2Jl^knAfEna7MDb>LkF?>aJSqR17d=svO@;!Sg@ zWCV%RN`alSxl#qnoiSWHn0Y9a6#9C2jNrgF6Cwi(n_#{?N0_jM`~z=6VZJqeuZ4&`f7a{nquhTVXVYYAr(WW2V&tk zfoH>?LAtv^U!ZB&_nBFqg}^O);N2`zqETOV`A^xDD?bO8m*SR&+2*QS3XGMyjod^N zxA02A=+OxsR(G_~h4&n`rG>vM{4HCFTdvk=BsN;fCKfG?Xe(Q(F`LVrR+^??L4%!3 z*H;%V6~3h}xcsN+aA`Yx)j+9sqTlk4#YUQUL3#i*+iax3@NZxnFMmQX++q#<1MT`3 zsWmiwQYxU5F9#lTFbz&~ABi0uAjSllvbNCghAGK-2|GMM>&5(|*5}~V$2T_Yfsqgd z{*Ty5Exbv^x_-i791HM*a4i-+7|3eEs<$Ueb`c8a^5}bWmfe0>gC^XY|tJ!Iz>N;&QqnI$@8#K!3bt^vK%)D|~J#ftzj! zGP2MuodaUGy6<-D8(gBvCAKIOTrl$L|2I>$D|Eo$xa_d{+tOz?rkEST$}qL89fDBqd}?e`UKn=J0C{ zW5zIj!<$6+zZ3j|E~WoU@GCa z9CJo6qW2cw?B6-`zD=7sZ0+l_yOuBCm07R%Uj84Y7)o`n%W&v2>pFp~SGCWIKNK_A zD#wkLGUr9mAl0W>SHfy_bGQPQSb)(Vswu?`Z1in(D91*LCGm%o-QANsQr-0pU0n_J z-C{spnCx!olGniTiY%YcY`~U$Nc*N@sq-^43$UJDoS856K~yA5tseY`Ys1o&tTw~a z&y8LgxY(GPX@zHTZ6lC0lg;G%8*6#iZzlaHuI-~u?!I7`=<2DPmz}4hbjQ48M@KSU zHIfu>C9fp)WHJz=^D$MTL^v{aWV9rd1W70el28sLk*Q<)PHJp07)X$W;50?ad*}su zD`)p!Okq&%j=0!WCiaHJZljLmR1p5158d@K+z z{y@II99ps@v?T+tsjCO!j2eP$W~NIH0Wt+-tn|THlTQ#T}T6G=~#EMa=iYhV zd5<)jx8|jJNF!-9l19?#VU4VpWyg|i#W-@JSRsy6Ct=CX9~|;j?wLE| zbMLu#pL6!vd!N1c=NoHp8~28jlancpR(k|NYqhfAK z0Q%q|Uo`m3Te&a^`yqC5fH3O8!m(j+!j8BBqqaz^5kU$tq^%%etfn~8n?g<>-e{r_ zYYG}lJ2z4{C=CF9Ng`DPKMCL1=s}J}o|mKf#zw@s(Z=_4yTEozYKO0-+3WN8kme;A z2>7BtCv%3vj&L~YbNHAK$#K+}smYjZYKk=*QfYlU-5isVR<@Zn$D%RL2&k*@EMzPe zpE%)vnv294sgy0)b!hjr2X@uCp+bxww z40S3E8MucMC?PY$E{4oP(P(Gr&W2-I`P3<6>X>i7F&B#tQz zMvosK9Xozpc0Hifd*&OC6?+>8@^|5^PWQkA=ENNh$MQpsdvdruWoTR+rRt-L==)Dg zY4#dCS{tNzErT`zJk$UjFh9VYoNyur3xXIx+g`B$0;+Qu?0|&A0N)0Rg5GvDEO`y? zx(1lp;cfsnO1qCc@kHb5+3-C~Tq&LONm}w_Ewr7ZSWSkq}&GIT`a%09Y4Q@}E$N442M=H}!)!LmK zJ)2Z{5cKq`#cWqTmCYyJ`B>g*%InCV;u?d_pI z)@mzUXxh_yu!A+tj_S6~DMPon$_*o#){?WKcPtf@=L>U>?~S()FYLQLuXDTeN2l6z zrEF8UxwWq^LwXwCh~h@Nq(=qA?#=<1W22j1qwIAQ@EGNAfxGXUy)@o73dBCk?O}>W ztciu18W(JCwk8q|H*>oKj3YBxtt?<>fdJIR+Ca+@qa){UtXv)WW`;MRoH4amqu$?{ zTt>Vm9|yF=qyNGA8ocZmTleV>1=5`?e5AEFy{|3YS#Q0qzc?LlHrZVH$?4w45ueL1 zXWJSNx?E2GJq4q4uy3Z=H|>ul>icdxSjgt;WZi9j{WD>|J3Bq!SmyvK3eYkWat>i5 zdpR*g_hJVNh78`Ze)KGqPTr#)ZRyr@K)u@`SuE6pkav(v2aj8Q5j=A(UdU)?tj%87 z-)r-@M?3S$kjpud&5xz~_YYYL$KN|uA1MU8#uCBq{^D4B^Bq%nKZqxrMwqDu-Q=t~ zZu#MrPuj0|Lac>Qa#8Le!1DxITu@gOU|DIZHH8h*xME&|(O~2gE=>a|+j^lbd1y3c z^CSoC)A@r%`N;lCXZoS}aet(Dx_+_me%jT4MzXW7fH?)Lo|k2-6AQbO3%@Aw5DDcYL<_KykRge_H?Zp{bxJRfJ2B)VlpovQzFs=9O4=f|jib zLLq_g2g&DnIKyT+80Y9S!5r42jiA_%bZ#I47yg;D5!A<`jge5;=eFA-(J&S zJ~YNKcYzRVH|$Nj#nOU)_mg{FNqw@Qm!11ML!thRrNOfGv&n9p;uR2$wsoD^wtn^* zJbXj4sMmde>KlVo0i42(YU$`3Q$<(;q+!A*KvbkddQq$ds_^F_yo3!HE|zF1yMh8X zi>nU&2D=5U7naTPt60vi+9_~Q#v-bD6KD~pzb4+41`AImqM?LZRRp}r*D0JsrjXX$ z0uMdr0kNcBk^c{YD|~9}-{$6+I(OH^DK@h8I`U{-XZb3`LN&*~6qUXsUcuU^WT7xD zA!2$N$109>96)q$bP`l1*>TVjw#}=XPIV2beLj>O?)&i&b&(m^F<>Y+duq;W*T64IviR6Ivf)0a^op#KFA!x^uK z+`LI$fqeeyKg7!+%M}5;gvq#HKo~$fph}T{5S!=;-KX@C!$TdDZ4Cf-P{W<+V0SiR zI5-2k{34OA?>svC_h0=0b7f1}hPqfTwDnDnt^F1GU)ZYv8!#wAm|B_isQf*4Q@I~~ ze^xMN2M|Xc!<6au8zetrmLcmR98Ca?}Hx z7(!Q|mc5SSI*yw-bPJnj$g=^ja;;w9r0+)H?mX-ACep?Pa}9p<-h*@ZDfd4yGJ>c5 zDcr;E+x}}jQve%9+tO!{xx88a2gM-Auhl>zFy7Fbh=}`<{J+^5<#y=()n!s7jIUfb z+$2S128s%DWkM&8m~&=@ha+D%-!#iWjk#`S!{!;Y>@c%!Gg~z?oUEI#m}O|qyc0w( zY>wEZ5`^7Z9E&(k<5@K6_f7eW?2>YX zOcN|Ah76Jxp(_^3B{Gr1Du8tv+7gd|v|>7WRj85fe*+#W@u7r5OO-Wi1Rsc)x``W?^Ov+h<`u zd^NIDMmB{YOz1qaLC4;&W8*sJ(Is^<;BmTj>|PzC45Q<+@`%jl@}7$X*z0>y%Z8SQ9AF9x3}+afIEHb|;5d$B35SuSD|$)d*f{7z%T_uu7&cCsMrhRV&!4$)5t_#sk0_1A^9AB zx^~g}S?iZ6qmT~Yc)s%T2|D{R#b`c3#Y0?S1G2&i|UzL(dZ0<%N?NAZC%0l*pvkgp*d;qUWS$}Y+oy9E0*VK-o9gAjr& zAbzn_!}m`Qpd%0p4qjK?J6S2XAx>M<{0O%4)9PHOdSr{+Z?X6lV~9dkCB;v@OSuT? zb7z4=nl)%;R6It;wil@Q6bh(ZpQ&f~il1pI!`83JbYEM|5`}K+8Cu3{cg%hN`@#9X zmdVc09LMvyLq52)wD+8&v99g5zMoeyeJmgccE4mB$9?)goD2s6OQwPh5$R6_8B7t7 zVVQD?u9IaZBJhirY3b#$m6Sn^=Td{UhthM9(Fb{HAkk848nNu(y3lhh8w%340f!9B zg%##%#Y%QJPKMe_zeZN`6I7g~;sO=*BsJ-&NTR58TK3XhwE$QK21$IpBr{Y-T4=u5 z*LmtG$0dXvF6yNVl&TS#lxR^LVy`}#I~;xI*-9|6`5_NuE%#A)YRQsPs8w zLk3Vdu4y(rVX~V-F}L15HQchN*~8vGA8e1gy|%yKvVMT@*@#x(wfJ4x$(|Q6vIO)o zCVCZqIatspdMP*y5FpG)TEE`~cPyP9MV@m(+*QhcBZZxm{iX!F30=XN$h)Ac<=4Mk zV-Drv&Rz8cRP~?Z88}om>-;AT25Pni_g-mPLw-XQ$}{|*cwc^^Sq!!?f>Q4~|FMZ5=xFMkDn)RC`n z2t)u?Q!a%L{8Zt_;RU=Hp5Xw%8)Gs-U5|05cjlLpRNdPv_0jY8tpO?FnzI-!b`&{B z6ZCixZCiNT8PPVlnOC&!q>->l1=;SZG#`de>gVmiLk-OJ4Cbj|u4N~zLW0iVQsTbL z5KIXDAuWAF+>>&dRTkCLlv|`k1ALJRS>!WA@HvLM37h~KlnmlGSk_CBv&v2j$&ln% zpL62t3JOZ19N#S4<6P~u0|d*-)jrr^pq7#j`s;Xz2KMYv`XhBVW=DR2I=^IMssT16 zpmi`wq%Mcr1HEs7GANRONxS^DKRP$^vlS2|4nLJ1+@`8K^SF~73&&=0NNael7_^Fjr=5k z1KS|iq`Wg9%_BG->RR^3MmAS&%9DYy+>*`X)IXI^0P}etae(jA^z_-^cn0+#L2kUlS<#x zgW+1AX`T0iK9|n4879=3sflBzYFEndysIO>;P*BJY%RH#HmysK?CR1t(WNnTX;yTU zbQ_+34aI!P9i8f!DorVGO|jvrnJIaDib;?G&+xA1%;s&`O;QL0ZXNCt+R`TA;y`Tz zqTUP`f?eK3z0*{Wb^M!F&pEQIUI!+0hBJUp!2mFz+~TP?^y>;1CqAoj&m!tZ_~Zu~ zJWYPoEsDBMbVx_Oyd1}ibl{T8N@5N9{Q25e?!Z4*R+`q3(}yFvmzSxla53&F+9#<_ zv>(I!RNJ2=L-HDmTigFS>hkuLIBwA(+Fu0oxpq3gl3&AEtku`<%`WqIivjGscJCbB z8%3?IcsJaeeUjIudkY2%t#c6%cN)hEjx`)QaT(zTiZ(8S&G}$}YC|ICN%qI$nXs>o z%w!OVAhAPTB&94u8xympMKVIH;G9ICFmi-pS447Nt;bpDMma8-T;O$p^^-u(N+~`Y z$L!01+c`JdH#fTZ$>fcVNzA@U=VWvevk$i0ksBK$xpM#H2re#{Mkeu5n|-&+mK>CT zWXq^p>L1dLC3~~^D@m5jDv+w`euB!@zZ#mO^|aRU%>!dquxA88MLBw8Vb64EwN zafHN*aXkv`>A|c*wiUR>ARnwc4vt^xx9%rp;!ffzMcWiM1ItB{KblE*_#_q2Qy$)D z$fCe{6t#GRmHd zvJXVq+FS*!08!M{pf|>Q|S0I2#_H8JD-mcbE?5XeTtP6!8dl0{6yZ^so59@44+?$j!tL*faFI zbmkZ0cOhg^{gZ;H{NLIK7W+ZqyWyx*P5V%BbVa&A&!sLjJKQyc^NOPw z!R^XR^d!T@nd&3do;xgm1NZq5-3QCNB!$GgI%tz@i5ERMa}4sAMcXZ?`8KEj?+ALL z?Ns#|Y|o8YU=K*a9*}}PAO(9s3Rcz>V1aA&E#a#F$NT9y-sTwynCa^EF5+7f0;{)^%*VFUhdW^@|55@1IKDCef=%e^sTKkJVr-9wh z3Bn`=CiYWYV=oITc;Wt5UM4={@lqlx1Y>d7UoVL})05Q_Lwk%kW`T;!khaS&QqfP` zFpC2IcNZM&l7l1~R>Q$g?E=EC8s-1wYicakFqm)$tQN@6F(-Cm0E2xu$3T8#>*xDB zTJ?Gj0K00f&wv{Hpqct=iaQ6v&XHrDtIkZ+Kv1!hxN{BgnliEi(9sK$0P5Pe10sGa z)U}q-l~*LiWUeMl1&la$0Quq! zt|;+fekia9#v@p_%fSq3CFNM0w(@CfCds#E_z2NP54UV+-*^zH zHkLS1NuzI5V?hVf1SVfkG6(%1N#>Hudh$wAo=LK$B)guxnUp<9a%p5%+PlI30DK|o z@r-j5u32K-JxxnBhlzSb!I?sf_C+e{sW?H!6J#g>tPAkjNk97#6%S(7E`N#&WUqtH zMrv3Sib}yb?!e25@pZ7K*cMxK7Sb81qOtRQTD+Q`&fweI@x99G2sRTAifxp zl^h^fUA>6w++}D{ZV|`Xw+)8=tzXT|=?z7un;F?>av5ND(f>r(k;&xrla`tNlXsff zjT5K01}U5Sy{Fl?cWf{j<-N&FrpeVa+)K{RN6@)C`Rf?#8CI3OEr`?R#uidOIr6uD zS&#Qw$2fz3(NzPsqOiw_ZB6wCsvs9MvMl4>G8R4P3y_zJlyPPtphY*YIiithE{fT_ zMsM%WL{Xz0iPm~P!F&GJtt`#|j!zOEhfjSSQX~FQ0+ykf1j{9uBZ2rUhiWh>LXQ+EGQ9;(PicYCVUe@w`)YTm8>~TnCl^4ygz`MCJrOuNkUQZ?k zJrzkRI?1>|$PF~ZlngoPp|h%eg|6DM_7rw^74V_h9|4x+N0ZYSQ{H_{z{jJjexT;z zhGZTz9~lT1S{n7NfA7{aKtKQ5Fnf9jOg=4twY@2ot?S=o54FUb`<9Hu9+h|Ln5M^lT5TZ;>CzcK&%FxmQQHGWd z*iT%1ak#daJDpnrT8`GBuylPuG_5 zIjCe7`HQ#?@%{`;zwOPMoyXCS3s(Er`*Fb%&dV@t6EiGIL2ZyYoc@a9C5RzVZk zII8a&p}Bb)MXl~B*mhj(ZP=M08{t}R@a7iVX1p|$p2WZF!1&G8`Rv?uCurCP$MvwapM^K8h^+^eCNh7_I3V!7DUT%t9zOgJLuI0K@NsEtyB8Y zDSs<}4TPCQJ)?A%o!ov?Y7^`GYCDmXiX{}4T>CJj&)TcBW94oK?}dh~5f}pmk+9Uc zv@75jof~oHh|$dw=OY7@);7ivOyPtBe}Jx$&O$^K0~KwAcXk|(p`M#RBS7h?-ibG> z#oNm41 zJtk`JQC_B<0kR`kI_gXHRjG*HJ%XSR@q9-`dk@yyD?3R4$_0kGyS-=+@KjfWT6-AM z@_yc4QM5Op8BCGL98FnE(EUI%v;)e@ZJb^(0?>P*ZgnT8WSNCwo+yiYv*=S{ScR8sB%QWwI+=7i;x3_B8v_;|o2Bp?s6d#=HYf(Nb45 z+C3WYnQh1=eV$ZjV{|Mq#=eCVWcBG{vavH0LTax-Ybo1xsMx4FT_)v~|DoF5ss41L zJMJ2(i?(<}X}{H}zPHw=zrlVb6du8ld0&(LN`ZNt>{tJ;jVI?QXtpxCHd-4`-(x4( zee!X|D95({b^Gwn+(SN@n%(S8xQ~87?na%sq~Beqy4_FX?Hum&m-2BrEytugw+}PA z-^-{g#wKJp3v4-9YpL!>ale~#H|oZvhj`ui_8;u7t9Wa5nO?fd7Gyh8sX*C79bsUv z&3V4Z1qc*z-#8vEr*!~zRIMo- zy^|HlaQ@Rsy+7^t2h~u$PiJs@@A3o^nE)Vobx}(k{?=%;>MjF=_j8;J!V$AF1DzLh zXB7@bSg@Gzs9^A@V6fUTsPar&!Lf$Jus~9r4zI6cc>OXLf4N|EJEWt^2bGJEl;F80 zUC}XzjxFg}4%uvXd`}!0nGqad-cYzD-aw_+8_SxxN_2S4;pz?{t5dY)CX8Y z#M*;=aKGLK=9))IDg^>9c$Y7XPtohDX2avq=H;6bTZiL|jFw5>+Z28{-5IE6-o{ps=|b()hp z4HkYkI<3)Tb_m}D?$^S7SE`OKHvv~$eG)PqyxVUDPr~<0euKU2sYED{NCZNOAI1aW zcsv}4D^YJa?Dd91-s&f)xYN@wik{Zx$}7-JDka@46hqolN1U2My}jEFVi~*At2owi z7~m`|qM1GoQ)T5z_91c~e-P);NK~DJu^eMp7+b<{-`&SL^f5Xe$1o1+V{((aldc1O z5o!AB{?9xt|I0ldQ1*HGAK70ihf&WgeYWGJ+OHbwwh&BUd6GF z;|h+~ap;MX4xB`AKG+OHgTF1*jVfGQtRZ0tMA0Z^{07{PNBkcy;8L@+&| zw=;HHX4hq8u!XA_4Mj+Iv=#Od+T~IX?7>aW%Qx9yWAxjkC&cJC+HjL~(kgOP05Evr zn!^||=8PEk@MSwwmTct8UU^pf1SW9V0GuvNO&*;|ftmBArd^(Hgv*1CNC$`Lv5Uvp- zkqPWNDt%rM4x(qa4E%0NwtiKH^g^C;{DbsRB#weO4i|G|4MGe|RFlhZ&1%m#J2k0B z1I4hZ4QepRoIp^Dsxqc4!CK;##87{QI?889VW#3T6)!{Mg|+2U9s7ix8SD*q?i9($ zWrY5a(E#u-;jW#HLR`#W>!|Y2F zq4u#>kHKO$mCRO?&K>WLffPPSeRIg;pOM}Wr0`ooiY4Muvw}liH4`DY%P1Vya|CxY zi-OV%MK@7i&Fq+@IKY@@S>aX}jUd`yFxS145c-m2b+M@-mUI}cCglBRbtK)<)&~YV z7(vDFZgyjwewqZi+tPQXxzYH2*x^2h^v#yXS98wjXv1M=gF{=n#jpxoWZ z6um+B&bJbcI^(boa9LeLpq(4E8|QfGRnryWrPEN6Zcw<5tc{af9c#3(aVF5x!Hl3h zN{``q9>-5RxRLydVjzS>0N#;Jk*^ryPNK$cuQ_-#{j645>j<5EkwboU~Jd|Amh?3 ziVdJ9s<6P(zS)jZmXC3*ySX`qy40Y?nBEVU;A$^UDf46%8K+tO9xAw;&S5IXG5jl2 zu6Y;!{VeT76-Z9+qdli)Drol!Zj{*w>yI#eNqKv`GAdp$vB_E>Hl*?De5eWG0 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..ff8bb56 100644 --- a/Project.swift +++ b/Project.swift @@ -202,7 +202,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( diff --git a/SparkApp/Sources/App/MainTabView.swift b/SparkApp/Sources/App/MainTabView.swift index a4f75ed..b9e0bb2 100644 --- a/SparkApp/Sources/App/MainTabView.swift +++ b/SparkApp/Sources/App/MainTabView.swift @@ -12,12 +12,20 @@ struct MainTabView: View { .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: "Map", systemImage: "map") + .tabItem { Label("Map", systemImage: "map") } + .tag(Tab.map) - ComingSoonTab(title: "Settings", systemImage: "gear") - .tabItem { Label("Settings", systemImage: "gear") } + ComingSoonTab(title: "Search", systemImage: "magnifyingglass") + .tabItem { Label("Search", systemImage: "magnifyingglass") } + .tag(Tab.search) + + ComingSoonTab(title: "Notifications", systemImage: "bell") + .tabItem { Label("Inbox", systemImage: "bell") } + .tag(Tab.notifications) + + SettingsPlaceholderView() + .tabItem { Label("Settings", systemImage: "gearshape") } .tag(Tab.settings) } .onChange(of: model.pendingRoute) { _, new in @@ -26,7 +34,9 @@ struct MainTabView: View { } } - enum Tab: Hashable { case today, timeline, settings } + enum Tab: Hashable { + case today, map, search, notifications, settings + } } private struct ComingSoonTab: View { diff --git a/SparkApp/Sources/App/SettingsPlaceholderView.swift b/SparkApp/Sources/App/SettingsPlaceholderView.swift new file mode 100644 index 0000000..3e69cff --- /dev/null +++ b/SparkApp/Sources/App/SettingsPlaceholderView.swift @@ -0,0 +1,31 @@ +import SparkUI +import SwiftUI + +/// Holds the Settings tab until the full tree lands in Week 3 of Phase 2. +/// Keeps Sign Out reachable so dogfood builds can rotate accounts. +struct SettingsPlaceholderView: View { + @Environment(AppModel.self) private var appModel + + var body: some View { + NavigationStack { + Form { + Section { + Text("Profile, notifications, integrations, HealthKit scopes, devices, API tokens, About, and Debug land in Week 3 of Phase 2.") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + + Section { + Button(role: .destructive) { + Task { await appModel.signOut() } + } label: { + Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") + } + } header: { + Text("Account") + } + } + .navigationTitle("Settings") + } + } +} diff --git a/SparkApp/Sources/Detail/BlockDetailView.swift b/SparkApp/Sources/Detail/BlockDetailView.swift new file mode 100644 index 0000000..f604ef7 --- /dev/null +++ b/SparkApp/Sources/Detail/BlockDetailView.swift @@ -0,0 +1,162 @@ +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 { + 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..cb13463 --- /dev/null +++ b/SparkApp/Sources/Detail/EventDetailViewModel.swift @@ -0,0 +1,41 @@ +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 { + 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..536735f --- /dev/null +++ b/SparkApp/Sources/Detail/MetricDetailView.swift @@ -0,0 +1,279 @@ +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 { + 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..512ea28 --- /dev/null +++ b/SparkApp/Sources/Detail/ObjectDetailView.swift @@ -0,0 +1,202 @@ +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 { + 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/SparkApp.swift b/SparkApp/Sources/SparkApp.swift index 256bdf4..0d1618a 100644 --- a/SparkApp/Sources/SparkApp.swift +++ b/SparkApp/Sources/SparkApp.swift @@ -9,6 +9,7 @@ struct SparkApp: App { @State private var model = AppModel.shared init() { + SparkFonts.registerBundledFonts() SparkObservability.start() } 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/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..4575697 --- /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(minor: abs(tx.amountMinor), currency: money.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..3a887f3 100644 --- a/SparkApp/Sources/Today/DayPagerView.swift +++ b/SparkApp/Sources/Today/DayPagerView.swift @@ -18,19 +18,10 @@ 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(edges: .top) + .toolbar(.hidden, for: .navigationBar) .navigationDestination(for: EventRoute.self) { route in - EventDetailPlaceholderView(eventId: route.id) + EventDetailView(eventId: route.id) } } .onChange(of: appModel.pendingRoute) { _, route in @@ -69,20 +60,6 @@ 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) - } -} - private struct DayKey: Identifiable, Hashable { let offset: Int let date: Date diff --git a/SparkApp/Sources/Today/TodaySnapshot.swift b/SparkApp/Sources/Today/TodaySnapshot.swift new file mode 100644 index 0000000..c86c4ea --- /dev/null +++ b/SparkApp/Sources/Today/TodaySnapshot.swift @@ -0,0 +1,238 @@ +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) + self.activity = ActivitySnapshot(summary?.sections.activity) + self.money = MoneySnapshot(summary?.sections.money) + self.media = MediaSnapshot(summary?.sections.media) + self.knowledge = KnowledgeSnapshot(summary?.sections.knowledge) + self.anomalies = summary?.anomalies ?? [] + self.heatmapRows = Self.buildHeatmapRows() + self.checkInStatus = .pending(slot: SparkTimeOfDay.from(date: now)) + } + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEEE · d MMMM" + return f + }() + + 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"]?.intValue + sleepDurationMinutes = payload["sleep_duration_minutes"]?.intValue + bedtime = payload["bedtime"]?.stringValue + wakeTime = payload["wake_time"]?.stringValue + restingHeartRate = payload["resting_heart_rate"]?.intValue + hrvOvernight = payload["hrv_overnight"]?.intValue + deepMinutes = payload["deep_minutes"]?.intValue + remMinutes = payload["rem_minutes"]?.intValue + } +} + +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 } + steps = payload["steps"]?.intValue + stepsGoal = payload["steps_goal"]?.intValue ?? 10_000 + activeCalories = payload["active_calories"]?.intValue + activeCaloriesGoal = payload["active_calories_goal"]?.intValue ?? 600 + exerciseMinutes = payload["exercise_minutes"]?.intValue + exerciseGoal = payload["exercise_goal"]?.intValue ?? 30 + standHours = payload["stand_hours"]?.intValue + standGoal = payload["stand_goal"]?.intValue ?? 12 + lastWorkout = payload["last_workout"]?.stringValue + } +} + +struct MoneySnapshot { + struct Transaction: Identifiable { + let id: String + let merchant: String + let amountMinor: Int + let category: String? + let time: String? + } + + let spentTodayMinor: Int? + let currency: String + let recent: [Transaction] + + var hasAny: Bool { spentTodayMinor != nil || !recent.isEmpty } + + var spentTodayDisplay: String? { + guard let spentTodayMinor else { return nil } + return Self.format(minor: abs(spentTodayMinor), currency: currency) + } + + static func format(minor: Int, currency: String) -> String { + let value = Double(minor) / 100 + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currency + formatter.maximumFractionDigits = 2 + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + } + + init?(_ payload: [String: AnyCodable]?) { + guard let payload, !payload.isEmpty else { return nil } + spentTodayMinor = payload["spent_today_minor"]?.intValue + currency = payload["spent_today_currency"]?.stringValue ?? "GBP" + let array = payload["recent"]?.arrayValue ?? [] + recent = array.enumerated().compactMap { idx, item -> Transaction? in + guard let obj = item.objectValue, + let merchant = obj["merchant"]?.stringValue, + let amount = obj["amount_minor"]?.intValue + else { return nil } + return Transaction( + id: "tx_\(idx)_\(merchant)", + merchant: merchant, + amountMinor: amount, + category: obj["category"]?.stringValue, + time: obj["time"]?.stringValue + ) + } + } +} + +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 } + bookmarksToday = payload["bookmarks_today"]?.intValue + newsletterStatus = payload["newsletter_status"]?.stringValue + if let event = payload["next_calendar_event"]?.objectValue, + let title = event["title"]?.stringValue, + let start = event["start"]?.stringValue, + let end = event["end"]?.stringValue { + nextCalendarEvent = CalendarEvent( + title: title, + start: start, + end: end, + location: event["location"]?.stringValue + ) + } else { + 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..e415cde 100644 --- a/SparkApp/Sources/Today/TodayView.swift +++ b/SparkApp/Sources/Today/TodayView.swift @@ -6,40 +6,59 @@ import SwiftUI 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 var body: some View { + let snapshot = TodaySnapshot(summary: viewModel?.cached, date: date) + 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 + hero(snapshot: snapshot) + + anomalyPill(for: snapshot) + + if let health = snapshot.health, health.hasSleep { + SleepCard(health: health) } - Text("History heatmap coming soon") - .font(SparkTypography.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .padding(.vertical, SparkSpacing.xl) + 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 + } + + if !snapshot.hasAnyDomainData { + loadingOrEmptyState + } + + HeatmapSection(rows: snapshot.heatmapRows) + .padding(.top, SparkSpacing.md) } - .padding(SparkSpacing.lg) + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.xl) } - .background(Color.sparkSurface.ignoresSafeArea()) + .scrollContentBackground(.hidden) + .background(TodayBackground(snapshot.timeOfDay)) .refreshable { await viewModel?.refresh() } + .sheet(isPresented: $showCheckIn) { CheckInPlaceholderView() } .task(id: date) { if viewModel == nil { viewModel = TodayViewModel( @@ -52,133 +71,119 @@ 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) + // MARK: - Hero + + private func hero(snapshot: TodaySnapshot) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + Text(snapshot.dateLabel.uppercased()) + .font(SparkTypography.monoSmall) .foregroundStyle(.secondary) + .accessibilityHidden(true) + + Text(heroTitle(snapshot: snapshot)) + .font(SparkFonts.display(.largeTitle, weight: .bold)) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .accessibilityAddTraits(.isHeader) + + if let subtitle = heroSubtitle(snapshot: snapshot) { + Text(subtitle) + .font(SparkTypography.body) + .foregroundStyle(.secondary) + } } + .frame(maxWidth: .infinity, alignment: .leading) } - @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." - ) + 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 { - LazyVStack(spacing: SparkSpacing.md) { - ForEach(domainRows(from: summary.sections)) { row in - MetricCard( - title: row.title, - value: row.value, - unit: row.unit, - caption: row.caption - ) - } - } + return snapshot.dateLabel + } + } - 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 - ) - } - } - .padding(.top, SparkSpacing.md) - } + private var firstName: String { + // TODO: source from /me endpoint when Settings → Profile lands. + "Will" + } + + 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 var loadingPlaceholders: some View { - VStack(spacing: SparkSpacing.md) { - LoadingShimmerCard() - LoadingShimmerCard() - LoadingShimmerCard() + private func formatSteps(_ count: Int) -> String { + if count >= 1_000 { + return String(format: "%.1fk", Double(count) / 1_000) } + return String(count) } - 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 + // 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?.description ?? "Anomaly detected", + trailing: "\(snapshot.anomalies.count) anomal\(snapshot.anomalies.count == 1 ? "y" : "ies")" ) } } - private struct DomainRow: Identifiable { - let id: String - let title: String - let value: String - let unit: String? - let caption: String? + private func shouldShowActivityMoneyRow(_ snapshot: TodaySnapshot) -> Bool { + (snapshot.activity?.hasAny ?? false) || (snapshot.money?.hasAny ?? false) } - private static let dateLabel: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "EEEE, d MMM" - return f - }() -} + // MARK: - Loading / empty -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" + @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 DaySummary.Sections { - var hasAnyContent: Bool { - [health, activity, money, media, knowledge] - .contains { ($0?.isEmpty == false) } - } -} - -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 - } +private extension TodaySnapshot { + var hasAnyDomainData: Bool { + (health?.hasSleep ?? false) + || (activity?.hasAny ?? false) + || (money?.hasAny ?? false) + || (media?.hasAny ?? false) + || (knowledge?.hasAny ?? false) } } From 363c3b5caa786d9850c692cc7140d81233b2e24f Mon Sep 17 00:00:00 2001 From: Will Date: Sat, 25 Apr 2026 22:58:07 +0100 Subject: [PATCH 02/10] :loud_sound: URL logging --- .../Sources/SparkKit/API/APIClient.swift | 40 ++++++++++++++---- .../SparkKit/Auth/AuthenticationService.swift | 8 +++- .../Tests/SparkKitTests/APIClientTests.swift | 41 +++++++++++++++++++ SparkApp/Sources/App/AppModel.swift | 3 ++ 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift b/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift index 9aba0dc..6a755fd 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 } @@ -101,7 +101,7 @@ 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") { @@ -143,23 +143,49 @@ public actor APIClient { private func buildURL(endpoint: Endpoint, absoluteBase: Bool) throws -> URL { let base: URL if absoluteBase { - base = environment.baseURL - .deletingLastPathComponent() // /api/v1 - .deletingLastPathComponent() // /api - .deletingLastPathComponent() // site root + base = oauthSiteRootURL() } else { base = environment.baseURL } 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/Auth/AuthenticationService.swift b/Packages/SparkKit/Sources/SparkKit/Auth/AuthenticationService.swift index 6286b66..778db58 100644 --- a/Packages/SparkKit/Sources/SparkKit/Auth/AuthenticationService.swift +++ b/Packages/SparkKit/Sources/SparkKit/Auth/AuthenticationService.swift @@ -20,6 +20,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(), @@ -45,6 +48,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 +63,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() } 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/SparkApp/Sources/App/AppModel.swift b/SparkApp/Sources/App/AppModel.swift index e17b60d..e136a67 100644 --- a/SparkApp/Sources/App/AppModel.swift +++ b/SparkApp/Sources/App/AppModel.swift @@ -73,6 +73,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) } } From 8ea26f05facaaa386baf74709bc2e6c6e671bb29 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 26 Apr 2026 09:05:36 +0100 Subject: [PATCH 03/10] :sparkles: Phase 2 Week 2: Map, Place, Integrations, Inbox, Search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five new primary surfaces, all reachable from MainTabView and the universal-link router wired in W1·D5. Every screen reuses the safe Inspector + glass-card language established in Week 1. - Map: SwiftUI Map with Spark-tinted pins, time-of-day scrubber overlay, persistent bottom sheet of in-region points; debounced /map/data refetch on pan and date change. - Place detail: hero + visit-streak chip, embedded mini-map, ledger, events here, nearby chip row. Replaces the Week 1 placeholder. - Integrations: list grouped by domain bucket; detail with status pill, Sync now, Reauthorise via ASWebAuthenticationSession (strong-ref pattern from commit 9572314); reachable from Settings and DetailRoute. - Notifications inbox: cursor pagination, mark-as-read on appear, swipe-to-delete, "Mark all read" toolbar, optimistic updates, unread badge driven by @Query against CachedNotification (added to SparkSchemaV1 in place — migration plan deferred until pre-TestFlight). - Search: .searchable + horizontal mode pills (default / > / # / $ / @ / ~), 300ms debounce, prefix-typed shortcut switches mode, polymorphic SearchResult enum, sectioned results. Co-Authored-By: Claude Opus 4.7 --- .../API/Endpoints/IntegrationsEndpoint.swift | 28 +++ .../SparkKit/API/Endpoints/MapEndpoint.swift | 23 ++ .../API/Endpoints/NotificationsEndpoint.swift | 27 +++ .../API/Endpoints/PlacesEndpoint.swift | 8 + .../API/Endpoints/SearchEndpoint.swift | 48 ++++ .../Auth/IntegrationReauthService.swift | 64 +++++ .../Sources/SparkKit/Deeplinks/DeepLink.swift | 48 +++- .../SparkKit/Models/IntegrationDetail.swift | 69 ++++++ .../SparkKit/Models/MapDataPoint.swift | 67 ++++++ .../SparkKit/Models/NotificationItem.swift | 51 ++++ .../Sources/SparkKit/Models/Page.swift | 18 ++ .../Sources/SparkKit/Models/PlaceDetail.swift | 39 +++ .../SparkKit/Models/SearchResult.swift | 154 ++++++++++++ .../Schema/CachedNotification.swift | 39 +++ .../SparkKit/Persistence/SchemaV1.swift | 1 + .../Tests/SparkKitTests/DeepLinkTests.swift | 42 ++++ SparkApp/Sources/App/AppModel.swift | 5 + SparkApp/Sources/App/MainTabView.swift | 11 +- SparkApp/Sources/App/RootView.swift | 10 + .../Sources/App/SettingsPlaceholderView.swift | 12 +- SparkApp/Sources/Detail/PlaceDetailView.swift | 224 ++++++++++++++++++ .../Integrations/IntegrationDetailView.swift | 187 +++++++++++++++ .../IntegrationDetailViewModel.swift | 75 ++++++ .../Integrations/IntegrationsListView.swift | 112 +++++++++ .../IntegrationsListViewModel.swift | 59 +++++ SparkApp/Sources/Map/MapBottomSheet.swift | 103 ++++++++ SparkApp/Sources/Map/MapView.swift | 158 ++++++++++++ SparkApp/Sources/Map/MapViewModel.swift | 107 +++++++++ SparkApp/Sources/Map/TimelineScrubber.swift | 58 +++++ .../NotificationsInboxView.swift | 187 +++++++++++++++ .../NotificationsInboxViewModel.swift | 168 +++++++++++++ SparkApp/Sources/Search/SearchView.swift | 195 +++++++++++++++ SparkApp/Sources/Search/SearchViewModel.swift | 105 ++++++++ SparkApp/Sources/Today/DayPagerView.swift | 49 +++- 34 files changed, 2529 insertions(+), 22 deletions(-) create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/IntegrationsEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/MapEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/NotificationsEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/PlacesEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/SearchEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Auth/IntegrationReauthService.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/IntegrationDetail.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/MapDataPoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/NotificationItem.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/Page.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/PlaceDetail.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Persistence/Schema/CachedNotification.swift create mode 100644 SparkApp/Sources/Detail/PlaceDetailView.swift create mode 100644 SparkApp/Sources/Integrations/IntegrationDetailView.swift create mode 100644 SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift create mode 100644 SparkApp/Sources/Integrations/IntegrationsListView.swift create mode 100644 SparkApp/Sources/Integrations/IntegrationsListViewModel.swift create mode 100644 SparkApp/Sources/Map/MapBottomSheet.swift create mode 100644 SparkApp/Sources/Map/MapView.swift create mode 100644 SparkApp/Sources/Map/MapViewModel.swift create mode 100644 SparkApp/Sources/Map/TimelineScrubber.swift create mode 100644 SparkApp/Sources/Notifications/NotificationsInboxView.swift create mode 100644 SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift create mode 100644 SparkApp/Sources/Search/SearchView.swift create mode 100644 SparkApp/Sources/Search/SearchViewModel.swift 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/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/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/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..04226ab --- /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<[SearchResult]> { + Endpoint( + method: .get, + path: "/search", + query: [ + URLQueryItem(name: "q", value: text), + URLQueryItem(name: "mode", value: mode.rawValue), + ] + ) + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Auth/IntegrationReauthService.swift b/Packages/SparkKit/Sources/SparkKit/Auth/IntegrationReauthService.swift new file mode 100644 index 0000000..5b8cf1d --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Auth/IntegrationReauthService.swift @@ -0,0 +1,64 @@ +import Foundation +@preconcurrency import AuthenticationServices +import UIKit + +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/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/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/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/Page.swift b/Packages/SparkKit/Sources/SparkKit/Models/Page.swift new file mode 100644 index 0000000..9db5ada --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/Page.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Cursor-paginated response wrapper used by paginated mobile endpoints. +/// Mirrors the backend's `{ "data": [...], "next_cursor": "..." }` shape. +public struct Page: Codable, Sendable { + public let data: [Item] + public let nextCursor: String? + + enum CodingKeys: String, CodingKey { + case data + case nextCursor = "next_cursor" + } + + public init(data: [Item], nextCursor: String? = nil) { + self.data = data + self.nextCursor = nextCursor + } +} 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/SearchResult.swift b/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift new file mode 100644 index 0000000..5b1a0b6 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift @@ -0,0 +1,154 @@ +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? + } +} 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/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/SparkApp/Sources/App/AppModel.swift b/SparkApp/Sources/App/AppModel.swift index e136a67..610fe44 100644 --- a/SparkApp/Sources/App/AppModel.swift +++ b/SparkApp/Sources/App/AppModel.swift @@ -14,6 +14,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 diff --git a/SparkApp/Sources/App/MainTabView.swift b/SparkApp/Sources/App/MainTabView.swift index b9e0bb2..e1e34b2 100644 --- a/SparkApp/Sources/App/MainTabView.swift +++ b/SparkApp/Sources/App/MainTabView.swift @@ -1,9 +1,13 @@ +import SparkKit import SparkUI +import SwiftData import SwiftUI struct MainTabView: View { @Environment(AppModel.self) private var model @State private var selection: Tab = .today + @Query(filter: #Predicate { !$0.isRead }) + private var unreadNotifications: [CachedNotification] var body: some View { @Bindable var model = model @@ -12,16 +16,17 @@ struct MainTabView: View { .tabItem { Label("Today", systemImage: "sun.max.fill") } .tag(Tab.today) - ComingSoonTab(title: "Map", systemImage: "map") + MapView() .tabItem { Label("Map", systemImage: "map") } .tag(Tab.map) - ComingSoonTab(title: "Search", systemImage: "magnifyingglass") + SearchView() .tabItem { Label("Search", systemImage: "magnifyingglass") } .tag(Tab.search) - ComingSoonTab(title: "Notifications", systemImage: "bell") + NotificationsInboxView() .tabItem { Label("Inbox", systemImage: "bell") } + .badge(unreadNotifications.count) .tag(Tab.notifications) SettingsPlaceholderView() diff --git a/SparkApp/Sources/App/RootView.swift b/SparkApp/Sources/App/RootView.swift index f801317..98b1898 100644 --- a/SparkApp/Sources/App/RootView.swift +++ b/SparkApp/Sources/App/RootView.swift @@ -31,6 +31,16 @@ struct RootView: View { 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/App/SettingsPlaceholderView.swift b/SparkApp/Sources/App/SettingsPlaceholderView.swift index 3e69cff..801652c 100644 --- a/SparkApp/Sources/App/SettingsPlaceholderView.swift +++ b/SparkApp/Sources/App/SettingsPlaceholderView.swift @@ -10,7 +10,17 @@ struct SettingsPlaceholderView: View { NavigationStack { Form { Section { - Text("Profile, notifications, integrations, HealthKit scopes, devices, API tokens, About, and Debug land in Week 3 of Phase 2.") + NavigationLink { + IntegrationsListView() + } label: { + Label("Integrations", systemImage: "link") + } + } header: { + Text("Connections") + } + + Section { + Text("Profile, notifications, HealthKit scopes, devices, API tokens, About, and Debug land in Week 3 of Phase 2.") .font(SparkTypography.bodySmall) .foregroundStyle(.secondary) } diff --git a/SparkApp/Sources/Detail/PlaceDetailView.swift b/SparkApp/Sources/Detail/PlaceDetailView.swift new file mode 100644 index 0000000..cf2ff73 --- /dev/null +++ b/SparkApp/Sources/Detail/PlaceDetailView.swift @@ -0,0 +1,224 @@ +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 { + 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/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..bf887ec --- /dev/null +++ b/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift @@ -0,0 +1,75 @@ +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 { + 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..1f8efe3 --- /dev/null +++ b/SparkApp/Sources/Integrations/IntegrationsListViewModel.swift @@ -0,0 +1,59 @@ +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 { + 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/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..37b0931 --- /dev/null +++ b/SparkApp/Sources/Map/MapView.swift @@ -0,0 +1,158 @@ +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 { + @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) + } + .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..bd7ce4d --- /dev/null +++ b/SparkApp/Sources/Map/MapViewModel.swift @@ -0,0 +1,107 @@ +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 { + 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..c35ca12 --- /dev/null +++ b/SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift @@ -0,0 +1,168 @@ +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 { + 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 { + 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 { + 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 { + 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 { + 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/Search/SearchView.swift b/SparkApp/Sources/Search/SearchView.swift new file mode 100644 index 0000000..23a3df4 --- /dev/null +++ b/SparkApp/Sources/Search/SearchView.swift @@ -0,0 +1,195 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct SearchView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: SearchViewModel? + @State private var path: [DetailRoute] = [] + + 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: .navigationBarDrawer(displayMode: .always), + prompt: "Search events, objects, metrics…" + ) + .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: + EmptyState( + systemImage: "magnifyingglass", + title: "Search Spark", + message: "Try `>` for actions, `#` for tags, `$` for metrics, `@` for integrations, `~` for semantic." + ) + case .searching: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + case .results(let items) where items.isEmpty: + EmptyState( + systemImage: "questionmark.circle", + title: "No matches", + message: "Try a different word or mode." + ) + case .results: + List { + ForEach(viewModel.grouped, id: \.0) { group in + Section(group.0) { + ForEach(group.1) { result in + Button { + 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) + } + } + + 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..04291ae --- /dev/null +++ b/SparkApp/Sources/Search/SearchViewModel.swift @@ -0,0 +1,105 @@ +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 results = try await apiClient.request(SearchEndpoint.query(text: text, mode: mode)) + state = .results(results) + } catch is CancellationError { + return + } catch { + 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/Today/DayPagerView.swift b/SparkApp/Sources/Today/DayPagerView.swift index 3a887f3..fa2a567 100644 --- a/SparkApp/Sources/Today/DayPagerView.swift +++ b/SparkApp/Sources/Today/DayPagerView.swift @@ -6,7 +6,7 @@ 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 @@ -20,8 +20,21 @@ struct DayPagerView: View { .tabViewStyle(.page(indexDisplayMode: .never)) .ignoresSafeArea(edges: .top) .toolbar(.hidden, for: .navigationBar) - .navigationDestination(for: EventRoute.self) { route in - EventDetailView(eventId: route.id) + .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) + } } } .onChange(of: appModel.pendingRoute) { _, route in @@ -40,11 +53,28 @@ 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) { + // Avoid duplicate pushes when the deep link fires twice in quick + // succession (Safari sometimes dispatches scene + onOpenURL). + 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 @@ -56,8 +86,15 @@ struct DayPagerView: View { } } -struct EventRoute: Hashable { - let id: String +/// Detail destinations pushed onto the Today tab's `NavigationStack`. New +/// detail surfaces should add a case here and a destination clause above. +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 { From 3a2a11a9fc865b655f43a11c7819f7f31b8a8478 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 26 Apr 2026 11:13:06 +0100 Subject: [PATCH 04/10] :sparkles: Phase 2 Week 3 D11: Settings tree Real SettingsRootView replaces SettingsPlaceholderView. Adds Profile, Devices, API Tokens, About and Debug sub-views wired to new SparkKit endpoints (MeEndpoint, DevicesEndpoint, ApiTokensEndpoint) and models (UserProfile, RegisteredDevice, ApiToken, CreatedApiToken). AppModel.bootstrap() now registers the current device on every login. Co-Authored-By: Claude Sonnet 4.6 --- .../API/Endpoints/ApiTokensEndpoint.swift | 19 ++ .../API/Endpoints/DevicesEndpoint.swift | 24 +++ .../SparkKit/API/Endpoints/MeEndpoint.swift | 8 + .../Sources/SparkKit/Models/ApiToken.swift | 36 ++++ .../SparkKit/Models/RegisteredDevice.swift | 23 +++ .../Sources/SparkKit/Models/UserProfile.swift | 22 +++ SparkApp/Sources/App/AppModel.swift | 10 + SparkApp/Sources/App/MainTabView.swift | 2 +- .../Sources/App/SettingsPlaceholderView.swift | 41 ---- SparkApp/Sources/Settings/AboutView.swift | 32 +++ SparkApp/Sources/Settings/ApiTokensView.swift | 187 ++++++++++++++++++ .../Sources/Settings/ApiTokensViewModel.swift | 53 +++++ SparkApp/Sources/Settings/DebugView.swift | 86 ++++++++ SparkApp/Sources/Settings/DevicesView.swift | 89 +++++++++ .../Sources/Settings/DevicesViewModel.swift | 41 ++++ .../Settings/HealthKitScopesView.swift | 12 ++ .../NotificationsPreferencesView.swift | 12 ++ SparkApp/Sources/Settings/ProfileView.swift | 76 +++++++ .../Sources/Settings/ProfileViewModel.swift | 29 +++ .../Sources/Settings/SettingsRootView.swift | 80 ++++++++ 20 files changed, 840 insertions(+), 42 deletions(-) create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/ApiTokensEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/DevicesEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/MeEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/ApiToken.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/RegisteredDevice.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/UserProfile.swift delete mode 100644 SparkApp/Sources/App/SettingsPlaceholderView.swift create mode 100644 SparkApp/Sources/Settings/AboutView.swift create mode 100644 SparkApp/Sources/Settings/ApiTokensView.swift create mode 100644 SparkApp/Sources/Settings/ApiTokensViewModel.swift create mode 100644 SparkApp/Sources/Settings/DebugView.swift create mode 100644 SparkApp/Sources/Settings/DevicesView.swift create mode 100644 SparkApp/Sources/Settings/DevicesViewModel.swift create mode 100644 SparkApp/Sources/Settings/HealthKitScopesView.swift create mode 100644 SparkApp/Sources/Settings/NotificationsPreferencesView.swift create mode 100644 SparkApp/Sources/Settings/ProfileView.swift create mode 100644 SparkApp/Sources/Settings/ProfileViewModel.swift create mode 100644 SparkApp/Sources/Settings/SettingsRootView.swift 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/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/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/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/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/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/SparkApp/Sources/App/AppModel.swift b/SparkApp/Sources/App/AppModel.swift index 610fe44..49b1fd5 100644 --- a/SparkApp/Sources/App/AppModel.swift +++ b/SparkApp/Sources/App/AppModel.swift @@ -64,11 +64,21 @@ final class AppModel { func bootstrap() async { if await tokenStore.accessToken() != nil { session = .loggedIn + await registerDevice() } else { session = .loggedOut } } + 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) diff --git a/SparkApp/Sources/App/MainTabView.swift b/SparkApp/Sources/App/MainTabView.swift index e1e34b2..6888460 100644 --- a/SparkApp/Sources/App/MainTabView.swift +++ b/SparkApp/Sources/App/MainTabView.swift @@ -29,7 +29,7 @@ struct MainTabView: View { .badge(unreadNotifications.count) .tag(Tab.notifications) - SettingsPlaceholderView() + SettingsRootView() .tabItem { Label("Settings", systemImage: "gearshape") } .tag(Tab.settings) } diff --git a/SparkApp/Sources/App/SettingsPlaceholderView.swift b/SparkApp/Sources/App/SettingsPlaceholderView.swift deleted file mode 100644 index 801652c..0000000 --- a/SparkApp/Sources/App/SettingsPlaceholderView.swift +++ /dev/null @@ -1,41 +0,0 @@ -import SparkUI -import SwiftUI - -/// Holds the Settings tab until the full tree lands in Week 3 of Phase 2. -/// Keeps Sign Out reachable so dogfood builds can rotate accounts. -struct SettingsPlaceholderView: View { - @Environment(AppModel.self) private var appModel - - var body: some View { - NavigationStack { - Form { - Section { - NavigationLink { - IntegrationsListView() - } label: { - Label("Integrations", systemImage: "link") - } - } header: { - Text("Connections") - } - - Section { - Text("Profile, notifications, HealthKit scopes, devices, API tokens, About, and Debug land in Week 3 of Phase 2.") - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - } - - Section { - Button(role: .destructive) { - Task { await appModel.signOut() } - } label: { - Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") - } - } header: { - Text("Account") - } - } - .navigationTitle("Settings") - } - } -} 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..6700fbb --- /dev/null +++ b/SparkApp/Sources/Settings/HealthKitScopesView.swift @@ -0,0 +1,12 @@ +import SparkUI +import SwiftUI + +/// HealthKit permission scopes view. Populated fully on D14. +struct HealthKitScopesView: View { + var body: some View { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Health & Activity") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/SparkApp/Sources/Settings/NotificationsPreferencesView.swift b/SparkApp/Sources/Settings/NotificationsPreferencesView.swift new file mode 100644 index 0000000..740434b --- /dev/null +++ b/SparkApp/Sources/Settings/NotificationsPreferencesView.swift @@ -0,0 +1,12 @@ +import SparkUI +import SwiftUI + +/// Notification preferences. Populated fully on D12. +struct NotificationsPreferencesView: View { + var body: some View { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Notifications") + .navigationBarTitleDisplayMode(.inline) + } +} 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") + } + } +} From 2969289d297435a15f7788bc593d6818cec8943b Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 26 Apr 2026 11:15:48 +0100 Subject: [PATCH 05/10] :sparkles: Phase 2 Week 3 D12: Notification preferences Real NotificationsPreferencesView with per-category toggles, delivery mode segmented picker, and conditional digest-time DatePicker. Saves on every edit with 500ms debounce. NotificationPreferences model and NotificationsPreferencesEndpoint (GET/PATCH /settings/notifications) land in SparkKit. Co-Authored-By: Claude Sonnet 4.6 --- .../NotificationsPreferencesEndpoint.swift | 14 ++ .../Models/NotificationPreferences.swift | 83 ++++++++++ .../NotificationsPreferencesView.swift | 145 +++++++++++++++++- .../NotificationsPreferencesViewModel.swift | 64 ++++++++ 4 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/NotificationsPreferencesEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/NotificationPreferences.swift create mode 100644 SparkApp/Sources/Settings/NotificationsPreferencesViewModel.swift 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/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/SparkApp/Sources/Settings/NotificationsPreferencesView.swift b/SparkApp/Sources/Settings/NotificationsPreferencesView.swift index 740434b..87b3297 100644 --- a/SparkApp/Sources/Settings/NotificationsPreferencesView.swift +++ b/SparkApp/Sources/Settings/NotificationsPreferencesView.swift @@ -1,12 +1,147 @@ +import SparkKit import SparkUI import SwiftUI -/// Notification preferences. Populated fully on D12. struct NotificationsPreferencesView: View { + @Environment(AppModel.self) private var appModel + @State private var viewModel: NotificationsPreferencesViewModel? + var body: some View { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .navigationTitle("Notifications") - .navigationBarTitleDisplayMode(.inline) + 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)) + } + } +} From 18d4129888ca7a566031f1f5504a65da5305b476 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 26 Apr 2026 11:23:43 +0100 Subject: [PATCH 06/10] :sparkles: Phase 2 Week 3 D13-D14: SparkHealth ingestion pipeline Full SparkHealth package: HealthKitPermissionManager (3-wave progressive auth), HealthKitTypeMap, HealthKitAnchorStore, HealthKitObserver (HKObserverQuery + HKAnchoredObjectQuery), HealthSampleUploader (background URLSession). HealthSample + HealthEndpoint in SparkKit. HealthKitScopesView in Settings. AppDelegate wired for background URL session events. Background-delivery entitlement added. Co-Authored-By: Claude Sonnet 4.6 --- Packages/SparkHealth/Package.swift | 10 +- .../SparkHealth/HealthKitAnchorStore.swift | 31 +++++ .../SparkHealth/HealthKitObserver.swift | 126 ++++++++++++++++++ .../HealthKitPermissionManager.swift | 109 +++++++++++++++ .../SparkHealth/HealthKitTypeMap.swift | 72 ++++++++++ .../SparkHealth/HealthSampleUploader.swift | 112 ++++++++++++++++ .../Sources/SparkHealth/SparkHealth.swift | 6 +- .../SparkHealthTests/SparkHealthTests.swift | 61 +++++++++ .../API/Endpoints/HealthEndpoint.swift | 9 ++ .../SparkKit/Models/HealthSample.swift | 59 ++++++++ Project.swift | 2 + SparkApp/Sources/App/AppModel.swift | 12 +- .../Settings/HealthKitScopesView.swift | 88 +++++++++++- SparkApp/Sources/SparkApp.swift | 25 ++++ SparkApp/SparkApp.entitlements | 2 + 15 files changed, 713 insertions(+), 11 deletions(-) create mode 100644 Packages/SparkHealth/Sources/SparkHealth/HealthKitAnchorStore.swift create mode 100644 Packages/SparkHealth/Sources/SparkHealth/HealthKitObserver.swift create mode 100644 Packages/SparkHealth/Sources/SparkHealth/HealthKitPermissionManager.swift create mode 100644 Packages/SparkHealth/Sources/SparkHealth/HealthKitTypeMap.swift create mode 100644 Packages/SparkHealth/Sources/SparkHealth/HealthSampleUploader.swift create mode 100644 Packages/SparkHealth/Tests/SparkHealthTests/SparkHealthTests.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/HealthEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/HealthSample.swift 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/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/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/Project.swift b/Project.swift index ff8bb56..3d36640 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)]), ]) @@ -194,6 +195,7 @@ let sparkApp: Target = .target( dependencies: [ .package(product: "SparkKit"), .package(product: "SparkUI"), + .package(product: "SparkHealth"), .package(product: "Sentry"), .target(name: "SparkWidgets"), .target(name: "SparkControls"), diff --git a/SparkApp/Sources/App/AppModel.swift b/SparkApp/Sources/App/AppModel.swift index 49b1fd5..76473e3 100644 --- a/SparkApp/Sources/App/AppModel.swift +++ b/SparkApp/Sources/App/AppModel.swift @@ -1,6 +1,7 @@ import Foundation import Observation import Sentry +import SparkHealth import SparkKit import SwiftData @@ -45,6 +46,7 @@ final class AppModel { let etagCache: ETagCache let apiClient: APIClient let authService: AuthenticationService + let healthPermissions = HealthKitPermissionManager.shared var session: SessionState = .unknown var lastError: String? @@ -62,14 +64,22 @@ final class AppModel { } func bootstrap() async { - if await tokenStore.accessToken() != nil { + if let token = await tokenStore.accessToken() { session = .loggedIn await registerDevice() + configureHealthUploader(accessToken: token) } else { session = .loggedOut } } + 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 diff --git a/SparkApp/Sources/Settings/HealthKitScopesView.swift b/SparkApp/Sources/Settings/HealthKitScopesView.swift index 6700fbb..bd3cf39 100644 --- a/SparkApp/Sources/Settings/HealthKitScopesView.swift +++ b/SparkApp/Sources/Settings/HealthKitScopesView.swift @@ -1,12 +1,90 @@ +import SparkHealth import SparkUI import SwiftUI -/// HealthKit permission scopes view. Populated fully on D14. struct HealthKitScopesView: View { + @Environment(AppModel.self) private var appModel + + private var mgr: HealthKitPermissionManager { appModel.healthPermissions } + var body: some View { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .navigationTitle("Health & Activity") - .navigationBarTitleDisplayMode(.inline) + 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/SparkApp.swift b/SparkApp/Sources/SparkApp.swift index 0d1618a..5279d6a 100644 --- a/SparkApp/Sources/SparkApp.swift +++ b/SparkApp/Sources/SparkApp.swift @@ -1,4 +1,5 @@ import Sentry +import SparkHealth import SparkKit import SparkUI import SwiftData @@ -6,6 +7,7 @@ import SwiftUI @main struct SparkApp: App { + @UIApplicationDelegateAdaptor(SparkAppDelegate.self) var appDelegate @State private var model = AppModel.shared init() { @@ -20,10 +22,27 @@ struct SparkApp: App { .modelContainer(model.container) .tint(.sparkAccent) .sparkDynamicTypeClamp() + .task(id: model.session) { + if model.session == .loggedIn { + HealthKitObserver.shared.startObserving() + } + } } } } +/// Temporary AppDelegate adaptor. Handles background URLSession events for +/// HealthKit sample uploads. Will be consolidated in Week 4 D16. +final class SparkAppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping @Sendable () -> Void + ) { + HealthSampleUploader.shared.addCompletionHandler(completionHandler, for: identifier) + } +} + enum SparkObservability { static let dsn = "https://1583f3671989ff49f2e578e5cef8ace9@sentry.cronx.co/5" @@ -60,6 +79,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/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 From 9fac0e4c719810f81a8ceeb62640726cde82d625 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 26 Apr 2026 11:28:57 +0100 Subject: [PATCH 07/10] :sparkles: Phase 2 Week 3 D15: OnboardingFlow + CheckIn modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full 7-step onboarding NavigationStack (Hero→SignIn→HealthKit×3→ Notifications→Location→Done) with progress persistence in App Group UserDefaults. RootView shows flow on first launch and resumes from last step. CheckInModalView replaces placeholder: mood chips (5 states with coloured fill), multi-select tag chips, 500-char note, POST to /check-ins with local UserDefaults persistence. TodaySnapshot reads persisted entries so CheckInCard flips to logged state after entry. Co-Authored-By: Claude Sonnet 4.6 --- .../API/Endpoints/CheckInsEndpoint.swift | 16 ++ .../Sources/SparkKit/Models/CheckIn.swift | 22 ++ SparkApp/Sources/App/RootView.swift | 13 +- .../Sources/CheckIn/CheckInModalView.swift | 212 ++++++++++++++++++ .../Sources/Onboarding/OnboardingFlow.swift | 76 +++++++ .../Sources/Onboarding/Steps/DoneStep.swift | 40 ++++ .../Onboarding/Steps/HealthKitWaveStep.swift | 130 +++++++++++ .../Sources/Onboarding/Steps/HeroStep.swift | 67 ++++++ .../Onboarding/Steps/LocationStep.swift | 61 +++++ .../Onboarding/Steps/NotificationsStep.swift | 74 ++++++ .../Sources/Onboarding/Steps/SignInStep.swift | 73 ++++++ SparkApp/Sources/Today/TodaySnapshot.swift | 34 ++- SparkApp/Sources/Today/TodayView.swift | 9 +- 13 files changed, 817 insertions(+), 10 deletions(-) create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/CheckInsEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift create mode 100644 SparkApp/Sources/CheckIn/CheckInModalView.swift create mode 100644 SparkApp/Sources/Onboarding/OnboardingFlow.swift create mode 100644 SparkApp/Sources/Onboarding/Steps/DoneStep.swift create mode 100644 SparkApp/Sources/Onboarding/Steps/HealthKitWaveStep.swift create mode 100644 SparkApp/Sources/Onboarding/Steps/HeroStep.swift create mode 100644 SparkApp/Sources/Onboarding/Steps/LocationStep.swift create mode 100644 SparkApp/Sources/Onboarding/Steps/NotificationsStep.swift create mode 100644 SparkApp/Sources/Onboarding/Steps/SignInStep.swift 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/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/SparkApp/Sources/App/RootView.swift b/SparkApp/Sources/App/RootView.swift index 98b1898..1383d45 100644 --- a/SparkApp/Sources/App/RootView.swift +++ b/SparkApp/Sources/App/RootView.swift @@ -4,6 +4,9 @@ import SwiftUI struct RootView: View { @Environment(AppModel.self) private var model + @State private var onboardingComplete: Bool = { + UserDefaults(suiteName: "group.co.cronx.spark")?.bool(forKey: "onboarding.completed") == true + }() var body: some View { Group { @@ -12,9 +15,13 @@ struct RootView: View { ProgressView() .task { await model.bootstrap() } case .loggedOut: - LoginView() + OnboardingFlow(isComplete: $onboardingComplete) case .loggedIn: - MainTabView() + if onboardingComplete { + MainTabView() + } else { + OnboardingFlow(isComplete: $onboardingComplete) + } } } .onOpenURL(perform: handle(url:)) @@ -24,7 +31,7 @@ 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): 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/Onboarding/OnboardingFlow.swift b/SparkApp/Sources/Onboarding/OnboardingFlow.swift new file mode 100644 index 0000000..bb202fd --- /dev/null +++ b/SparkApp/Sources/Onboarding/OnboardingFlow.swift @@ -0,0 +1,76 @@ +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 } + guard let raw = UserDefaults(suiteName: "group.co.cronx.spark")?.string(forKey: "onboarding.lastStep"), + let last = Step(rawValue: raw) + else { return } + // Re-build the path up to and including the last step + let ordered = Step.allCases + guard let idx = ordered.firstIndex(of: last) 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/Today/TodaySnapshot.swift b/SparkApp/Sources/Today/TodaySnapshot.swift index c86c4ea..78cca5f 100644 --- a/SparkApp/Sources/Today/TodaySnapshot.swift +++ b/SparkApp/Sources/Today/TodaySnapshot.swift @@ -2,6 +2,7 @@ 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. /// @@ -25,14 +26,15 @@ struct TodaySnapshot { self.date = date self.timeOfDay = SparkTimeOfDay.from(date: now) self.dateLabel = Self.dateFormatter.string(from: date) - self.health = HealthSnapshot(summary?.sections.health) - self.activity = ActivitySnapshot(summary?.sections.activity) - self.money = MoneySnapshot(summary?.sections.money) - self.media = MediaSnapshot(summary?.sections.media) - self.knowledge = KnowledgeSnapshot(summary?.sections.knowledge) + 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() - self.checkInStatus = .pending(slot: SparkTimeOfDay.from(date: now)) + let slot = SparkTimeOfDay.from(date: now) + self.checkInStatus = Self.loadCheckIn(date: date, slot: slot) } private static let dateFormatter: DateFormatter = { @@ -41,6 +43,26 @@ struct TodaySnapshot { return f }() + private static func loadCheckIn(date: Date, slot: SparkTimeOfDay) -> CheckInStatus { + let dateKey = isoDate(date) + let key = "checkin_\(dateKey)_\(slot.rawValue)" + guard let data = UserDefaults(suiteName: "group.co.cronx.spark")?.data(forKey: key) else { + return .pending(slot: slot) + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let entry = try? decoder.decode(CheckIn.self, from: data) else { + return .pending(slot: slot) + } + return .logged(mood: entry.mood, note: entry.note) + } + + private static func isoDate(_ date: Date) -> String { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f.string(from: date) + } + private static func buildHeatmapRows() -> [DomainHeatmapRow] { // Backend `/api/v1/mobile/heatmap` doesn't exist yet — Phase 2 ships a // deterministic placeholder so the UX lands now and we swap data diff --git a/SparkApp/Sources/Today/TodayView.swift b/SparkApp/Sources/Today/TodayView.swift index e415cde..f522b66 100644 --- a/SparkApp/Sources/Today/TodayView.swift +++ b/SparkApp/Sources/Today/TodayView.swift @@ -58,7 +58,14 @@ struct TodayView: View { .scrollContentBackground(.hidden) .background(TodayBackground(snapshot.timeOfDay)) .refreshable { await viewModel?.refresh() } - .sheet(isPresented: $showCheckIn) { CheckInPlaceholderView() } + .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) + } + } .task(id: date) { if viewModel == nil { viewModel = TodayViewModel( From d24bdb5c30bd15dbb2106cc433944d67126f39ef Mon Sep 17 00:00:00 2001 From: Will Date: Sat, 2 May 2026 16:25:13 +0100 Subject: [PATCH 08/10] :sparkles: Phase 2 build-out --- .gitignore | 1 + .../Sources/SparkKit/API/APIClient.swift | 21 ++- .../SparkKit/API/Endpoints/FeedEndpoint.swift | 13 ++ .../SparkKit/Auth/KeychainTokenStore.swift | 2 +- .../Sources/SparkKit/Models/Anomaly.swift | 48 ++++- .../Sources/SparkKit/Models/DaySummary.swift | 10 +- SparkApp/Sources/App/AppModel.swift | 3 + SparkApp/Sources/App/MainTabView.swift | 58 ++---- SparkApp/Sources/App/RootView.swift | 10 +- SparkApp/Sources/Detail/BlockDetailView.swift | 1 + .../Sources/Detail/EventDetailViewModel.swift | 1 + .../Sources/Detail/MetricDetailView.swift | 1 + .../Sources/Detail/ObjectDetailView.swift | 1 + SparkApp/Sources/Detail/PlaceDetailView.swift | 1 + SparkApp/Sources/Explore/ExploreView.swift | 101 ++++++++++ .../Sources/Explore/HealthExploreView.swift | 61 ++++++ .../Sources/Explore/MetricsExploreView.swift | 92 +++++++++ .../Sources/Explore/MoneyExploreView.swift | 65 +++++++ SparkApp/Sources/Flint/FlintView.swift | 57 ++++++ .../IntegrationDetailViewModel.swift | 1 + .../IntegrationsListViewModel.swift | 1 + .../Sources/Knowledge/KnowledgeView.swift | 51 +++++ SparkApp/Sources/Map/MapView.swift | 3 + SparkApp/Sources/Map/MapViewModel.swift | 1 + .../NotificationsInboxViewModel.swift | 5 + .../Sources/Onboarding/OnboardingFlow.swift | 12 +- SparkApp/Sources/Search/SearchView.swift | 3 +- SparkApp/Sources/Search/SearchViewModel.swift | 1 + .../Sources/Today/Cards/FeedSection.swift | 65 +++++++ SparkApp/Sources/Today/Cards/MoneyCard.swift | 2 +- SparkApp/Sources/Today/DayPagerView.swift | 13 +- SparkApp/Sources/Today/TodaySnapshot.swift | 90 +++++---- SparkApp/Sources/Today/TodayView.swift | 177 +++++++++++++----- SparkApp/Sources/Today/TodayViewModel.swift | 36 ++++ 34 files changed, 845 insertions(+), 163 deletions(-) create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/FeedEndpoint.swift create mode 100644 SparkApp/Sources/Explore/ExploreView.swift create mode 100644 SparkApp/Sources/Explore/HealthExploreView.swift create mode 100644 SparkApp/Sources/Explore/MetricsExploreView.swift create mode 100644 SparkApp/Sources/Explore/MoneyExploreView.swift create mode 100644 SparkApp/Sources/Flint/FlintView.swift create mode 100644 SparkApp/Sources/Knowledge/KnowledgeView.swift create mode 100644 SparkApp/Sources/Today/Cards/FeedSection.swift 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/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift b/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift index 3ce564a..5751732 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift @@ -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 } @@ -108,6 +119,11 @@ public actor APIClient { 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) } } 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..e365f0c --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FeedEndpoint.swift @@ -0,0 +1,13 @@ +import Foundation + +public enum FeedEndpoint { + /// GET /feed — cursor-paginated reverse-chronological event feed. + public static func feed(cursor: String? = nil, limit: Int = 20) -> Endpoint> { + var query: [URLQueryItem] = [] + if let cursor { + query.append(URLQueryItem(name: "cursor", value: cursor)) + } + query.append(URLQueryItem(name: "limit", value: String(limit))) + return Endpoint(method: .get, path: "/feed", query: query) + } +} 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/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/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/SparkApp/Sources/App/AppModel.swift b/SparkApp/Sources/App/AppModel.swift index 76473e3..7d15129 100644 --- a/SparkApp/Sources/App/AppModel.swift +++ b/SparkApp/Sources/App/AppModel.swift @@ -49,6 +49,7 @@ final class AppModel { let healthPermissions = HealthKitPermissionManager.shared var session: SessionState = .unknown + var onboardingComplete: Bool var lastError: String? var pendingRoute: AppRoute? @@ -61,10 +62,12 @@ final class AppModel { self.etagCache = etagCache self.apiClient = client self.authService = AuthenticationService(tokenStore: tokenStore, apiClient: client) + self.onboardingComplete = UserDefaults(suiteName: "group.co.cronx.spark")?.bool(forKey: "onboarding.completed") == true } func bootstrap() async { if let token = await tokenStore.accessToken() { + onboardingComplete = true session = .loggedIn await registerDevice() configureHealthUploader(accessToken: token) diff --git a/SparkApp/Sources/App/MainTabView.swift b/SparkApp/Sources/App/MainTabView.swift index 6888460..798bd76 100644 --- a/SparkApp/Sources/App/MainTabView.swift +++ b/SparkApp/Sources/App/MainTabView.swift @@ -1,57 +1,37 @@ import SparkKit import SparkUI -import SwiftData import SwiftUI struct MainTabView: View { @Environment(AppModel.self) private var model - @State private var selection: Tab = .today - @Query(filter: #Predicate { !$0.isRead }) - private var unreadNotifications: [CachedNotification] + @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) - - MapView() - .tabItem { Label("Map", systemImage: "map") } - .tag(Tab.map) - - SearchView() - .tabItem { Label("Search", systemImage: "magnifyingglass") } - .tag(Tab.search) - - NotificationsInboxView() - .tabItem { Label("Inbox", systemImage: "bell") } - .badge(unreadNotifications.count) - .tag(Tab.notifications) - - SettingsRootView() - .tabItem { Label("Settings", systemImage: "gearshape") } - .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, map, search, notifications, 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 1383d45..292cb66 100644 --- a/SparkApp/Sources/App/RootView.swift +++ b/SparkApp/Sources/App/RootView.swift @@ -4,23 +4,21 @@ import SwiftUI struct RootView: View { @Environment(AppModel.self) private var model - @State private var onboardingComplete: Bool = { - UserDefaults(suiteName: "group.co.cronx.spark")?.bool(forKey: "onboarding.completed") == true - }() var body: some View { + @Bindable var model = model Group { switch model.session { case .unknown: ProgressView() .task { await model.bootstrap() } case .loggedOut: - OnboardingFlow(isComplete: $onboardingComplete) + OnboardingFlow(isComplete: $model.onboardingComplete) case .loggedIn: - if onboardingComplete { + if model.onboardingComplete { MainTabView() } else { - OnboardingFlow(isComplete: $onboardingComplete) + OnboardingFlow(isComplete: $model.onboardingComplete) } } } diff --git a/SparkApp/Sources/Detail/BlockDetailView.swift b/SparkApp/Sources/Detail/BlockDetailView.swift index f604ef7..6dd4e39 100644 --- a/SparkApp/Sources/Detail/BlockDetailView.swift +++ b/SparkApp/Sources/Detail/BlockDetailView.swift @@ -23,6 +23,7 @@ final class BlockDetailViewModel { } catch APIError.notModified { return } catch { + SparkObservability.captureHandled(error) let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) state = .error(msg) } diff --git a/SparkApp/Sources/Detail/EventDetailViewModel.swift b/SparkApp/Sources/Detail/EventDetailViewModel.swift index cb13463..cf3284c 100644 --- a/SparkApp/Sources/Detail/EventDetailViewModel.swift +++ b/SparkApp/Sources/Detail/EventDetailViewModel.swift @@ -30,6 +30,7 @@ final class EventDetailViewModel { // Already loaded — keep current state. return } catch { + SparkObservability.captureHandled(error) let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) state = .error(message) } diff --git a/SparkApp/Sources/Detail/MetricDetailView.swift b/SparkApp/Sources/Detail/MetricDetailView.swift index 536735f..cd27634 100644 --- a/SparkApp/Sources/Detail/MetricDetailView.swift +++ b/SparkApp/Sources/Detail/MetricDetailView.swift @@ -27,6 +27,7 @@ final class MetricDetailViewModel { } catch APIError.notModified { return } catch { + SparkObservability.captureHandled(error) let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) state = .error(msg) } diff --git a/SparkApp/Sources/Detail/ObjectDetailView.swift b/SparkApp/Sources/Detail/ObjectDetailView.swift index 512ea28..72ed768 100644 --- a/SparkApp/Sources/Detail/ObjectDetailView.swift +++ b/SparkApp/Sources/Detail/ObjectDetailView.swift @@ -23,6 +23,7 @@ final class ObjectDetailViewModel { } catch APIError.notModified { return } catch { + SparkObservability.captureHandled(error) let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) state = .error(msg) } diff --git a/SparkApp/Sources/Detail/PlaceDetailView.swift b/SparkApp/Sources/Detail/PlaceDetailView.swift index cf2ff73..0f05b10 100644 --- a/SparkApp/Sources/Detail/PlaceDetailView.swift +++ b/SparkApp/Sources/Detail/PlaceDetailView.swift @@ -25,6 +25,7 @@ final class PlaceDetailViewModel { } catch APIError.notModified { return } catch { + SparkObservability.captureHandled(error) let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) state = .error(msg) } 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..5873d58 --- /dev/null +++ b/SparkApp/Sources/Explore/HealthExploreView.swift @@ -0,0 +1,61 @@ +import SparkUI +import SwiftUI + +struct HealthExploreView: View { + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: SparkSpacing.lg) { + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + GlassCardHeader( + icon: "figure.walk", + tint: .domainActivity, + title: "Activity" + ) + EmptyState( + systemImage: "figure.walk.circle", + title: "Activity Rings", + message: "Steps, calories, exercise and stand hours — coming in Phase 3." + ) + } + } + + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + GlassCardHeader( + icon: "moon.zzz.fill", + tint: .sparkOcean, + title: "Sleep" + ) + EmptyState( + systemImage: "bed.double", + title: "Sleep Analysis", + message: "Duration, quality, bedtime and wake trends — coming in Phase 3." + ) + } + } + + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + GlassCardHeader( + icon: "heart.fill", + tint: .domainHealth, + title: "Heart & Recovery" + ) + EmptyState( + systemImage: "waveform.path.ecg", + title: "Heart Rate & HRV", + message: "Resting HR, heart rate variability and respiratory rate — coming in Phase 3." + ) + } + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.xl) + } + .navigationTitle("Health") + .navigationBarTitleDisplayMode(.large) + } + } +} diff --git a/SparkApp/Sources/Explore/MetricsExploreView.swift b/SparkApp/Sources/Explore/MetricsExploreView.swift new file mode 100644 index 0000000..698401b --- /dev/null +++ b/SparkApp/Sources/Explore/MetricsExploreView.swift @@ -0,0 +1,92 @@ +import SparkUI +import SwiftUI + +struct MetricsExploreView: View { + @State private var filterDomain: MetricDomain? = nil + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: SparkSpacing.lg) { + domainFilter + .padding(.horizontal, SparkSpacing.lg) + + ForEach(visibleCategories, id: \.title) { category in + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + GlassCardHeader( + icon: category.icon, + tint: category.tint, + title: category.title + ) + EmptyState( + systemImage: category.icon, + title: "No data yet", + message: "Metrics will appear here once your integrations sync." + ) + } + } + .padding(.horizontal, SparkSpacing.lg) + } + } + .padding(.vertical, SparkSpacing.xl) + } + .navigationTitle("Metrics") + .navigationBarTitleDisplayMode(.large) + } + } + + 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) + } + } + } + } + + private var visibleCategories: [MetricCategory] { + if let filter = filterDomain { + MetricCategory.all.filter { $0.domain == filter } + } else { + MetricCategory.all + } + } +} + +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 + + static let all: [MetricCategory] = [ + .init(domain: .health, title: "Sleep Score", icon: "moon.zzz.fill", tint: .sparkOcean), + .init(domain: .health, title: "Heart Rate", icon: "heart.fill", tint: .domainHealth), + .init(domain: .activity, title: "Steps", icon: "figure.walk", tint: .domainActivity), + .init(domain: .activity, title: "Calories", icon: "flame.fill", tint: .domainActivity), + .init(domain: .money, title: "Daily Spend", icon: "sterlingsign.circle.fill", tint: .domainMoney), + .init(domain: .media, title: "Screen Time", icon: "iphone", tint: .domainMedia), + ] +} diff --git a/SparkApp/Sources/Explore/MoneyExploreView.swift b/SparkApp/Sources/Explore/MoneyExploreView.swift new file mode 100644 index 0000000..fd154a7 --- /dev/null +++ b/SparkApp/Sources/Explore/MoneyExploreView.swift @@ -0,0 +1,65 @@ +import SparkUI +import SwiftUI + +struct MoneyExploreView: View { + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: SparkSpacing.lg) { + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + GlassCardHeader( + icon: "sterlingsign.circle.fill", + tint: .domainMoney, + title: "Spending Overview" + ) + HStack(spacing: SparkSpacing.sm) { + SpendingPeriodCell(period: "Today", amount: "—") + SpendingPeriodCell(period: "This Week", amount: "—") + SpendingPeriodCell(period: "This Month", amount: "—") + } + } + } + + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + GlassCardHeader( + icon: "list.bullet.rectangle", + tint: .domainMoney, + title: "Transactions" + ) + EmptyState( + systemImage: "creditcard", + title: "No transactions yet", + message: "Connect a bank integration to see your transactions here." + ) + } + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.xl) + } + .navigationTitle("Money") + .navigationBarTitleDisplayMode(.large) + } + } +} + +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/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/IntegrationDetailViewModel.swift b/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift index bf887ec..97f2f1b 100644 --- a/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift +++ b/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift @@ -34,6 +34,7 @@ final class IntegrationDetailViewModel { } catch APIError.notModified { return } catch { + SparkObservability.captureHandled(error) let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) state = .error(msg) } diff --git a/SparkApp/Sources/Integrations/IntegrationsListViewModel.swift b/SparkApp/Sources/Integrations/IntegrationsListViewModel.swift index 1f8efe3..59158f7 100644 --- a/SparkApp/Sources/Integrations/IntegrationsListViewModel.swift +++ b/SparkApp/Sources/Integrations/IntegrationsListViewModel.swift @@ -29,6 +29,7 @@ final class IntegrationsListViewModel { } 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) diff --git a/SparkApp/Sources/Knowledge/KnowledgeView.swift b/SparkApp/Sources/Knowledge/KnowledgeView.swift new file mode 100644 index 0000000..cea9ffd --- /dev/null +++ b/SparkApp/Sources/Knowledge/KnowledgeView.swift @@ -0,0 +1,51 @@ +import SparkUI +import SwiftUI + +struct KnowledgeView: View { + private let placeholderTags = [ + "swift", "ios", "productivity", "health", "reading", + "work", "travel", "recipes", "finance", "notes", + ] + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: SparkSpacing.lg) { + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + GlassCardHeader( + icon: "bookmark.fill", + tint: .domainKnowledge, + title: "Bookmarks" + ) + EmptyState( + systemImage: "bookmark.circle", + title: "No bookmarks yet", + message: "Save articles, links and notes from the share sheet — coming in Phase 3." + ) + } + } + + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + GlassCardHeader( + icon: "tag.fill", + tint: .domainKnowledge, + title: "Tags" + ) + TagChipRow(placeholderTags, allowAdd: false) + .opacity(0.4) + Text("Tag your events, blocks and objects to organise your knowledge base.") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.vertical, SparkSpacing.xl) + } + .navigationTitle("Knowledge") + .navigationBarTitleDisplayMode(.large) + } + } +} diff --git a/SparkApp/Sources/Map/MapView.swift b/SparkApp/Sources/Map/MapView.swift index 37b0931..b4e334f 100644 --- a/SparkApp/Sources/Map/MapView.swift +++ b/SparkApp/Sources/Map/MapView.swift @@ -7,6 +7,8 @@ import SwiftUI /// 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] = [] @@ -33,6 +35,7 @@ struct MapView: View { } .navigationTitle("Map") .navigationBarTitleDisplayMode(.inline) + .toolbar(isEmbedded ? .hidden : .visible, for: .navigationBar) } .task { if viewModel == nil { diff --git a/SparkApp/Sources/Map/MapViewModel.swift b/SparkApp/Sources/Map/MapViewModel.swift index bd7ce4d..12ed4f5 100644 --- a/SparkApp/Sources/Map/MapViewModel.swift +++ b/SparkApp/Sources/Map/MapViewModel.swift @@ -81,6 +81,7 @@ final class MapViewModel { } 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." } diff --git a/SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift b/SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift index c35ca12..04657b4 100644 --- a/SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift +++ b/SparkApp/Sources/Notifications/NotificationsInboxViewModel.swift @@ -41,6 +41,7 @@ final class NotificationsInboxViewModel { } catch APIError.notModified { state = .loaded } catch { + SparkObservability.captureHandled(error) logger.error("Notifications fetch failed: \(String(describing: error))") state = .error("Couldn't load notifications.") } @@ -56,6 +57,7 @@ final class NotificationsInboxViewModel { nextCursor = page.nextCursor await persist(page.data, replaceAll: false) } catch { + SparkObservability.captureHandled(error) logger.error("Notifications load-more failed: \(String(describing: error))") } } @@ -77,6 +79,7 @@ final class NotificationsInboxViewModel { _ = 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))") } } @@ -97,6 +100,7 @@ final class NotificationsInboxViewModel { _ = try await apiClient.request(NotificationsEndpoint.markAllRead()) await updateAllReadFlag(isRead: true) } catch { + SparkObservability.captureHandled(error) logger.error("markAllRead failed: \(String(describing: error))") } } @@ -107,6 +111,7 @@ final class NotificationsInboxViewModel { _ = try await apiClient.request(NotificationsEndpoint.delete(id: id)) await removeCached(id: id) } catch { + SparkObservability.captureHandled(error) logger.error("delete failed: \(String(describing: error))") } } diff --git a/SparkApp/Sources/Onboarding/OnboardingFlow.swift b/SparkApp/Sources/Onboarding/OnboardingFlow.swift index bb202fd..aa01d97 100644 --- a/SparkApp/Sources/Onboarding/OnboardingFlow.swift +++ b/SparkApp/Sources/Onboarding/OnboardingFlow.swift @@ -65,12 +65,14 @@ struct OnboardingFlow: View { private func restoreProgress() { guard model.session == .loggedIn else { return } - guard let raw = UserDefaults(suiteName: "group.co.cronx.spark")?.string(forKey: "onboarding.lastStep"), - let last = Step(rawValue: raw) - else { return } - // Re-build the path up to and including the last step + 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: last) else { return } + guard let idx = ordered.firstIndex(of: effective) else { return } path = Array(ordered[...idx]) } } diff --git a/SparkApp/Sources/Search/SearchView.swift b/SparkApp/Sources/Search/SearchView.swift index 23a3df4..196e131 100644 --- a/SparkApp/Sources/Search/SearchView.swift +++ b/SparkApp/Sources/Search/SearchView.swift @@ -30,9 +30,10 @@ struct SearchView: View { } .searchable( text: queryBinding, - placement: .navigationBarDrawer(displayMode: .always), + placement: .automatic, prompt: "Search events, objects, metrics…" ) + .searchToolbarBehavior(.minimize) .task { if viewModel == nil { viewModel = SearchViewModel(apiClient: appModel.apiClient) diff --git a/SparkApp/Sources/Search/SearchViewModel.swift b/SparkApp/Sources/Search/SearchViewModel.swift index 04291ae..71aacef 100644 --- a/SparkApp/Sources/Search/SearchViewModel.swift +++ b/SparkApp/Sources/Search/SearchViewModel.swift @@ -87,6 +87,7 @@ final class SearchViewModel { } catch is CancellationError { return } catch { + SparkObservability.captureHandled(error) logger.error("Search failed: \(String(describing: error))") state = .error("Couldn't search.") } 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/MoneyCard.swift b/SparkApp/Sources/Today/Cards/MoneyCard.swift index 4575697..f404c20 100644 --- a/SparkApp/Sources/Today/Cards/MoneyCard.swift +++ b/SparkApp/Sources/Today/Cards/MoneyCard.swift @@ -32,7 +32,7 @@ struct MoneyCard: View { .lineLimit(1) .truncationMode(.tail) Spacer(minLength: SparkSpacing.sm) - Text(MoneySnapshot.format(minor: abs(tx.amountMinor), currency: money.currency)) + Text(MoneySnapshot.format(amount: abs(tx.amount), currency: tx.currency)) .font(SparkTypography.monoSmall) .foregroundStyle(.secondary) .monospacedDigit() diff --git a/SparkApp/Sources/Today/DayPagerView.swift b/SparkApp/Sources/Today/DayPagerView.swift index fa2a567..ef0daee 100644 --- a/SparkApp/Sources/Today/DayPagerView.swift +++ b/SparkApp/Sources/Today/DayPagerView.swift @@ -1,5 +1,6 @@ import SparkKit import SparkUI +import SwiftData import SwiftUI struct DayPagerView: View { @@ -18,8 +19,9 @@ struct DayPagerView: View { } } .tabViewStyle(.page(indexDisplayMode: .never)) - .ignoresSafeArea(edges: .top) + .ignoresSafeArea() .toolbar(.hidden, for: .navigationBar) + .toolbarBackground(.hidden, for: .navigationBar) .navigationDestination(for: DetailRoute.self) { route in switch route { case .event(let id): @@ -69,8 +71,6 @@ struct DayPagerView: View { } private func push(_ route: DetailRoute) { - // Avoid duplicate pushes when the deep link fires twice in quick - // succession (Safari sometimes dispatches scene + onOpenURL). if path.last == route { return } path.append(route) } @@ -80,14 +80,12 @@ struct DayPagerView: View { selectedOffset = match.offset return } - // Outside the default window — rebuild anchored on the requested date. dates = DayKey.window(anchor: date) selectedOffset = 0 } } -/// Detail destinations pushed onto the Today tab's `NavigationStack`. New -/// detail surfaces should add a case here and a destination clause above. +/// Detail destinations pushed onto the Day tab's `NavigationStack`. enum DetailRoute: Hashable { case event(id: String) case object(id: String) @@ -105,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)) } @@ -120,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 index 78cca5f..33dbb8b 100644 --- a/SparkApp/Sources/Today/TodaySnapshot.swift +++ b/SparkApp/Sources/Today/TodaySnapshot.swift @@ -93,14 +93,16 @@ struct HealthSnapshot { init?(_ payload: [String: AnyCodable]?) { guard let payload, !payload.isEmpty else { return nil } - sleepScore = payload["sleep_score"]?.intValue - sleepDurationMinutes = payload["sleep_duration_minutes"]?.intValue - bedtime = payload["bedtime"]?.stringValue - wakeTime = payload["wake_time"]?.stringValue - restingHeartRate = payload["resting_heart_rate"]?.intValue - hrvOvernight = payload["hrv_overnight"]?.intValue - deepMinutes = payload["deep_minutes"]?.intValue - remMinutes = payload["rem_minutes"]?.intValue + 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 } } } @@ -136,15 +138,19 @@ struct ActivitySnapshot { init?(_ payload: [String: AnyCodable]?) { guard let payload, !payload.isEmpty else { return nil } - steps = payload["steps"]?.intValue - stepsGoal = payload["steps_goal"]?.intValue ?? 10_000 - activeCalories = payload["active_calories"]?.intValue - activeCaloriesGoal = payload["active_calories_goal"]?.intValue ?? 600 - exerciseMinutes = payload["exercise_minutes"]?.intValue - exerciseGoal = payload["exercise_goal"]?.intValue ?? 30 - standHours = payload["stand_hours"]?.intValue - standGoal = payload["stand_goal"]?.intValue ?? 12 - lastWorkout = payload["last_workout"]?.stringValue + 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 } } @@ -152,49 +158,51 @@ struct MoneySnapshot { struct Transaction: Identifiable { let id: String let merchant: String - let amountMinor: Int + let amount: Double + let currency: String let category: String? let time: String? } - let spentTodayMinor: Int? + let spentToday: Double? let currency: String let recent: [Transaction] - var hasAny: Bool { spentTodayMinor != nil || !recent.isEmpty } + var hasAny: Bool { spentToday != nil || !recent.isEmpty } var spentTodayDisplay: String? { - guard let spentTodayMinor else { return nil } - return Self.format(minor: abs(spentTodayMinor), currency: currency) + guard let spentToday else { return nil } + return Self.format(amount: abs(spentToday), currency: currency) } - static func format(minor: Int, currency: String) -> String { - let value = Double(minor) / 100 + 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: value)) ?? "\(value)" + return formatter.string(from: NSNumber(value: amount)) ?? "\(amount)" } init?(_ payload: [String: AnyCodable]?) { guard let payload, !payload.isEmpty else { return nil } - spentTodayMinor = payload["spent_today_minor"]?.intValue - currency = payload["spent_today_currency"]?.stringValue ?? "GBP" - let array = payload["recent"]?.arrayValue ?? [] + 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_minor"]?.intValue + let amount = obj["amount"]?.doubleValue else { return nil } + let txId = obj["id"]?.stringValue ?? "tx_\(idx)_\(merchant)" return Transaction( - id: "tx_\(idx)_\(merchant)", + id: txId, merchant: merchant, - amountMinor: amount, + amount: amount, + currency: obj["currency"]?.stringValue ?? "GBP", category: obj["category"]?.stringValue, time: obj["time"]?.stringValue ) } + currency = recent.first?.currency ?? "GBP" } } @@ -233,21 +241,11 @@ struct KnowledgeSnapshot { init?(_ payload: [String: AnyCodable]?) { guard let payload, !payload.isEmpty else { return nil } - bookmarksToday = payload["bookmarks_today"]?.intValue - newsletterStatus = payload["newsletter_status"]?.stringValue - if let event = payload["next_calendar_event"]?.objectValue, - let title = event["title"]?.stringValue, - let start = event["start"]?.stringValue, - let end = event["end"]?.stringValue { - nextCalendarEvent = CalendarEvent( - title: title, - start: start, - end: end, - location: event["location"]?.stringValue - ) - } else { - nextCalendarEvent = 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 } } diff --git a/SparkApp/Sources/Today/TodayView.swift b/SparkApp/Sources/Today/TodayView.swift index f522b66..91bf2bc 100644 --- a/SparkApp/Sources/Today/TodayView.swift +++ b/SparkApp/Sources/Today/TodayView.swift @@ -2,62 +2,84 @@ import SparkKit import SparkUI import SwiftData import SwiftUI +import UIKit struct TodayView: View { let date: Date @Environment(AppModel.self) private var appModel @State private var viewModel: TodayViewModel? @State private var showCheckIn = false + @State private var showSettings = false + @State private var showNotifications = false + + @Query(filter: #Predicate { !$0.isRead }) + private var unreadNotifications: [CachedNotification] + @Query private var allIntegrations: [CachedIntegration] + + private var errorIntegrations: [CachedIntegration] { + let healthy: Set = ["up_to_date", "ok", "active", "syncing", "running"] + return allIntegrations.filter { !healthy.contains($0.status) } + } var body: some View { let snapshot = TodaySnapshot(summary: viewModel?.cached, date: date) - ScrollView { - VStack(alignment: .leading, spacing: SparkSpacing.lg) { - hero(snapshot: snapshot) + ZStack { + TodayBackground(snapshot.timeOfDay) + .ignoresSafeArea() - anomalyPill(for: snapshot) + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + hero(snapshot: snapshot) - if let health = snapshot.health, health.hasSleep { - SleepCard(health: health) - } + anomalyPill(for: snapshot) - 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 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 media = snapshot.media, media.hasAny { + MediaCard(media: media) + } - if let next = snapshot.knowledge?.nextCalendarEvent { - UpNextCard(event: next) - } + if let next = snapshot.knowledge?.nextCalendarEvent { + UpNextCard(event: next) + } - CheckInCard(status: snapshot.checkInStatus) { - showCheckIn = true - } + CheckInCard(status: snapshot.checkInStatus) { + showCheckIn = true + } - if !snapshot.hasAnyDomainData { - loadingOrEmptyState - } + FeedSection(date: date) - HeatmapSection(rows: snapshot.heatmapRows) - .padding(.top, SparkSpacing.md) + 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) } - .padding(.horizontal, SparkSpacing.lg) - .padding(.vertical, SparkSpacing.xl) + .scrollContentBackground(.hidden) + .refreshable { await viewModel?.refresh() } + + headerButtons } - .scrollContentBackground(.hidden) - .background(TodayBackground(snapshot.timeOfDay)) - .refreshable { await viewModel?.refresh() } + .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 { @@ -66,6 +88,12 @@ struct TodayView: View { CheckInModalView(slot: SparkTimeOfDay.from(date: .now).rawValue, date: date) } } + .sheet(isPresented: $showSettings) { + SettingsRootView() + } + .sheet(isPresented: $showNotifications) { + NotificationsInboxView() + } .task(id: date) { if viewModel == nil { viewModel = TodayViewModel( @@ -78,25 +106,71 @@ struct TodayView: View { } } + // MARK: - Header buttons + + private var headerButtons: some View { + SparkGlassStack(spacing: 0) { + HStack(spacing: 0) { + Button { + showSettings = true + } label: { + Image(systemName: "gearshape") + .font(.body.weight(.semibold)) + .foregroundStyle(errorIntegrations.isEmpty ? Color.primary : Color.sparkError) + .frame(width: 36, height: 36) + .sparkGlass(.circle) + } + .accessibilityLabel("Settings") + + Rectangle() + .fill(Color.primary.opacity(0.12)) + .frame(width: 1, height: 22) + + Button { + showNotifications = true + } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: "bell") + .font(.body.weight(.semibold)) + .foregroundStyle(unreadNotifications.isEmpty ? Color.primary : Color.sparkAccent) + .frame(width: 36, height: 36) + .sparkGlass(.circle) + if !unreadNotifications.isEmpty { + Circle() + .fill(Color.sparkError) + .frame(width: 9, height: 9) + .offset(x: 3, y: -3) + } + } + } + .accessibilityLabel( + unreadNotifications.isEmpty + ? "Notifications" + : "Notifications, \(unreadNotifications.count) unread" + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.top, deviceSafeAreaTop + SparkSpacing.xl) + .padding(.trailing, SparkSpacing.lg) + } + // MARK: - Hero private func hero(snapshot: TodaySnapshot) -> some View { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - Text(snapshot.dateLabel.uppercased()) - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - + let isDark = snapshot.timeOfDay.prefersDarkTreatment + return VStack(alignment: .leading, spacing: SparkSpacing.sm) { Text(heroTitle(snapshot: snapshot)) - .font(SparkFonts.display(.largeTitle, weight: .bold)) + .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(.secondary) + .foregroundStyle(isDark ? Color.white.opacity(0.7) : Color.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -107,11 +181,26 @@ struct TodayView: View { 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 var deviceSafeAreaTop: CGFloat { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene }.first? + .keyWindow?.safeAreaInsets.top ?? 59 + } + + private var deviceSafeAreaBottom: CGFloat { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene }.first? + .keyWindow?.safeAreaInsets.bottom ?? 34 + } + private var firstName: String { // TODO: source from /me endpoint when Settings → Profile lands. "Will" @@ -148,7 +237,9 @@ struct TodayView: View { } else { StatusPill( .warning, - message: snapshot.anomalies.first?.description ?? "Anomaly detected", + message: snapshot.anomalies.first?.displayName + ?? snapshot.anomalies.first?.metric + ?? "Anomaly detected", trailing: "\(snapshot.anomalies.count) anomal\(snapshot.anomalies.count == 1 ? "y" : "ies")" ) } 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) From b536b5f255b63f77ec7f9463290793f6615b4cc0 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 3 May 2026 15:28:18 +0100 Subject: [PATCH 09/10] :sparkles: Live data --- .../Sources/SparkControlsBundle.swift | 70 +++- .../Sources/SparkIntentsBundle.swift | 26 +- .../DailyActivityLiveActivityViews.swift | 98 ++++++ .../Sources/SleepLiveActivityViews.swift | 97 ++++++ .../Sources/SparkLiveActivitiesBundle.swift | 102 ++++-- .../Sources/NotificationService.swift | 64 +++- .../Sources/ShareViewController.swift | 162 ++++++++- .../Sources/LockScreenWidgets.swift | 157 +++++++++ .../Sources/NextEventWidget.swift | 69 ++++ .../SparkWidgets/Sources/RingView.swift | 92 +++++ .../Sources/SleepScoreWidget.swift | 77 +++++ .../Sources/SparkTimelineProvider.swift | 35 ++ .../Sources/SparkWidgetsBundle.swift | 16 +- .../Sources/SpendTodayWidget.swift | 63 ++++ .../SparkWidgets/Sources/StandByWidget.swift | 83 +++++ .../Sources/StepsRingWidget.swift | 58 ++++ .../Sources/TodayDashboardWidget.swift | 130 +++++++ .../Sources/TodayGlanceWidget.swift | 92 +++++ .../Sources/WidgetDataSnapshot.swift | 165 +++++++++ .../SparkIntelligence/ActionIntents.swift | 207 +++++++++++ .../SparkIntelligence/IntentService.swift | 93 +++++ .../SparkIntelligence/ReadIntents.swift | 80 +++++ .../SparkIntelligence/SparkIntelligence.swift | 7 +- .../SparkIntelligence/SparkShortcuts.swift | 71 ++++ .../SparkIntelligence/SpotlightIndexer.swift | 135 ++++++++ .../Sources/SparkKit/API/APIEnvironment.swift | 43 ++- .../SparkKit/API/Endpoints/FeedEndpoint.swift | 6 +- .../Endpoints/LiveActivitiesEndpoint.swift | 30 ++ .../SparkKit/API/Endpoints/SyncEndpoint.swift | 30 ++ .../API/Endpoints/WidgetsEndpoint.swift | 8 + .../Models/DailyActivityAttributes.swift | 46 +++ .../Sources/SparkKit/Models/Event.swift | 42 ++- .../Sources/SparkKit/Models/EventDetail.swift | 22 ++ .../SparkKit/Models/MetricDetail.swift | 112 +++++- .../Sources/SparkKit/Models/Page.swift | 7 +- .../Models/SleepActivityAttributes.swift | 62 ++++ .../Sources/SparkKit/Models/SpendWidget.swift | 39 +++ .../EventDetailDecodingTests.swift | 62 ++++ Packages/SparkSync/Package.swift | 6 +- .../Sources/SparkSync/BGTaskCoordinator.swift | 141 ++++++++ .../Sources/SparkSync/DeltaSyncer.swift | 111 ++++++ .../Sources/SparkSync/ReverbClient.swift | 327 ++++++++++++++++++ .../Sources/SparkSync/SilentPushHandler.swift | 77 +++++ .../Sources/SparkSync/SparkSync.swift | 12 +- Project.swift | 9 + SparkApp/Sources/App/AppModel.swift | 67 ++++ .../Sources/App/LiveActivityManager.swift | 138 ++++++++ .../Sources/Explore/HealthExploreView.swift | 262 +++++++++++--- .../Explore/HealthExploreViewModel.swift | 62 ++++ .../Sources/Explore/MetricsExploreView.swift | 185 +++++++++- .../Explore/MetricsExploreViewModel.swift | 54 +++ .../Sources/Explore/MoneyExploreView.swift | 252 ++++++++++++-- .../Explore/MoneyExploreViewModel.swift | 50 +++ .../Knowledge/KnowledgeItemDetailView.swift | 190 ++++++++++ .../Sources/Knowledge/KnowledgeView.swift | 247 +++++++++++-- .../Knowledge/KnowledgeViewModel.swift | 83 +++++ SparkApp/Sources/Search/SearchView.swift | 120 ++++++- SparkApp/Sources/SparkApp.swift | 171 ++++++++- 58 files changed, 5096 insertions(+), 226 deletions(-) create mode 100644 Extensions/SparkLiveActivities/Sources/DailyActivityLiveActivityViews.swift create mode 100644 Extensions/SparkLiveActivities/Sources/SleepLiveActivityViews.swift create mode 100644 Extensions/SparkWidgets/Sources/LockScreenWidgets.swift create mode 100644 Extensions/SparkWidgets/Sources/NextEventWidget.swift create mode 100644 Extensions/SparkWidgets/Sources/RingView.swift create mode 100644 Extensions/SparkWidgets/Sources/SleepScoreWidget.swift create mode 100644 Extensions/SparkWidgets/Sources/SparkTimelineProvider.swift create mode 100644 Extensions/SparkWidgets/Sources/SpendTodayWidget.swift create mode 100644 Extensions/SparkWidgets/Sources/StandByWidget.swift create mode 100644 Extensions/SparkWidgets/Sources/StepsRingWidget.swift create mode 100644 Extensions/SparkWidgets/Sources/TodayDashboardWidget.swift create mode 100644 Extensions/SparkWidgets/Sources/TodayGlanceWidget.swift create mode 100644 Extensions/SparkWidgets/Sources/WidgetDataSnapshot.swift create mode 100644 Packages/SparkIntelligence/Sources/SparkIntelligence/ActionIntents.swift create mode 100644 Packages/SparkIntelligence/Sources/SparkIntelligence/IntentService.swift create mode 100644 Packages/SparkIntelligence/Sources/SparkIntelligence/ReadIntents.swift create mode 100644 Packages/SparkIntelligence/Sources/SparkIntelligence/SparkShortcuts.swift create mode 100644 Packages/SparkIntelligence/Sources/SparkIntelligence/SpotlightIndexer.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/LiveActivitiesEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/SyncEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/API/Endpoints/WidgetsEndpoint.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/DailyActivityAttributes.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/SleepActivityAttributes.swift create mode 100644 Packages/SparkKit/Sources/SparkKit/Models/SpendWidget.swift create mode 100644 Packages/SparkKit/Tests/SparkKitTests/EventDetailDecodingTests.swift create mode 100644 Packages/SparkSync/Sources/SparkSync/BGTaskCoordinator.swift create mode 100644 Packages/SparkSync/Sources/SparkSync/DeltaSyncer.swift create mode 100644 Packages/SparkSync/Sources/SparkSync/ReverbClient.swift create mode 100644 Packages/SparkSync/Sources/SparkSync/SilentPushHandler.swift create mode 100644 SparkApp/Sources/App/LiveActivityManager.swift create mode 100644 SparkApp/Sources/Explore/HealthExploreViewModel.swift create mode 100644 SparkApp/Sources/Explore/MetricsExploreViewModel.swift create mode 100644 SparkApp/Sources/Explore/MoneyExploreViewModel.swift create mode 100644 SparkApp/Sources/Knowledge/KnowledgeItemDetailView.swift create mode 100644 SparkApp/Sources/Knowledge/KnowledgeViewModel.swift 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/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/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/FeedEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FeedEndpoint.swift index e365f0c..5d32ce7 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FeedEndpoint.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/FeedEndpoint.swift @@ -2,12 +2,16 @@ import Foundation public enum FeedEndpoint { /// GET /feed — cursor-paginated reverse-chronological event feed. - public static func feed(cursor: String? = nil, limit: Int = 20) -> Endpoint> { + /// 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/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/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/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/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 index ee526ab..9d43006 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/EventDetail.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/EventDetail.swift @@ -81,4 +81,26 @@ public struct EventDetail: Codable, Sendable, Hashable, Identifiable { 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/MetricDetail.swift b/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift index 07e7503..ebc40ea 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/MetricDetail.swift @@ -3,7 +3,7 @@ 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: Codable, Sendable, Hashable, Identifiable { +public struct MetricDetail: Sendable, Hashable, Identifiable { public let id: String public let title: String public let domain: String @@ -16,7 +16,7 @@ public struct MetricDetail: Codable, Sendable, Hashable, Identifiable { public let anomalies: [AnomalyPoint] public let compares: [Compare]? - public struct Baseline: Codable, Sendable, Hashable { + public struct Baseline: Sendable, Hashable { public let low: Double public let high: Double public init(low: Double, high: Double) { @@ -25,7 +25,7 @@ public struct MetricDetail: Codable, Sendable, Hashable, Identifiable { } } - public struct Point: Codable, Sendable, Hashable, Identifiable { + public struct Point: Sendable, Hashable, Identifiable { public let date: Date public let value: Double public var id: Date { date } @@ -35,7 +35,7 @@ public struct MetricDetail: Codable, Sendable, Hashable, Identifiable { } } - public struct AnomalyPoint: Codable, Sendable, Hashable, Identifiable { + public struct AnomalyPoint: Sendable, Hashable, Identifiable { public let id: String public let date: Date public let severity: String @@ -50,7 +50,7 @@ public struct MetricDetail: Codable, Sendable, Hashable, Identifiable { } } - public struct Compare: Codable, Sendable, Hashable, Identifiable { + public struct Compare: Sendable, Hashable, Identifiable { public let label: String public let value: Double public let delta: Double? @@ -62,11 +62,6 @@ public struct MetricDetail: Codable, Sendable, Hashable, Identifiable { } } - enum CodingKeys: String, CodingKey { - case id, title, domain, unit, today, yesterday, baseline, series, anomalies, compares - case average30d = "average_30d" - } - public init( id: String, title: String, @@ -94,6 +89,103 @@ public struct MetricDetail: Codable, Sendable, Hashable, Identifiable { } } +// 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. diff --git a/Packages/SparkKit/Sources/SparkKit/Models/Page.swift b/Packages/SparkKit/Sources/SparkKit/Models/Page.swift index 9db5ada..a6b7773 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/Page.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/Page.swift @@ -1,18 +1,21 @@ import Foundation /// Cursor-paginated response wrapper used by paginated mobile endpoints. -/// Mirrors the backend's `{ "data": [...], "next_cursor": "..." }` shape. +/// 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) { + 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/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/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/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/Project.swift b/Project.swift index 3d36640..89837f0 100644 --- a/Project.swift +++ b/Project.swift @@ -57,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": [ [ @@ -196,6 +201,8 @@ let sparkApp: Target = .target( .package(product: "SparkKit"), .package(product: "SparkUI"), .package(product: "SparkHealth"), + .package(product: "SparkIntelligence"), + .package(product: "SparkSync"), .package(product: "Sentry"), .target(name: "SparkWidgets"), .target(name: "SparkControls"), @@ -229,6 +236,7 @@ let sparkWidgets: Target = .target( dependencies: [ .package(product: "SparkKit"), .package(product: "SparkUI"), + .package(product: "SparkIntelligence"), ], settings: sharedSettings(bundleId: "\(bundleIdBase).Widgets") ) @@ -290,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 7d15129..b884ce8 100644 --- a/SparkApp/Sources/App/AppModel.swift +++ b/SparkApp/Sources/App/AppModel.swift @@ -3,7 +3,9 @@ import Observation import Sentry import SparkHealth import SparkKit +import SparkSync import SwiftData +import WidgetKit enum SessionState: Equatable { case unknown @@ -47,6 +49,7 @@ final class AppModel { let apiClient: APIClient let authService: AuthenticationService let healthPermissions = HealthKitPermissionManager.shared + let reverb: ReverbClient var session: SessionState = .unknown var onboardingComplete: Bool @@ -62,6 +65,7 @@ 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 } @@ -70,12 +74,75 @@ final class AppModel { 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(), 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/Explore/HealthExploreView.swift b/SparkApp/Sources/Explore/HealthExploreView.swift index 5873d58..f1429f5 100644 --- a/SparkApp/Sources/Explore/HealthExploreView.swift +++ b/SparkApp/Sources/Explore/HealthExploreView.swift @@ -1,61 +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 { + NavigationStack(path: $path) { ScrollView { VStack(spacing: SparkSpacing.lg) { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "figure.walk", - tint: .domainActivity, - title: "Activity" - ) - EmptyState( - systemImage: "figure.walk.circle", - title: "Activity Rings", - message: "Steps, calories, exercise and stand hours — coming in Phase 3." - ) - } - } - - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "moon.zzz.fill", - tint: .sparkOcean, - title: "Sleep" - ) - EmptyState( - systemImage: "bed.double", - title: "Sleep Analysis", - message: "Duration, quality, bedtime and wake trends — coming in Phase 3." - ) - } - } - - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "heart.fill", - tint: .domainHealth, - title: "Heart & Recovery" - ) - EmptyState( - systemImage: "waveform.path.ecg", - title: "Heart Rate & HRV", - message: "Resting HR, heart rate variability and respiratory rate — coming in Phase 3." - ) + 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 index 698401b..16e9a5d 100644 --- a/SparkApp/Sources/Explore/MetricsExploreView.swift +++ b/SparkApp/Sources/Explore/MetricsExploreView.swift @@ -1,17 +1,38 @@ +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 { + NavigationStack(path: $path) { ScrollView { VStack(spacing: SparkSpacing.lg) { domainFilter .padding(.horizontal, SparkSpacing.lg) - ForEach(visibleCategories, id: \.title) { category in + ForEach(visibleCategories, id: \.identifier) { category in GlassCard { VStack(alignment: .leading, spacing: SparkSpacing.sm) { GlassCardHeader( @@ -19,11 +40,7 @@ struct MetricsExploreView: View { tint: category.tint, title: category.title ) - EmptyState( - systemImage: category.icon, - title: "No data yet", - message: "Metrics will appear here once your integrations sync." - ) + tileContent(for: category) } } .padding(.horizontal, SparkSpacing.lg) @@ -31,8 +48,26 @@ struct MetricsExploreView: View { } .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) } } @@ -53,15 +88,29 @@ struct MetricsExploreView: View { } } - private var visibleCategories: [MetricCategory] { - if let filter = filterDomain { - MetricCategory.all.filter { $0.domain == filter } + @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 { - MetricCategory.all + 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 @@ -80,13 +129,111 @@ private struct MetricCategory { let title: String let icon: String let tint: Color + let identifier: String +} - static let all: [MetricCategory] = [ - .init(domain: .health, title: "Sleep Score", icon: "moon.zzz.fill", tint: .sparkOcean), - .init(domain: .health, title: "Heart Rate", icon: "heart.fill", tint: .domainHealth), - .init(domain: .activity, title: "Steps", icon: "figure.walk", tint: .domainActivity), - .init(domain: .activity, title: "Calories", icon: "flame.fill", tint: .domainActivity), - .init(domain: .money, title: "Daily Spend", icon: "sterlingsign.circle.fill", tint: .domainMoney), - .init(domain: .media, title: "Screen Time", icon: "iphone", tint: .domainMedia), - ] +// 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 index fd154a7..374856a 100644 --- a/SparkApp/Sources/Explore/MoneyExploreView.swift +++ b/SparkApp/Sources/Explore/MoneyExploreView.swift @@ -1,50 +1,248 @@ +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 { + NavigationStack(path: $path) { ScrollView { VStack(spacing: SparkSpacing.lg) { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "sterlingsign.circle.fill", - tint: .domainMoney, - title: "Spending Overview" - ) - HStack(spacing: SparkSpacing.sm) { - SpendingPeriodCell(period: "Today", amount: "—") - SpendingPeriodCell(period: "This Week", amount: "—") - SpendingPeriodCell(period: "This Month", amount: "—") - } - } - } - - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "list.bullet.rectangle", - tint: .domainMoney, - title: "Transactions" - ) + 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: "creditcard", - title: "No transactions yet", - message: "Connect a bank integration to see your transactions here." - ) + 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 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/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 index cea9ffd..1cc6329 100644 --- a/SparkApp/Sources/Knowledge/KnowledgeView.swift +++ b/SparkApp/Sources/Knowledge/KnowledgeView.swift @@ -1,51 +1,230 @@ +import SparkKit import SparkUI import SwiftUI struct KnowledgeView: View { - private let placeholderTags = [ - "swift", "ios", "productivity", "health", "reading", - "work", "travel", "recipes", "finance", "notes", - ] + @Environment(AppModel.self) private var appModel + @State private var viewModel: KnowledgeViewModel? + @State private var path: [Event] = [] var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: SparkSpacing.lg) { - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "bookmark.fill", - tint: .domainKnowledge, - title: "Bookmarks" - ) - EmptyState( - systemImage: "bookmark.circle", - title: "No bookmarks yet", - message: "Save articles, links and notes from the share sheet — coming in Phase 3." - ) + 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() - GlassCard { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - GlassCardHeader( - icon: "tag.fill", - tint: .domainKnowledge, - title: "Tags" - ) - TagChipRow(placeholderTags, allowAdd: false) - .opacity(0.4) - Text("Tag your events, blocks and objects to organise your knowledge base.") - .font(SparkTypography.bodySmall) + 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(.horizontal, SparkSpacing.lg) - .padding(.vertical, SparkSpacing.xl) + .padding(SparkSpacing.lg) } - .navigationTitle("Knowledge") - .navigationBarTitleDisplayMode(.large) } } + + 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/Search/SearchView.swift b/SparkApp/Sources/Search/SearchView.swift index 196e131..d45fab2 100644 --- a/SparkApp/Sources/Search/SearchView.swift +++ b/SparkApp/Sources/Search/SearchView.swift @@ -2,10 +2,16 @@ 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) { @@ -87,18 +93,14 @@ struct SearchView: View { if let viewModel { switch viewModel.state { case .idle: - EmptyState( - systemImage: "magnifyingglass", - title: "Search Spark", - message: "Try `>` for actions, `#` for tags, `$` for metrics, `@` for integrations, `~` for semantic." - ) + idleState(viewModel: viewModel) case .searching: ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) case .results(let items) where items.isEmpty: EmptyState( - systemImage: "questionmark.circle", - title: "No matches", - message: "Try a different word or mode." + systemImage: "magnifyingglass", + title: "No results for \u{201C}\(viewModel.query)\u{201D}", + message: "Try a shorter search or switch mode." ) case .results: List { @@ -106,6 +108,7 @@ struct SearchView: View { Section(group.0) { ForEach(group.1) { result in Button { + saveRecent(viewModel.query) handleTap(result) } label: { SearchResultRow(result: result) @@ -128,6 +131,107 @@ struct SearchView: View { } } + @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) diff --git a/SparkApp/Sources/SparkApp.swift b/SparkApp/Sources/SparkApp.swift index 5279d6a..a2bc57d 100644 --- a/SparkApp/Sources/SparkApp.swift +++ b/SparkApp/Sources/SparkApp.swift @@ -1,14 +1,19 @@ +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() @@ -27,13 +32,70 @@ struct SparkApp: App { 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, @@ -41,6 +103,109 @@ final class SparkAppDelegate: NSObject, UIApplicationDelegate { ) { 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 { From 583aff0ec9c64222b48c42d29ada60c3c6e98356 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 3 May 2026 16:03:32 +0100 Subject: [PATCH 10/10] :bug: Fix handling bugs --- .../API/Endpoints/SearchEndpoint.swift | 2 +- .../SparkKit/Auth/AuthenticationService.swift | 13 ++++- .../Auth/IntegrationReauthService.swift | 1 - .../SparkKit/Models/SearchResult.swift | 56 +++++++++++++++++++ .../SearchResponseDecodingTests.swift | 38 +++++++++++++ SparkApp/Sources/Search/SearchViewModel.swift | 4 +- 6 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 Packages/SparkKit/Tests/SparkKitTests/SearchResponseDecodingTests.swift diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SearchEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SearchEndpoint.swift index 04226ab..e14f03c 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SearchEndpoint.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SearchEndpoint.swift @@ -35,7 +35,7 @@ public enum SearchEndpoint { } /// GET /search?q=…&mode=… - public static func query(text: String, mode: Mode = .default) -> Endpoint<[SearchResult]> { + public static func query(text: String, mode: Mode = .default) -> Endpoint { Endpoint( method: .get, path: "/search", diff --git a/Packages/SparkKit/Sources/SparkKit/Auth/AuthenticationService.swift b/Packages/SparkKit/Sources/SparkKit/Auth/AuthenticationService.swift index 778db58..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 @@ -39,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 @@ -115,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 index 5b8cf1d..7346551 100644 --- a/Packages/SparkKit/Sources/SparkKit/Auth/IntegrationReauthService.swift +++ b/Packages/SparkKit/Sources/SparkKit/Auth/IntegrationReauthService.swift @@ -1,6 +1,5 @@ import Foundation @preconcurrency import AuthenticationServices -import UIKit public enum IntegrationReauthError: Error, Sendable { case cancelled diff --git a/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift b/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift index 5b1a0b6..a3eeaff 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift @@ -152,3 +152,59 @@ public enum SearchResult: Codable, Sendable, Hashable, Identifiable { 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/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/SparkApp/Sources/Search/SearchViewModel.swift b/SparkApp/Sources/Search/SearchViewModel.swift index 71aacef..3f86e86 100644 --- a/SparkApp/Sources/Search/SearchViewModel.swift +++ b/SparkApp/Sources/Search/SearchViewModel.swift @@ -82,8 +82,8 @@ final class SearchViewModel { private func performSearch(text: String) async { state = .searching do { - let results = try await apiClient.request(SearchEndpoint.query(text: text, mode: mode)) - state = .results(results) + let response = try await apiClient.request(SearchEndpoint.query(text: text, mode: mode)) + state = .results(response.results) } catch is CancellationError { return } catch {