2 Commits

Author SHA1 Message Date
25cdebd1b2 Fix Yahoo Finance API: switch from v7 quote to v8 chart endpoint
The v7 /quote endpoint returns 401 Unauthorized as it now requires
cookie-based authentication. The v8 /chart endpoint works without auth.

- Switch to v8/finance/chart/{symbol} endpoint
- Fetch each symbol individually (chart API limitation)
- Update response parsing for chart JSON structure
- Calculate price change from regularMarketPrice - chartPreviousClose

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:54:25 +00:00
9528b0b57e Add Yahoo Finance stock data integration
- Add StockDataSource to fetch quotes from Yahoo Finance API
- Add StockSettingsStore for persisting user's stock symbols
- Add StockSettingsView with UI to manage symbols (max 5)
- Add STOCK feed item type and ranker weight (0.3)
- Integrate stock fetch into ContextOrchestrator pipeline
- Stock cards appear in FYI bucket and sync to Glass via BLE

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:30:18 +00:00
99 changed files with 35 additions and 9745 deletions

View File

@@ -17,14 +17,10 @@ struct ContentView: View {
.tabItem { Label("BLE", systemImage: "dot.radiowaves.left.and.right") }
OrchestratorView()
.tabItem { Label("Orchestrator", systemImage: "bolt.horizontal.circle") }
TodosView()
.tabItem { Label("Todos", systemImage: "checklist") }
NavigationStack {
StockSettingsView(store: stockSettings)
}
.tabItem { Label("Stocks", systemImage: "chart.line.uptrend.xyaxis") }
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }
}
.onAppear { orchestrator.start() }
}
@@ -34,12 +30,10 @@ struct ContentView_Previews: PreviewProvider {
static var previews: some View {
if #available(iOS 16.0, *) {
let ble = BlePeripheralManager()
let spotifyAuth = SpotifyAuthManager()
let orchestrator = ContextOrchestrator(ble: ble, spotifyAuth: spotifyAuth)
let orchestrator = ContextOrchestrator(ble: ble)
ContentView()
.environmentObject(ble)
.environmentObject(orchestrator)
.environmentObject(spotifyAuth)
} else {
ContentView()
}

View File

@@ -1,193 +0,0 @@
//
// 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?
}
}

View File

@@ -2,17 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<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>
<array/>
<key>NSCalendarsFullAccessUsageDescription</key>

View File

@@ -86,7 +86,6 @@ final class HeuristicRanker {
switch type {
case .weatherWarning: return 1.0
case .weatherAlert: return 0.9
case .transitAlert: return 0.85
case .calendarEvent: return 0.8
case .transit: return 0.75
case .poiNearby: return 0.6

View File

@@ -1,31 +0,0 @@
//
// 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
}
}

View File

@@ -1,116 +0,0 @@
//
// 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
}
}

View File

@@ -17,6 +17,5 @@ enum FeedItemType: String, Codable, CaseIterable {
case currentWeather = "CURRENT_WEATHER"
case calendarEvent = "CALENDAR_EVENT"
case stock = "STOCK"
case transitAlert = "TRANSIT_ALERT"
case allQuiet = "ALL_QUIET"
}

View File

