Compare commits
19 Commits
25cdebd1b2
...
kenneth/ar
| Author | SHA1 | Date | |
|---|---|---|---|
|
e818846657
|
|||
|
4439e0e027
|
|||
|
75cfbe8dd4
|
|||
|
8305726f83
|
|||
|
8873c372f0
|
|||
| 22fbfb9790 | |||
| d8929d3776 | |||
| 2860ab0786 | |||
| e15be9ddc4 | |||
| b6ff4e81e9 | |||
| 34838f5ae1 | |||
| b00bf670e6 | |||
| 3b73a092d1 | |||
| 89e985fd39 | |||
| 29a26da043 | |||
| 11ee893367 | |||
|
5af6d38e0f
|
|||
| ceaf4a956e | |||
|
c28e3ecc4b
|
@@ -17,10 +17,14 @@ struct ContentView: View {
|
|||||||
.tabItem { Label("BLE", systemImage: "dot.radiowaves.left.and.right") }
|
.tabItem { Label("BLE", systemImage: "dot.radiowaves.left.and.right") }
|
||||||
OrchestratorView()
|
OrchestratorView()
|
||||||
.tabItem { Label("Orchestrator", systemImage: "bolt.horizontal.circle") }
|
.tabItem { Label("Orchestrator", systemImage: "bolt.horizontal.circle") }
|
||||||
|
TodosView()
|
||||||
|
.tabItem { Label("Todos", systemImage: "checklist") }
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
StockSettingsView(store: stockSettings)
|
StockSettingsView(store: stockSettings)
|
||||||
}
|
}
|
||||||
.tabItem { Label("Stocks", systemImage: "chart.line.uptrend.xyaxis") }
|
.tabItem { Label("Stocks", systemImage: "chart.line.uptrend.xyaxis") }
|
||||||
|
SettingsView()
|
||||||
|
.tabItem { Label("Settings", systemImage: "gearshape") }
|
||||||
}
|
}
|
||||||
.onAppear { orchestrator.start() }
|
.onAppear { orchestrator.start() }
|
||||||
}
|
}
|
||||||
@@ -30,10 +34,12 @@ struct ContentView_Previews: PreviewProvider {
|
|||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
let ble = BlePeripheralManager()
|
let ble = BlePeripheralManager()
|
||||||
let orchestrator = ContextOrchestrator(ble: ble)
|
let spotifyAuth = SpotifyAuthManager()
|
||||||
|
let orchestrator = ContextOrchestrator(ble: ble, spotifyAuth: spotifyAuth)
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(ble)
|
.environmentObject(ble)
|
||||||
.environmentObject(orchestrator)
|
.environmentObject(orchestrator)
|
||||||
|
.environmentObject(spotifyAuth)
|
||||||
} else {
|
} else {
|
||||||
ContentView()
|
ContentView()
|
||||||
}
|
}
|
||||||
|
|||||||
193
IrisCompanion/iris/DataSources/TFLDataSource.swift
Normal file
193
IrisCompanion/iris/DataSources/TFLDataSource.swift
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
//
|
||||||
|
// TFLDataSource.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TFLDataSourceConfig: Sendable {
|
||||||
|
var cacheValiditySec: Int = 120
|
||||||
|
var ttlSec: Int = 300
|
||||||
|
var maxDisruptions: Int = 3
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TFLDataSource {
|
||||||
|
struct Disruption: Sendable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let lineName: String
|
||||||
|
let lineId: String
|
||||||
|
let severity: Int
|
||||||
|
let severityDescription: String
|
||||||
|
let reason: String?
|
||||||
|
let isMajor: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TFLData: Sendable, Equatable {
|
||||||
|
let disruptions: [Disruption]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Snapshot: Sendable {
|
||||||
|
let data: TFLData
|
||||||
|
let diagnostics: [String: String]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TFLError: Error, LocalizedError, Sendable {
|
||||||
|
case networkFailed(message: String, diagnostics: [String: String])
|
||||||
|
case invalidResponse(diagnostics: [String: String])
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .networkFailed(let message, _):
|
||||||
|
return message
|
||||||
|
case .invalidResponse:
|
||||||
|
return "Invalid TFL response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Severity 4 = Planned Closure, 5 = Part Closure, 10 = Good Service
|
||||||
|
private static let ignoredSeverities: Set<Int> = [4, 5, 10]
|
||||||
|
// Severity 1 = Closed, 2 = Suspended, 3 = Part Suspended, 6 = Severe Delays
|
||||||
|
private static let majorSeverities: Set<Int> = [1, 2, 3, 6]
|
||||||
|
|
||||||
|
private let config: TFLDataSourceConfig
|
||||||
|
private var cache: (timestamp: Int, data: TFLData)?
|
||||||
|
|
||||||
|
var ttlSec: Int { config.ttlSec }
|
||||||
|
|
||||||
|
init(config: TFLDataSourceConfig = .init()) {
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataWithDiagnostics(now: Int) async throws -> Snapshot {
|
||||||
|
var diagnostics: [String: String] = [
|
||||||
|
"now": String(now),
|
||||||
|
"cache_validity_sec": String(config.cacheValiditySec),
|
||||||
|
]
|
||||||
|
|
||||||
|
if let cache = cache, now - cache.timestamp < config.cacheValiditySec {
|
||||||
|
diagnostics["source"] = "cache"
|
||||||
|
diagnostics["cache_age_sec"] = String(now - cache.timestamp)
|
||||||
|
return Snapshot(data: cache.data, diagnostics: diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = URL(string: "https://api.tfl.gov.uk/Line/Mode/tube,elizabeth-line/Status") else {
|
||||||
|
throw TFLError.invalidResponse(diagnostics: diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
|
diagnostics["http_status"] = String(httpResponse.statusCode)
|
||||||
|
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||||
|
throw TFLError.networkFailed(
|
||||||
|
message: "HTTP \(httpResponse.statusCode)",
|
||||||
|
diagnostics: diagnostics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines = try JSONDecoder().decode([TFLLineStatus].self, from: data)
|
||||||
|
diagnostics["source"] = "network"
|
||||||
|
diagnostics["lines_returned"] = String(lines.count)
|
||||||
|
|
||||||
|
var disruptions: [Disruption] = []
|
||||||
|
var seenLines: Set<String> = []
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
guard !seenLines.contains(line.id) else { continue }
|
||||||
|
|
||||||
|
for status in line.lineStatuses {
|
||||||
|
guard !Self.ignoredSeverities.contains(status.statusSeverity) else { continue }
|
||||||
|
|
||||||
|
seenLines.insert(line.id)
|
||||||
|
let isMajor = Self.majorSeverities.contains(status.statusSeverity)
|
||||||
|
|
||||||
|
let disruption = Disruption(
|
||||||
|
id: "\(line.id):\(status.statusSeverity)",
|
||||||
|
lineName: line.name,
|
||||||
|
lineId: line.id,
|
||||||
|
severity: status.statusSeverity,
|
||||||
|
severityDescription: status.statusSeverityDescription,
|
||||||
|
reason: status.reason ?? status.disruption?.description,
|
||||||
|
isMajor: isMajor
|
||||||
|
)
|
||||||
|
disruptions.append(disruption)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disruptions.sort { $0.severity < $1.severity }
|
||||||
|
|
||||||
|
let limited = Array(disruptions.prefix(config.maxDisruptions))
|
||||||
|
diagnostics["disruptions_found"] = String(disruptions.count)
|
||||||
|
diagnostics["disruptions_returned"] = String(limited.count)
|
||||||
|
|
||||||
|
let tflData = TFLData(disruptions: limited)
|
||||||
|
cache = (timestamp: now, data: tflData)
|
||||||
|
|
||||||
|
return Snapshot(data: tflData, diagnostics: diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func disruptionTitle(_ disruption: Disruption) -> String {
|
||||||
|
var name = disruption.lineName
|
||||||
|
name = name.replacingOccurrences(of: " & City", with: "")
|
||||||
|
name = name.replacingOccurrences(of: "Hammersmith", with: "H'smith")
|
||||||
|
name = name.replacingOccurrences(of: "Metropolitan", with: "Met")
|
||||||
|
name = name.replacingOccurrences(of: "Waterloo", with: "W'loo")
|
||||||
|
name = name.replacingOccurrences(of: "Elizabeth line", with: "Eliz.")
|
||||||
|
|
||||||
|
var severity = disruption.severityDescription
|
||||||
|
severity = severity.replacingOccurrences(of: "Minor Delays", with: "Delays")
|
||||||
|
severity = severity.replacingOccurrences(of: "Severe Delays", with: "Severe")
|
||||||
|
severity = severity.replacingOccurrences(of: "Part Closure", with: "Part Closed")
|
||||||
|
severity = severity.replacingOccurrences(of: "Part Suspended", with: "Part Susp.")
|
||||||
|
|
||||||
|
return "\(name): \(severity)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func disruptionSubtitle(_ disruption: Disruption) -> String {
|
||||||
|
guard let reason = disruption.reason else {
|
||||||
|
return "Check TFL for details"
|
||||||
|
}
|
||||||
|
let phrases = reason.components(separatedBy: ".")
|
||||||
|
if let first = phrases.first?.trimmingCharacters(in: .whitespacesAndNewlines), !first.isEmpty {
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
return "Check TFL for details"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TFL API Response Models
|
||||||
|
|
||||||
|
private struct TFLLineStatus: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let modeName: String
|
||||||
|
let lineStatuses: [LineStatus]
|
||||||
|
|
||||||
|
struct LineStatus: Codable {
|
||||||
|
let statusSeverity: Int
|
||||||
|
let statusSeverityDescription: String
|
||||||
|
let reason: String?
|
||||||
|
let validityPeriods: [ValidityPeriod]?
|
||||||
|
let disruption: Disruption?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ValidityPeriod: Codable {
|
||||||
|
let fromDate: String?
|
||||||
|
let toDate: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Disruption: Codable {
|
||||||
|
let category: String?
|
||||||
|
let description: String?
|
||||||
|
let closureText: String?
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,17 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>sh.nym.iris.spotify-auth</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>iris-spotify-auth</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>MKDirectionsApplicationSupportedModes</key>
|
<key>MKDirectionsApplicationSupportedModes</key>
|
||||||
<array/>
|
<array/>
|
||||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ final class HeuristicRanker {
|
|||||||
switch type {
|
switch type {
|
||||||
case .weatherWarning: return 1.0
|
case .weatherWarning: return 1.0
|
||||||
case .weatherAlert: return 0.9
|
case .weatherAlert: return 0.9
|
||||||
|
case .transitAlert: return 0.85
|
||||||
case .calendarEvent: return 0.8
|
case .calendarEvent: return 0.8
|
||||||
case .transit: return 0.75
|
case .transit: return 0.75
|
||||||
case .poiNearby: return 0.6
|
case .poiNearby: return 0.6
|
||||||
|
|||||||
31
IrisCompanion/iris/Models/TodoItem.swift
Normal file
31
IrisCompanion/iris/Models/TodoItem.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
//
|
||||||
|
// TodoItem.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
// Created by Codex.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TodoItem: Identifiable, Codable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var title: String
|
||||||
|
var isCompleted: Bool
|
||||||
|
var createdAt: Date
|
||||||
|
var updatedAt: Date
|
||||||
|
var completedAt: Date?
|
||||||
|
|
||||||
|
init(id: UUID = UUID(),
|
||||||
|
title: String,
|
||||||
|
isCompleted: Bool = false,
|
||||||
|
createdAt: Date = Date(),
|
||||||
|
updatedAt: Date = Date(),
|
||||||
|
completedAt: Date? = nil) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.isCompleted = isCompleted
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
self.completedAt = completedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
116
IrisCompanion/iris/Models/TodoStore.swift
Normal file
116
IrisCompanion/iris/Models/TodoStore.swift
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
//
|
||||||
|
// TodoStore.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
// Created by Codex.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class TodoStore {
|
||||||
|
private struct Persisted: Codable {
|
||||||
|
var schema: Int?
|
||||||
|
var items: [TodoItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "iris.todostore.queue")
|
||||||
|
private let fileURL: URL
|
||||||
|
private var items: [TodoItem]
|
||||||
|
|
||||||
|
init(filename: String = "todos_v1.json") {
|
||||||
|
self.fileURL = Self.defaultFileURL(filename: filename)
|
||||||
|
let persisted = Self.load(from: fileURL)
|
||||||
|
self.items = persisted?.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> [TodoItem] {
|
||||||
|
queue.sync { items }
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func add(title: String, now: Date = Date()) -> TodoItem {
|
||||||
|
queue.sync {
|
||||||
|
let item = TodoItem(title: title, createdAt: now, updatedAt: now)
|
||||||
|
items.append(item)
|
||||||
|
save()
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateTitle(id: UUID, title: String, now: Date = Date()) -> TodoItem? {
|
||||||
|
queue.sync {
|
||||||
|
guard let index = items.firstIndex(where: { $0.id == id }) else { return nil }
|
||||||
|
var item = items[index]
|
||||||
|
item.title = title
|
||||||
|
item.updatedAt = now
|
||||||
|
items[index] = item
|
||||||
|
save()
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setCompleted(id: UUID, completed: Bool, now: Date = Date()) -> TodoItem? {
|
||||||
|
queue.sync {
|
||||||
|
guard let index = items.firstIndex(where: { $0.id == id }) else { return nil }
|
||||||
|
var item = items[index]
|
||||||
|
item.isCompleted = completed
|
||||||
|
item.updatedAt = now
|
||||||
|
item.completedAt = completed ? now : nil
|
||||||
|
items[index] = item
|
||||||
|
save()
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(id: UUID) {
|
||||||
|
queue.sync {
|
||||||
|
items.removeAll { $0.id == id }
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let persisted = Persisted(schema: 1, items: items)
|
||||||
|
Self.save(persisted, to: fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultFileURL(filename: String) -> URL {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory
|
||||||
|
let bundle = Bundle.main.bundleIdentifier ?? "iris"
|
||||||
|
return base
|
||||||
|
.appendingPathComponent(bundle, isDirectory: true)
|
||||||
|
.appendingPathComponent(filename, isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func load(from url: URL) -> Persisted? {
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
return try? makeDecoder().decode(Persisted.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func save(_ persisted: Persisted, to url: URL) {
|
||||||
|
do {
|
||||||
|
let fm = FileManager.default
|
||||||
|
try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
let data = try makeEncoder().encode(persisted)
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
} catch {
|
||||||
|
// Best-effort persistence.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeEncoder() -> JSONEncoder {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
return encoder
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeDecoder() -> JSONDecoder {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
return decoder
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,5 +17,6 @@ enum FeedItemType: String, Codable, CaseIterable {
|
|||||||
case currentWeather = "CURRENT_WEATHER"
|
case currentWeather = "CURRENT_WEATHER"
|
||||||
case calendarEvent = "CALENDAR_EVENT"
|
case calendarEvent = "CALENDAR_EVENT"
|
||||||
case stock = "STOCK"
|
case stock = "STOCK"
|
||||||
|
case transitAlert = "TRANSIT_ALERT"
|
||||||
case allQuiet = "ALL_QUIET"
|
case allQuiet = "ALL_QUIET"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,18 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
@Published private(set) var lastCalendarDiagnostics: [String: String] = [:]
|
@Published private(set) var lastCalendarDiagnostics: [String: String] = [:]
|
||||||
@Published private(set) var lastPipelineElapsedMs: Int? = nil
|
@Published private(set) var lastPipelineElapsedMs: Int? = nil
|
||||||
@Published private(set) var lastFetchFailed: Bool = false
|
@Published private(set) var lastFetchFailed: Bool = false
|
||||||
|
@Published private(set) var lastTFLDiagnostics: [String: String] = [:]
|
||||||
@Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined
|
@Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined
|
||||||
@Published private(set) var nowPlaying: NowPlayingSnapshot? = nil
|
@Published private(set) var nowPlaying: NowPlayingSnapshot? = nil
|
||||||
|
@Published private(set) var spotifyNowPlaying: SpotifyNowPlaying? = nil
|
||||||
|
@Published var musicSource: MusicSource = .appleMusic {
|
||||||
|
didSet {
|
||||||
|
if oldValue != musicSource {
|
||||||
|
switchMusicMonitor()
|
||||||
|
UserDefaults.standard.set(musicSource.rawValue, forKey: "music_source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "ContextOrchestrator")
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "ContextOrchestrator")
|
||||||
|
|
||||||
@@ -34,11 +44,14 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
private let calendarDataSource = CalendarDataSource()
|
private let calendarDataSource = CalendarDataSource()
|
||||||
private let poiDataSource = POIDataSource()
|
private let poiDataSource = POIDataSource()
|
||||||
private let stockDataSource = StockDataSource()
|
private let stockDataSource = StockDataSource()
|
||||||
|
private let tflDataSource = TFLDataSource()
|
||||||
private let ranker: HeuristicRanker
|
private let ranker: HeuristicRanker
|
||||||
private let store: FeedStore
|
private let store: FeedStore
|
||||||
private let server: LocalServer
|
private let server: LocalServer
|
||||||
private let ble: BlePeripheralManager
|
private let ble: BlePeripheralManager
|
||||||
private let nowPlayingMonitor = NowPlayingMonitor()
|
private let nowPlayingMonitor = NowPlayingMonitor()
|
||||||
|
private let spotifyMonitor: SpotifyNowPlayingMonitor
|
||||||
|
private let spotifyAuth: SpotifyAuthManager
|
||||||
|
|
||||||
private var lastRecomputeLocation: CLLocation? = nil
|
private var lastRecomputeLocation: CLLocation? = nil
|
||||||
private var lastRecomputeAccuracy: CLLocationAccuracy? = nil
|
private var lastRecomputeAccuracy: CLLocationAccuracy? = nil
|
||||||
@@ -47,11 +60,20 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
|
|
||||||
init(store: FeedStore = FeedStore(),
|
init(store: FeedStore = FeedStore(),
|
||||||
server: LocalServer = LocalServer(),
|
server: LocalServer = LocalServer(),
|
||||||
ble: BlePeripheralManager) {
|
ble: BlePeripheralManager,
|
||||||
|
spotifyAuth: SpotifyAuthManager) {
|
||||||
self.store = store
|
self.store = store
|
||||||
self.server = server
|
self.server = server
|
||||||
self.ble = ble
|
self.ble = ble
|
||||||
|
self.spotifyAuth = spotifyAuth
|
||||||
|
self.spotifyMonitor = SpotifyNowPlayingMonitor(authManager: spotifyAuth)
|
||||||
self.ranker = HeuristicRanker(lastShownAt: { id in store.lastShownAt(feedItemId: id) })
|
self.ranker = HeuristicRanker(lastShownAt: { id in store.lastShownAt(feedItemId: id) })
|
||||||
|
|
||||||
|
if let savedSource = UserDefaults.standard.string(forKey: "music_source"),
|
||||||
|
let source = MusicSource(rawValue: savedSource) {
|
||||||
|
self.musicSource = source
|
||||||
|
}
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
locationManager.delegate = self
|
locationManager.delegate = self
|
||||||
@@ -74,13 +96,21 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
|
|
||||||
nowPlayingMonitor.onUpdate = { [weak self] update in
|
nowPlayingMonitor.onUpdate = { [weak self] update in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self else { return }
|
guard let self, self.musicSource == .appleMusic else { return }
|
||||||
self.musicAuthorization = update.authorization
|
self.musicAuthorization = update.authorization
|
||||||
self.nowPlaying = update.snapshot
|
self.nowPlaying = update.snapshot
|
||||||
self.pushLatestFeedToBle()
|
self.pushLatestFeedToBle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spotifyMonitor.onUpdate = { [weak self] update in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self, self.musicSource == .spotify else { return }
|
||||||
|
self.spotifyNowPlaying = update.snapshot
|
||||||
|
self.pushLatestFeedToBle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let feed = store.getFeed()
|
let feed = store.getFeed()
|
||||||
lastFeed = feed
|
lastFeed = feed
|
||||||
}
|
}
|
||||||
@@ -89,7 +119,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
authorization = locationManager.authorizationStatus
|
authorization = locationManager.authorizationStatus
|
||||||
logger.info("start auth=\(String(describing: self.authorization), privacy: .public)")
|
logger.info("start auth=\(String(describing: self.authorization), privacy: .public)")
|
||||||
server.start()
|
server.start()
|
||||||
nowPlayingMonitor.start()
|
startMusicMonitor()
|
||||||
requestPermissionsIfNeeded()
|
requestPermissionsIfNeeded()
|
||||||
locationManager.startUpdatingLocation()
|
locationManager.startUpdatingLocation()
|
||||||
}
|
}
|
||||||
@@ -97,6 +127,27 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
func stop() {
|
func stop() {
|
||||||
locationManager.stopUpdatingLocation()
|
locationManager.stopUpdatingLocation()
|
||||||
nowPlayingMonitor.stop()
|
nowPlayingMonitor.stop()
|
||||||
|
spotifyMonitor.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startMusicMonitor() {
|
||||||
|
switch musicSource {
|
||||||
|
case .appleMusic:
|
||||||
|
nowPlayingMonitor.start()
|
||||||
|
case .spotify:
|
||||||
|
spotifyMonitor.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func switchMusicMonitor() {
|
||||||
|
nowPlayingMonitor.stop()
|
||||||
|
spotifyMonitor.stop()
|
||||||
|
nowPlaying = nil
|
||||||
|
spotifyNowPlaying = nil
|
||||||
|
|
||||||
|
startMusicMonitor()
|
||||||
|
pushLatestFeedToBle()
|
||||||
|
logger.info("Switched music source to \(self.musicSource.rawValue)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func recomputeNow(reason: String = "manual") {
|
func recomputeNow(reason: String = "manual") {
|
||||||
@@ -209,11 +260,15 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
async let stockResult = withTimeoutResult(seconds: 6) {
|
async let stockResult = withTimeoutResult(seconds: 6) {
|
||||||
try await self.stockDataSource.dataWithDiagnostics(symbols: stockSymbols, now: nowEpoch)
|
try await self.stockDataSource.dataWithDiagnostics(symbols: stockSymbols, now: nowEpoch)
|
||||||
}
|
}
|
||||||
|
async let tflResult = withTimeoutResult(seconds: 6) {
|
||||||
|
try await self.tflDataSource.dataWithDiagnostics(now: nowEpoch)
|
||||||
|
}
|
||||||
|
|
||||||
let wxRes = await weatherResult
|
let wxRes = await weatherResult
|
||||||
let calRes = await calendarResult
|
let calRes = await calendarResult
|
||||||
let poiRes = await poiResult
|
let poiRes = await poiResult
|
||||||
let stockRes = await stockResult
|
let stockRes = await stockResult
|
||||||
|
let tflRes = await tflResult
|
||||||
|
|
||||||
func calendarTTL(endAt: Int, now: Int) -> Int {
|
func calendarTTL(endAt: Int, now: Int) -> Int {
|
||||||
let ttl = endAt - now
|
let ttl = endAt - now
|
||||||
@@ -230,6 +285,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
var calendarItems: [FeedItem] = []
|
var calendarItems: [FeedItem] = []
|
||||||
var poiItems: [FeedItem] = []
|
var poiItems: [FeedItem] = []
|
||||||
var stockItems: [FeedItem] = []
|
var stockItems: [FeedItem] = []
|
||||||
|
var tflItems: [FeedItem] = []
|
||||||
var weatherNowItem: FeedItem? = nil
|
var weatherNowItem: FeedItem? = nil
|
||||||
var fetchFailed = false
|
var fetchFailed = false
|
||||||
var wxDiagnostics: [String: String] = [:]
|
var wxDiagnostics: [String: String] = [:]
|
||||||
@@ -334,6 +390,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
if pois.isEmpty {
|
if pois.isEmpty {
|
||||||
logger.info("no points of interests found")
|
logger.info("no points of interests found")
|
||||||
}
|
}
|
||||||
|
// POIs are FYI-only; do not compete for the right-now winner.
|
||||||
for poi in pois.prefix(2) {
|
for poi in pois.prefix(2) {
|
||||||
let subtitle = poiSubtitle(for: poi)
|
let subtitle = poiSubtitle(for: poi)
|
||||||
let confidence = min(max(poi.confidence, 0.0), 1.0)
|
let confidence = min(max(poi.confidence, 0.0), 1.0)
|
||||||
@@ -351,7 +408,6 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
actions: ["DISMISS"]
|
actions: ["DISMISS"]
|
||||||
)
|
)
|
||||||
poiItems.append(item)
|
poiItems.append(item)
|
||||||
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true))
|
|
||||||
}
|
}
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
fetchFailed = true
|
fetchFailed = true
|
||||||
@@ -405,13 +461,49 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
logger.warning("stock fetch failed: \(String(describing: error), privacy: .public)")
|
logger.warning("stock fetch failed: \(String(describing: error), privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tflDiagnostics: [String: String] = [:]
|
||||||
|
switch tflRes {
|
||||||
|
case .success(let snapshot):
|
||||||
|
tflDiagnostics = snapshot.diagnostics
|
||||||
|
for disruption in snapshot.data.disruptions {
|
||||||
|
let confidence: Double = disruption.isMajor ? 0.9 : 0.6
|
||||||
|
let item = FeedItem(
|
||||||
|
id: "tfl:\(disruption.lineId):\(nowEpoch / 300)",
|
||||||
|
type: .transitAlert,
|
||||||
|
title: tflDataSource.disruptionTitle(disruption).truncated(maxLength: TextConstraints.titleMax),
|
||||||
|
subtitle: tflDataSource.disruptionSubtitle(disruption).truncated(maxLength: TextConstraints.subtitleMax),
|
||||||
|
priority: confidence,
|
||||||
|
ttlSec: tflDataSource.ttlSec,
|
||||||
|
condition: nil,
|
||||||
|
startsAt: nil,
|
||||||
|
poiType: nil,
|
||||||
|
bucket: disruption.isMajor ? .rightNow : .fyi,
|
||||||
|
actions: ["DISMISS"]
|
||||||
|
)
|
||||||
|
tflItems.append(item)
|
||||||
|
if disruption.isMajor {
|
||||||
|
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !snapshot.data.disruptions.isEmpty {
|
||||||
|
logger.info("tfl disruptions fetched count=\(snapshot.data.disruptions.count)")
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
if case TimeoutError.timedOut = error {
|
||||||
|
logger.warning("tfl fetch timeout")
|
||||||
|
} else {
|
||||||
|
logger.warning("tfl fetch failed: \(String(describing: error), privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||||
lastPipelineElapsedMs = elapsedMs
|
lastPipelineElapsedMs = elapsedMs
|
||||||
lastFetchFailed = fetchFailed
|
lastFetchFailed = fetchFailed
|
||||||
lastWeatherDiagnostics = wxDiagnostics
|
lastWeatherDiagnostics = wxDiagnostics
|
||||||
lastCalendarDiagnostics = calDiagnostics
|
lastCalendarDiagnostics = calDiagnostics
|
||||||
|
lastTFLDiagnostics = tflDiagnostics
|
||||||
|
|
||||||
logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) poi_items=\(poiItems.count) stock_items=\(stockItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
|
logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) poi_items=\(poiItems.count) stock_items=\(stockItems.count) tfl_items=\(tflItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
|
||||||
|
|
||||||
if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil {
|
if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil {
|
||||||
let fallbackFeed = store.getFeed(now: nowEpoch)
|
let fallbackFeed = store.getFeed(now: nowEpoch)
|
||||||
@@ -422,10 +514,17 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let eligibleUnsuppressed = rightNowCandidates.filter { ranked in
|
let poiCandidateCount = rightNowCandidates.filter { $0.item.type == .poiNearby }.count
|
||||||
!store.isSuppressed(id: ranked.item.id, type: ranked.item.type, now: nowEpoch)
|
if poiCandidateCount > 0 {
|
||||||
|
logger.warning("dropping poi candidates from right-now ranking count=\(poiCandidateCount)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let eligibleUnsuppressed = rightNowCandidates
|
||||||
|
.filter { $0.item.type != .poiNearby }
|
||||||
|
.filter { ranked in
|
||||||
|
!store.isSuppressed(id: ranked.item.id, type: ranked.item.type, now: nowEpoch)
|
||||||
|
}
|
||||||
|
|
||||||
let winnerSelection = ranker.pickWinner(from: eligibleUnsuppressed, now: nowEpoch, context: userContext)
|
let winnerSelection = ranker.pickWinner(from: eligibleUnsuppressed, now: nowEpoch, context: userContext)
|
||||||
let winnerItem = winnerSelection?.item ?? FeedEnvelope.allQuiet(now: nowEpoch).feed[0]
|
let winnerItem = winnerSelection?.item ?? FeedEnvelope.allQuiet(now: nowEpoch).feed[0]
|
||||||
|
|
||||||
@@ -500,6 +599,28 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let fyiTFL = tflItems
|
||||||
|
.filter { $0.bucket == .fyi }
|
||||||
|
.filter { $0.id != winnerItem.id }
|
||||||
|
.filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) }
|
||||||
|
.prefix(2)
|
||||||
|
|
||||||
|
fyi.append(contentsOf: fyiTFL.map { item in
|
||||||
|
FeedItem(
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
title: item.title.truncated(maxLength: TextConstraints.titleMax),
|
||||||
|
subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||||
|
priority: min(max(item.priority, 0.0), 1.0),
|
||||||
|
ttlSec: max(1, item.ttlSec),
|
||||||
|
condition: item.condition,
|
||||||
|
startsAt: item.startsAt,
|
||||||
|
poiType: item.poiType,
|
||||||
|
bucket: .fyi,
|
||||||
|
actions: ["DISMISS"]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
let items = [winnerItem] + fyi
|
let items = [winnerItem] + fyi
|
||||||
let feedEnvelope = FeedEnvelope(
|
let feedEnvelope = FeedEnvelope(
|
||||||
schema: 1,
|
schema: 1,
|
||||||
@@ -541,7 +662,15 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func feedForGlass(base: FeedEnvelope, now: Int) -> FeedEnvelope {
|
private func feedForGlass(base: FeedEnvelope, now: Int) -> FeedEnvelope {
|
||||||
guard let nowPlayingCard = nowPlaying?.asFeedItem(baseGeneratedAt: base.generatedAt, now: now) else {
|
let nowPlayingCard: FeedItem?
|
||||||
|
switch musicSource {
|
||||||
|
case .appleMusic:
|
||||||
|
nowPlayingCard = nowPlaying?.asFeedItem(baseGeneratedAt: base.generatedAt, now: now)
|
||||||
|
case .spotify:
|
||||||
|
nowPlayingCard = spotifyNowPlaying?.asFeedItem(baseGeneratedAt: base.generatedAt, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let nowPlayingCard else {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
IrisCompanion/iris/Spotify/KeychainHelper.swift
Normal file
61
IrisCompanion/iris/Spotify/KeychainHelper.swift
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//
|
||||||
|
// KeychainHelper.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
// Secure token storage using iOS Keychain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum KeychainHelper {
|
||||||
|
private static let service = "sh.nym.iris.spotify"
|
||||||
|
|
||||||
|
static func save(key: String, data: Data) throws {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
|
||||||
|
let attributes: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data,
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
]
|
||||||
|
let status = SecItemAdd(attributes as CFDictionary, nil)
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw KeychainError.saveFailed(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(key: String) -> Data? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne
|
||||||
|
]
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
guard status == errSecSuccess else { return nil }
|
||||||
|
return result as? Data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(key: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum KeychainError: Error {
|
||||||
|
case saveFailed(OSStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
IrisCompanion/iris/Spotify/SpotifyAPIClient.swift
Normal file
77
IrisCompanion/iris/Spotify/SpotifyAPIClient.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// SpotifyAPIClient.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
// Spotify Web API client for fetching currently playing track.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
@MainActor
|
||||||
|
final class SpotifyAPIClient {
|
||||||
|
private let authManager: SpotifyAuthManager
|
||||||
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "SpotifyAPI")
|
||||||
|
private let baseUrl = "https://api.spotify.com/v1"
|
||||||
|
|
||||||
|
init(authManager: SpotifyAuthManager) {
|
||||||
|
self.authManager = authManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentPlayback() async throws -> SpotifyPlaybackState? {
|
||||||
|
try await getCurrentPlayback(isRetry: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getCurrentPlayback(isRetry: Bool) async throws -> SpotifyPlaybackState? {
|
||||||
|
let refreshed = await authManager.refreshTokenIfNeeded()
|
||||||
|
guard refreshed else {
|
||||||
|
throw SpotifyAPIError.notAuthenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let token = authManager.accessToken else {
|
||||||
|
throw SpotifyAPIError.notAuthenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: URL(string: "\(baseUrl)/me/player/currently-playing")!)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw SpotifyAPIError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
switch httpResponse.statusCode {
|
||||||
|
case 200:
|
||||||
|
return try JSONDecoder().decode(SpotifyPlaybackState.self, from: data)
|
||||||
|
case 204:
|
||||||
|
return nil
|
||||||
|
case 401:
|
||||||
|
guard !isRetry else {
|
||||||
|
throw SpotifyAPIError.tokenExpired
|
||||||
|
}
|
||||||
|
let refreshed = await authManager.refreshTokenIfNeeded()
|
||||||
|
if refreshed {
|
||||||
|
return try await getCurrentPlayback(isRetry: true)
|
||||||
|
}
|
||||||
|
throw SpotifyAPIError.tokenExpired
|
||||||
|
case 429:
|
||||||
|
let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After")
|
||||||
|
logger.warning("Rate limited, retry after: \(retryAfter ?? "unknown")")
|
||||||
|
throw SpotifyAPIError.rateLimited
|
||||||
|
default:
|
||||||
|
logger.error("API error: \(httpResponse.statusCode)")
|
||||||
|
throw SpotifyAPIError.apiError(httpResponse.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SpotifyAPIError: Error {
|
||||||
|
case notAuthenticated
|
||||||
|
case tokenExpired
|
||||||
|
case invalidResponse
|
||||||
|
case rateLimited
|
||||||
|
case apiError(Int)
|
||||||
|
}
|
||||||
|
}
|
||||||
276
IrisCompanion/iris/Spotify/SpotifyAuthManager.swift
Normal file
276
IrisCompanion/iris/Spotify/SpotifyAuthManager.swift
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
//
|
||||||
|
// SpotifyAuthManager.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
// OAuth 2.0 PKCE authentication for Spotify Web API.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AuthenticationServices
|
||||||
|
import CommonCrypto
|
||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
enum SpotifyConfig {
|
||||||
|
static let redirectUri = "iris-spotify-auth://callback"
|
||||||
|
static let scopes = "user-read-playback-state user-read-currently-playing"
|
||||||
|
static let authUrl = "https://accounts.spotify.com/authorize"
|
||||||
|
static let tokenUrl = "https://accounts.spotify.com/api/token"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
@MainActor
|
||||||
|
final class SpotifyAuthManager: NSObject, ObservableObject {
|
||||||
|
@Published private(set) var isConnected: Bool = false
|
||||||
|
@Published private(set) var isAuthenticating: Bool = false
|
||||||
|
@Published private(set) var error: String? = nil
|
||||||
|
@Published private(set) var clientId: String = ""
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "SpotifyAuth")
|
||||||
|
private let tokensKey = "spotify_tokens"
|
||||||
|
private let clientIdKey = "spotify_client_id"
|
||||||
|
private var codeVerifier: String? = nil
|
||||||
|
private var authSession: ASWebAuthenticationSession? = nil
|
||||||
|
|
||||||
|
var isConfigured: Bool {
|
||||||
|
!clientId.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
clientId = UserDefaults.standard.string(forKey: clientIdKey) ?? ""
|
||||||
|
loadTokens()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setClientId(_ id: String) {
|
||||||
|
let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
clientId = trimmed
|
||||||
|
UserDefaults.standard.set(trimmed, forKey: clientIdKey)
|
||||||
|
logger.info("Spotify Client ID updated")
|
||||||
|
}
|
||||||
|
|
||||||
|
var accessToken: String? {
|
||||||
|
loadStoredTokens()?.accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAuth() {
|
||||||
|
guard !isAuthenticating else { return }
|
||||||
|
isAuthenticating = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
let verifier = generateCodeVerifier()
|
||||||
|
codeVerifier = verifier
|
||||||
|
let challenge = generateCodeChallenge(verifier: verifier)
|
||||||
|
|
||||||
|
var components = URLComponents(string: SpotifyConfig.authUrl)!
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "client_id", value: clientId),
|
||||||
|
URLQueryItem(name: "response_type", value: "code"),
|
||||||
|
URLQueryItem(name: "redirect_uri", value: SpotifyConfig.redirectUri),
|
||||||
|
URLQueryItem(name: "scope", value: SpotifyConfig.scopes),
|
||||||
|
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||||
|
URLQueryItem(name: "code_challenge", value: challenge)
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let authUrl = components.url else {
|
||||||
|
error = "Failed to build auth URL"
|
||||||
|
isAuthenticating = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = ASWebAuthenticationSession(
|
||||||
|
url: authUrl,
|
||||||
|
callbackURLScheme: "iris-spotify-auth"
|
||||||
|
) { [weak self] callbackUrl, authError in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.handleAuthCallback(callbackUrl: callbackUrl, error: authError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.presentationContextProvider = self
|
||||||
|
session.prefersEphemeralWebBrowserSession = false
|
||||||
|
authSession = session
|
||||||
|
|
||||||
|
if !session.start() {
|
||||||
|
error = "Failed to start auth session"
|
||||||
|
isAuthenticating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCallback(url: URL) {
|
||||||
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||||
|
let code = components.queryItems?.first(where: { $0.name == "code" })?.value else {
|
||||||
|
error = "Invalid callback URL"
|
||||||
|
isAuthenticating = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await exchangeCodeForTokens(code: code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
KeychainHelper.delete(key: tokensKey)
|
||||||
|
isConnected = false
|
||||||
|
logger.info("Disconnected from Spotify")
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshTokenIfNeeded() async -> Bool {
|
||||||
|
guard let tokens = loadStoredTokens() else { return false }
|
||||||
|
|
||||||
|
if !tokens.expiresWithinMinutes {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return await refreshToken(refreshToken: tokens.refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAuthCallback(callbackUrl: URL?, error authError: Error?) {
|
||||||
|
isAuthenticating = false
|
||||||
|
authSession = nil
|
||||||
|
|
||||||
|
if let authError = authError as? ASWebAuthenticationSessionError,
|
||||||
|
authError.code == .canceledLogin {
|
||||||
|
logger.info("User cancelled Spotify login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let authError {
|
||||||
|
error = authError.localizedDescription
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let callbackUrl else {
|
||||||
|
error = "No callback URL received"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCallback(url: callbackUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exchangeCodeForTokens(code: String) async {
|
||||||
|
guard let verifier = codeVerifier else {
|
||||||
|
error = "Missing code verifier"
|
||||||
|
isAuthenticating = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = [
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": SpotifyConfig.redirectUri,
|
||||||
|
"client_id": clientId,
|
||||||
|
"code_verifier": verifier
|
||||||
|
]
|
||||||
|
|
||||||
|
do {
|
||||||
|
let tokenResponse = try await postTokenRequest(body: body)
|
||||||
|
let tokens = SpotifyTokens(
|
||||||
|
accessToken: tokenResponse.accessToken,
|
||||||
|
refreshToken: tokenResponse.refreshToken ?? "",
|
||||||
|
expiresAt: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn))
|
||||||
|
)
|
||||||
|
try saveTokens(tokens)
|
||||||
|
isConnected = true
|
||||||
|
logger.info("Successfully authenticated with Spotify")
|
||||||
|
} catch {
|
||||||
|
self.error = "Token exchange failed: \(error.localizedDescription)"
|
||||||
|
logger.error("Token exchange failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
codeVerifier = nil
|
||||||
|
isAuthenticating = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshToken(refreshToken: String) async -> Bool {
|
||||||
|
let body = [
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
"client_id": clientId
|
||||||
|
]
|
||||||
|
|
||||||
|
do {
|
||||||
|
let tokenResponse = try await postTokenRequest(body: body)
|
||||||
|
let newRefreshToken = tokenResponse.refreshToken ?? refreshToken
|
||||||
|
let tokens = SpotifyTokens(
|
||||||
|
accessToken: tokenResponse.accessToken,
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
expiresAt: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn))
|
||||||
|
)
|
||||||
|
try saveTokens(tokens)
|
||||||
|
logger.info("Refreshed Spotify token")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logger.error("Token refresh failed: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postTokenRequest(body: [String: String]) async throws -> SpotifyTokenResponse {
|
||||||
|
var request = URLRequest(url: URL(string: SpotifyConfig.tokenUrl)!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let bodyString = body.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" }
|
||||||
|
.joined(separator: "&")
|
||||||
|
request.httpBody = bodyString.data(using: .utf8)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
|
httpResponse.statusCode == 200 else {
|
||||||
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
throw SpotifyAuthError.tokenRequestFailed(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try JSONDecoder().decode(SpotifyTokenResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadTokens() {
|
||||||
|
isConnected = loadStoredTokens() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadStoredTokens() -> SpotifyTokens? {
|
||||||
|
guard let data = KeychainHelper.load(key: tokensKey) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(SpotifyTokens.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveTokens(_ tokens: SpotifyTokens) throws {
|
||||||
|
let data = try JSONEncoder().encode(tokens)
|
||||||
|
try KeychainHelper.save(key: tokensKey, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateCodeVerifier() -> String {
|
||||||
|
var bytes = [UInt8](repeating: 0, count: 32)
|
||||||
|
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||||
|
return Data(bytes).base64URLEncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateCodeChallenge(verifier: String) -> String {
|
||||||
|
guard let data = verifier.data(using: .utf8) else { return "" }
|
||||||
|
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
||||||
|
data.withUnsafeBytes { buffer in
|
||||||
|
_ = CC_SHA256(buffer.baseAddress, CC_LONG(data.count), &hash)
|
||||||
|
}
|
||||||
|
return Data(hash).base64URLEncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SpotifyAuthError: Error {
|
||||||
|
case tokenRequestFailed(Int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
extension SpotifyAuthManager: ASWebAuthenticationPresentationContextProviding {
|
||||||
|
nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||||
|
ASPresentationAnchor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
func base64URLEncodedString() -> String {
|
||||||
|
base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
91
IrisCompanion/iris/Spotify/SpotifyModels.swift
Normal file
91
IrisCompanion/iris/Spotify/SpotifyModels.swift
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
//
|
||||||
|
// SpotifyModels.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
// Codable models for Spotify Web API responses.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct SpotifyTokens: Codable {
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String
|
||||||
|
let expiresAt: Date
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case accessToken = "access_token"
|
||||||
|
case refreshToken = "refresh_token"
|
||||||
|
case expiresAt = "expires_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var isExpired: Bool {
|
||||||
|
Date() >= expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresWithinMinutes: Bool {
|
||||||
|
Date().addingTimeInterval(5 * 60) >= expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SpotifyTokenResponse: Codable {
|
||||||
|
let accessToken: String
|
||||||
|
let tokenType: String
|
||||||
|
let expiresIn: Int
|
||||||
|
let refreshToken: String?
|
||||||
|
let scope: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case accessToken = "access_token"
|
||||||
|
case tokenType = "token_type"
|
||||||
|
case expiresIn = "expires_in"
|
||||||
|
case refreshToken = "refresh_token"
|
||||||
|
case scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SpotifyPlaybackState: Codable {
|
||||||
|
let isPlaying: Bool
|
||||||
|
let progressMs: Int?
|
||||||
|
let item: SpotifyTrack?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case isPlaying = "is_playing"
|
||||||
|
case progressMs = "progress_ms"
|
||||||
|
case item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SpotifyTrack: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let artists: [SpotifyArtist]
|
||||||
|
let album: SpotifyAlbum
|
||||||
|
let durationMs: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, artists, album
|
||||||
|
case durationMs = "duration_ms"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SpotifyArtist: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SpotifyAlbum: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MusicSource: String, CaseIterable, Codable {
|
||||||
|
case appleMusic = "apple_music"
|
||||||
|
case spotify = "spotify"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .appleMusic: return "Apple Music"
|
||||||
|
case .spotify: return "Spotify"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
IrisCompanion/iris/Spotify/SpotifyNowPlayingMonitor.swift
Normal file
145
IrisCompanion/iris/Spotify/SpotifyNowPlayingMonitor.swift
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
//
|
||||||
|
// SpotifyNowPlayingMonitor.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
// Polling monitor for Spotify currently playing track.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct SpotifyNowPlaying: Equatable, Sendable {
|
||||||
|
let itemId: String
|
||||||
|
let title: String
|
||||||
|
let artist: String?
|
||||||
|
let album: String?
|
||||||
|
let isPlaying: Bool
|
||||||
|
|
||||||
|
func asFeedItem(baseGeneratedAt: Int, now: Int) -> FeedItem {
|
||||||
|
let desiredLifetimeSec = 30
|
||||||
|
let ttl = max(1, (now - baseGeneratedAt) + desiredLifetimeSec)
|
||||||
|
|
||||||
|
let subtitleParts = [artist, album]
|
||||||
|
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
let subtitle = subtitleParts.isEmpty ? "Spotify" : subtitleParts.joined(separator: " • ")
|
||||||
|
|
||||||
|
return FeedItem(
|
||||||
|
id: "spotify:now:\(itemId)",
|
||||||
|
type: .nowPlaying,
|
||||||
|
title: title.truncated(maxLength: TextConstraints.titleMax),
|
||||||
|
subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||||
|
priority: isPlaying ? 0.35 : 0.2,
|
||||||
|
ttlSec: ttl,
|
||||||
|
condition: nil,
|
||||||
|
startsAt: nil,
|
||||||
|
bucket: .fyi,
|
||||||
|
actions: ["DISMISS"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
@MainActor
|
||||||
|
final class SpotifyNowPlayingMonitor {
|
||||||
|
struct Update: Sendable {
|
||||||
|
let isConnected: Bool
|
||||||
|
let snapshot: SpotifyNowPlaying?
|
||||||
|
}
|
||||||
|
|
||||||
|
var onUpdate: ((Update) -> Void)? = nil
|
||||||
|
|
||||||
|
private let authManager: SpotifyAuthManager
|
||||||
|
private let apiClient: SpotifyAPIClient
|
||||||
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "SpotifyNowPlaying")
|
||||||
|
|
||||||
|
private var pollTimer: DispatchSourceTimer?
|
||||||
|
private var isRunning = false
|
||||||
|
private var lastSnapshot: SpotifyNowPlaying? = nil
|
||||||
|
|
||||||
|
init(authManager: SpotifyAuthManager) {
|
||||||
|
self.authManager = authManager
|
||||||
|
self.apiClient = SpotifyAPIClient(authManager: authManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !isRunning else { return }
|
||||||
|
isRunning = true
|
||||||
|
logger.info("Spotify monitor started")
|
||||||
|
startPolling()
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
await refresh(reason: "start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard isRunning else { return }
|
||||||
|
isRunning = false
|
||||||
|
logger.info("Spotify monitor stopped")
|
||||||
|
|
||||||
|
pollTimer?.cancel()
|
||||||
|
pollTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPolling() {
|
||||||
|
guard pollTimer == nil else { return }
|
||||||
|
let timer = DispatchSource.makeTimerSource(queue: .main)
|
||||||
|
timer.schedule(deadline: .now() + 4, repeating: 4)
|
||||||
|
timer.setEventHandler { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.refresh(reason: "poll")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.resume()
|
||||||
|
pollTimer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh(reason: String) async {
|
||||||
|
guard authManager.isConnected else {
|
||||||
|
if lastSnapshot != nil {
|
||||||
|
lastSnapshot = nil
|
||||||
|
onUpdate?(Update(isConnected: false, snapshot: nil))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let playback = try await apiClient.getCurrentPlayback()
|
||||||
|
|
||||||
|
guard let track = playback?.item, playback?.isPlaying == true else {
|
||||||
|
if lastSnapshot != nil {
|
||||||
|
lastSnapshot = nil
|
||||||
|
onUpdate?(Update(isConnected: true, snapshot: nil))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let artistName = track.artists.first?.name
|
||||||
|
let snapshot = SpotifyNowPlaying(
|
||||||
|
itemId: track.id,
|
||||||
|
title: track.name,
|
||||||
|
artist: artistName,
|
||||||
|
album: track.album.name,
|
||||||
|
isPlaying: playback?.isPlaying ?? false
|
||||||
|
)
|
||||||
|
|
||||||
|
guard snapshot != lastSnapshot else { return }
|
||||||
|
lastSnapshot = snapshot
|
||||||
|
onUpdate?(Update(isConnected: true, snapshot: snapshot))
|
||||||
|
logger.debug("Spotify now playing: \(snapshot.title)")
|
||||||
|
|
||||||
|
} catch SpotifyAPIClient.SpotifyAPIError.notAuthenticated {
|
||||||
|
if lastSnapshot != nil {
|
||||||
|
lastSnapshot = nil
|
||||||
|
onUpdate?(Update(isConnected: false, snapshot: nil))
|
||||||
|
}
|
||||||
|
} catch SpotifyAPIClient.SpotifyAPIError.rateLimited {
|
||||||
|
logger.warning("Spotify rate limited, skipping this poll")
|
||||||
|
} catch {
|
||||||
|
logger.error("Spotify fetch error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
IrisCompanion/iris/ViewModels/TodosViewModel.swift
Normal file
73
IrisCompanion/iris/ViewModels/TodosViewModel.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// TodosViewModel.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
// Created by Codex.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TodosViewModel: ObservableObject {
|
||||||
|
@Published private(set) var todos: [TodoItem] = []
|
||||||
|
@Published var newTitle: String = ""
|
||||||
|
|
||||||
|
private let store: TodoStore
|
||||||
|
|
||||||
|
init(store: TodoStore = TodoStore()) {
|
||||||
|
self.store = store
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
todos = sortedItems(store.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTodo() {
|
||||||
|
let trimmed = normalizedTitle(newTitle)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
store.add(title: trimmed)
|
||||||
|
newTitle = ""
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteTodo(id: UUID) {
|
||||||
|
store.delete(id: id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleCompleted(id: UUID) {
|
||||||
|
guard let current = todos.first(where: { $0.id == id }) else { return }
|
||||||
|
store.setCompleted(id: id, completed: !current.isCompleted)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTitle(id: UUID, title: String) {
|
||||||
|
let trimmed = normalizedTitle(title)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
store.updateTitle(id: id, title: trimmed)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedTitle(_ raw: String) -> String {
|
||||||
|
raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sortedItems(_ items: [TodoItem]) -> [TodoItem] {
|
||||||
|
items.sorted { lhs, rhs in
|
||||||
|
if lhs.isCompleted != rhs.isCompleted {
|
||||||
|
return !lhs.isCompleted
|
||||||
|
}
|
||||||
|
if lhs.isCompleted {
|
||||||
|
let lhsDate = lhs.completedAt ?? lhs.updatedAt
|
||||||
|
let rhsDate = rhs.completedAt ?? rhs.updatedAt
|
||||||
|
if lhsDate != rhsDate {
|
||||||
|
return lhsDate > rhsDate
|
||||||
|
}
|
||||||
|
} else if lhs.createdAt != rhs.createdAt {
|
||||||
|
return lhs.createdAt > rhs.createdAt
|
||||||
|
}
|
||||||
|
return lhs.id.uuidString < rhs.id.uuidString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ struct OrchestratorView: View {
|
|||||||
Button("Recompute Now") { orchestrator.recomputeNow() }
|
Button("Recompute Now") { orchestrator.recomputeNow() }
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Feed") {
|
Section("Winner") {
|
||||||
if let feed = orchestrator.lastFeed, let winner = feed.winnerItem() {
|
if let feed = orchestrator.lastFeed, let winner = feed.winnerItem() {
|
||||||
Text(winner.title)
|
Text(winner.title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -54,36 +54,56 @@ struct OrchestratorView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text("type \(winner.type.rawValue) • prio \(String(format: "%.2f", winner.priority)) • ttl \(winner.ttlSec)s")
|
LabeledContent("Type") { Text(winner.type.rawValue) }
|
||||||
.font(.caption)
|
LabeledContent("Bucket") { Text(winner.bucket.rawValue) }
|
||||||
.foregroundStyle(.secondary)
|
LabeledContent("Priority") { Text(String(format: "%.2f", winner.priority)) }
|
||||||
|
LabeledContent("TTL") { Text("\(winner.ttlSec)s") }
|
||||||
if feed.feed.count > 1 {
|
if let poiType = winner.poiType {
|
||||||
Divider()
|
LabeledContent("POI type") { Text(poiType.rawValue) }
|
||||||
}
|
}
|
||||||
|
if let startsAt = winner.startsAt {
|
||||||
|
LabeledContent("Starts at") { Text("\(startsAt)") }
|
||||||
|
}
|
||||||
|
LabeledContent("ID") {
|
||||||
|
Text(winner.id)
|
||||||
|
.font(.caption)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("No winner yet")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ForEach(feed.feed, id: \.id) { item in
|
Section("Feed") {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
if let feed = orchestrator.lastFeed {
|
||||||
HStack {
|
if feed.feed.isEmpty {
|
||||||
Text(item.title)
|
Text("No feed items yet")
|
||||||
.font(.headline)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
} else {
|
||||||
Spacer()
|
ForEach(feed.feed, id: \.id) { item in
|
||||||
Text(item.type.rawValue)
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text(item.title)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text(item.type.rawValue)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if !item.subtitle.isEmpty {
|
||||||
|
Text(item.subtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Text("bucket \(item.bucket.rawValue) • prio \(String(format: "%.2f", item.priority)) • ttl \(item.ttlSec)s")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if !item.subtitle.isEmpty {
|
.padding(.vertical, 4)
|
||||||
Text(item.subtitle)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
Text("bucket \(item.bucket.rawValue) • prio \(String(format: "%.2f", item.priority)) • ttl \(item.ttlSec)s")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No feed yet")
|
Text("No feed yet")
|
||||||
|
|||||||
177
IrisCompanion/iris/Views/SettingsView.swift
Normal file
177
IrisCompanion/iris/Views/SettingsView.swift
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
//
|
||||||
|
// SettingsView.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
// Settings UI for music source selection and Spotify connection.
|
||||||
|
//
|
||||||
|
|
||||||
|
import MusicKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct SettingsView: View {
|
||||||
|
@EnvironmentObject private var orchestrator: ContextOrchestrator
|
||||||
|
@EnvironmentObject private var spotifyAuth: SpotifyAuthManager
|
||||||
|
@State private var isConfigExpanded: Bool = false
|
||||||
|
@State private var clientIdInput: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section("Music Source") {
|
||||||
|
Picker("Source", selection: Binding(
|
||||||
|
get: { orchestrator.musicSource },
|
||||||
|
set: { newValue in
|
||||||
|
if newValue == .spotify && !spotifyAuth.isConnected {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orchestrator.musicSource = newValue
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
ForEach(MusicSource.allCases, id: \.self) { source in
|
||||||
|
Text(source.displayName).tag(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
if !spotifyAuth.isConnected {
|
||||||
|
Text("Connect Spotify below to enable it as a source")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
DisclosureGroup("Configuration", isExpanded: $isConfigExpanded) {
|
||||||
|
TextField("Client ID", text: $clientIdInput)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
|
||||||
|
Text("Get your Client ID from developer.spotify.com/dashboard")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Button("Save") {
|
||||||
|
spotifyAuth.setClientId(clientIdInput)
|
||||||
|
isConfigExpanded = false
|
||||||
|
}
|
||||||
|
.disabled(clientIdInput.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
clientIdInput = spotifyAuth.clientId
|
||||||
|
}
|
||||||
|
|
||||||
|
if spotifyAuth.isConnected {
|
||||||
|
HStack {
|
||||||
|
Label("Connected", systemImage: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Button("Disconnect", role: .destructive) {
|
||||||
|
spotifyAuth.disconnect()
|
||||||
|
if orchestrator.musicSource == .spotify {
|
||||||
|
orchestrator.musicSource = .appleMusic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if spotifyAuth.isConfigured {
|
||||||
|
Button {
|
||||||
|
spotifyAuth.startAuth()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label("Connect to Spotify", systemImage: "link")
|
||||||
|
Spacer()
|
||||||
|
if spotifyAuth.isAuthenticating {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(spotifyAuth.isAuthenticating)
|
||||||
|
} else {
|
||||||
|
Text("Enter your Client ID above to connect")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = spotifyAuth.error {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Spotify")
|
||||||
|
} footer: {
|
||||||
|
if !spotifyAuth.isConnected && spotifyAuth.isConfigured {
|
||||||
|
Text("Connect your Spotify account to display current track on Glass.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("Authorization")
|
||||||
|
Spacer()
|
||||||
|
Text(authStatusText(orchestrator.musicAuthorization))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Apple Music")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
if let nowPlayingInfo = currentNowPlaying {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(nowPlayingInfo.title)
|
||||||
|
.font(.headline)
|
||||||
|
if let artist = nowPlayingInfo.artist {
|
||||||
|
Text(artist)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Nothing playing")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Now Playing")
|
||||||
|
} footer: {
|
||||||
|
Text("Source: \(orchestrator.musicSource.displayName)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentNowPlaying: (title: String, artist: String?)? {
|
||||||
|
switch orchestrator.musicSource {
|
||||||
|
case .appleMusic:
|
||||||
|
guard let np = orchestrator.nowPlaying else { return nil }
|
||||||
|
return (np.title, np.artist)
|
||||||
|
case .spotify:
|
||||||
|
guard let np = orchestrator.spotifyNowPlaying else { return nil }
|
||||||
|
return (np.title, np.artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func authStatusText(_ status: MusicAuthorization.Status) -> String {
|
||||||
|
switch status {
|
||||||
|
case .notDetermined: return "Not Determined"
|
||||||
|
case .denied: return "Denied"
|
||||||
|
case .restricted: return "Restricted"
|
||||||
|
case .authorized: return "Authorized"
|
||||||
|
@unknown default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct SettingsView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
let ble = BlePeripheralManager()
|
||||||
|
let spotifyAuth = SpotifyAuthManager()
|
||||||
|
let orchestrator = ContextOrchestrator(ble: ble, spotifyAuth: spotifyAuth)
|
||||||
|
SettingsView()
|
||||||
|
.environmentObject(orchestrator)
|
||||||
|
.environmentObject(spotifyAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
195
IrisCompanion/iris/Views/TodosView.swift
Normal file
195
IrisCompanion/iris/Views/TodosView.swift
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
//
|
||||||
|
// TodosView.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
// Created by Codex.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TodosView: View {
|
||||||
|
@StateObject private var model = TodosViewModel()
|
||||||
|
@State private var isDoneExpanded = false
|
||||||
|
@State private var lastDoneCount = 0
|
||||||
|
|
||||||
|
private var openTodos: [TodoItem] {
|
||||||
|
model.todos.filter { !$0.isCompleted }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var doneTodos: [TodoItem] {
|
||||||
|
model.todos.filter { $0.isCompleted }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section("Add") {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
TextField("Add a todo", text: $model.newTitle)
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
.disableAutocorrection(false)
|
||||||
|
.submitLabel(.done)
|
||||||
|
.onSubmit { model.addTodo() }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
model.addTodo()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(model.newTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
.accessibilityLabel("Add todo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Todos") {
|
||||||
|
if openTodos.isEmpty {
|
||||||
|
Text("No open todos")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(openTodos) { item in
|
||||||
|
TodoRow(
|
||||||
|
item: item,
|
||||||
|
onToggle: { model.toggleCompleted(id: item.id) },
|
||||||
|
onDelete: { model.deleteTodo(id: item.id) },
|
||||||
|
onUpdateTitle: { model.updateTitle(id: item.id, title: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
if isDoneExpanded {
|
||||||
|
if doneTodos.isEmpty {
|
||||||
|
Text("No completed todos")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(doneTodos) { item in
|
||||||
|
TodoRow(
|
||||||
|
item: item,
|
||||||
|
onToggle: { model.toggleCompleted(id: item.id) },
|
||||||
|
onDelete: { model.deleteTodo(id: item.id) },
|
||||||
|
onUpdateTitle: { model.updateTitle(id: item.id, title: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.15)) {
|
||||||
|
isDoneExpanded.toggle()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("Done")
|
||||||
|
Spacer()
|
||||||
|
if doneTodos.count > 0 {
|
||||||
|
Text("\(doneTodos.count)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Image(systemName: isDoneExpanded ? "chevron.down" : "chevron.right")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Todos")
|
||||||
|
.onAppear {
|
||||||
|
lastDoneCount = doneTodos.count
|
||||||
|
if lastDoneCount == 0 {
|
||||||
|
isDoneExpanded = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: doneTodos.count) { newCount in
|
||||||
|
if lastDoneCount == 0, newCount > 0, !isDoneExpanded {
|
||||||
|
withAnimation(.easeInOut(duration: 0.15)) {
|
||||||
|
isDoneExpanded = true
|
||||||
|
}
|
||||||
|
} else if newCount == 0, isDoneExpanded {
|
||||||
|
withAnimation(.easeInOut(duration: 0.15)) {
|
||||||
|
isDoneExpanded = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastDoneCount = newCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TodoRow: View {
|
||||||
|
let item: TodoItem
|
||||||
|
let onToggle: () -> Void
|
||||||
|
let onDelete: () -> Void
|
||||||
|
let onUpdateTitle: (String) -> Void
|
||||||
|
|
||||||
|
@State private var draftTitle: String
|
||||||
|
@FocusState private var isFocused: Bool
|
||||||
|
|
||||||
|
init(item: TodoItem,
|
||||||
|
onToggle: @escaping () -> Void,
|
||||||
|
onDelete: @escaping () -> Void,
|
||||||
|
onUpdateTitle: @escaping (String) -> Void) {
|
||||||
|
self.item = item
|
||||||
|
self.onToggle = onToggle
|
||||||
|
self.onDelete = onDelete
|
||||||
|
self.onUpdateTitle = onUpdateTitle
|
||||||
|
_draftTitle = State(initialValue: item.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: onToggle) {
|
||||||
|
Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(item.isCompleted ? .secondary : .primary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(item.isCompleted ? "Mark incomplete" : "Mark complete")
|
||||||
|
|
||||||
|
TextField("Todo", text: $draftTitle)
|
||||||
|
.focused($isFocused)
|
||||||
|
.submitLabel(.done)
|
||||||
|
.onSubmit { commitTitleIfNeeded() }
|
||||||
|
.onChange(of: isFocused) { focused in
|
||||||
|
if !focused {
|
||||||
|
commitTitleIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: item.title) { newValue in
|
||||||
|
if !isFocused {
|
||||||
|
draftTitle = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(item.isCompleted ? .secondary : .primary)
|
||||||
|
.opacity(item.isCompleted ? 0.7 : 1.0)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
if isFocused {
|
||||||
|
commitTitleIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button(role: .destructive, action: onDelete) {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commitTitleIfNeeded() {
|
||||||
|
let trimmed = draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
draftTitle = item.title
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard trimmed != item.title else { return }
|
||||||
|
onUpdateTitle(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TodosView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
TodosView()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,12 +12,15 @@ struct irisApp: App {
|
|||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@StateObject private var ble: BlePeripheralManager
|
@StateObject private var ble: BlePeripheralManager
|
||||||
@StateObject private var orchestrator: ContextOrchestrator
|
@StateObject private var orchestrator: ContextOrchestrator
|
||||||
|
@StateObject private var spotifyAuth: SpotifyAuthManager
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let bleManager = BlePeripheralManager()
|
let bleManager = BlePeripheralManager()
|
||||||
bleManager.start()
|
bleManager.start()
|
||||||
|
let spotify = SpotifyAuthManager()
|
||||||
_ble = StateObject(wrappedValue: bleManager)
|
_ble = StateObject(wrappedValue: bleManager)
|
||||||
_orchestrator = StateObject(wrappedValue: ContextOrchestrator(ble: bleManager))
|
_spotifyAuth = StateObject(wrappedValue: spotify)
|
||||||
|
_orchestrator = StateObject(wrappedValue: ContextOrchestrator(ble: bleManager, spotifyAuth: spotify))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -25,6 +28,12 @@ struct irisApp: App {
|
|||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(ble)
|
.environmentObject(ble)
|
||||||
.environmentObject(orchestrator)
|
.environmentObject(orchestrator)
|
||||||
|
.environmentObject(spotifyAuth)
|
||||||
|
.onOpenURL { url in
|
||||||
|
if url.scheme == "iris-spotify-auth" {
|
||||||
|
spotifyAuth.handleCallback(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: scenePhase) { phase in
|
.onChange(of: scenePhase) { phase in
|
||||||
if phase == .active || phase == .background {
|
if phase == .active || phase == .background {
|
||||||
ble.start()
|
ble.start()
|
||||||
|
|||||||
24
aris/.gitignore
vendored
Normal file
24
aris/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
npm-debug.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
web-build/
|
||||||
|
# expo router
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ios
|
||||||
|
android
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Temporary files created by Metro to check the health of the file watcher
|
||||||
|
.metro-health-check*
|
||||||
24
aris/apps/companion/.gitignore
vendored
Normal file
24
aris/apps/companion/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
npm-debug.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
web-build/
|
||||||
|
# expo router
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ios
|
||||||
|
android
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Temporary files created by Metro to check the health of the file watcher
|
||||||
|
.metro-health-check*
|
||||||
49
aris/apps/companion/app.json
Normal file
49
aris/apps/companion/app.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "aris",
|
||||||
|
"slug": "aris",
|
||||||
|
"version": "1.0.0",
|
||||||
|
|
||||||
|
"scheme": "aris",
|
||||||
|
"platforms": ["ios", "android"],
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": ["expo-router"],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
|
||||||
|
"tsconfigPaths": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
|
||||||
|
"userInterfaceStyle": "light",
|
||||||
|
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"assetBundlePatterns": ["**/*"],
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "sh.nym.aris",
|
||||||
|
"backgroundModes": ["bluetooth-peripheral"],
|
||||||
|
"infoPlist": {
|
||||||
|
"NSBluetoothAlwaysUsageDescription": "Allow Bluetooth to connect to Iris Glass.",
|
||||||
|
"NSCalendarsUsageDescription": "Allow Iris to access your calendar for upcoming events."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "sh.nym.aris",
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
aris/apps/companion/app/(tabs)/_layout.tsx
Normal file
81
aris/apps/companion/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Tabs } from "expo-router";
|
||||||
|
|
||||||
|
const iconSize = 22;
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<Tabs screenOptions={{ headerShown: false }}>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="orchestrator"
|
||||||
|
options={{
|
||||||
|
title: "Orchestrator",
|
||||||
|
tabBarLabel: "Orchestrator",
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<Ionicons
|
||||||
|
color={color}
|
||||||
|
name={focused ? "flash" : "flash-outline"}
|
||||||
|
size={iconSize}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "BLE",
|
||||||
|
tabBarLabel: "BLE",
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<Ionicons
|
||||||
|
color={color}
|
||||||
|
name={focused ? "bluetooth" : "bluetooth-outline"}
|
||||||
|
size={iconSize}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="todos"
|
||||||
|
options={{
|
||||||
|
title: "Todos",
|
||||||
|
tabBarLabel: "Todos",
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<Ionicons
|
||||||
|
color={color}
|
||||||
|
name={focused ? "checkmark-done" : "checkmark-done-outline"}
|
||||||
|
size={iconSize}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="stocks"
|
||||||
|
options={{
|
||||||
|
title: "Stocks",
|
||||||
|
tabBarLabel: "Stocks",
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<Ionicons
|
||||||
|
color={color}
|
||||||
|
name={focused ? "stats-chart" : "stats-chart-outline"}
|
||||||
|
size={iconSize}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
title: "Settings",
|
||||||
|
tabBarLabel: "Settings",
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<Ionicons
|
||||||
|
color={color}
|
||||||
|
name={focused ? "settings" : "settings-outline"}
|
||||||
|
size={iconSize}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
aris/apps/companion/app/(tabs)/index.tsx
Normal file
255
aris/apps/companion/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
|
||||||
|
import { Container } from "@/components/Container";
|
||||||
|
import { SafeAreaScrollView } from "@/components/safe-area-scroll-view";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Ble } from "@aris/ble";
|
||||||
|
import { useBleStore } from "@/store/ble";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number | null) => {
|
||||||
|
if (!timestamp) {
|
||||||
|
return "Never";
|
||||||
|
}
|
||||||
|
return new Date(timestamp).toLocaleTimeString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BleScreen() {
|
||||||
|
const initialize = useBleStore((state) => state.initialize);
|
||||||
|
const isSupported = useBleStore((state) => state.isSupported);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialize();
|
||||||
|
}, [initialize]);
|
||||||
|
|
||||||
|
if (!isSupported) {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-1 bg-white">
|
||||||
|
<Container className="px-8">
|
||||||
|
<View className="gap-1">
|
||||||
|
<Text variant="h3">BLE</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground">
|
||||||
|
Bluetooth peripheral status and controls.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="mt-4 rounded-xl border border-muted bg-muted/20 px-4 py-3">
|
||||||
|
<Text className="text-sm font-medium">BLE Unavailable</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground">
|
||||||
|
This page is currently supported on iOS only.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Container>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-1 bg-white">
|
||||||
|
<View>
|
||||||
|
<SafeAreaScrollView>
|
||||||
|
<View className="gap-4" style={{ paddingBottom: insets.bottom + 24 }}>
|
||||||
|
<View className="gap-1 px-8">
|
||||||
|
<Text variant="h3">BLE</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground">
|
||||||
|
Bluetooth peripheral status and controls.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<ControlPanel />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<BleStatusSection />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<TelemetrySection />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<UuidsSection />
|
||||||
|
</View>
|
||||||
|
</SafeAreaScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ValueRow({ label, value }: { label: ReactNode; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-row items-center justify-between">
|
||||||
|
{typeof label === "string" ? (
|
||||||
|
<Text className="text-sm text-muted-foreground">{label}</Text>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
{typeof value === "string" || typeof value === "number" ? (
|
||||||
|
<Text className="text-sm">{value}</Text>
|
||||||
|
) : (
|
||||||
|
value
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BleStatusSection() {
|
||||||
|
const bluetoothState = useBleStore((state) => state.bluetoothState);
|
||||||
|
const isSubscribed = useBleStore((state) => state.isSubscribed);
|
||||||
|
const subscribedCount = useBleStore((state) => state.subscribedCount);
|
||||||
|
const connectionLabel = isSubscribed ? "Connected" : "Not Connected";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
Status
|
||||||
|
</Text>
|
||||||
|
<View className="gap-1">
|
||||||
|
<ValueRow label="State" value={connectionLabel} />
|
||||||
|
<ValueRow label="Bluetooth" value={bluetoothState} />
|
||||||
|
<ValueRow label="Subscribers" value={subscribedCount} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ControlPanel() {
|
||||||
|
const advertisingEnabled = useBleStore((state) => state.advertisingEnabled);
|
||||||
|
const setAdvertisingEnabled = useBleStore(
|
||||||
|
(state) => state.setAdvertisingEnabled,
|
||||||
|
);
|
||||||
|
const wifiRequested = useBleStore((state) => state.wifiRequested);
|
||||||
|
const setWifiRequested = useBleStore((state) => state.setWifiRequested);
|
||||||
|
const sendFixtureFeedNow = useBleStore((state) => state.sendFixtureFeedNow);
|
||||||
|
const handleCopyUUIDs = async () => {
|
||||||
|
const uuids = Ble.getUuids();
|
||||||
|
const text = [
|
||||||
|
`SERVICE_UUID=${uuids.serviceUUID}`,
|
||||||
|
`FEED_TX_UUID=${uuids.feedTxUUID}`,
|
||||||
|
`CONTROL_RX_UUID=${uuids.controlRxUUID}`,
|
||||||
|
`WIFI_REQUEST_TX_UUID=${uuids.wifiRequestTxUUID}`,
|
||||||
|
].join("\n");
|
||||||
|
await Clipboard.setStringAsync(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-3 font-medium">
|
||||||
|
Control Panel
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="flex-row gap-3 w-full mb-3">
|
||||||
|
<ControlTile
|
||||||
|
icon={<Ionicons name="radio-outline" size={20} />}
|
||||||
|
label="Advertising"
|
||||||
|
checked={advertisingEnabled}
|
||||||
|
onCheckChange={setAdvertisingEnabled}
|
||||||
|
/>
|
||||||
|
<ControlTile
|
||||||
|
icon={<Ionicons name="wifi-outline" size={20} />}
|
||||||
|
label="WiFi"
|
||||||
|
checked={wifiRequested}
|
||||||
|
onCheckChange={setWifiRequested}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-2">
|
||||||
|
<Button onPress={sendFixtureFeedNow} variant="outline">
|
||||||
|
<Text>Send Fixture Feed Now</Text>
|
||||||
|
</Button>
|
||||||
|
<Button onPress={handleCopyUUIDs} variant="outline">
|
||||||
|
<Text>Copy UUIDs</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ControlTile({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onCheckChange,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: ReactNode;
|
||||||
|
checked: boolean;
|
||||||
|
onCheckChange: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [isPressed, setIsPressed] = useState(false);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPressIn={() => {
|
||||||
|
setIsPressed(true);
|
||||||
|
}}
|
||||||
|
onPressOut={() => {
|
||||||
|
setIsPressed(false);
|
||||||
|
}}
|
||||||
|
onPress={() => {
|
||||||
|
onCheckChange(!checked);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 items-start justify-center rounded-md px-3 py-2 gap-1",
|
||||||
|
checked ? "border-2 border-primary" : "border border-border",
|
||||||
|
{ "bg-accent": isPressed },
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<Text className={cn({ "font-bold": checked })}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TelemetrySection() {
|
||||||
|
const lastMsgIdSent = useBleStore((state) => state.lastMsgIdSent);
|
||||||
|
const lastPingAt = useBleStore((state) => state.lastPingAt);
|
||||||
|
const lastDataAt = useBleStore((state) => state.lastDataAt);
|
||||||
|
const lastNotifyAt = useBleStore((state) => state.lastNotifyAt);
|
||||||
|
const notifyQueueDepth = useBleStore((state) => state.notifyQueueDepth);
|
||||||
|
const droppedNotifyPackets = useBleStore(
|
||||||
|
(state) => state.droppedNotifyPackets,
|
||||||
|
);
|
||||||
|
const lastCommand = useBleStore((state) => state.lastCommand);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
Telemetry
|
||||||
|
</Text>
|
||||||
|
<View className="gap-1">
|
||||||
|
<ValueRow label="Last msgId" value={lastMsgIdSent} />
|
||||||
|
<ValueRow label="Last ping" value={formatTime(lastPingAt)} />
|
||||||
|
<ValueRow label="Last data" value={formatTime(lastDataAt)} />
|
||||||
|
<ValueRow label="Notify queue" value={notifyQueueDepth} />
|
||||||
|
{droppedNotifyPackets > 0 ? (
|
||||||
|
<ValueRow
|
||||||
|
label="Dropped notify packets"
|
||||||
|
value={droppedNotifyPackets}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ValueRow label="Last notify" value={formatTime(lastNotifyAt)} />
|
||||||
|
{lastCommand ? (
|
||||||
|
<ValueRow label="Last control" value={lastCommand} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UuidsSection() {
|
||||||
|
const uuids = Ble.getUuids();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
UUIDs
|
||||||
|
</Text>
|
||||||
|
<Text selectable className="text-xs text-muted-foreground">
|
||||||
|
{`SERVICE_UUID=${uuids.serviceUUID}\nFEED_TX_UUID=${uuids.feedTxUUID}\nCONTROL_RX_UUID=${uuids.controlRxUUID}\nWIFI_REQUEST_TX_UUID=${uuids.wifiRequestTxUUID}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
426
aris/apps/companion/app/(tabs)/orchestrator.tsx
Normal file
426
aris/apps/companion/app/(tabs)/orchestrator.tsx
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
import { SafeAreaScrollView } from "@/components/safe-area-scroll-view";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const fixture = {
|
||||||
|
location: {
|
||||||
|
authorization: "When In Use",
|
||||||
|
lastLocation: {
|
||||||
|
latitude: 37.33182,
|
||||||
|
longitude: -122.03118,
|
||||||
|
accuracyMeters: 12.4,
|
||||||
|
speedMps: 0.4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recompute: {
|
||||||
|
lastReason: "manual",
|
||||||
|
lastTime: new Date("2024-05-21T09:32:00Z"),
|
||||||
|
elapsedMs: 482,
|
||||||
|
fetchFailed: false,
|
||||||
|
error: null as string | null,
|
||||||
|
},
|
||||||
|
winner: {
|
||||||
|
title: "Glass Now online",
|
||||||
|
subtitle: "Connected to iPhone",
|
||||||
|
type: "INFO",
|
||||||
|
bucket: "RIGHT_NOW",
|
||||||
|
priority: 0.8,
|
||||||
|
ttlSec: 86400,
|
||||||
|
poiType: "cafe",
|
||||||
|
startsAt: 1767717000,
|
||||||
|
id: "demo:welcome",
|
||||||
|
},
|
||||||
|
feed: [
|
||||||
|
{
|
||||||
|
id: "demo:welcome",
|
||||||
|
title: "Glass Now online",
|
||||||
|
subtitle: "Connected to iPhone",
|
||||||
|
type: "INFO",
|
||||||
|
bucket: "RIGHT_NOW",
|
||||||
|
priority: 0.8,
|
||||||
|
ttlSec: 86400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cal:demo:1767717000",
|
||||||
|
title: "Team Sync",
|
||||||
|
subtitle: "",
|
||||||
|
type: "CALENDAR_EVENT",
|
||||||
|
bucket: "FYI",
|
||||||
|
priority: 0.7,
|
||||||
|
ttlSec: 5400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo:next",
|
||||||
|
title: "Next: Calendar",
|
||||||
|
subtitle: "Then Weather + POI",
|
||||||
|
type: "INFO",
|
||||||
|
bucket: "FYI",
|
||||||
|
priority: 0.4,
|
||||||
|
ttlSec: 86400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "music:now:demo",
|
||||||
|
title: "Midnight City",
|
||||||
|
subtitle: "M83 - Hurry Up, We're Dreaming",
|
||||||
|
type: "NOW_PLAYING",
|
||||||
|
bucket: "FYI",
|
||||||
|
priority: 0.35,
|
||||||
|
ttlSec: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nowPlaying: {
|
||||||
|
auth: "Authorized",
|
||||||
|
title: "Midnight City",
|
||||||
|
artist: "M83",
|
||||||
|
album: "Hurry Up, We're Dreaming",
|
||||||
|
playbackStatus: "playing",
|
||||||
|
},
|
||||||
|
weatherDiagnostics: {
|
||||||
|
provider: "WeatherKit",
|
||||||
|
last_fetch: "2024-05-21 09:30:14",
|
||||||
|
alerts: "0",
|
||||||
|
conditions: "mostly_clear_day",
|
||||||
|
},
|
||||||
|
calendarDiagnostics: {
|
||||||
|
events_upcoming: "3",
|
||||||
|
next_event: "Team Sync - 10:30",
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
note: "Test actions are not available in the React Native client.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date | null) => {
|
||||||
|
if (!date) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return date.toLocaleTimeString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSpeed = (speedMps: number) => {
|
||||||
|
if (speedMps < 0) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return `${speedMps.toFixed(1)} m/s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLatLon = (value: number) => value.toFixed(5);
|
||||||
|
|
||||||
|
const formatPriority = (value: number) => value.toFixed(2);
|
||||||
|
|
||||||
|
export default function OrchestratorScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-1 bg-white">
|
||||||
|
<SafeAreaScrollView>
|
||||||
|
<View className="gap-4" style={{ paddingBottom: insets.bottom + 24 }}>
|
||||||
|
<View className="gap-1 px-8">
|
||||||
|
<Text variant="h3">Orchestrator</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground">
|
||||||
|
Context engine status and diagnostics.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<LocationSection />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<RecomputeSection />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<WinnerSection />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<FeedSection />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<NowPlayingSection />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<DiagnosticsSection
|
||||||
|
title="Weather Diagnostics"
|
||||||
|
entries={fixture.weatherDiagnostics}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<DiagnosticsSection
|
||||||
|
title="Calendar Diagnostics"
|
||||||
|
entries={fixture.calendarDiagnostics}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<TestSection />
|
||||||
|
</View>
|
||||||
|
</SafeAreaScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ValueRow({ label, value }: { label: ReactNode; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-row items-center justify-between">
|
||||||
|
{typeof label === "string" ? (
|
||||||
|
<Text className="text-sm text-muted-foreground">{label}</Text>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
{typeof value === "string" || typeof value === "number" ? (
|
||||||
|
<Text className="text-sm">{value}</Text>
|
||||||
|
) : (
|
||||||
|
value
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationSection() {
|
||||||
|
const { authorization, lastLocation } = fixture.location;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
Location
|
||||||
|
</Text>
|
||||||
|
<View className="gap-1">
|
||||||
|
<ValueRow label="Auth" value={authorization} />
|
||||||
|
{lastLocation ? (
|
||||||
|
<>
|
||||||
|
<ValueRow
|
||||||
|
label="Lat/Lon"
|
||||||
|
value={
|
||||||
|
<Text selectable className="text-sm">
|
||||||
|
{`${formatLatLon(lastLocation.latitude)}, ${formatLatLon(lastLocation.longitude)}`}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ValueRow
|
||||||
|
label="Accuracy"
|
||||||
|
value={`${Math.round(lastLocation.accuracyMeters)} m`}
|
||||||
|
/>
|
||||||
|
<ValueRow
|
||||||
|
label="Speed"
|
||||||
|
value={formatSpeed(lastLocation.speedMps)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text className="text-sm text-muted-foreground">No location yet</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecomputeSection() {
|
||||||
|
const { lastReason, lastTime, elapsedMs, fetchFailed, error } =
|
||||||
|
fixture.recompute;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
Recompute
|
||||||
|
</Text>
|
||||||
|
<View className="gap-1">
|
||||||
|
<ValueRow label="Last reason" value={lastReason || "--"} />
|
||||||
|
<ValueRow label="Last time" value={formatTime(lastTime)} />
|
||||||
|
<ValueRow label="Elapsed" value={`${elapsedMs} ms`} />
|
||||||
|
<ValueRow label="Fetch failed" value={fetchFailed ? "Yes" : "No"} />
|
||||||
|
{error ? (
|
||||||
|
<Text className="text-xs text-muted-foreground">{error}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Button onPress={() => {}} variant="outline" className="mt-3">
|
||||||
|
<Text>Recompute Now</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WinnerSection() {
|
||||||
|
const winner = fixture.winner;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text variant="h4" className="mb-2 font-medium px-8">
|
||||||
|
Winner
|
||||||
|
</Text>
|
||||||
|
{winner ? (
|
||||||
|
<Card className="mx-4 gap-2 pb-2">
|
||||||
|
<CardHeader className="gap-0 border-b border-border pb-2 flex-row items-start justify-between">
|
||||||
|
<View className="gap-1.5">
|
||||||
|
<CardTitle className="flex-1">{winner.title}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{winner.subtitle ?? "No description"}
|
||||||
|
</CardDescription>
|
||||||
|
</View>
|
||||||
|
<Badge variant="default">
|
||||||
|
<Text>{winner.type}</Text>
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="gap-1">
|
||||||
|
<ValueRow label="Bucket" value={winner.bucket} />
|
||||||
|
<ValueRow
|
||||||
|
label="Priority"
|
||||||
|
value={formatPriority(winner.priority)}
|
||||||
|
/>
|
||||||
|
<ValueRow label="TTL" value={`${winner.ttlSec}s`} />
|
||||||
|
{winner.poiType ? (
|
||||||
|
<ValueRow label="POI type" value={winner.poiType} />
|
||||||
|
) : null}
|
||||||
|
{winner.startsAt ? (
|
||||||
|
<ValueRow label="Starts at" value={`${winner.startsAt}`} />
|
||||||
|
) : null}
|
||||||
|
<ValueRow
|
||||||
|
label="ID"
|
||||||
|
value={
|
||||||
|
<Text selectable className="text-xs text-muted-foreground">
|
||||||
|
{winner.id}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Text className="text-sm text-muted-foreground">No winner yet</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedSection() {
|
||||||
|
const feed = fixture.feed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text variant="h4" className="mb-2 font-medium px-8">
|
||||||
|
Feed
|
||||||
|
</Text>
|
||||||
|
{feed.length === 0 ? (
|
||||||
|
<Text className="text-sm text-muted-foreground">No feed items yet</Text>
|
||||||
|
) : (
|
||||||
|
<View className="px-4">
|
||||||
|
{feed.map((item, i) => (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
className={cn("px-4 py-2 gap-1 border border-b-0 border-border", {
|
||||||
|
"rounded-t-lg": i === 0,
|
||||||
|
"rounded-b-lg border-b": i === feed.length - 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center justify-between gap-2">
|
||||||
|
<View className="gap-1">
|
||||||
|
<Text
|
||||||
|
className="text-sm font-semibold flex-1"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.subtitle || "No description"}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground">
|
||||||
|
{`bucket ${item.bucket} | prio ${formatPriority(item.priority)} | ttl ${item.ttlSec}s`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<Text>{item.type}</Text>
|
||||||
|
</Badge>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NowPlayingSection() {
|
||||||
|
const { auth, title, artist, album, playbackStatus } = fixture.nowPlaying;
|
||||||
|
const subtitle = [artist, album].filter(Boolean).join(" | ") || "Apple Music";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
Now Playing
|
||||||
|
</Text>
|
||||||
|
<View className="gap-1">
|
||||||
|
<ValueRow label="Music auth" value={auth} />
|
||||||
|
{title ? (
|
||||||
|
<>
|
||||||
|
<Text className="text-sm font-semibold" numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground" numberOfLines={1}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground">
|
||||||
|
{playbackStatus}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text className="text-sm text-muted-foreground">
|
||||||
|
{auth === "Authorized" ? "Nothing playing" : "Not authorized"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagnosticsSection({
|
||||||
|
title,
|
||||||
|
entries,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
entries: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
const keys = Object.keys(entries).sort();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<View className="gap-1">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<ValueRow
|
||||||
|
key={key}
|
||||||
|
label={key}
|
||||||
|
value={
|
||||||
|
<Text selectable className="text-xs text-muted-foreground">
|
||||||
|
{entries[key]}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestSection() {
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
Test
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground">{fixture.test.note}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
aris/apps/companion/app/(tabs)/settings.tsx
Normal file
22
aris/apps/companion/app/(tabs)/settings.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
import { Container } from "@/components/Container";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
return (
|
||||||
|
<View className={styles.container}>
|
||||||
|
<Container>
|
||||||
|
<Text variant="h3">Settings</Text>
|
||||||
|
<Text className={styles.subtitle}>
|
||||||
|
Port of SwiftUI SettingsView will live here.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: "flex flex-1 bg-white",
|
||||||
|
subtitle: "mt-2 text-sm text-muted-foreground",
|
||||||
|
};
|
||||||
22
aris/apps/companion/app/(tabs)/stocks.tsx
Normal file
22
aris/apps/companion/app/(tabs)/stocks.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
import { Container } from "@/components/Container";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export default function StocksScreen() {
|
||||||
|
return (
|
||||||
|
<View className={styles.container}>
|
||||||
|
<Container>
|
||||||
|
<Text variant="h3">Stocks</Text>
|
||||||
|
<Text className={styles.subtitle}>
|
||||||
|
Port of SwiftUI StockSettingsView will live here.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: "flex flex-1 bg-white",
|
||||||
|
subtitle: "mt-2 text-sm text-muted-foreground",
|
||||||
|
};
|
||||||
22
aris/apps/companion/app/(tabs)/todos.tsx
Normal file
22
aris/apps/companion/app/(tabs)/todos.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
import { Container } from "@/components/Container";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export default function TodosScreen() {
|
||||||
|
return (
|
||||||
|
<View className={styles.container}>
|
||||||
|
<Container>
|
||||||
|
<Text variant="h3">Todos</Text>
|
||||||
|
<Text className={styles.subtitle}>
|
||||||
|
Port of SwiftUI TodosView will live here.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: "flex flex-1 bg-white",
|
||||||
|
subtitle: "mt-2 text-sm text-muted-foreground",
|
||||||
|
};
|
||||||
46
aris/apps/companion/app/+html.tsx
Normal file
46
aris/apps/companion/app/+html.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||||
|
|
||||||
|
// This file is web-only and used to configure the root HTML for every
|
||||||
|
// web page during static rendering.
|
||||||
|
// The contents of this function only run in Node.js environments and
|
||||||
|
// do not have access to the DOM or browser APIs.
|
||||||
|
export default function Root({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
|
||||||
|
{/*
|
||||||
|
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||||
|
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
*/}
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
{/*
|
||||||
|
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||||
|
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||||
|
*/}
|
||||||
|
<ScrollViewStyleReset />
|
||||||
|
|
||||||
|
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||||
|
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsiveBackground = `
|
||||||
|
body {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
}`;
|
||||||
26
aris/apps/companion/app/+not-found.tsx
Normal file
26
aris/apps/companion/app/+not-found.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Container } from '@/components/Container';
|
||||||
|
|
||||||
|
export default function NotFoundScreen() {
|
||||||
|
return (
|
||||||
|
<View className={styles.container}>
|
||||||
|
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||||
|
<Container>
|
||||||
|
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
|
||||||
|
<Link href="/" className={styles.link}>
|
||||||
|
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||||
|
</Link>
|
||||||
|
</Container>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: `flex flex-1 bg-white`,
|
||||||
|
title: `text-xl font-bold`,
|
||||||
|
link: `mt-4 pt-4`,
|
||||||
|
linkText: `text-base text-[#2e78b7]`,
|
||||||
|
};
|
||||||
15
aris/apps/companion/app/_layout.tsx
Normal file
15
aris/apps/companion/app/_layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import "../global.css";
|
||||||
|
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { PortalHost } from "@rn-primitives/portal";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
</Stack>
|
||||||
|
<PortalHost />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
aris/apps/companion/app/details.tsx
Normal file
23
aris/apps/companion/app/details.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
|
||||||
|
import { Container } from '@/components/Container';
|
||||||
|
import { ScreenContent } from '@/components/ScreenContent';
|
||||||
|
|
||||||
|
export default function Details() {
|
||||||
|
const { name } = useLocalSearchParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.container}>
|
||||||
|
<Stack.Screen options={{ title: 'Details' }} />
|
||||||
|
<Container>
|
||||||
|
<ScreenContent path="screens/details.tsx" title={`Showing details for user ${name}`} />
|
||||||
|
</Container>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: 'flex flex-1 bg-white',
|
||||||
|
};
|
||||||
BIN
aris/apps/companion/assets/adaptive-icon.png
Normal file
BIN
aris/apps/companion/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
aris/apps/companion/assets/favicon.png
Normal file
BIN
aris/apps/companion/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
aris/apps/companion/assets/icon.png
Normal file
BIN
aris/apps/companion/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
aris/apps/companion/assets/splash.png
Normal file
BIN
aris/apps/companion/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
12
aris/apps/companion/babel.config.js
Normal file
12
aris/apps/companion/babel.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
let plugins = [];
|
||||||
|
|
||||||
|
plugins.push('react-native-worklets/plugin');
|
||||||
|
|
||||||
|
return {
|
||||||
|
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||||
|
|
||||||
|
plugins,
|
||||||
|
};
|
||||||
|
};
|
||||||
2062
aris/apps/companion/bun.lock
Normal file
2062
aris/apps/companion/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
42
aris/apps/companion/cesconfig.jsonc
Normal file
42
aris/apps/companion/cesconfig.jsonc
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// This is an optional configuration file used primarily for debugging purposes when reporting issues.
|
||||||
|
// It is safe to delete this file as it does not affect the functionality of your application.
|
||||||
|
{
|
||||||
|
"cesVersion": "2.20.1",
|
||||||
|
"projectName": "aris",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "expo-router",
|
||||||
|
"type": "navigation",
|
||||||
|
"options": {
|
||||||
|
"type": "stack"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nativewind",
|
||||||
|
"type": "styling"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "zustand",
|
||||||
|
"type": "state-management"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"flags": {
|
||||||
|
"noGit": false,
|
||||||
|
"noInstall": false,
|
||||||
|
"overwrite": false,
|
||||||
|
"importAlias": true,
|
||||||
|
"packageManager": "bun",
|
||||||
|
"eas": false,
|
||||||
|
"publish": false
|
||||||
|
},
|
||||||
|
"packageManager": {
|
||||||
|
"type": "bun",
|
||||||
|
"version": "1.2.21"
|
||||||
|
},
|
||||||
|
"os": {
|
||||||
|
"type": "Darwin",
|
||||||
|
"platform": "darwin",
|
||||||
|
"arch": "arm64",
|
||||||
|
"kernelVersion": "24.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
aris/apps/companion/components.json
Normal file
19
aris/apps/companion/components.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "global.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
aris/apps/companion/components/Button.tsx
Normal file
24
aris/apps/companion/components/Button.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { forwardRef } from 'react';
|
||||||
|
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
|
||||||
|
|
||||||
|
type ButtonProps = {
|
||||||
|
title: string;
|
||||||
|
} & TouchableOpacityProps;
|
||||||
|
|
||||||
|
export const Button = forwardRef<View, ButtonProps>(({ title, ...touchableProps }, ref) => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
ref={ref}
|
||||||
|
{...touchableProps}
|
||||||
|
className={`${styles.button} ${touchableProps.className}`}>
|
||||||
|
<Text className={styles.buttonText}>{title}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
|
||||||
|
buttonText: 'text-white text-lg font-semibold text-center',
|
||||||
|
};
|
||||||
16
aris/apps/companion/components/Container.tsx
Normal file
16
aris/apps/companion/components/Container.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export const Container = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className={cn("flex flex-1", className)}>
|
||||||
|
{children}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
29
aris/apps/companion/components/EditScreenInfo.tsx
Normal file
29
aris/apps/companion/components/EditScreenInfo.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export const EditScreenInfo = ({ path }: { path: string }) => {
|
||||||
|
const title = 'Open up the code for this screen:';
|
||||||
|
const description =
|
||||||
|
'Change any of the text, save the file, and your app will automatically update.';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View className={styles.getStartedContainer}>
|
||||||
|
<Text className={styles.getStartedText}>{title}</Text>
|
||||||
|
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||||
|
<Text>{path}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={styles.getStartedText}>{description}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
codeHighlightContainer: `rounded-md px-1`,
|
||||||
|
getStartedContainer: `items-center mx-12`,
|
||||||
|
getStartedText: `text-lg leading-6 text-center`,
|
||||||
|
helpContainer: `items-center mx-5 mt-4`,
|
||||||
|
helpLink: `py-4`,
|
||||||
|
helpLinkText: `text-center`,
|
||||||
|
homeScreenFilename: `my-2`,
|
||||||
|
};
|
||||||
26
aris/apps/companion/components/ScreenContent.tsx
Normal file
26
aris/apps/companion/components/ScreenContent.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { EditScreenInfo } from './EditScreenInfo';
|
||||||
|
|
||||||
|
type ScreenContentProps = {
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
|
||||||
|
return (
|
||||||
|
<View className={styles.container}>
|
||||||
|
<Text className={styles.title}>{title}</Text>
|
||||||
|
<View className={styles.separator} />
|
||||||
|
<EditScreenInfo path={path} />
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const styles = {
|
||||||
|
container: `items-center flex-1 justify-center bg-white`,
|
||||||
|
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
|
||||||
|
title: `text-xl font-bold`,
|
||||||
|
};
|
||||||
19
aris/apps/companion/components/safe-area-scroll-view.tsx
Normal file
19
aris/apps/companion/components/safe-area-scroll-view.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ScrollView } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export function SafeAreaScrollView({ children }: React.PropsWithChildren) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
aris/apps/companion/components/ui/badge.tsx
Normal file
67
aris/apps/companion/components/ui/badge.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { TextClassContext } from '@/components/ui/text';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import * as Slot from '@rn-primitives/slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { Platform, View, ViewProps } from 'react-native';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
cn(
|
||||||
|
'border-border group shrink-0 flex-row items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5',
|
||||||
|
Platform.select({
|
||||||
|
web: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: cn(
|
||||||
|
'bg-primary border-transparent',
|
||||||
|
Platform.select({ web: '[a&]:hover:bg-primary/90' })
|
||||||
|
),
|
||||||
|
secondary: cn(
|
||||||
|
'bg-secondary border-transparent',
|
||||||
|
Platform.select({ web: '[a&]:hover:bg-secondary/90' })
|
||||||
|
),
|
||||||
|
destructive: cn(
|
||||||
|
'bg-destructive border-transparent',
|
||||||
|
Platform.select({ web: '[a&]:hover:bg-destructive/90' })
|
||||||
|
),
|
||||||
|
outline: Platform.select({ web: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const badgeTextVariants = cva('text-xs font-medium', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'text-primary-foreground',
|
||||||
|
secondary: 'text-secondary-foreground',
|
||||||
|
destructive: 'text-white',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type BadgeProps = ViewProps &
|
||||||
|
React.RefAttributes<View> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
} & VariantProps<typeof badgeVariants>;
|
||||||
|
|
||||||
|
function Badge({ className, variant, asChild, ...props }: BadgeProps) {
|
||||||
|
const Component = asChild ? Slot.View : View;
|
||||||
|
return (
|
||||||
|
<TextClassContext.Provider value={badgeTextVariants({ variant })}>
|
||||||
|
<Component className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
</TextClassContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeTextVariants, badgeVariants };
|
||||||
|
export type { BadgeProps };
|
||||||
108
aris/apps/companion/components/ui/button.tsx
Normal file
108
aris/apps/companion/components/ui/button.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { TextClassContext } from '@/components/ui/text';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { Platform, Pressable } from 'react-native';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
cn(
|
||||||
|
'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none',
|
||||||
|
Platform.select({
|
||||||
|
web: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: cn(
|
||||||
|
'bg-primary active:bg-primary/90 shadow-sm shadow-black/5',
|
||||||
|
Platform.select({ web: 'hover:bg-primary/90' })
|
||||||
|
),
|
||||||
|
destructive: cn(
|
||||||
|
'bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm shadow-black/5',
|
||||||
|
Platform.select({
|
||||||
|
web: 'hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
outline: cn(
|
||||||
|
'border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm shadow-black/5',
|
||||||
|
Platform.select({
|
||||||
|
web: 'hover:bg-accent dark:hover:bg-input/50',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
secondary: cn(
|
||||||
|
'bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5',
|
||||||
|
Platform.select({ web: 'hover:bg-secondary/80' })
|
||||||
|
),
|
||||||
|
ghost: cn(
|
||||||
|
'active:bg-accent dark:active:bg-accent/50',
|
||||||
|
Platform.select({ web: 'hover:bg-accent dark:hover:bg-accent/50' })
|
||||||
|
),
|
||||||
|
link: '',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: cn('h-10 px-4 py-2 sm:h-9', Platform.select({ web: 'has-[>svg]:px-3' })),
|
||||||
|
sm: cn('h-9 gap-1.5 rounded-md px-3 sm:h-8', Platform.select({ web: 'has-[>svg]:px-2.5' })),
|
||||||
|
lg: cn('h-11 rounded-md px-6 sm:h-10', Platform.select({ web: 'has-[>svg]:px-4' })),
|
||||||
|
icon: 'h-10 w-10 sm:h-9 sm:w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttonTextVariants = cva(
|
||||||
|
cn(
|
||||||
|
'text-foreground text-sm font-medium',
|
||||||
|
Platform.select({ web: 'pointer-events-none transition-colors' })
|
||||||
|
),
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'text-primary-foreground',
|
||||||
|
destructive: 'text-white',
|
||||||
|
outline: cn(
|
||||||
|
'group-active:text-accent-foreground',
|
||||||
|
Platform.select({ web: 'group-hover:text-accent-foreground' })
|
||||||
|
),
|
||||||
|
secondary: 'text-secondary-foreground',
|
||||||
|
ghost: 'group-active:text-accent-foreground',
|
||||||
|
link: cn(
|
||||||
|
'text-primary group-active:underline',
|
||||||
|
Platform.select({ web: 'underline-offset-4 hover:underline group-hover:underline' })
|
||||||
|
),
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: '',
|
||||||
|
sm: '',
|
||||||
|
lg: '',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type ButtonProps = React.ComponentProps<typeof Pressable> &
|
||||||
|
React.RefAttributes<typeof Pressable> &
|
||||||
|
VariantProps<typeof buttonVariants>;
|
||||||
|
|
||||||
|
function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
|
||||||
|
<Pressable
|
||||||
|
className={cn(props.disabled && 'opacity-50', buttonVariants({ variant, size }), className)}
|
||||||
|
role="button"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TextClassContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonTextVariants, buttonVariants };
|
||||||
|
export type { ButtonProps };
|
||||||
80
aris/apps/companion/components/ui/card.tsx
Normal file
80
aris/apps/companion/components/ui/card.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Text, TextClassContext } from "@/components/ui/text";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
||||||
|
return (
|
||||||
|
<TextClassContext.Provider value="text-card-foreground">
|
||||||
|
<View
|
||||||
|
className={cn(
|
||||||
|
"bg-card border-border flex flex-col gap-4 rounded-xl border py-4 shadow-sm shadow-black/5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TextClassContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ViewProps & React.RefAttributes<View>) {
|
||||||
|
return (
|
||||||
|
<View className={cn("flex flex-col gap-1.5 px-4", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
role="heading"
|
||||||
|
aria-level={3}
|
||||||
|
className={cn("font-semibold leading-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ViewProps & React.RefAttributes<View>) {
|
||||||
|
return <View className={cn("px-4", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ViewProps & React.RefAttributes<View>) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={cn("flex flex-row items-center px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
};
|
||||||
36
aris/apps/companion/components/ui/switch.tsx
Normal file
36
aris/apps/companion/components/ui/switch.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import * as SwitchPrimitives from '@rn-primitives/switch';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SwitchPrimitives.RootProps & React.RefAttributes<SwitchPrimitives.RootRef>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
'flex h-[1.15rem] w-8 shrink-0 flex-row items-center rounded-full border border-transparent shadow-sm shadow-black/5',
|
||||||
|
Platform.select({
|
||||||
|
web: 'focus-visible:border-ring focus-visible:ring-ring/50 peer inline-flex outline-none transition-all focus-visible:ring-[3px] disabled:cursor-not-allowed',
|
||||||
|
}),
|
||||||
|
props.checked ? 'bg-primary' : 'bg-input dark:bg-input/80',
|
||||||
|
props.disabled && 'opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
'bg-background size-4 rounded-full transition-transform',
|
||||||
|
Platform.select({
|
||||||
|
web: 'pointer-events-none block ring-0',
|
||||||
|
}),
|
||||||
|
props.checked
|
||||||
|
? 'dark:bg-primary-foreground translate-x-3.5'
|
||||||
|
: 'dark:bg-foreground translate-x-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
89
aris/apps/companion/components/ui/text.tsx
Normal file
89
aris/apps/companion/components/ui/text.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import * as Slot from '@rn-primitives/slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Platform, Text as RNText, type Role } from 'react-native';
|
||||||
|
|
||||||
|
const textVariants = cva(
|
||||||
|
cn(
|
||||||
|
'text-foreground text-base',
|
||||||
|
Platform.select({
|
||||||
|
web: 'select-text',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: '',
|
||||||
|
h1: cn(
|
||||||
|
'text-center text-4xl font-extrabold tracking-tight',
|
||||||
|
Platform.select({ web: 'scroll-m-20 text-balance' })
|
||||||
|
),
|
||||||
|
h2: cn(
|
||||||
|
'border-border border-b pb-2 text-3xl font-semibold tracking-tight',
|
||||||
|
Platform.select({ web: 'scroll-m-20 first:mt-0' })
|
||||||
|
),
|
||||||
|
h3: cn('text-2xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
|
||||||
|
h4: cn('text-xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
|
||||||
|
p: 'mt-3 leading-7 sm:mt-6',
|
||||||
|
blockquote: 'mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6',
|
||||||
|
code: cn(
|
||||||
|
'bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold'
|
||||||
|
),
|
||||||
|
lead: 'text-muted-foreground text-xl',
|
||||||
|
large: 'text-lg font-semibold',
|
||||||
|
small: 'text-sm font-medium leading-none',
|
||||||
|
muted: 'text-muted-foreground text-sm',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type TextVariantProps = VariantProps<typeof textVariants>;
|
||||||
|
|
||||||
|
type TextVariant = NonNullable<TextVariantProps['variant']>;
|
||||||
|
|
||||||
|
const ROLE: Partial<Record<TextVariant, Role>> = {
|
||||||
|
h1: 'heading',
|
||||||
|
h2: 'heading',
|
||||||
|
h3: 'heading',
|
||||||
|
h4: 'heading',
|
||||||
|
blockquote: Platform.select({ web: 'blockquote' as Role }),
|
||||||
|
code: Platform.select({ web: 'code' as Role }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
|
||||||
|
h1: '1',
|
||||||
|
h2: '2',
|
||||||
|
h3: '3',
|
||||||
|
h4: '4',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TextClassContext = React.createContext<string | undefined>(undefined);
|
||||||
|
|
||||||
|
function Text({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RNText> &
|
||||||
|
TextVariantProps &
|
||||||
|
React.RefAttributes<RNText> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const textClass = React.useContext(TextClassContext);
|
||||||
|
const Component = asChild ? Slot.Text : RNText;
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={cn(textVariants({ variant }), textClass, className)}
|
||||||
|
role={variant ? ROLE[variant] : undefined}
|
||||||
|
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Text, TextClassContext };
|
||||||
15
aris/apps/companion/eslint.config.js
Normal file
15
aris/apps/companion/eslint.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const expoConfig = require('eslint-config-expo/flat');
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{
|
||||||
|
ignores: ['dist/*'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'react/display-name': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
58
aris/apps/companion/global.css
Normal file
58
aris/apps/companion/global.css
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 3.9%;
|
||||||
|
--primary: 0 0% 9%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 0 0% 96.1%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
--muted: 0 0% 96.1%;
|
||||||
|
--muted-foreground: 0 0% 45.1%;
|
||||||
|
--accent: 0 0% 96.1%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--border: 0 0% 89.8%;
|
||||||
|
--input: 0 0% 89.8%;
|
||||||
|
--ring: 0 0% 63%;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark:root {
|
||||||
|
--background: 0 0% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 0 0% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 0 0% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 0 0% 9%;
|
||||||
|
--secondary: 0 0% 14.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 0 0% 14.9%;
|
||||||
|
--muted-foreground: 0 0% 63.9%;
|
||||||
|
--accent: 0 0% 14.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 70.9% 59.4%;
|
||||||
|
--border: 0 0% 14.9%;
|
||||||
|
--input: 0 0% 14.9%;
|
||||||
|
--ring: 300 0% 45%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
aris/apps/companion/lib/ble/fixtures.ts
Normal file
51
aris/apps/companion/lib/ble/fixtures.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export const fullFeedFixture = {
|
||||||
|
schema: 1,
|
||||||
|
generated_at: 1767716400,
|
||||||
|
feed: [
|
||||||
|
{
|
||||||
|
id: "demo:welcome",
|
||||||
|
type: "INFO",
|
||||||
|
title: "Glass Now online",
|
||||||
|
subtitle: "Connected to iPhone",
|
||||||
|
priority: 0.8,
|
||||||
|
ttl_sec: 86400,
|
||||||
|
bucket: "RIGHT_NOW",
|
||||||
|
actions: ["DISMISS"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cal:demo:1767717000",
|
||||||
|
type: "CALENDAR_EVENT",
|
||||||
|
title: "Team Sync",
|
||||||
|
subtitle: "",
|
||||||
|
priority: 0.7,
|
||||||
|
ttl_sec: 5400,
|
||||||
|
starts_at: 1767717000,
|
||||||
|
bucket: "FYI",
|
||||||
|
actions: ["DISMISS"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo:next",
|
||||||
|
type: "INFO",
|
||||||
|
title: "Next: Calendar",
|
||||||
|
subtitle: "Then Weather + POI",
|
||||||
|
priority: 0.4,
|
||||||
|
ttl_sec: 86400,
|
||||||
|
bucket: "FYI",
|
||||||
|
actions: ["DISMISS"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "music:now:demo",
|
||||||
|
type: "NOW_PLAYING",
|
||||||
|
title: "Midnight City",
|
||||||
|
subtitle: "M83 • Hurry Up, We're Dreaming",
|
||||||
|
priority: 0.35,
|
||||||
|
ttl_sec: 30,
|
||||||
|
bucket: "FYI",
|
||||||
|
actions: ["DISMISS"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
winner_id: "demo:welcome",
|
||||||
|
unread_count: 4,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
81
aris/apps/companion/lib/theme.ts
Normal file
81
aris/apps/companion/lib/theme.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native";
|
||||||
|
|
||||||
|
export const THEME = {
|
||||||
|
light: {
|
||||||
|
background: "hsl(0 0% 100%)",
|
||||||
|
foreground: "hsl(0 0% 3.9%)",
|
||||||
|
card: "hsl(0 0% 100%)",
|
||||||
|
cardForeground: "hsl(0 0% 3.9%)",
|
||||||
|
popover: "hsl(0 0% 100%)",
|
||||||
|
popoverForeground: "hsl(0 0% 3.9%)",
|
||||||
|
primary: "hsl(0 0% 9%)",
|
||||||
|
primaryForeground: "hsl(0 0% 98%)",
|
||||||
|
secondary: "hsl(0 0% 96.1%)",
|
||||||
|
secondaryForeground: "hsl(0 0% 9%)",
|
||||||
|
muted: "hsl(0 0% 96.1%)",
|
||||||
|
mutedForeground: "hsl(0 0% 45.1%)",
|
||||||
|
accent: "hsl(0 0% 96.1%)",
|
||||||
|
accentForeground: "hsl(0 0% 9%)",
|
||||||
|
destructive: "hsl(0 84.2% 60.2%)",
|
||||||
|
border: "hsl(0 0% 89.8%)",
|
||||||
|
input: "hsl(0 0% 89.8%)",
|
||||||
|
ring: "hsl(0 0% 63%)",
|
||||||
|
radius: "0.625rem",
|
||||||
|
chart1: "hsl(12 76% 61%)",
|
||||||
|
chart2: "hsl(173 58% 39%)",
|
||||||
|
chart3: "hsl(197 37% 24%)",
|
||||||
|
chart4: "hsl(43 74% 66%)",
|
||||||
|
chart5: "hsl(27 87% 67%)",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "hsl(0 0% 3.9%)",
|
||||||
|
foreground: "hsl(0 0% 98%)",
|
||||||
|
card: "hsl(0 0% 3.9%)",
|
||||||
|
cardForeground: "hsl(0 0% 98%)",
|
||||||
|
popover: "hsl(0 0% 3.9%)",
|
||||||
|
popoverForeground: "hsl(0 0% 98%)",
|
||||||
|
primary: "hsl(0 0% 98%)",
|
||||||
|
primaryForeground: "hsl(0 0% 9%)",
|
||||||
|
secondary: "hsl(0 0% 14.9%)",
|
||||||
|
secondaryForeground: "hsl(0 0% 98%)",
|
||||||
|
muted: "hsl(0 0% 14.9%)",
|
||||||
|
mutedForeground: "hsl(0 0% 63.9%)",
|
||||||
|
accent: "hsl(0 0% 14.9%)",
|
||||||
|
accentForeground: "hsl(0 0% 98%)",
|
||||||
|
destructive: "hsl(0 70.9% 59.4%)",
|
||||||
|
border: "hsl(0 0% 14.9%)",
|
||||||
|
input: "hsl(0 0% 14.9%)",
|
||||||
|
ring: "hsl(300 0% 45%)",
|
||||||
|
radius: "0.625rem",
|
||||||
|
chart1: "hsl(220 70% 50%)",
|
||||||
|
chart2: "hsl(160 60% 45%)",
|
||||||
|
chart3: "hsl(30 80% 55%)",
|
||||||
|
chart4: "hsl(280 65% 60%)",
|
||||||
|
chart5: "hsl(340 75% 55%)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NAV_THEME: Record<"light" | "dark", Theme> = {
|
||||||
|
light: {
|
||||||
|
...DefaultTheme,
|
||||||
|
colors: {
|
||||||
|
background: THEME.light.background,
|
||||||
|
border: THEME.light.border,
|
||||||
|
card: THEME.light.card,
|
||||||
|
notification: THEME.light.destructive,
|
||||||
|
primary: THEME.light.primary,
|
||||||
|
text: THEME.light.foreground,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
...DarkTheme,
|
||||||
|
colors: {
|
||||||
|
background: THEME.dark.background,
|
||||||
|
border: THEME.dark.border,
|
||||||
|
card: THEME.dark.card,
|
||||||
|
notification: THEME.dark.destructive,
|
||||||
|
primary: THEME.dark.primary,
|
||||||
|
text: THEME.dark.foreground,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
6
aris/apps/companion/lib/utils.ts
Normal file
6
aris/apps/companion/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
24
aris/apps/companion/metro.config.js
Normal file
24
aris/apps/companion/metro.config.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||||
|
const path = require("path");
|
||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
|
||||||
|
const { withNativeWind } = require("nativewind/metro");
|
||||||
|
|
||||||
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
|
|
||||||
|
const projectRoot = __dirname;
|
||||||
|
const workspaceRoot = path.resolve(projectRoot, "../..");
|
||||||
|
|
||||||
|
const config = getDefaultConfig(projectRoot);
|
||||||
|
|
||||||
|
config.watchFolders = [workspaceRoot];
|
||||||
|
config.resolver.nodeModulesPaths = [
|
||||||
|
path.resolve(projectRoot, "node_modules"),
|
||||||
|
path.resolve(workspaceRoot, "node_modules"),
|
||||||
|
];
|
||||||
|
config.resolver.disableHierarchicalLookup = true;
|
||||||
|
|
||||||
|
module.exports = withNativeWind(config, {
|
||||||
|
input: "./global.css",
|
||||||
|
inlineRem: 16,
|
||||||
|
});
|
||||||
2
aris/apps/companion/nativewind-env.d.ts
vendored
Normal file
2
aris/apps/companion/nativewind-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
/// <reference types="nativewind/types" />
|
||||||
58
aris/apps/companion/package.json
Normal file
58
aris/apps/companion/package.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "aris",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"start": "expo start",
|
||||||
|
"prebuild": "expo prebuild",
|
||||||
|
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||||
|
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aris/ble": "*",
|
||||||
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@react-navigation/native": "^7.1.6",
|
||||||
|
"@rn-primitives/portal": "^1.3.0",
|
||||||
|
"@rn-primitives/slot": "^1.2.0",
|
||||||
|
"@rn-primitives/switch": "^1.2.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"expo": "^54.0.0",
|
||||||
|
"expo-clipboard": "~8.0.8",
|
||||||
|
"expo-calendar": "~14.0.0",
|
||||||
|
"expo-constants": "~18.0.9",
|
||||||
|
"expo-linking": "~8.0.8",
|
||||||
|
"expo-router": "~6.0.10",
|
||||||
|
"expo-status-bar": "~3.0.8",
|
||||||
|
"expo-system-ui": "~6.0.7",
|
||||||
|
"expo-web-browser": "~15.0.7",
|
||||||
|
"nativewind": "latest",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-web": "^0.21.0",
|
||||||
|
"react-native-worklets": "0.5.1",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zustand": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.20.0",
|
||||||
|
"@types/react": "~19.1.10",
|
||||||
|
"eslint": "^9.25.1",
|
||||||
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"eslint-config-prettier": "^10.1.2",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
10
aris/apps/companion/prettier.config.js
Normal file
10
aris/apps/companion/prettier.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module.exports = {
|
||||||
|
printWidth: 100,
|
||||||
|
tabWidth: 2,
|
||||||
|
singleQuote: true,
|
||||||
|
bracketSameLine: true,
|
||||||
|
trailingComma: 'es5',
|
||||||
|
|
||||||
|
plugins: [require.resolve('prettier-plugin-tailwindcss')],
|
||||||
|
tailwindAttributes: ['className'],
|
||||||
|
};
|
||||||
61
aris/apps/companion/store/ble.ts
Normal file
61
aris/apps/companion/store/ble.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import { fullFeedFixture } from "@/lib/ble/fixtures";
|
||||||
|
import { Ble, defaultBleState } from "@aris/ble";
|
||||||
|
import type { BleStatePayload } from "@aris/ble";
|
||||||
|
|
||||||
|
export type BleStoreState = BleStatePayload & {
|
||||||
|
isSupported: boolean;
|
||||||
|
isReady: boolean;
|
||||||
|
initialize: () => void;
|
||||||
|
setAdvertisingEnabled: (enabled: boolean) => void;
|
||||||
|
setWifiRequested: (requested: boolean) => void;
|
||||||
|
sendFixtureFeedNow: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let subscription: EventSubscription | null = null;
|
||||||
|
|
||||||
|
const noopSubscription: EventSubscription = {
|
||||||
|
remove: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBleStore = create<BleStoreState>((set, get) => ({
|
||||||
|
...defaultBleState,
|
||||||
|
isSupported: Ble.isSupported,
|
||||||
|
isReady: false,
|
||||||
|
initialize: () => {
|
||||||
|
if (!Ble.isSupported || get().isReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = Ble.getState();
|
||||||
|
set({ ...state, isReady: true });
|
||||||
|
subscription?.remove();
|
||||||
|
subscription =
|
||||||
|
Ble.addStateListener?.((nextState) => {
|
||||||
|
set({ ...nextState });
|
||||||
|
}) ?? noopSubscription;
|
||||||
|
Ble.start();
|
||||||
|
},
|
||||||
|
setAdvertisingEnabled: (enabled) => {
|
||||||
|
if (!Ble.isSupported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({ advertisingEnabled: enabled });
|
||||||
|
Ble.setAdvertisingEnabled(enabled);
|
||||||
|
},
|
||||||
|
setWifiRequested: (requested) => {
|
||||||
|
if (!Ble.isSupported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({ wifiRequested: requested });
|
||||||
|
Ble.setWifiRequested(requested);
|
||||||
|
},
|
||||||
|
sendFixtureFeedNow: () => {
|
||||||
|
if (!Ble.isSupported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = JSON.stringify(fullFeedFixture);
|
||||||
|
Ble.sendOpaque(payload, 1);
|
||||||
|
},
|
||||||
|
}));
|
||||||
15
aris/apps/companion/store/store.ts
Normal file
15
aris/apps/companion/store/store.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface BearState {
|
||||||
|
bears: number;
|
||||||
|
increasePopulation: () => void;
|
||||||
|
removeAllBears: () => void;
|
||||||
|
updateBears: (newBears: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<BearState>((set) => ({
|
||||||
|
bears: 0,
|
||||||
|
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
|
||||||
|
removeAllBears: () => set({ bears: 0 }),
|
||||||
|
updateBears: (newBears) => set({ bears: newBears }),
|
||||||
|
}));
|
||||||
73
aris/apps/companion/tailwind.config.js
Normal file
73
aris/apps/companion/tailwind.config.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const { hairlineWidth } = require("nativewind/theme");
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: "class",
|
||||||
|
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||||
|
presets: [require("nativewind/preset")],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
borderWidth: {
|
||||||
|
hairline: hairlineWidth(),
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
future: {
|
||||||
|
hoverOnlyWhenSupported: true,
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
};
|
||||||
11
aris/apps/companion/tsconfig.json
Normal file
11
aris/apps/companion/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||||
|
}
|
||||||
2659
aris/bun.lock
Normal file
2659
aris/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
5
aris/package.json
Normal file
5
aris/package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "iris",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": ["apps/*", "packages/*"]
|
||||||
|
}
|
||||||
2
aris/packages/ble/.eslintrc.js
Normal file
2
aris/packages/ble/.eslintrc.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// @generated by expo-module-scripts
|
||||||
|
module.exports = require('expo-module-scripts/eslintrc.base.js');
|
||||||
32
aris/packages/ble/README.md
Normal file
32
aris/packages/ble/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# @aris/ble
|
||||||
|
|
||||||
|
BLE peripheral module for the Aris companion app.
|
||||||
|
|
||||||
|
# API documentation
|
||||||
|
|
||||||
|
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/example.com/)
|
||||||
|
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/example.com/)
|
||||||
|
|
||||||
|
# Installation in managed Expo projects
|
||||||
|
|
||||||
|
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release.
|
||||||
|
|
||||||
|
# Installation in bare React Native projects
|
||||||
|
|
||||||
|
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
|
||||||
|
|
||||||
|
### Add the package to your npm dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install @aris/ble
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Configure for iOS
|
||||||
|
|
||||||
|
Run `npx pod-install` after installing the npm package.
|
||||||
|
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
Contributions are very welcome! Please refer to guidelines described in the [contributing guide]( https://github.com/expo/expo#contributing).
|
||||||
7
aris/packages/ble/expo-module.config.json
Normal file
7
aris/packages/ble/expo-module.config.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["apple"],
|
||||||
|
"apple": {
|
||||||
|
"modules": ["ArisBleModule"],
|
||||||
|
"appDelegateSubscribers": ["ArisBleAppDelegateSubscriber"]
|
||||||
|
}
|
||||||
|
}
|
||||||
35
aris/packages/ble/package.json
Normal file
35
aris/packages/ble/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@aris/ble",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "BLE peripheral module for the Aris companion app.",
|
||||||
|
"author": "Iris",
|
||||||
|
"homepage": "https://example.com",
|
||||||
|
"main": "build/index.js",
|
||||||
|
"types": "build/index.d.ts",
|
||||||
|
"sideEffects": false,
|
||||||
|
"exports": {
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
".": {
|
||||||
|
"types": "./build/index.d.ts",
|
||||||
|
"default": "./build/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "expo-module build",
|
||||||
|
"clean": "expo-module clean",
|
||||||
|
"lint": "expo-module lint",
|
||||||
|
"test": "expo-module test",
|
||||||
|
"prepare": "expo-module prepare",
|
||||||
|
"prepublishOnly": "expo-module prepublishOnly",
|
||||||
|
"expo-module": "expo-module"
|
||||||
|
},
|
||||||
|
"keywords": ["react-native", "expo", "ble"],
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"expo-module-scripts": "^5.0.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
aris/packages/ble/src/index.ts
Normal file
7
aris/packages/ble/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { Ble, defaultBleState } from "./native";
|
||||||
|
export { BleBluetoothState } from "./types";
|
||||||
|
export type {
|
||||||
|
BleNativeModuleEvents,
|
||||||
|
BleStatePayload,
|
||||||
|
BleUuids,
|
||||||
|
} from "./types";
|
||||||
90
aris/packages/ble/src/native.ts
Normal file
90
aris/packages/ble/src/native.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { NativeModule, requireNativeModule } from "expo";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BleBluetoothState,
|
||||||
|
type BleNativeModuleEvents,
|
||||||
|
type BleStatePayload,
|
||||||
|
type BleUuids,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
declare class ArisBleNativeModule extends NativeModule<BleNativeModuleEvents> {
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
setAdvertisingEnabled: (enabled: boolean) => void;
|
||||||
|
setWifiRequested: (requested: boolean) => void;
|
||||||
|
sendOpaque: (payload: string, msgType?: number) => void;
|
||||||
|
getState: () => BleStatePayload;
|
||||||
|
serviceUUID: string;
|
||||||
|
feedTxUUID: string;
|
||||||
|
controlRxUUID: string;
|
||||||
|
wifiRequestTxUUID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSupported = Platform.OS === "ios";
|
||||||
|
|
||||||
|
const nativeModule = isSupported
|
||||||
|
? requireNativeModule<ArisBleNativeModule>("ArisBle")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
export const defaultBleState: BleStatePayload = {
|
||||||
|
bluetoothState: BleBluetoothState.Unknown,
|
||||||
|
advertisingEnabled: true,
|
||||||
|
isAdvertising: false,
|
||||||
|
isSubscribed: false,
|
||||||
|
subscribedCount: 0,
|
||||||
|
lastMsgIdSent: 0,
|
||||||
|
lastPingAt: null,
|
||||||
|
lastCommand: null,
|
||||||
|
notifyQueueDepth: 0,
|
||||||
|
droppedNotifyPackets: 0,
|
||||||
|
lastNotifyAt: null,
|
||||||
|
lastDataAt: null,
|
||||||
|
wifiRequested: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyUuids: BleUuids = {
|
||||||
|
serviceUUID: "",
|
||||||
|
feedTxUUID: "",
|
||||||
|
controlRxUUID: "",
|
||||||
|
wifiRequestTxUUID: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
type BleApi = {
|
||||||
|
isSupported: boolean;
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
setAdvertisingEnabled: (enabled: boolean) => void;
|
||||||
|
setWifiRequested: (requested: boolean) => void;
|
||||||
|
sendOpaque: (payload: string, msgType?: number) => void;
|
||||||
|
getState: () => BleStatePayload;
|
||||||
|
addStateListener: (
|
||||||
|
listener: (state: BleStatePayload) => void,
|
||||||
|
) => EventSubscription | undefined;
|
||||||
|
getUuids: () => BleUuids;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Ble: BleApi = {
|
||||||
|
isSupported,
|
||||||
|
start: () => nativeModule?.start?.(),
|
||||||
|
stop: () => nativeModule?.stop?.(),
|
||||||
|
setAdvertisingEnabled: (enabled: boolean) =>
|
||||||
|
nativeModule?.setAdvertisingEnabled?.(enabled),
|
||||||
|
setWifiRequested: (requested: boolean) =>
|
||||||
|
nativeModule?.setWifiRequested?.(requested),
|
||||||
|
sendOpaque: (payload: string, msgType?: number) =>
|
||||||
|
nativeModule?.sendOpaque?.(payload, msgType),
|
||||||
|
getState: () => nativeModule?.getState?.() ?? { ...defaultBleState },
|
||||||
|
addStateListener: (listener: (state: BleStatePayload) => void) =>
|
||||||
|
nativeModule?.addListener("onStateChange", listener),
|
||||||
|
getUuids: (): BleUuids =>
|
||||||
|
nativeModule
|
||||||
|
? {
|
||||||
|
serviceUUID: nativeModule.serviceUUID,
|
||||||
|
feedTxUUID: nativeModule.feedTxUUID,
|
||||||
|
controlRxUUID: nativeModule.controlRxUUID,
|
||||||
|
wifiRequestTxUUID: nativeModule.wifiRequestTxUUID,
|
||||||
|
}
|
||||||
|
: emptyUuids,
|
||||||
|
};
|
||||||
39
aris/packages/ble/src/types.ts
Normal file
39
aris/packages/ble/src/types.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export const BleBluetoothState = {
|
||||||
|
Unknown: "Unknown",
|
||||||
|
Resetting: "Resetting",
|
||||||
|
Unsupported: "Unsupported",
|
||||||
|
Unauthorized: "Unauthorized",
|
||||||
|
PoweredOff: "Powered Off",
|
||||||
|
PoweredOn: "Powered On",
|
||||||
|
Other: "Other",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BleBluetoothState =
|
||||||
|
(typeof BleBluetoothState)[keyof typeof BleBluetoothState];
|
||||||
|
|
||||||
|
export type BleStatePayload = {
|
||||||
|
bluetoothState: BleBluetoothState;
|
||||||
|
advertisingEnabled: boolean;
|
||||||
|
isAdvertising: boolean;
|
||||||
|
isSubscribed: boolean;
|
||||||
|
subscribedCount: number;
|
||||||
|
lastMsgIdSent: number;
|
||||||
|
lastPingAt: number | null;
|
||||||
|
lastCommand: string | null;
|
||||||
|
notifyQueueDepth: number;
|
||||||
|
droppedNotifyPackets: number;
|
||||||
|
lastNotifyAt: number | null;
|
||||||
|
lastDataAt: number | null;
|
||||||
|
wifiRequested: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BleUuids = {
|
||||||
|
serviceUUID: string;
|
||||||
|
feedTxUUID: string;
|
||||||
|
controlRxUUID: string;
|
||||||
|
wifiRequestTxUUID: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BleNativeModuleEvents = {
|
||||||
|
onStateChange: (state: BleStatePayload) => void;
|
||||||
|
};
|
||||||
9
aris/packages/ble/tsconfig.json
Normal file
9
aris/packages/ble/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// @generated by expo-module-scripts
|
||||||
|
{
|
||||||
|
"extends": "expo-module-scripts/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./build"
|
||||||
|
},
|
||||||
|
"include": ["./src"],
|
||||||
|
"exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
|
||||||
|
}
|
||||||
2
aris/packages/data-sources/.eslintrc.js
Normal file
2
aris/packages/data-sources/.eslintrc.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// @generated by expo-module-scripts
|
||||||
|
module.exports = require('expo-module-scripts/eslintrc.base.js');
|
||||||
32
aris/packages/data-sources/README.md
Normal file
32
aris/packages/data-sources/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# @aris/data-sources
|
||||||
|
|
||||||
|
Data source module for the Aris companion app.
|
||||||
|
|
||||||
|
# API documentation
|
||||||
|
|
||||||
|
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/example.com/)
|
||||||
|
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/example.com/)
|
||||||
|
|
||||||
|
# Installation in managed Expo projects
|
||||||
|
|
||||||
|
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release.
|
||||||
|
|
||||||
|
# Installation in bare React Native projects
|
||||||
|
|
||||||
|
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
|
||||||
|
|
||||||
|
### Add the package to your npm dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install @aris/data-sources
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Configure for iOS
|
||||||
|
|
||||||
|
Run `npx pod-install` after installing the npm package.
|
||||||
|
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
Contributions are very welcome! Please refer to guidelines described in the [contributing guide]( https://github.com/expo/expo#contributing).
|
||||||
6
aris/packages/data-sources/expo-module.config.json
Normal file
6
aris/packages/data-sources/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["apple"],
|
||||||
|
"apple": {
|
||||||
|
"modules": ["WeatherDataSourceModule", "PoiDataSourceModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
38
aris/packages/data-sources/package.json
Normal file
38
aris/packages/data-sources/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@aris/data-sources",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Data source module for the Aris companion app.",
|
||||||
|
"author": "Iris",
|
||||||
|
"homepage": "https://example.com",
|
||||||
|
"main": "build/index.js",
|
||||||
|
"types": "build/index.d.ts",
|
||||||
|
"sideEffects": false,
|
||||||
|
"exports": {
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
".": {
|
||||||
|
"types": "./build/index.d.ts",
|
||||||
|
"default": "./build/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "expo-module build",
|
||||||
|
"clean": "expo-module clean",
|
||||||
|
"lint": "expo-module lint",
|
||||||
|
"test": "expo-module test",
|
||||||
|
"prepare": "expo-module prepare",
|
||||||
|
"prepublishOnly": "expo-module prepublishOnly",
|
||||||
|
"expo-module": "expo-module"
|
||||||
|
},
|
||||||
|
"keywords": ["react-native", "expo", "data-sources"],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expo-calendar": "~14.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"expo-module-scripts": "^5.0.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
178
aris/packages/data-sources/src/calendar/calendar.ts
Normal file
178
aris/packages/data-sources/src/calendar/calendar.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import * as Calendar from "expo-calendar";
|
||||||
|
|
||||||
|
import { DataSourceError } from "../common/errors";
|
||||||
|
import type { Diagnostics } from "../common/types";
|
||||||
|
import type {
|
||||||
|
CalendarData,
|
||||||
|
CalendarDataSource,
|
||||||
|
CalendarDataSourceConfig,
|
||||||
|
CalendarEvent,
|
||||||
|
CalendarRequest,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const defaultConfig: Required<CalendarDataSourceConfig> = {
|
||||||
|
lookaheadSec: 2 * 60 * 60,
|
||||||
|
soonWindowSec: 30 * 60,
|
||||||
|
maxCandidates: 3,
|
||||||
|
includeAllDay: false,
|
||||||
|
includeDeclined: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveConfig = (
|
||||||
|
base: CalendarDataSourceConfig,
|
||||||
|
override?: CalendarDataSourceConfig,
|
||||||
|
): Required<CalendarDataSourceConfig> => ({
|
||||||
|
...defaultConfig,
|
||||||
|
...base,
|
||||||
|
...(override ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensureCalendarAccess = async (diagnostics: Diagnostics) => {
|
||||||
|
const permission = await Calendar.getCalendarPermissionsAsync();
|
||||||
|
let status = permission.status;
|
||||||
|
diagnostics.auth = status ?? "unknown";
|
||||||
|
|
||||||
|
if (status !== "granted") {
|
||||||
|
const requested = await Calendar.requestCalendarPermissionsAsync();
|
||||||
|
status = requested.status;
|
||||||
|
diagnostics.auth = status ?? diagnostics.auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const granted = status === "granted";
|
||||||
|
diagnostics.access_granted = granted ? "true" : "false";
|
||||||
|
|
||||||
|
if (!granted) {
|
||||||
|
throw new DataSourceError(
|
||||||
|
"access_not_granted",
|
||||||
|
"Calendar access not granted.",
|
||||||
|
diagnostics,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldIncludeEvent = (
|
||||||
|
event: Calendar.Event,
|
||||||
|
config: Required<CalendarDataSourceConfig>,
|
||||||
|
) => {
|
||||||
|
if (event.allDay && !config.includeAllDay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!config.includeDeclined &&
|
||||||
|
event.status === Calendar.EventStatus.CANCELED
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toDate = (value?: Date | string | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildEvents = (
|
||||||
|
events: Calendar.Event[],
|
||||||
|
nowDate: Date,
|
||||||
|
config: Required<CalendarDataSourceConfig>,
|
||||||
|
) => {
|
||||||
|
const results: CalendarEvent[] = [];
|
||||||
|
for (const event of events) {
|
||||||
|
if (results.length >= config.maxCandidates) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const startDate = toDate(event.startDate);
|
||||||
|
const endDate = toDate(event.endDate);
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOngoing = startDate <= nowDate && endDate > nowDate;
|
||||||
|
const startsInSec = Math.floor(
|
||||||
|
(startDate.getTime() - nowDate.getTime()) / 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOngoing && startsInSec > config.soonWindowSec) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAt = Math.floor(startDate.getTime() / 1000);
|
||||||
|
const endAt = Math.floor(endDate.getTime() / 1000);
|
||||||
|
const title = (event.title ?? "Event").trim() || "Event";
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
id: `cal:${event.id}:${startAt}`,
|
||||||
|
title,
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
isAllDay: !!event.allDay,
|
||||||
|
location: event.location ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCalendarData = async (
|
||||||
|
request: CalendarRequest,
|
||||||
|
config: CalendarDataSourceConfig,
|
||||||
|
): Promise<{ data: CalendarData; diagnostics: Diagnostics }> => {
|
||||||
|
const resolvedConfig = resolveConfig(config, request.config);
|
||||||
|
const diagnostics: Diagnostics = {
|
||||||
|
now: String(request.now),
|
||||||
|
lookahead_sec: String(resolvedConfig.lookaheadSec),
|
||||||
|
soon_window_sec: String(resolvedConfig.soonWindowSec),
|
||||||
|
};
|
||||||
|
|
||||||
|
await ensureCalendarAccess(diagnostics);
|
||||||
|
|
||||||
|
const nowDate = new Date(request.now * 1000);
|
||||||
|
const endDate = new Date(
|
||||||
|
nowDate.getTime() + resolvedConfig.lookaheadSec * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calendars: Calendar.Calendar[] = await Calendar.getCalendarsAsync(
|
||||||
|
Calendar.EntityTypes.EVENT,
|
||||||
|
);
|
||||||
|
const calendarIds = calendars.map(
|
||||||
|
(calendar: Calendar.Calendar) => calendar.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const events: Calendar.Event[] = await Calendar.getEventsAsync(
|
||||||
|
calendarIds,
|
||||||
|
nowDate,
|
||||||
|
endDate,
|
||||||
|
);
|
||||||
|
diagnostics.events_matched = String(events.length);
|
||||||
|
|
||||||
|
const filtered = events
|
||||||
|
.filter((event: Calendar.Event) =>
|
||||||
|
shouldIncludeEvent(event, resolvedConfig),
|
||||||
|
)
|
||||||
|
.sort((a: Calendar.Event, b: Calendar.Event) => {
|
||||||
|
const aStart = toDate(a.startDate)?.getTime() ?? 0;
|
||||||
|
const bStart = toDate(b.startDate)?.getTime() ?? 0;
|
||||||
|
return aStart - bStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
diagnostics.events_filtered = String(filtered.length);
|
||||||
|
|
||||||
|
const outputEvents = buildEvents(filtered, nowDate, resolvedConfig);
|
||||||
|
diagnostics.events_output = String(outputEvents.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
events: outputEvents,
|
||||||
|
},
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCalendarDataSource = (
|
||||||
|
config: CalendarDataSourceConfig = {},
|
||||||
|
): CalendarDataSource => ({
|
||||||
|
dataWithDiagnostics: (request) => fetchCalendarData(request, config),
|
||||||
|
});
|
||||||
8
aris/packages/data-sources/src/calendar/index.ts
Normal file
8
aris/packages/data-sources/src/calendar/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { createCalendarDataSource } from "./calendar";
|
||||||
|
export type {
|
||||||
|
CalendarData,
|
||||||
|
CalendarDataSource,
|
||||||
|
CalendarDataSourceConfig,
|
||||||
|
CalendarEvent,
|
||||||
|
CalendarRequest,
|
||||||
|
} from "./types";
|
||||||
49
aris/packages/data-sources/src/calendar/types.ts
Normal file
49
aris/packages/data-sources/src/calendar/types.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { DataSource } from "../common/types";
|
||||||
|
|
||||||
|
export type CalendarDataSourceConfig = {
|
||||||
|
/**
|
||||||
|
* How far ahead (in seconds) to scan for events.
|
||||||
|
* Defaults to 7200 (2 hours).
|
||||||
|
*/
|
||||||
|
lookaheadSec?: number;
|
||||||
|
/**
|
||||||
|
* How soon (in seconds) a future event must start to be included.
|
||||||
|
* Defaults to 1800 (30 minutes).
|
||||||
|
*/
|
||||||
|
soonWindowSec?: number;
|
||||||
|
/**
|
||||||
|
* Maximum number of candidate events to return.
|
||||||
|
* Defaults to 3.
|
||||||
|
*/
|
||||||
|
maxCandidates?: number;
|
||||||
|
/**
|
||||||
|
* Whether all-day events should be included.
|
||||||
|
* Defaults to false.
|
||||||
|
*/
|
||||||
|
includeAllDay?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether declined/canceled events should be included.
|
||||||
|
* Defaults to false.
|
||||||
|
*/
|
||||||
|
includeDeclined?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarEvent = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startAt: number;
|
||||||
|
endAt: number;
|
||||||
|
isAllDay: boolean;
|
||||||
|
location?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarData = {
|
||||||
|
events: CalendarEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarRequest = {
|
||||||
|
now: number;
|
||||||
|
config?: CalendarDataSourceConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarDataSource = DataSource<CalendarRequest, CalendarData>;
|
||||||
18
aris/packages/data-sources/src/common/errors.ts
Normal file
18
aris/packages/data-sources/src/common/errors.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Diagnostics } from "./types";
|
||||||
|
|
||||||
|
export class DataSourceError extends Error {
|
||||||
|
readonly code: string;
|
||||||
|
readonly diagnostics?: Diagnostics;
|
||||||
|
|
||||||
|
constructor(code: string, message: string, diagnostics?: Diagnostics) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.diagnostics = diagnostics;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnsupportedError extends DataSourceError {
|
||||||
|
constructor(message = "Unsupported on this platform") {
|
||||||
|
super("unsupported", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
aris/packages/data-sources/src/common/types.ts
Normal file
17
aris/packages/data-sources/src/common/types.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export type Diagnostics = Record<string, string>;
|
||||||
|
|
||||||
|
export type LocationInput = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
horizontalAccuracy?: number | null;
|
||||||
|
speed?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataSourceResult<TData> = {
|
||||||
|
data: TData;
|
||||||
|
diagnostics: Diagnostics;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DataSource<TInput, TData> {
|
||||||
|
dataWithDiagnostics(input: TInput): Promise<DataSourceResult<TData>>;
|
||||||
|
}
|
||||||
8
aris/packages/data-sources/src/index.ts
Normal file
8
aris/packages/data-sources/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type { DataSource, DataSourceResult, Diagnostics, LocationInput } from "./common/types";
|
||||||
|
export { DataSourceError, UnsupportedError } from "./common/errors";
|
||||||
|
|
||||||
|
export * from "./calendar";
|
||||||
|
export * from "./poi";
|
||||||
|
export * from "./stock";
|
||||||
|
export * from "./tfl";
|
||||||
|
export * from "./weather";
|
||||||
10
aris/packages/data-sources/src/poi/index.ts
Normal file
10
aris/packages/data-sources/src/poi/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { createPoiDataSource } from "./poi";
|
||||||
|
export type {
|
||||||
|
PoiData,
|
||||||
|
PoiDataSource,
|
||||||
|
PoiDataSourceConfig,
|
||||||
|
PoiItem,
|
||||||
|
PoiRequest,
|
||||||
|
PoiSnapshot,
|
||||||
|
PoiType,
|
||||||
|
} from "./types";
|
||||||
8
aris/packages/data-sources/src/poi/poi.android.ts
Normal file
8
aris/packages/data-sources/src/poi/poi.android.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { UnsupportedError } from "../common/errors";
|
||||||
|
import type { PoiDataSource } from "./types";
|
||||||
|
|
||||||
|
export const createPoiDataSource = (): PoiDataSource => ({
|
||||||
|
dataWithDiagnostics: async () => {
|
||||||
|
throw new UnsupportedError("POI data source is not supported on Android.");
|
||||||
|
},
|
||||||
|
});
|
||||||
37
aris/packages/data-sources/src/poi/poi.ios.ts
Normal file
37
aris/packages/data-sources/src/poi/poi.ios.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { requireNativeModule } from "expo";
|
||||||
|
import type {
|
||||||
|
PoiDataSource,
|
||||||
|
PoiDataSourceConfig,
|
||||||
|
PoiRequest,
|
||||||
|
PoiSnapshot,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type PoiDataSourceNativeModule = {
|
||||||
|
getPoiData: (request: PoiRequest) => Promise<PoiSnapshot>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PoiDataSourceNative =
|
||||||
|
requireNativeModule<PoiDataSourceNativeModule>("PoiDataSource");
|
||||||
|
|
||||||
|
const mergeConfig = (
|
||||||
|
base: PoiDataSourceConfig,
|
||||||
|
override?: PoiDataSourceConfig,
|
||||||
|
) => ({
|
||||||
|
...base,
|
||||||
|
...(override ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createPoiDataSource = (
|
||||||
|
config: PoiDataSourceConfig = {},
|
||||||
|
): PoiDataSource => ({
|
||||||
|
dataWithDiagnostics: async (request: PoiRequest) => {
|
||||||
|
const mergedConfig = mergeConfig(config, request.config);
|
||||||
|
const resolvedRequest = {
|
||||||
|
...request,
|
||||||
|
config:
|
||||||
|
Object.keys(mergedConfig).length > 0 ? mergedConfig : undefined,
|
||||||
|
};
|
||||||
|
const snapshot = await PoiDataSourceNative.getPoiData(resolvedRequest);
|
||||||
|
return { data: snapshot.data, diagnostics: snapshot.diagnostics };
|
||||||
|
},
|
||||||
|
});
|
||||||
10
aris/packages/data-sources/src/poi/poi.ts
Normal file
10
aris/packages/data-sources/src/poi/poi.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
import type { PoiDataSource, PoiDataSourceConfig } from "./types";
|
||||||
|
|
||||||
|
type CreatePoiDataSource = (config?: PoiDataSourceConfig) => PoiDataSource;
|
||||||
|
|
||||||
|
const impl: { createPoiDataSource: CreatePoiDataSource } =
|
||||||
|
Platform.OS === "ios" ? require("./poi.ios") : require("./poi.android");
|
||||||
|
|
||||||
|
export const createPoiDataSource = impl.createPoiDataSource;
|
||||||
78
aris/packages/data-sources/src/poi/types.ts
Normal file
78
aris/packages/data-sources/src/poi/types.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { DataSource } from "../common/types";
|
||||||
|
import type { LocationInput } from "../common/types";
|
||||||
|
|
||||||
|
export type PoiDataSourceConfig = {
|
||||||
|
/**
|
||||||
|
* Maximum number of POIs to return.
|
||||||
|
* Defaults to 2 on iOS.
|
||||||
|
*/
|
||||||
|
maxCandidates?: number;
|
||||||
|
/**
|
||||||
|
* Search radius in meters.
|
||||||
|
* Defaults to 600 on iOS.
|
||||||
|
*/
|
||||||
|
searchRadiusMeters?: number;
|
||||||
|
/**
|
||||||
|
* Distance in meters within which transit POIs get a score boost.
|
||||||
|
* Defaults to 200 on iOS.
|
||||||
|
*/
|
||||||
|
transitBoostRadiusMeters?: number;
|
||||||
|
/**
|
||||||
|
* Assumed walking speed in meters per second.
|
||||||
|
* Defaults to 1.4 on iOS.
|
||||||
|
*/
|
||||||
|
walkingSpeedMps?: number;
|
||||||
|
/**
|
||||||
|
* Minimum TTL in seconds for POI items.
|
||||||
|
* Defaults to 60 on iOS.
|
||||||
|
*/
|
||||||
|
minTtlSeconds?: number;
|
||||||
|
/**
|
||||||
|
* Maximum TTL in seconds for POI items.
|
||||||
|
* Defaults to 1200 on iOS.
|
||||||
|
*/
|
||||||
|
maxTtlSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PoiType =
|
||||||
|
| "transit"
|
||||||
|
| "cafe"
|
||||||
|
| "food"
|
||||||
|
| "park"
|
||||||
|
| "shopping"
|
||||||
|
| "grocery"
|
||||||
|
| "fitness"
|
||||||
|
| "entertainment"
|
||||||
|
| "health"
|
||||||
|
| "lodging"
|
||||||
|
| "education"
|
||||||
|
| "services"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export type PoiItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
poiType: PoiType;
|
||||||
|
distanceMeters: number;
|
||||||
|
walkingMinutes: number;
|
||||||
|
ttlSec: number;
|
||||||
|
confidence: number;
|
||||||
|
isTransit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PoiData = {
|
||||||
|
pois: PoiItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PoiRequest = {
|
||||||
|
location: LocationInput;
|
||||||
|
now: number;
|
||||||
|
config?: PoiDataSourceConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PoiSnapshot = {
|
||||||
|
data: PoiData;
|
||||||
|
diagnostics: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PoiDataSource = DataSource<PoiRequest, PoiData>;
|
||||||
8
aris/packages/data-sources/src/stock/index.ts
Normal file
8
aris/packages/data-sources/src/stock/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { createStockDataSource } from "./stock";
|
||||||
|
export type {
|
||||||
|
StockData,
|
||||||
|
StockDataSource,
|
||||||
|
StockDataSourceConfig,
|
||||||
|
StockQuote,
|
||||||
|
StockRequest,
|
||||||
|
} from "./types";
|
||||||
147
aris/packages/data-sources/src/stock/stock.ts
Normal file
147
aris/packages/data-sources/src/stock/stock.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { DataSourceError } from "../common/errors";
|
||||||
|
import type { Diagnostics } from "../common/types";
|
||||||
|
import type {
|
||||||
|
StockData,
|
||||||
|
StockDataSource,
|
||||||
|
StockDataSourceConfig,
|
||||||
|
StockQuote,
|
||||||
|
StockRequest,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
timestamp: number;
|
||||||
|
data: StockData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultConfig: Required<StockDataSourceConfig> = {
|
||||||
|
maxSymbols: 5,
|
||||||
|
cacheValiditySec: 300,
|
||||||
|
ttlSec: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cache: CacheEntry | null = null;
|
||||||
|
|
||||||
|
const resolveConfig = (
|
||||||
|
base: StockDataSourceConfig,
|
||||||
|
override?: StockDataSourceConfig,
|
||||||
|
): Required<StockDataSourceConfig> => ({
|
||||||
|
...defaultConfig,
|
||||||
|
...base,
|
||||||
|
...(override ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchQuote = async (
|
||||||
|
symbol: string,
|
||||||
|
): Promise<StockQuote | null> => {
|
||||||
|
const encodedSymbol = encodeURIComponent(symbol);
|
||||||
|
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodedSymbol}?interval=1d&range=1d`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new DataSourceError(
|
||||||
|
"network_failed",
|
||||||
|
`HTTP ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
chart?: {
|
||||||
|
result?: Array<{
|
||||||
|
meta?: {
|
||||||
|
symbol?: string;
|
||||||
|
shortName?: string;
|
||||||
|
regularMarketPrice?: number;
|
||||||
|
chartPreviousClose?: number;
|
||||||
|
previousClose?: number;
|
||||||
|
marketState?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
error?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = payload.chart?.result?.[0];
|
||||||
|
const meta = result?.meta;
|
||||||
|
const price = meta?.regularMarketPrice;
|
||||||
|
|
||||||
|
if (!meta || price == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousClose = meta.chartPreviousClose ?? meta.previousClose ?? price;
|
||||||
|
const change = price - previousClose;
|
||||||
|
const changePercent = previousClose > 0 ? (change / previousClose) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbol: meta.symbol ?? symbol,
|
||||||
|
shortName: meta.shortName ?? meta.symbol ?? symbol,
|
||||||
|
price,
|
||||||
|
change,
|
||||||
|
changePercent,
|
||||||
|
marketState: meta.marketState ?? "CLOSED",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchStockData = async (
|
||||||
|
request: StockRequest,
|
||||||
|
config: StockDataSourceConfig,
|
||||||
|
): Promise<{ data: StockData; diagnostics: Diagnostics }> => {
|
||||||
|
const resolvedConfig = resolveConfig(config, request.config);
|
||||||
|
const diagnostics: Diagnostics = {
|
||||||
|
now: String(request.now),
|
||||||
|
symbols_requested: request.symbols.join(","),
|
||||||
|
max_symbols: String(resolvedConfig.maxSymbols),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.symbols.length === 0) {
|
||||||
|
diagnostics.result = "no_symbols";
|
||||||
|
return { data: { quotes: [] }, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedSymbols = request.symbols.slice(0, resolvedConfig.maxSymbols);
|
||||||
|
diagnostics.symbols_queried = limitedSymbols.join(",");
|
||||||
|
|
||||||
|
if (cache && request.now - cache.timestamp < resolvedConfig.cacheValiditySec) {
|
||||||
|
diagnostics.source = "cache";
|
||||||
|
diagnostics.cache_age_sec = String(request.now - cache.timestamp);
|
||||||
|
return { data: cache.data, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotes: StockQuote[] = [];
|
||||||
|
const fetchErrors: string[] = [];
|
||||||
|
|
||||||
|
for (const symbol of limitedSymbols) {
|
||||||
|
try {
|
||||||
|
const quote = await fetchQuote(symbol);
|
||||||
|
if (quote) {
|
||||||
|
quotes.push(quote);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
fetchErrors.push(`${symbol}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostics.source = "network";
|
||||||
|
diagnostics.quotes_returned = String(quotes.length);
|
||||||
|
if (fetchErrors.length > 0) {
|
||||||
|
diagnostics.fetch_errors = fetchErrors.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { quotes };
|
||||||
|
cache = { timestamp: request.now, data };
|
||||||
|
|
||||||
|
return { data, diagnostics };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createStockDataSource = (
|
||||||
|
config: StockDataSourceConfig = {},
|
||||||
|
): StockDataSource => ({
|
||||||
|
dataWithDiagnostics: (request) => fetchStockData(request, config),
|
||||||
|
});
|
||||||
40
aris/packages/data-sources/src/stock/types.ts
Normal file
40
aris/packages/data-sources/src/stock/types.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { DataSource } from "../common/types";
|
||||||
|
|
||||||
|
export type StockDataSourceConfig = {
|
||||||
|
/**
|
||||||
|
* Maximum number of symbols to request per fetch.
|
||||||
|
* Defaults to 5.
|
||||||
|
*/
|
||||||
|
maxSymbols?: number;
|
||||||
|
/**
|
||||||
|
* In-memory cache validity (seconds) before re-fetching.
|
||||||
|
* Defaults to 300.
|
||||||
|
*/
|
||||||
|
cacheValiditySec?: number;
|
||||||
|
/**
|
||||||
|
* Suggested TTL (seconds) for downstream items built from quotes.
|
||||||
|
* Defaults to 600.
|
||||||
|
*/
|
||||||
|
ttlSec?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StockQuote = {
|
||||||
|
symbol: string;
|
||||||
|
shortName: string;
|
||||||
|
price: number;
|
||||||
|
change: number;
|
||||||
|
changePercent: number;
|
||||||
|
marketState: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StockData = {
|
||||||
|
quotes: StockQuote[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StockRequest = {
|
||||||
|
symbols: string[];
|
||||||
|
now: number;
|
||||||
|
config?: StockDataSourceConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StockDataSource = DataSource<StockRequest, StockData>;
|
||||||
12
aris/packages/data-sources/src/tfl/index.ts
Normal file
12
aris/packages/data-sources/src/tfl/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
createTflDataSource,
|
||||||
|
formatTflDisruptionSubtitle,
|
||||||
|
formatTflDisruptionTitle,
|
||||||
|
} from "./tfl";
|
||||||
|
export type {
|
||||||
|
TflData,
|
||||||
|
TflDataSource,
|
||||||
|
TflDataSourceConfig,
|
||||||
|
TflDisruption,
|
||||||
|
TflRequest,
|
||||||
|
} from "./types";
|
||||||
165
aris/packages/data-sources/src/tfl/tfl.ts
Normal file
165
aris/packages/data-sources/src/tfl/tfl.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { DataSourceError } from "../common/errors";
|
||||||
|
import type { Diagnostics } from "../common/types";
|
||||||
|
import type {
|
||||||
|
TflData,
|
||||||
|
TflDataSource,
|
||||||
|
TflDataSourceConfig,
|
||||||
|
TflDisruption,
|
||||||
|
TflRequest,
|
||||||
|
TflStatusSeverity,
|
||||||
|
} from "./types";
|
||||||
|
import { TFL_STATUS_SEVERITY } from "./types";
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
timestamp: number;
|
||||||
|
data: TflData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultConfig: Required<TflDataSourceConfig> = {
|
||||||
|
cacheValiditySec: 120,
|
||||||
|
ttlSec: 300,
|
||||||
|
maxDisruptions: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ignoredSeverities = new Set<TflStatusSeverity>([
|
||||||
|
TFL_STATUS_SEVERITY.PlannedClosure,
|
||||||
|
TFL_STATUS_SEVERITY.PartClosure,
|
||||||
|
TFL_STATUS_SEVERITY.GoodService,
|
||||||
|
]);
|
||||||
|
const majorSeverities = new Set<TflStatusSeverity>([
|
||||||
|
TFL_STATUS_SEVERITY.Closed,
|
||||||
|
TFL_STATUS_SEVERITY.Suspended,
|
||||||
|
TFL_STATUS_SEVERITY.PartSuspended,
|
||||||
|
TFL_STATUS_SEVERITY.SevereDelays,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let cache: CacheEntry | null = null;
|
||||||
|
|
||||||
|
const resolveConfig = (
|
||||||
|
base: TflDataSourceConfig,
|
||||||
|
override?: TflDataSourceConfig,
|
||||||
|
): Required<TflDataSourceConfig> => ({
|
||||||
|
...defaultConfig,
|
||||||
|
...base,
|
||||||
|
...(override ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchTflData = async (
|
||||||
|
request: TflRequest,
|
||||||
|
config: TflDataSourceConfig,
|
||||||
|
): Promise<{ data: TflData; diagnostics: Diagnostics }> => {
|
||||||
|
const resolvedConfig = resolveConfig(config, request.config);
|
||||||
|
const diagnostics: Diagnostics = {
|
||||||
|
now: String(request.now),
|
||||||
|
cache_validity_sec: String(resolvedConfig.cacheValiditySec),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cache && request.now - cache.timestamp < resolvedConfig.cacheValiditySec) {
|
||||||
|
diagnostics.source = "cache";
|
||||||
|
diagnostics.cache_age_sec = String(request.now - cache.timestamp);
|
||||||
|
return { data: cache.data, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = "https://api.tfl.gov.uk/Line/Mode/tube,elizabeth-line/Status";
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
diagnostics.http_status = String(response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new DataSourceError(
|
||||||
|
"network_failed",
|
||||||
|
`HTTP ${response.status}`,
|
||||||
|
diagnostics,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = (await response.json()) as Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
lineStatuses?: Array<{
|
||||||
|
statusSeverity: number;
|
||||||
|
statusSeverityDescription: string;
|
||||||
|
reason?: string | null;
|
||||||
|
disruption?: { description?: string | null } | null;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
diagnostics.source = "network";
|
||||||
|
diagnostics.lines_returned = String(lines.length);
|
||||||
|
|
||||||
|
const disruptions: TflDisruption[] = [];
|
||||||
|
const seenLines = new Set<string>();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (seenLines.has(line.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const statuses = line.lineStatuses ?? [];
|
||||||
|
for (const status of statuses) {
|
||||||
|
if (ignoredSeverities.has(status.statusSeverity)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenLines.add(line.id);
|
||||||
|
const isMajor = majorSeverities.has(status.statusSeverity);
|
||||||
|
disruptions.push({
|
||||||
|
id: `${line.id}:${status.statusSeverity}`,
|
||||||
|
lineName: line.name,
|
||||||
|
lineId: line.id,
|
||||||
|
severity: status.statusSeverity,
|
||||||
|
severityDescription: status.statusSeverityDescription,
|
||||||
|
reason: status.reason ?? status.disruption?.description ?? null,
|
||||||
|
isMajor,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disruptions.sort((a, b) => a.severity - b.severity);
|
||||||
|
|
||||||
|
const limited = disruptions.slice(0, resolvedConfig.maxDisruptions);
|
||||||
|
diagnostics.disruptions_found = String(disruptions.length);
|
||||||
|
diagnostics.disruptions_returned = String(limited.length);
|
||||||
|
|
||||||
|
const data = { disruptions: limited };
|
||||||
|
cache = { timestamp: request.now, data };
|
||||||
|
|
||||||
|
return { data, diagnostics };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTflDataSource = (
|
||||||
|
config: TflDataSourceConfig = {},
|
||||||
|
): TflDataSource => ({
|
||||||
|
dataWithDiagnostics: (request) => fetchTflData(request, config),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const formatTflDisruptionTitle = (disruption: TflDisruption) => {
|
||||||
|
let name = disruption.lineName;
|
||||||
|
name = name.replace(" & City", "");
|
||||||
|
name = name.replace("Hammersmith", "H'smith");
|
||||||
|
name = name.replace("Metropolitan", "Met");
|
||||||
|
name = name.replace("Waterloo", "W'loo");
|
||||||
|
name = name.replace("Elizabeth line", "Eliz.");
|
||||||
|
|
||||||
|
let severity = disruption.severityDescription;
|
||||||
|
severity = severity.replace("Minor Delays", "Delays");
|
||||||
|
severity = severity.replace("Severe Delays", "Severe");
|
||||||
|
severity = severity.replace("Part Closure", "Part Closed");
|
||||||
|
severity = severity.replace("Part Suspended", "Part Susp.");
|
||||||
|
|
||||||
|
return `${name}: ${severity}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatTflDisruptionSubtitle = (disruption: TflDisruption) => {
|
||||||
|
if (!disruption.reason) {
|
||||||
|
return "Check TFL for details";
|
||||||
|
}
|
||||||
|
const first = disruption.reason
|
||||||
|
.split(".")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.find((part) => part.length > 0);
|
||||||
|
return first && first.length > 0 ? first : "Check TFL for details";
|
||||||
|
};
|
||||||
53
aris/packages/data-sources/src/tfl/types.ts
Normal file
53
aris/packages/data-sources/src/tfl/types.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { DataSource } from "../common/types";
|
||||||
|
|
||||||
|
export type TflDataSourceConfig = {
|
||||||
|
/**
|
||||||
|
* In-memory cache validity (seconds) before re-fetching.
|
||||||
|
* Defaults to 120.
|
||||||
|
*/
|
||||||
|
cacheValiditySec?: number;
|
||||||
|
/**
|
||||||
|
* Suggested TTL (seconds) for downstream disruption items.
|
||||||
|
* Defaults to 300.
|
||||||
|
*/
|
||||||
|
ttlSec?: number;
|
||||||
|
/**
|
||||||
|
* Maximum number of disruptions to return.
|
||||||
|
* Defaults to 3.
|
||||||
|
*/
|
||||||
|
maxDisruptions?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TFL_STATUS_SEVERITY = {
|
||||||
|
Closed: 1,
|
||||||
|
Suspended: 2,
|
||||||
|
PartSuspended: 3,
|
||||||
|
PlannedClosure: 4,
|
||||||
|
PartClosure: 5,
|
||||||
|
SevereDelays: 6,
|
||||||
|
GoodService: 10,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TflStatusSeverity =
|
||||||
|
(typeof TFL_STATUS_SEVERITY)[keyof typeof TFL_STATUS_SEVERITY];
|
||||||
|
|
||||||
|
export type TflDisruption = {
|
||||||
|
id: string;
|
||||||
|
lineName: string;
|
||||||
|
lineId: string;
|
||||||
|
severity: number;
|
||||||
|
severityDescription: string;
|
||||||
|
reason?: string | null;
|
||||||
|
isMajor: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TflData = {
|
||||||
|
disruptions: TflDisruption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TflRequest = {
|
||||||
|
now: number;
|
||||||
|
config?: TflDataSourceConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TflDataSource = DataSource<TflRequest, TflData>;
|
||||||
13
aris/packages/data-sources/src/weather/index.ts
Normal file
13
aris/packages/data-sources/src/weather/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export { createWeatherDataSource } from "./weather";
|
||||||
|
export type {
|
||||||
|
WeatherAlertConfig,
|
||||||
|
WeatherCurrent,
|
||||||
|
WeatherData,
|
||||||
|
WeatherDataSource,
|
||||||
|
WeatherRainSoon,
|
||||||
|
WeatherRainSource,
|
||||||
|
WeatherRequest,
|
||||||
|
WeatherSnapshot,
|
||||||
|
WeatherWarning,
|
||||||
|
WeatherWindAlert,
|
||||||
|
} from "./types";
|
||||||
78
aris/packages/data-sources/src/weather/types.ts
Normal file
78
aris/packages/data-sources/src/weather/types.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { DataSource } from "../common/types";
|
||||||
|
import type { LocationInput } from "../common/types";
|
||||||
|
|
||||||
|
export type WeatherAlertConfig = {
|
||||||
|
/**
|
||||||
|
* Lookahead window (seconds) for rain alerts.
|
||||||
|
* Defaults to 1200 (20 minutes) on iOS.
|
||||||
|
*/
|
||||||
|
rainLookaheadSec?: number;
|
||||||
|
/**
|
||||||
|
* Minimum precipitation chance (0-1) to trigger rain alerts.
|
||||||
|
* Defaults to 0.5 on iOS.
|
||||||
|
*/
|
||||||
|
precipitationChanceThreshold?: number;
|
||||||
|
/**
|
||||||
|
* Gust threshold in meters per second for wind alerts.
|
||||||
|
* Defaults to null (disabled) on iOS.
|
||||||
|
*/
|
||||||
|
gustThresholdMps?: number | null;
|
||||||
|
/**
|
||||||
|
* TTL (seconds) for rain alerts.
|
||||||
|
* Defaults to 1800 on iOS.
|
||||||
|
*/
|
||||||
|
rainTtlSec?: number;
|
||||||
|
/**
|
||||||
|
* TTL (seconds) for wind alerts.
|
||||||
|
* Defaults to 3600 on iOS.
|
||||||
|
*/
|
||||||
|
windTtlSec?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeatherWarning = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
ttlSec: number;
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeatherCurrent = {
|
||||||
|
temperatureC: number;
|
||||||
|
feelsLikeC: number;
|
||||||
|
condition: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeatherRainSource = "minutely" | "hourlyApprox";
|
||||||
|
|
||||||
|
export type WeatherRainSoon = {
|
||||||
|
startAt: number;
|
||||||
|
ttlSec: number;
|
||||||
|
source: WeatherRainSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeatherWindAlert = {
|
||||||
|
gustMps: number;
|
||||||
|
thresholdMps: number;
|
||||||
|
ttlSec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeatherData = {
|
||||||
|
current?: WeatherCurrent | null;
|
||||||
|
rainSoon?: WeatherRainSoon | null;
|
||||||
|
windAlert?: WeatherWindAlert | null;
|
||||||
|
warnings: WeatherWarning[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeatherRequest = {
|
||||||
|
location: LocationInput;
|
||||||
|
now: number;
|
||||||
|
config?: WeatherAlertConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeatherSnapshot = {
|
||||||
|
data: WeatherData;
|
||||||
|
diagnostics: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeatherDataSource = DataSource<WeatherRequest, WeatherData>;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { UnsupportedError } from "../common/errors";
|
||||||
|
import type { WeatherDataSource } from "./types";
|
||||||
|
|
||||||
|
export const createWeatherDataSource = (): WeatherDataSource => ({
|
||||||
|
dataWithDiagnostics: async () => {
|
||||||
|
throw new UnsupportedError("Weather data source is not supported on Android.");
|
||||||
|
},
|
||||||
|
});
|
||||||
38
aris/packages/data-sources/src/weather/weather.ios.ts
Normal file
38
aris/packages/data-sources/src/weather/weather.ios.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { requireNativeModule } from "expo";
|
||||||
|
import type {
|
||||||
|
WeatherAlertConfig,
|
||||||
|
WeatherDataSource,
|
||||||
|
WeatherRequest,
|
||||||
|
WeatherSnapshot,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type WeatherDataSourceNativeModule = {
|
||||||
|
getWeatherData: (request: WeatherRequest) => Promise<WeatherSnapshot>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WeatherDataSourceNative =
|
||||||
|
requireNativeModule<WeatherDataSourceNativeModule>("WeatherDataSource");
|
||||||
|
|
||||||
|
const mergeConfig = (
|
||||||
|
base: WeatherAlertConfig,
|
||||||
|
override?: WeatherAlertConfig,
|
||||||
|
) => ({
|
||||||
|
...base,
|
||||||
|
...(override ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createWeatherDataSource = (
|
||||||
|
config: WeatherAlertConfig = {},
|
||||||
|
): WeatherDataSource => ({
|
||||||
|
dataWithDiagnostics: async (request: WeatherRequest) => {
|
||||||
|
const mergedConfig = mergeConfig(config, request.config);
|
||||||
|
const resolvedRequest = {
|
||||||
|
...request,
|
||||||
|
config:
|
||||||
|
Object.keys(mergedConfig).length > 0 ? mergedConfig : undefined,
|
||||||
|
};
|
||||||
|
const snapshot =
|
||||||
|
await WeatherDataSourceNative.getWeatherData(resolvedRequest);
|
||||||
|
return { data: snapshot.data, diagnostics: snapshot.diagnostics };
|
||||||
|
},
|
||||||
|
});
|
||||||
14
aris/packages/data-sources/src/weather/weather.ts
Normal file
14
aris/packages/data-sources/src/weather/weather.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
import type { WeatherAlertConfig, WeatherDataSource } from "./types";
|
||||||
|
|
||||||
|
type CreateWeatherDataSource = (
|
||||||
|
config?: WeatherAlertConfig,
|
||||||
|
) => WeatherDataSource;
|
||||||
|
|
||||||
|
const impl: { createWeatherDataSource: CreateWeatherDataSource } =
|
||||||
|
Platform.OS === "ios"
|
||||||
|
? require("./weather.ios")
|
||||||
|
: require("./weather.android");
|
||||||
|
|
||||||
|
export const createWeatherDataSource = impl.createWeatherDataSource;
|
||||||
9
aris/packages/data-sources/tsconfig.json
Normal file
9
aris/packages/data-sources/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// @generated by expo-module-scripts
|
||||||
|
{
|
||||||
|
"extends": "expo-module-scripts/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./build"
|
||||||
|
},
|
||||||
|
"include": ["./src"],
|
||||||
|
"exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user