@@ -24,18 +24,8 @@ final class ContextOrchestrator: NSObject, ObservableObject {
@Published private(set) var lastCalendarDiagnostics: [String: String] = [:]
@Published private(set) var lastPipelineElapsedMs: Int? = nil
@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 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")
@@ -44,14 +34,11 @@ final class ContextOrchestrator: NSObject, ObservableObject {
private let calendarDataSource = CalendarDataSource()
private let poiDataSource = POIDataSource()
private let stockDataSource = StockDataSource()
private let tflDataSource = TFLDataSource()
private let ranker: HeuristicRanker
private let store: FeedStore
private let server: LocalServer
private let ble: BlePeripheralManager
private let nowPlayingMonitor = NowPlayingMonitor()
private let spotifyMonitor: SpotifyNowPlayingMonitor
private let spotifyAuth: SpotifyAuthManager
private var lastRecomputeLocation: CLLocation? = nil
private var lastRecomputeAccuracy: CLLocationAccuracy? = nil
@@ -60,20 +47,11 @@ final class ContextOrchestrator: NSObject, ObservableObject {
init(store: FeedStore = FeedStore(),
server: LocalServer = LocalServer(),
ble: BlePeripheralManager,
spotifyAuth: SpotifyAuthManager) {
ble: BlePeripheralManager) {
self.store = store
self.server = server
self.ble = ble
self.spotifyAuth = spotifyAuth
self.spotifyMonitor = SpotifyNowPlayingMonitor(authManager: spotifyAuth)
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()
locationManager.delegate = self
@@ -96,21 +74,13 @@ final class ContextOrchestrator: NSObject, ObservableObject {
nowPlayingMonitor.onUpdate = { [weak self] update in
Task { @MainActor in
guard let self, self.musicSource == .appleMusic else { return }
guard let self else { return }
self.musicAuthorization = update.authorization
self.nowPlaying = update.snapshot
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()
lastFeed = feed
}
@@ -119,7 +89,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
authorization = locationManager.authorizationStatus
logger.info("start auth=\(String(describing: self.authorization), privacy: .public)")
server.start()
startMusicMonitor()
nowPlayingMonitor.start()
requestPermissionsIfNeeded()
locationManager.startUpdatingLocation()
}
@@ -127,27 +97,6 @@ final class ContextOrchestrator: NSObject, ObservableObject {
func stop() {
locationManager.stopUpdatingLocation()
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") {
@@ -260,15 +209,11 @@ final class ContextOrchestrator: NSObject, ObservableObject {
async let stockResult = withTimeoutResult(seconds: 6) {
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 calRes = await calendarResult
let poiRes = await poiResult
let stockRes = await stockResult
let tflRes = await tflResult
func calendarTTL(endAt: Int, now: Int) -> Int {
let ttl = endAt - now
@@ -285,7 +230,6 @@ final class ContextOrchestrator: NSObject, ObservableObject {
var calendarItems: [FeedItem] = []
var poiItems: [FeedItem] = []
var stockItems: [FeedItem] = []
var tflItems: [FeedItem] = []
var weatherNowItem: FeedItem? = nil
var fetchFailed = false
var wxDiagnostics: [String: String] = [:]
@@ -390,7 +334,6 @@ final class ContextOrchestrator: NSObject, ObservableObject {
if pois.isEmpty {
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) {
let subtitle = poiSubtitle(for: poi)
let confidence = min(max(poi.confidence, 0.0), 1.0)
@@ -408,6 +351,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
actions: ["DISMISS"]
)
poiItems.append(item)
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true))
}
case .failure(let error):
fetchFailed = true
@@ -461,49 +405,13 @@ final class ContextOrchestrator: NSObject, ObservableObject {
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)
lastPipelineElapsedMs = elapsedMs
lastFetchFailed = fetchFailed
lastWeatherDiagnostics = wxDiagnostics
lastCalendarDiagnostics = calDiagnostics
lastTFLDiagnostics = tflDiagnostics
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)")
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)")
if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil {
let fallbackFeed = store.getFeed(now: nowEpoch)
@@ -514,17 +422,10 @@ final class ContextOrchestrator: NSObject, ObservableObject {
return
}
let poiCandidateCount = rightNowCandidates.filter { $0.item.type == .poiNearby }.count
if poiCandidateCount > 0 {
logger.warning("dropping poi candidates from right-now ranking count=\(poiCandidateCount)")
let eligibleUnsuppressed = rightNowCandidates.filter { ranked in
!store.isSuppressed(id: ranked.item.id, type: ranked.item.type, now: nowEpoch)
}
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 winnerItem = winnerSelection?.item ?? FeedEnvelope.allQuiet(now: nowEpoch).feed[0]
@@ -599,28 +500,6 @@ 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 feedEnvelope = FeedEnvelope(
schema: 1,
@@ -662,15 +541,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
}
private func feedForGlass(base: FeedEnvelope, now: Int) -> FeedEnvelope {
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 {
guard let nowPlayingCard = nowPlaying?.asFeedItem(baseGeneratedAt: base.generatedAt, now: now) else {
return base
}

View File

@@ -1,61 +0,0 @@
//
// 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)
}
}

View File

@@ -1,77 +0,0 @@
//
// 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)
}
}

View File

@@ -1,276 +0,0 @@
//
// 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: "")
}
}

View File

@@ -1,91 +0,0 @@
//
// 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"
}
}
}

View File

@@ -1,145 +0,0 @@
//
// 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)")
}
}
}

View File

@@ -1,73 +0,0 @@
//
// 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
}
}
}

View File

@@ -45,7 +45,7 @@ struct OrchestratorView: View {
Button("Recompute Now") { orchestrator.recomputeNow() }
}
Section("Winner") {
Section("Feed") {
if let feed = orchestrator.lastFeed, let winner = feed.winnerItem() {
Text(winner.title)
.font(.headline)
@@ -54,56 +54,36 @@ struct OrchestratorView: View {
.font(.subheadline)
.foregroundStyle(.secondary)
}
LabeledContent("Type") { Text(winner.type.rawValue) }
LabeledContent("Bucket") { Text(winner.bucket.rawValue) }
LabeledContent("Priority") { Text(String(format: "%.2f", winner.priority)) }
LabeledContent("TTL") { Text("\(winner.ttlSec)s") }
if let poiType = winner.poiType {
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")
Text("type \(winner.type.rawValue) • prio \(String(format: "%.2f", winner.priority)) • ttl \(winner.ttlSec)s")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Feed") {
if let feed = orchestrator.lastFeed {
if feed.feed.isEmpty {
Text("No feed items yet")
.foregroundStyle(.secondary)
} else {
ForEach(feed.feed, id: \.id) { item in
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")
if feed.feed.count > 1 {
Divider()
}
ForEach(feed.feed, id: \.id) { item in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(item.title)
.font(.headline)
.lineLimit(1)
Spacer()
Text(item.type.rawValue)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
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)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
} else {
Text("No feed yet")

View File

@@ -1,177 +0,0 @@
//
// 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)
}
}

View File

@@ -1,195 +0,0 @@
//
// 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()
}
}

View File

@@ -12,15 +12,12 @@ struct irisApp: App {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var ble: BlePeripheralManager
@StateObject private var orchestrator: ContextOrchestrator
@StateObject private var spotifyAuth: SpotifyAuthManager
init() {
let bleManager = BlePeripheralManager()
bleManager.start()
let spotify = SpotifyAuthManager()
_ble = StateObject(wrappedValue: bleManager)
_spotifyAuth = StateObject(wrappedValue: spotify)
_orchestrator = StateObject(wrappedValue: ContextOrchestrator(ble: bleManager, spotifyAuth: spotify))
_orchestrator = StateObject(wrappedValue: ContextOrchestrator(ble: bleManager))
}
var body: some Scene {
@@ -28,12 +25,6 @@ struct irisApp: App {
ContentView()
.environmentObject(ble)
.environmentObject(orchestrator)
.environmentObject(spotifyAuth)
.onOpenURL { url in
if url.scheme == "iris-spotify-auth" {
spotifyAuth.handleCallback(url: url)
}
}
.onChange(of: scenePhase) { phase in
if phase == .active || phase == .background {
ble.start()

24
aris/.gitignore vendored
View File

@@ -1,24 +0,0 @@
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*

View File

@@ -1,24 +0,0 @@
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*

View File

@@ -1,49 +0,0 @@
{
"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"
}
}
}
}

View File

@@ -1,81 +0,0 @@
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>
);
}

View File

@@ -1,255 +0,0 @@
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>
);
}

View File

@@ -1,426 +0,0 @@
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>
);
}

View File

@@ -1,22 +0,0 @@
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",
};

View File

@@ -1,22 +0,0 @@
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",
};

View File

@@ -1,22 +0,0 @@
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",
};

View File

@@ -1,46 +0,0 @@
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;
}
}`;

View File

@@ -1,26 +0,0 @@
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]`,
};

View File

@@ -1,15 +0,0 @@
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 />
</>
);
}

View File

@@ -1,23 +0,0 @@
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',
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -1,12 +0,0 @@
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,
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +0,0 @@
// 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"
}
}

View File

@@ -1,19 +0,0 @@
{
"$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"
}
}

View File

@@ -1,24 +0,0 @@
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',
};

View File

@@ -1,16 +0,0 @@
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>
);
};

View File

@@ -1,29 +0,0 @@
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`,
};

View File

@@ -1,26 +0,0 @@
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`,
};

View File

@@ -1,19 +0,0 @@
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>
);
}

View File

@@ -1,67 +0,0 @@
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 };

View File

@@ -1,108 +0,0 @@
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 };

View File

@@ -1,80 +0,0 @@
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,
};

View File

@@ -1,36 +0,0 @@
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 };

View File

@@ -1,89 +0,0 @@
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 };

View File

@@ -1,15 +0,0 @@
/* 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',
},
},
]);

View File

@@ -1,58 +0,0 @@
@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%;
}
}

View File

@@ -1,51 +0,0 @@
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;

View File

@@ -1,81 +0,0 @@
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,
},
},
};

View File

@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,24 +0,0 @@
// 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,
});

View File

@@ -1,2 +0,0 @@
// @ts-ignore
/// <reference types="nativewind/types" />

View File

@@ -1,58 +0,0 @@
{
"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
}

View File

@@ -1,10 +0,0 @@
module.exports = {
printWidth: 100,
tabWidth: 2,
singleQuote: true,
bracketSameLine: true,
trailingComma: 'es5',
plugins: [require.resolve('prettier-plugin-tailwindcss')],
tailwindAttributes: ['className'],
};

View File

@@ -1,61 +0,0 @@
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);
},
}));

View File

@@ -1,15 +0,0 @@
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 }),
}));

View File

@@ -1,73 +0,0 @@
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")],
};

View File

@@ -1,11 +0,0 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
{
"name": "iris",
"private": true,
"workspaces": ["apps/*", "packages/*"]
}

View File

@@ -1,2 +0,0 @@
// @generated by expo-module-scripts
module.exports = require('expo-module-scripts/eslintrc.base.js');

View File

@@ -1,32 +0,0 @@
# @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 &mdash; 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).

View File

@@ -1,7 +0,0 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["ArisBleModule"],
"appDelegateSubscribers": ["ArisBleAppDelegateSubscriber"]
}
}

View File

@@ -1,35 +0,0 @@
{
"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": "*"
}
}

View File

@@ -1,7 +0,0 @@
export { Ble, defaultBleState } from "./native";
export { BleBluetoothState } from "./types";
export type {
BleNativeModuleEvents,
BleStatePayload,
BleUuids,
} from "./types";

View File

@@ -1,90 +0,0 @@
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,
};

View File

@@ -1,39 +0,0 @@
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;
};

View File

@@ -1,9 +0,0 @@
// @generated by expo-module-scripts
{
"extends": "expo-module-scripts/tsconfig.base",
"compilerOptions": {
"outDir": "./build"
},
"include": ["./src"],
"exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
}

View File

@@ -1,2 +0,0 @@
// @generated by expo-module-scripts
module.exports = require('expo-module-scripts/eslintrc.base.js');

View File

@@ -1,32 +0,0 @@
# @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 &mdash; 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).

View File

@@ -1,6 +0,0 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["WeatherDataSourceModule", "PoiDataSourceModule"]
}
}

View File

@@ -1,38 +0,0 @@
{
"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": "*"
}
}

View File

@@ -1,178 +0,0 @@
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),
});

View File

@@ -1,8 +0,0 @@
export { createCalendarDataSource } from "./calendar";
export type {
CalendarData,
CalendarDataSource,
CalendarDataSourceConfig,
CalendarEvent,
CalendarRequest,
} from "./types";

View File

@@ -1,49 +0,0 @@
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>;

View File

@@ -1,18 +0,0 @@
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);
}
}

View File

@@ -1,17 +0,0 @@
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>>;
}

View File

@@ -1,8 +0,0 @@
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";

View File

@@ -1,10 +0,0 @@
export { createPoiDataSource } from "./poi";
export type {
PoiData,
PoiDataSource,
PoiDataSourceConfig,
PoiItem,
PoiRequest,
PoiSnapshot,
PoiType,
} from "./types";

View File

@@ -1,8 +0,0 @@
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.");
},
});

View File

@@ -1,37 +0,0 @@
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 };
},
});

View File

@@ -1,10 +0,0 @@
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;

View File

@@ -1,78 +0,0 @@
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>;

View File

@@ -1,8 +0,0 @@
export { createStockDataSource } from "./stock";
export type {
StockData,
StockDataSource,
StockDataSourceConfig,
StockQuote,
StockRequest,
} from "./types";

View File

@@ -1,147 +0,0 @@
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),
});

View File

@@ -1,40 +0,0 @@
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>;

View File

@@ -1,12 +0,0 @@
export {
createTflDataSource,
formatTflDisruptionSubtitle,
formatTflDisruptionTitle,
} from "./tfl";
export type {
TflData,
TflDataSource,
TflDataSourceConfig,
TflDisruption,
TflRequest,
} from "./types";

View File

@@ -1,165 +0,0 @@
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";
};

View File

@@ -1,53 +0,0 @@
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>;

View File

@@ -1,13 +0,0 @@
export { createWeatherDataSource } from "./weather";
export type {
WeatherAlertConfig,
WeatherCurrent,
WeatherData,
WeatherDataSource,
WeatherRainSoon,
WeatherRainSource,
WeatherRequest,
WeatherSnapshot,
WeatherWarning,
WeatherWindAlert,
} from "./types";

View File

@@ -1,78 +0,0 @@
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>;

View File

@@ -1,8 +0,0 @@
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.");
},
});

View File

@@ -1,38 +0,0 @@
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 };
},
});

View File

@@ -1,14 +0,0 @@
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;

View File

@@ -1,9 +0,0 @@
// @generated by expo-module-scripts
{
"extends": "expo-module-scripts/tsconfig.base",
"compilerOptions": {
"outDir": "./build"
},
"include": ["./src"],
"exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
}