10 Commits

Author SHA1 Message Date
e818846657 feat(companion): add data sources package 2026-01-13 23:30:57 +00:00
4439e0e027 feat(companion): orchestrator page skeleton 2026-01-12 23:47:19 +00:00
75cfbe8dd4 feat(companion): implement ble 2026-01-12 22:24:33 +00:00
8305726f83 feat(companion): move orchestrator before ble tab 2026-01-11 18:13:34 +00:00
8873c372f0 feat(companion): initial rn port scaffold 2026-01-11 18:10:27 +00:00
22fbfb9790 Merge pull request 'Add TFL train disruption alerts' (#5) from feat-tflalerts into main
Reviewed-on: kennethnym/aris#5
2026-01-10 21:57:26 +00:00
d8929d3776 Refactor TFL severity filtering to use Sets
Replace magic numbers with documented static Sets for clarity.
ignoredSeverities and majorSeverities make the filtering logic
self-documenting.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:54:21 +00:00
2860ab0786 Filter out planned and part closures from TFL alerts
Only show active disruptions (delays, suspensions), not planned
closures which aren't relevant for real-time commute decisions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:52:55 +00:00
e15be9ddc4 Add TFL train disruption alerts integration
Query TFL API for Tube and Elizabeth Line status, displaying
disruptions as feed cards. Major disruptions (severity 1-6) appear
as RIGHT_NOW spotlight cards, minor delays (7-9) as FYI items.

- Add TFLDataSource with 2-min cache and severity classification
- Add .transitAlert FeedItemType with 0.85 base weight
- Wire up async fetch in ContextOrchestrator pipeline
- Handle timeout and failure cases gracefully

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:46:23 +00:00
b6ff4e81e9 Merge feat-yahoofinance: Add Yahoo Finance stock data integration 2026-01-10 21:17:23 +00:00
85 changed files with 8359 additions and 1 deletions

View File

@@ -0,0 +1,193 @@
//
// TFLDataSource.swift
// iris
//
import Foundation
struct TFLDataSourceConfig: Sendable {
var cacheValiditySec: Int = 120
var ttlSec: Int = 300
var maxDisruptions: Int = 3
init() {}
}
@MainActor
final class TFLDataSource {
struct Disruption: Sendable, Equatable {
let id: String
let lineName: String
let lineId: String
let severity: Int
let severityDescription: String
let reason: String?
let isMajor: Bool
}
struct TFLData: Sendable, Equatable {
let disruptions: [Disruption]
}
struct Snapshot: Sendable {
let data: TFLData
let diagnostics: [String: String]
}
enum TFLError: Error, LocalizedError, Sendable {
case networkFailed(message: String, diagnostics: [String: String])
case invalidResponse(diagnostics: [String: String])
var errorDescription: String? {
switch self {
case .networkFailed(let message, _):
return message
case .invalidResponse:
return "Invalid TFL response"
}
}
}
// Severity 4 = Planned Closure, 5 = Part Closure, 10 = Good Service
private static let ignoredSeverities: Set<Int> = [4, 5, 10]
// Severity 1 = Closed, 2 = Suspended, 3 = Part Suspended, 6 = Severe Delays
private static let majorSeverities: Set<Int> = [1, 2, 3, 6]
private let config: TFLDataSourceConfig
private var cache: (timestamp: Int, data: TFLData)?
var ttlSec: Int { config.ttlSec }
init(config: TFLDataSourceConfig = .init()) {
self.config = config
}
func dataWithDiagnostics(now: Int) async throws -> Snapshot {
var diagnostics: [String: String] = [
"now": String(now),
"cache_validity_sec": String(config.cacheValiditySec),
]
if let cache = cache, now - cache.timestamp < config.cacheValiditySec {
diagnostics["source"] = "cache"
diagnostics["cache_age_sec"] = String(now - cache.timestamp)
return Snapshot(data: cache.data, diagnostics: diagnostics)
}
guard let url = URL(string: "https://api.tfl.gov.uk/Line/Mode/tube,elizabeth-line/Status") else {
throw TFLError.invalidResponse(diagnostics: diagnostics)
}
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
diagnostics["http_status"] = String(httpResponse.statusCode)
guard (200..<300).contains(httpResponse.statusCode) else {
throw TFLError.networkFailed(
message: "HTTP \(httpResponse.statusCode)",
diagnostics: diagnostics
)
}
}
let lines = try JSONDecoder().decode([TFLLineStatus].self, from: data)
diagnostics["source"] = "network"
diagnostics["lines_returned"] = String(lines.count)
var disruptions: [Disruption] = []
var seenLines: Set<String> = []
for line in lines {
guard !seenLines.contains(line.id) else { continue }
for status in line.lineStatuses {
guard !Self.ignoredSeverities.contains(status.statusSeverity) else { continue }
seenLines.insert(line.id)
let isMajor = Self.majorSeverities.contains(status.statusSeverity)
let disruption = Disruption(
id: "\(line.id):\(status.statusSeverity)",
lineName: line.name,
lineId: line.id,
severity: status.statusSeverity,
severityDescription: status.statusSeverityDescription,
reason: status.reason ?? status.disruption?.description,
isMajor: isMajor
)
disruptions.append(disruption)
break
}
}
disruptions.sort { $0.severity < $1.severity }
let limited = Array(disruptions.prefix(config.maxDisruptions))
diagnostics["disruptions_found"] = String(disruptions.count)
diagnostics["disruptions_returned"] = String(limited.count)
let tflData = TFLData(disruptions: limited)
cache = (timestamp: now, data: tflData)
return Snapshot(data: tflData, diagnostics: diagnostics)
}
func disruptionTitle(_ disruption: Disruption) -> String {
var name = disruption.lineName
name = name.replacingOccurrences(of: " & City", with: "")
name = name.replacingOccurrences(of: "Hammersmith", with: "H'smith")
name = name.replacingOccurrences(of: "Metropolitan", with: "Met")
name = name.replacingOccurrences(of: "Waterloo", with: "W'loo")
name = name.replacingOccurrences(of: "Elizabeth line", with: "Eliz.")
var severity = disruption.severityDescription
severity = severity.replacingOccurrences(of: "Minor Delays", with: "Delays")
severity = severity.replacingOccurrences(of: "Severe Delays", with: "Severe")
severity = severity.replacingOccurrences(of: "Part Closure", with: "Part Closed")
severity = severity.replacingOccurrences(of: "Part Suspended", with: "Part Susp.")
return "\(name): \(severity)"
}
func disruptionSubtitle(_ disruption: Disruption) -> String {
guard let reason = disruption.reason else {
return "Check TFL for details"
}
let phrases = reason.components(separatedBy: ".")
if let first = phrases.first?.trimmingCharacters(in: .whitespacesAndNewlines), !first.isEmpty {
return first
}
return "Check TFL for details"
}
}
// MARK: - TFL API Response Models
private struct TFLLineStatus: Codable {
let id: String
let name: String
let modeName: String
let lineStatuses: [LineStatus]
struct LineStatus: Codable {
let statusSeverity: Int
let statusSeverityDescription: String
let reason: String?
let validityPeriods: [ValidityPeriod]?
let disruption: Disruption?
}
struct ValidityPeriod: Codable {
let fromDate: String?
let toDate: String?
}
struct Disruption: Codable {
let category: String?
let description: String?
let closureText: String?
}
}

View File

@@ -86,6 +86,7 @@ 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

@@ -17,5 +17,6 @@ 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,6 +24,7 @@ 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
@@ -43,6 +44,7 @@ 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
@@ -258,11 +260,15 @@ 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
@@ -279,6 +285,7 @@ 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] = [:]
@@ -454,13 +461,49 @@ 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) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) poi_items=\(poiItems.count) stock_items=\(stockItems.count) tfl_items=\(tflItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil {
let fallbackFeed = store.getFeed(now: nowEpoch)
@@ -556,6 +599,28 @@ final class ContextOrchestrator: NSObject, ObservableObject {
)
})
let fyiTFL = tflItems
.filter { $0.bucket == .fyi }
.filter { $0.id != winnerItem.id }
.filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) }
.prefix(2)
fyi.append(contentsOf: fyiTFL.map { item in
FeedItem(
id: item.id,
type: item.type,
title: item.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: min(max(item.priority, 0.0), 1.0),
ttlSec: max(1, item.ttlSec),
condition: item.condition,
startsAt: item.startsAt,
poiType: item.poiType,
bucket: .fyi,
actions: ["DISMISS"]
)
})
let items = [winnerItem] + fyi
let feedEnvelope = FeedEnvelope(
schema: 1,

24
aris/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# expo router
expo-env.d.ts
ios
android
# macOS
.DS_Store
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*

24
aris/apps/companion/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# expo router
expo-env.d.ts
ios
android
# macOS
.DS_Store
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*

View File

@@ -0,0 +1,49 @@
{
"expo": {
"name": "aris",
"slug": "aris",
"version": "1.0.0",
"scheme": "aris",
"platforms": ["ios", "android"],
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": ["expo-router"],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "sh.nym.aris",
"backgroundModes": ["bluetooth-peripheral"],
"infoPlist": {
"NSBluetoothAlwaysUsageDescription": "Allow Bluetooth to connect to Iris Glass.",
"NSCalendarsUsageDescription": "Allow Iris to access your calendar for upcoming events."
}
},
"android": {
"package": "sh.nym.aris",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
}
}
}

View File

@@ -0,0 +1,81 @@
import { Ionicons } from "@expo/vector-icons";
import { Tabs } from "expo-router";
const iconSize = 22;
export default function TabLayout() {
return (
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen
name="orchestrator"
options={{
title: "Orchestrator",
tabBarLabel: "Orchestrator",
tabBarIcon: ({ color, focused }) => (
<Ionicons
color={color}
name={focused ? "flash" : "flash-outline"}
size={iconSize}
/>
),
}}
/>
<Tabs.Screen
name="index"
options={{
title: "BLE",
tabBarLabel: "BLE",
tabBarIcon: ({ color, focused }) => (
<Ionicons
color={color}
name={focused ? "bluetooth" : "bluetooth-outline"}
size={iconSize}
/>
),
}}
/>
<Tabs.Screen
name="todos"
options={{
title: "Todos",
tabBarLabel: "Todos",
tabBarIcon: ({ color, focused }) => (
<Ionicons
color={color}
name={focused ? "checkmark-done" : "checkmark-done-outline"}
size={iconSize}
/>
),
}}
/>
<Tabs.Screen
name="stocks"
options={{
title: "Stocks",
tabBarLabel: "Stocks",
tabBarIcon: ({ color, focused }) => (
<Ionicons
color={color}
name={focused ? "stats-chart" : "stats-chart-outline"}
size={iconSize}
/>
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarLabel: "Settings",
tabBarIcon: ({ color, focused }) => (
<Ionicons
color={color}
name={focused ? "settings" : "settings-outline"}
size={iconSize}
/>
),
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,255 @@
import { useEffect, useState, type ReactNode } from "react";
import { Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Clipboard from "expo-clipboard";
import { Container } from "@/components/Container";
import { SafeAreaScrollView } from "@/components/safe-area-scroll-view";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { Ble } from "@aris/ble";
import { useBleStore } from "@/store/ble";
import { cn } from "@/lib/utils";
import { Ionicons } from "@expo/vector-icons";
const formatTime = (timestamp: number | null) => {
if (!timestamp) {
return "Never";
}
return new Date(timestamp).toLocaleTimeString();
};
export default function BleScreen() {
const initialize = useBleStore((state) => state.initialize);
const isSupported = useBleStore((state) => state.isSupported);
const insets = useSafeAreaInsets();
useEffect(() => {
initialize();
}, [initialize]);
if (!isSupported) {
return (
<View className="flex flex-1 bg-white">
<Container className="px-8">
<View className="gap-1">
<Text variant="h3">BLE</Text>
<Text className="text-sm text-muted-foreground">
Bluetooth peripheral status and controls.
</Text>
</View>
<View className="mt-4 rounded-xl border border-muted bg-muted/20 px-4 py-3">
<Text className="text-sm font-medium">BLE Unavailable</Text>
<Text className="text-sm text-muted-foreground">
This page is currently supported on iOS only.
</Text>
</View>
</Container>
</View>
);
}
return (
<View className="flex flex-1 bg-white">
<View>
<SafeAreaScrollView>
<View className="gap-4" style={{ paddingBottom: insets.bottom + 24 }}>
<View className="gap-1 px-8">
<Text variant="h3">BLE</Text>
<Text className="text-sm text-muted-foreground">
Bluetooth peripheral status and controls.
</Text>
</View>
<View className="w-full h-px bg-muted" />
<ControlPanel />
<View className="w-full h-px bg-muted" />
<BleStatusSection />
<View className="w-full h-px bg-muted" />
<TelemetrySection />
<View className="w-full h-px bg-muted" />
<UuidsSection />
</View>
</SafeAreaScrollView>
</View>
</View>
);
}
function ValueRow({ label, value }: { label: ReactNode; value: ReactNode }) {
return (
<View className="flex flex-row items-center justify-between">
{typeof label === "string" ? (
<Text className="text-sm text-muted-foreground">{label}</Text>
) : (
label
)}
{typeof value === "string" || typeof value === "number" ? (
<Text className="text-sm">{value}</Text>
) : (
value
)}
</View>
);
}
function BleStatusSection() {
const bluetoothState = useBleStore((state) => state.bluetoothState);
const isSubscribed = useBleStore((state) => state.isSubscribed);
const subscribedCount = useBleStore((state) => state.subscribedCount);
const connectionLabel = isSubscribed ? "Connected" : "Not Connected";
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
Status
</Text>
<View className="gap-1">
<ValueRow label="State" value={connectionLabel} />
<ValueRow label="Bluetooth" value={bluetoothState} />
<ValueRow label="Subscribers" value={subscribedCount} />
</View>
</View>
);
}
function ControlPanel() {
const advertisingEnabled = useBleStore((state) => state.advertisingEnabled);
const setAdvertisingEnabled = useBleStore(
(state) => state.setAdvertisingEnabled,
);
const wifiRequested = useBleStore((state) => state.wifiRequested);
const setWifiRequested = useBleStore((state) => state.setWifiRequested);
const sendFixtureFeedNow = useBleStore((state) => state.sendFixtureFeedNow);
const handleCopyUUIDs = async () => {
const uuids = Ble.getUuids();
const text = [
`SERVICE_UUID=${uuids.serviceUUID}`,
`FEED_TX_UUID=${uuids.feedTxUUID}`,
`CONTROL_RX_UUID=${uuids.controlRxUUID}`,
`WIFI_REQUEST_TX_UUID=${uuids.wifiRequestTxUUID}`,
].join("\n");
await Clipboard.setStringAsync(text);
};
return (
<View className="px-8">
<Text variant="h4" className="mb-3 font-medium">
Control Panel
</Text>
<View className="flex-row gap-3 w-full mb-3">
<ControlTile
icon={<Ionicons name="radio-outline" size={20} />}
label="Advertising"
checked={advertisingEnabled}
onCheckChange={setAdvertisingEnabled}
/>
<ControlTile
icon={<Ionicons name="wifi-outline" size={20} />}
label="WiFi"
checked={wifiRequested}
onCheckChange={setWifiRequested}
/>
</View>
<View className="gap-2">
<Button onPress={sendFixtureFeedNow} variant="outline">
<Text>Send Fixture Feed Now</Text>
</Button>
<Button onPress={handleCopyUUIDs} variant="outline">
<Text>Copy UUIDs</Text>
</Button>
</View>
</View>
);
}
function ControlTile({
icon,
label,
checked,
onCheckChange,
}: {
icon: ReactNode;
label: ReactNode;
checked: boolean;
onCheckChange: (checked: boolean) => void;
}) {
const [isPressed, setIsPressed] = useState(false);
return (
<Pressable
onPressIn={() => {
setIsPressed(true);
}}
onPressOut={() => {
setIsPressed(false);
}}
onPress={() => {
onCheckChange(!checked);
}}
className={cn(
"flex-1 items-start justify-center rounded-md px-3 py-2 gap-1",
checked ? "border-2 border-primary" : "border border-border",
{ "bg-accent": isPressed },
)}
>
{icon}
<Text className={cn({ "font-bold": checked })}>{label}</Text>
</Pressable>
);
}
function TelemetrySection() {
const lastMsgIdSent = useBleStore((state) => state.lastMsgIdSent);
const lastPingAt = useBleStore((state) => state.lastPingAt);
const lastDataAt = useBleStore((state) => state.lastDataAt);
const lastNotifyAt = useBleStore((state) => state.lastNotifyAt);
const notifyQueueDepth = useBleStore((state) => state.notifyQueueDepth);
const droppedNotifyPackets = useBleStore(
(state) => state.droppedNotifyPackets,
);
const lastCommand = useBleStore((state) => state.lastCommand);
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
Telemetry
</Text>
<View className="gap-1">
<ValueRow label="Last msgId" value={lastMsgIdSent} />
<ValueRow label="Last ping" value={formatTime(lastPingAt)} />
<ValueRow label="Last data" value={formatTime(lastDataAt)} />
<ValueRow label="Notify queue" value={notifyQueueDepth} />
{droppedNotifyPackets > 0 ? (
<ValueRow
label="Dropped notify packets"
value={droppedNotifyPackets}
/>
) : null}
<ValueRow label="Last notify" value={formatTime(lastNotifyAt)} />
{lastCommand ? (
<ValueRow label="Last control" value={lastCommand} />
) : null}
</View>
</View>
);
}
function UuidsSection() {
const uuids = Ble.getUuids();
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
UUIDs
</Text>
<Text selectable className="text-xs text-muted-foreground">
{`SERVICE_UUID=${uuids.serviceUUID}\nFEED_TX_UUID=${uuids.feedTxUUID}\nCONTROL_RX_UUID=${uuids.controlRxUUID}\nWIFI_REQUEST_TX_UUID=${uuids.wifiRequestTxUUID}`}
</Text>
</View>
);
}

View File

@@ -0,0 +1,426 @@
import type { ReactNode } from "react";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SafeAreaScrollView } from "@/components/safe-area-scroll-view";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
const fixture = {
location: {
authorization: "When In Use",
lastLocation: {
latitude: 37.33182,
longitude: -122.03118,
accuracyMeters: 12.4,
speedMps: 0.4,
},
},
recompute: {
lastReason: "manual",
lastTime: new Date("2024-05-21T09:32:00Z"),
elapsedMs: 482,
fetchFailed: false,
error: null as string | null,
},
winner: {
title: "Glass Now online",
subtitle: "Connected to iPhone",
type: "INFO",
bucket: "RIGHT_NOW",
priority: 0.8,
ttlSec: 86400,
poiType: "cafe",
startsAt: 1767717000,
id: "demo:welcome",
},
feed: [
{
id: "demo:welcome",
title: "Glass Now online",
subtitle: "Connected to iPhone",
type: "INFO",
bucket: "RIGHT_NOW",
priority: 0.8,
ttlSec: 86400,
},
{
id: "cal:demo:1767717000",
title: "Team Sync",
subtitle: "",
type: "CALENDAR_EVENT",
bucket: "FYI",
priority: 0.7,
ttlSec: 5400,
},
{
id: "demo:next",
title: "Next: Calendar",
subtitle: "Then Weather + POI",
type: "INFO",
bucket: "FYI",
priority: 0.4,
ttlSec: 86400,
},
{
id: "music:now:demo",
title: "Midnight City",
subtitle: "M83 - Hurry Up, We're Dreaming",
type: "NOW_PLAYING",
bucket: "FYI",
priority: 0.35,
ttlSec: 30,
},
],
nowPlaying: {
auth: "Authorized",
title: "Midnight City",
artist: "M83",
album: "Hurry Up, We're Dreaming",
playbackStatus: "playing",
},
weatherDiagnostics: {
provider: "WeatherKit",
last_fetch: "2024-05-21 09:30:14",
alerts: "0",
conditions: "mostly_clear_day",
},
calendarDiagnostics: {
events_upcoming: "3",
next_event: "Team Sync - 10:30",
},
test: {
note: "Test actions are not available in the React Native client.",
},
};
const formatTime = (date: Date | null) => {
if (!date) {
return "--";
}
return date.toLocaleTimeString();
};
const formatSpeed = (speedMps: number) => {
if (speedMps < 0) {
return "--";
}
return `${speedMps.toFixed(1)} m/s`;
};
const formatLatLon = (value: number) => value.toFixed(5);
const formatPriority = (value: number) => value.toFixed(2);
export default function OrchestratorScreen() {
const insets = useSafeAreaInsets();
return (
<View className="flex flex-1 bg-white">
<SafeAreaScrollView>
<View className="gap-4" style={{ paddingBottom: insets.bottom + 24 }}>
<View className="gap-1 px-8">
<Text variant="h3">Orchestrator</Text>
<Text className="text-sm text-muted-foreground">
Context engine status and diagnostics.
</Text>
</View>
<View className="w-full h-px bg-muted" />
<LocationSection />
<View className="w-full h-px bg-muted" />
<RecomputeSection />
<View className="w-full h-px bg-muted" />
<WinnerSection />
<View className="w-full h-px bg-muted" />
<FeedSection />
<View className="w-full h-px bg-muted" />
<NowPlayingSection />
<View className="w-full h-px bg-muted" />
<DiagnosticsSection
title="Weather Diagnostics"
entries={fixture.weatherDiagnostics}
/>
<View className="w-full h-px bg-muted" />
<DiagnosticsSection
title="Calendar Diagnostics"
entries={fixture.calendarDiagnostics}
/>
<View className="w-full h-px bg-muted" />
<TestSection />
</View>
</SafeAreaScrollView>
</View>
);
}
function ValueRow({ label, value }: { label: ReactNode; value: ReactNode }) {
return (
<View className="flex flex-row items-center justify-between">
{typeof label === "string" ? (
<Text className="text-sm text-muted-foreground">{label}</Text>
) : (
label
)}
{typeof value === "string" || typeof value === "number" ? (
<Text className="text-sm">{value}</Text>
) : (
value
)}
</View>
);
}
function LocationSection() {
const { authorization, lastLocation } = fixture.location;
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
Location
</Text>
<View className="gap-1">
<ValueRow label="Auth" value={authorization} />
{lastLocation ? (
<>
<ValueRow
label="Lat/Lon"
value={
<Text selectable className="text-sm">
{`${formatLatLon(lastLocation.latitude)}, ${formatLatLon(lastLocation.longitude)}`}
</Text>
}
/>
<ValueRow
label="Accuracy"
value={`${Math.round(lastLocation.accuracyMeters)} m`}
/>
<ValueRow
label="Speed"
value={formatSpeed(lastLocation.speedMps)}
/>
</>
) : (
<Text className="text-sm text-muted-foreground">No location yet</Text>
)}
</View>
</View>
);
}
function RecomputeSection() {
const { lastReason, lastTime, elapsedMs, fetchFailed, error } =
fixture.recompute;
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
Recompute
</Text>
<View className="gap-1">
<ValueRow label="Last reason" value={lastReason || "--"} />
<ValueRow label="Last time" value={formatTime(lastTime)} />
<ValueRow label="Elapsed" value={`${elapsedMs} ms`} />
<ValueRow label="Fetch failed" value={fetchFailed ? "Yes" : "No"} />
{error ? (
<Text className="text-xs text-muted-foreground">{error}</Text>
) : null}
</View>
<Button onPress={() => {}} variant="outline" className="mt-3">
<Text>Recompute Now</Text>
</Button>
</View>
);
}
function WinnerSection() {
const winner = fixture.winner;
return (
<View>
<Text variant="h4" className="mb-2 font-medium px-8">
Winner
</Text>
{winner ? (
<Card className="mx-4 gap-2 pb-2">
<CardHeader className="gap-0 border-b border-border pb-2 flex-row items-start justify-between">
<View className="gap-1.5">
<CardTitle className="flex-1">{winner.title}</CardTitle>
<CardDescription>
{winner.subtitle ?? "No description"}
</CardDescription>
</View>
<Badge variant="default">
<Text>{winner.type}</Text>
</Badge>
</CardHeader>
<CardContent className="gap-1">
<ValueRow label="Bucket" value={winner.bucket} />
<ValueRow
label="Priority"
value={formatPriority(winner.priority)}
/>
<ValueRow label="TTL" value={`${winner.ttlSec}s`} />
{winner.poiType ? (
<ValueRow label="POI type" value={winner.poiType} />
) : null}
{winner.startsAt ? (
<ValueRow label="Starts at" value={`${winner.startsAt}`} />
) : null}
<ValueRow
label="ID"
value={
<Text selectable className="text-xs text-muted-foreground">
{winner.id}
</Text>
}
/>
</CardContent>
</Card>
) : (
<Text className="text-sm text-muted-foreground">No winner yet</Text>
)}
</View>
);
}
function FeedSection() {
const feed = fixture.feed;
return (
<View>
<Text variant="h4" className="mb-2 font-medium px-8">
Feed
</Text>
{feed.length === 0 ? (
<Text className="text-sm text-muted-foreground">No feed items yet</Text>
) : (
<View className="px-4">
{feed.map((item, i) => (
<View
key={item.id}
className={cn("px-4 py-2 gap-1 border border-b-0 border-border", {
"rounded-t-lg": i === 0,
"rounded-b-lg border-b": i === feed.length - 1,
})}
>
<View className="flex-row items-center justify-between gap-2">
<View className="gap-1">
<Text
className="text-sm font-semibold flex-1"
numberOfLines={1}
>
{item.title}
</Text>
<Text
className="text-xs text-muted-foreground"
numberOfLines={1}
>
{item.subtitle || "No description"}
</Text>
<Text className="text-xs text-muted-foreground">
{`bucket ${item.bucket} | prio ${formatPriority(item.priority)} | ttl ${item.ttlSec}s`}
</Text>
</View>
<Badge variant="secondary">
<Text>{item.type}</Text>
</Badge>
</View>
</View>
))}
</View>
)}
</View>
);
}
function NowPlayingSection() {
const { auth, title, artist, album, playbackStatus } = fixture.nowPlaying;
const subtitle = [artist, album].filter(Boolean).join(" | ") || "Apple Music";
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
Now Playing
</Text>
<View className="gap-1">
<ValueRow label="Music auth" value={auth} />
{title ? (
<>
<Text className="text-sm font-semibold" numberOfLines={1}>
{title}
</Text>
<Text className="text-xs text-muted-foreground" numberOfLines={1}>
{subtitle}
</Text>
<Text className="text-xs text-muted-foreground">
{playbackStatus}
</Text>
</>
) : (
<Text className="text-sm text-muted-foreground">
{auth === "Authorized" ? "Nothing playing" : "Not authorized"}
</Text>
)}
</View>
</View>
);
}
function DiagnosticsSection({
title,
entries,
}: {
title: string;
entries: Record<string, string>;
}) {
const keys = Object.keys(entries).sort();
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
{title}
</Text>
<View className="gap-1">
{keys.map((key) => (
<ValueRow
key={key}
label={key}
value={
<Text selectable className="text-xs text-muted-foreground">
{entries[key]}
</Text>
}
/>
))}
</View>
</View>
);
}
function TestSection() {
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
Test
</Text>
<Text className="text-sm text-muted-foreground">{fixture.test.note}</Text>
</View>
);
}

View File

@@ -0,0 +1,22 @@
import { View } from "react-native";
import { Container } from "@/components/Container";
import { Text } from "@/components/ui/text";
export default function SettingsScreen() {
return (
<View className={styles.container}>
<Container>
<Text variant="h3">Settings</Text>
<Text className={styles.subtitle}>
Port of SwiftUI SettingsView will live here.
</Text>
</Container>
</View>
);
}
const styles = {
container: "flex flex-1 bg-white",
subtitle: "mt-2 text-sm text-muted-foreground",
};

View File

@@ -0,0 +1,22 @@
import { View } from "react-native";
import { Container } from "@/components/Container";
import { Text } from "@/components/ui/text";
export default function StocksScreen() {
return (
<View className={styles.container}>
<Container>
<Text variant="h3">Stocks</Text>
<Text className={styles.subtitle}>
Port of SwiftUI StockSettingsView will live here.
</Text>
</Container>
</View>
);
}
const styles = {
container: "flex flex-1 bg-white",
subtitle: "mt-2 text-sm text-muted-foreground",
};

View File

@@ -0,0 +1,22 @@
import { View } from "react-native";
import { Container } from "@/components/Container";
import { Text } from "@/components/ui/text";
export default function TodosScreen() {
return (
<View className={styles.container}>
<Container>
<Text variant="h3">Todos</Text>
<Text className={styles.subtitle}>
Port of SwiftUI TodosView will live here.
</Text>
</Container>
</View>
);
}
const styles = {
container: "flex flex-1 bg-white",
subtitle: "mt-2 text-sm text-muted-foreground",
};

View File

@@ -0,0 +1,46 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View File

@@ -0,0 +1,26 @@
import { Link, Stack } from 'expo-router';
import { Text, View } from 'react-native';
import { Container } from '@/components/Container';
export default function NotFoundScreen() {
return (
<View className={styles.container}>
<Stack.Screen options={{ title: 'Oops!' }} />
<Container>
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
<Link href="/" className={styles.link}>
<Text className={styles.linkText}>Go to home screen!</Text>
</Link>
</Container>
</View>
);
}
const styles = {
container: `flex flex-1 bg-white`,
title: `text-xl font-bold`,
link: `mt-4 pt-4`,
linkText: `text-base text-[#2e78b7]`,
};

View File

@@ -0,0 +1,15 @@
import "../global.css";
import { Stack } from "expo-router";
import { PortalHost } from "@rn-primitives/portal";
export default function Layout() {
return (
<>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
<PortalHost />
</>
);
}

View File

@@ -0,0 +1,23 @@
import { View } from 'react-native';
import { Stack, useLocalSearchParams } from 'expo-router';
import { Container } from '@/components/Container';
import { ScreenContent } from '@/components/ScreenContent';
export default function Details() {
const { name } = useLocalSearchParams();
return (
<View className={styles.container}>
<Stack.Screen options={{ title: 'Details' }} />
<Container>
<ScreenContent path="screens/details.tsx" title={`Showing details for user ${name}`} />
</Container>
</View>
);
}
const styles = {
container: 'flex flex-1 bg-white',
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,12 @@
module.exports = function (api) {
api.cache(true);
let plugins = [];
plugins.push('react-native-worklets/plugin');
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
plugins,
};
};

2062
aris/apps/companion/bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
// This is an optional configuration file used primarily for debugging purposes when reporting issues.
// It is safe to delete this file as it does not affect the functionality of your application.
{
"cesVersion": "2.20.1",
"projectName": "aris",
"packages": [
{
"name": "expo-router",
"type": "navigation",
"options": {
"type": "stack"
}
},
{
"name": "nativewind",
"type": "styling"
},
{
"name": "zustand",
"type": "state-management"
}
],
"flags": {
"noGit": false,
"noInstall": false,
"overwrite": false,
"importAlias": true,
"packageManager": "bun",
"eas": false,
"publish": false
},
"packageManager": {
"type": "bun",
"version": "1.2.21"
},
"os": {
"type": "Darwin",
"platform": "darwin",
"arch": "arm64",
"kernelVersion": "24.5.0"
}
}

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "global.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@@ -0,0 +1,24 @@
import { forwardRef } from 'react';
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
type ButtonProps = {
title: string;
} & TouchableOpacityProps;
export const Button = forwardRef<View, ButtonProps>(({ title, ...touchableProps }, ref) => {
return (
<TouchableOpacity
ref={ref}
{...touchableProps}
className={`${styles.button} ${touchableProps.className}`}>
<Text className={styles.buttonText}>{title}</Text>
</TouchableOpacity>
);
});
Button.displayName = 'Button';
const styles = {
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
buttonText: 'text-white text-lg font-semibold text-center',
};

View File

@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
import { SafeAreaView } from "react-native-safe-area-context";
export const Container = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<SafeAreaView className={cn("flex flex-1", className)}>
{children}
</SafeAreaView>
);
};

View File

@@ -0,0 +1,29 @@
import { Text, View } from 'react-native';
export const EditScreenInfo = ({ path }: { path: string }) => {
const title = 'Open up the code for this screen:';
const description =
'Change any of the text, save the file, and your app will automatically update.';
return (
<View>
<View className={styles.getStartedContainer}>
<Text className={styles.getStartedText}>{title}</Text>
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
<Text>{path}</Text>
</View>
<Text className={styles.getStartedText}>{description}</Text>
</View>
</View>
);
};
const styles = {
codeHighlightContainer: `rounded-md px-1`,
getStartedContainer: `items-center mx-12`,
getStartedText: `text-lg leading-6 text-center`,
helpContainer: `items-center mx-5 mt-4`,
helpLink: `py-4`,
helpLinkText: `text-center`,
homeScreenFilename: `my-2`,
};

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { Text, View } from 'react-native';
import { EditScreenInfo } from './EditScreenInfo';
type ScreenContentProps = {
title: string;
path: string;
children?: React.ReactNode;
};
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
return (
<View className={styles.container}>
<Text className={styles.title}>{title}</Text>
<View className={styles.separator} />
<EditScreenInfo path={path} />
{children}
</View>
);
};
const styles = {
container: `items-center flex-1 justify-center bg-white`,
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
title: `text-xl font-bold`,
};

View File

@@ -0,0 +1,19 @@
import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export function SafeAreaScrollView({ children }: React.PropsWithChildren) {
const insets = useSafeAreaInsets();
return (
<ScrollView
style={{
paddingTop: insets.top,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
{children}
</ScrollView>
);
}

View File

@@ -0,0 +1,67 @@
import { TextClassContext } from '@/components/ui/text';
import { cn } from '@/lib/utils';
import * as Slot from '@rn-primitives/slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Platform, View, ViewProps } from 'react-native';
const badgeVariants = cva(
cn(
'border-border group shrink-0 flex-row items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5',
Platform.select({
web: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
})
),
{
variants: {
variant: {
default: cn(
'bg-primary border-transparent',
Platform.select({ web: '[a&]:hover:bg-primary/90' })
),
secondary: cn(
'bg-secondary border-transparent',
Platform.select({ web: '[a&]:hover:bg-secondary/90' })
),
destructive: cn(
'bg-destructive border-transparent',
Platform.select({ web: '[a&]:hover:bg-destructive/90' })
),
outline: Platform.select({ web: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground' }),
},
},
defaultVariants: {
variant: 'default',
},
}
);
const badgeTextVariants = cva('text-xs font-medium', {
variants: {
variant: {
default: 'text-primary-foreground',
secondary: 'text-secondary-foreground',
destructive: 'text-white',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
});
type BadgeProps = ViewProps &
React.RefAttributes<View> & {
asChild?: boolean;
} & VariantProps<typeof badgeVariants>;
function Badge({ className, variant, asChild, ...props }: BadgeProps) {
const Component = asChild ? Slot.View : View;
return (
<TextClassContext.Provider value={badgeTextVariants({ variant })}>
<Component className={cn(badgeVariants({ variant }), className)} {...props} />
</TextClassContext.Provider>
);
}
export { Badge, badgeTextVariants, badgeVariants };
export type { BadgeProps };

View File

@@ -0,0 +1,108 @@
import { TextClassContext } from '@/components/ui/text';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { Platform, Pressable } from 'react-native';
const buttonVariants = cva(
cn(
'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none',
Platform.select({
web: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
})
),
{
variants: {
variant: {
default: cn(
'bg-primary active:bg-primary/90 shadow-sm shadow-black/5',
Platform.select({ web: 'hover:bg-primary/90' })
),
destructive: cn(
'bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm shadow-black/5',
Platform.select({
web: 'hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
})
),
outline: cn(
'border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm shadow-black/5',
Platform.select({
web: 'hover:bg-accent dark:hover:bg-input/50',
})
),
secondary: cn(
'bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5',
Platform.select({ web: 'hover:bg-secondary/80' })
),
ghost: cn(
'active:bg-accent dark:active:bg-accent/50',
Platform.select({ web: 'hover:bg-accent dark:hover:bg-accent/50' })
),
link: '',
},
size: {
default: cn('h-10 px-4 py-2 sm:h-9', Platform.select({ web: 'has-[>svg]:px-3' })),
sm: cn('h-9 gap-1.5 rounded-md px-3 sm:h-8', Platform.select({ web: 'has-[>svg]:px-2.5' })),
lg: cn('h-11 rounded-md px-6 sm:h-10', Platform.select({ web: 'has-[>svg]:px-4' })),
icon: 'h-10 w-10 sm:h-9 sm:w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const buttonTextVariants = cva(
cn(
'text-foreground text-sm font-medium',
Platform.select({ web: 'pointer-events-none transition-colors' })
),
{
variants: {
variant: {
default: 'text-primary-foreground',
destructive: 'text-white',
outline: cn(
'group-active:text-accent-foreground',
Platform.select({ web: 'group-hover:text-accent-foreground' })
),
secondary: 'text-secondary-foreground',
ghost: 'group-active:text-accent-foreground',
link: cn(
'text-primary group-active:underline',
Platform.select({ web: 'underline-offset-4 hover:underline group-hover:underline' })
),
},
size: {
default: '',
sm: '',
lg: '',
icon: '',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
type ButtonProps = React.ComponentProps<typeof Pressable> &
React.RefAttributes<typeof Pressable> &
VariantProps<typeof buttonVariants>;
function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
<Pressable
className={cn(props.disabled && 'opacity-50', buttonVariants({ variant, size }), className)}
role="button"
{...props}
/>
</TextClassContext.Provider>
);
}
export { Button, buttonTextVariants, buttonVariants };
export type { ButtonProps };

View File

@@ -0,0 +1,80 @@
import { Text, TextClassContext } from "@/components/ui/text";
import { cn } from "@/lib/utils";
import { View, type ViewProps } from "react-native";
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
return (
<TextClassContext.Provider value="text-card-foreground">
<View
className={cn(
"bg-card border-border flex flex-col gap-4 rounded-xl border py-4 shadow-sm shadow-black/5",
className,
)}
{...props}
/>
</TextClassContext.Provider>
);
}
function CardHeader({
className,
...props
}: ViewProps & React.RefAttributes<View>) {
return (
<View className={cn("flex flex-col gap-1.5 px-4", className)} {...props} />
);
}
function CardTitle({
className,
...props
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
return (
<Text
role="heading"
aria-level={3}
className={cn("font-semibold leading-none", className)}
{...props}
/>
);
}
function CardDescription({
className,
...props
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
return (
<Text
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardContent({
className,
...props
}: ViewProps & React.RefAttributes<View>) {
return <View className={cn("px-4", className)} {...props} />;
}
function CardFooter({
className,
...props
}: ViewProps & React.RefAttributes<View>) {
return (
<View
className={cn("flex flex-row items-center px-6", className)}
{...props}
/>
);
}
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

View File

@@ -0,0 +1,36 @@
import { cn } from '@/lib/utils';
import * as SwitchPrimitives from '@rn-primitives/switch';
import { Platform } from 'react-native';
function Switch({
className,
...props
}: SwitchPrimitives.RootProps & React.RefAttributes<SwitchPrimitives.RootRef>) {
return (
<SwitchPrimitives.Root
className={cn(
'flex h-[1.15rem] w-8 shrink-0 flex-row items-center rounded-full border border-transparent shadow-sm shadow-black/5',
Platform.select({
web: 'focus-visible:border-ring focus-visible:ring-ring/50 peer inline-flex outline-none transition-all focus-visible:ring-[3px] disabled:cursor-not-allowed',
}),
props.checked ? 'bg-primary' : 'bg-input dark:bg-input/80',
props.disabled && 'opacity-50',
className
)}
{...props}>
<SwitchPrimitives.Thumb
className={cn(
'bg-background size-4 rounded-full transition-transform',
Platform.select({
web: 'pointer-events-none block ring-0',
}),
props.checked
? 'dark:bg-primary-foreground translate-x-3.5'
: 'dark:bg-foreground translate-x-0'
)}
/>
</SwitchPrimitives.Root>
);
}
export { Switch };

View File

@@ -0,0 +1,89 @@
import { cn } from '@/lib/utils';
import * as Slot from '@rn-primitives/slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Platform, Text as RNText, type Role } from 'react-native';
const textVariants = cva(
cn(
'text-foreground text-base',
Platform.select({
web: 'select-text',
})
),
{
variants: {
variant: {
default: '',
h1: cn(
'text-center text-4xl font-extrabold tracking-tight',
Platform.select({ web: 'scroll-m-20 text-balance' })
),
h2: cn(
'border-border border-b pb-2 text-3xl font-semibold tracking-tight',
Platform.select({ web: 'scroll-m-20 first:mt-0' })
),
h3: cn('text-2xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
h4: cn('text-xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
p: 'mt-3 leading-7 sm:mt-6',
blockquote: 'mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6',
code: cn(
'bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold'
),
lead: 'text-muted-foreground text-xl',
large: 'text-lg font-semibold',
small: 'text-sm font-medium leading-none',
muted: 'text-muted-foreground text-sm',
},
},
defaultVariants: {
variant: 'default',
},
}
);
type TextVariantProps = VariantProps<typeof textVariants>;
type TextVariant = NonNullable<TextVariantProps['variant']>;
const ROLE: Partial<Record<TextVariant, Role>> = {
h1: 'heading',
h2: 'heading',
h3: 'heading',
h4: 'heading',
blockquote: Platform.select({ web: 'blockquote' as Role }),
code: Platform.select({ web: 'code' as Role }),
};
const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
h1: '1',
h2: '2',
h3: '3',
h4: '4',
};
const TextClassContext = React.createContext<string | undefined>(undefined);
function Text({
className,
asChild = false,
variant = 'default',
...props
}: React.ComponentProps<typeof RNText> &
TextVariantProps &
React.RefAttributes<RNText> & {
asChild?: boolean;
}) {
const textClass = React.useContext(TextClassContext);
const Component = asChild ? Slot.Text : RNText;
return (
<Component
className={cn(textVariants({ variant }), textClass, className)}
role={variant ? ROLE[variant] : undefined}
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
{...props}
/>
);
}
export { Text, TextClassContext };

View File

@@ -0,0 +1,15 @@
/* eslint-env node */
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
{
rules: {
'react/display-name': 'off',
},
},
]);

View File

@@ -0,0 +1,58 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 63%;
--radius: 0.625rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark:root {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 70.9% 59.4%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 300 0% 45%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}

View File

@@ -0,0 +1,51 @@
export const fullFeedFixture = {
schema: 1,
generated_at: 1767716400,
feed: [
{
id: "demo:welcome",
type: "INFO",
title: "Glass Now online",
subtitle: "Connected to iPhone",
priority: 0.8,
ttl_sec: 86400,
bucket: "RIGHT_NOW",
actions: ["DISMISS"],
},
{
id: "cal:demo:1767717000",
type: "CALENDAR_EVENT",
title: "Team Sync",
subtitle: "",
priority: 0.7,
ttl_sec: 5400,
starts_at: 1767717000,
bucket: "FYI",
actions: ["DISMISS"],
},
{
id: "demo:next",
type: "INFO",
title: "Next: Calendar",
subtitle: "Then Weather + POI",
priority: 0.4,
ttl_sec: 86400,
bucket: "FYI",
actions: ["DISMISS"],
},
{
id: "music:now:demo",
type: "NOW_PLAYING",
title: "Midnight City",
subtitle: "M83 • Hurry Up, We're Dreaming",
priority: 0.35,
ttl_sec: 30,
bucket: "FYI",
actions: ["DISMISS"],
},
],
meta: {
winner_id: "demo:welcome",
unread_count: 4,
},
} as const;

View File

@@ -0,0 +1,81 @@
import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native";
export const THEME = {
light: {
background: "hsl(0 0% 100%)",
foreground: "hsl(0 0% 3.9%)",
card: "hsl(0 0% 100%)",
cardForeground: "hsl(0 0% 3.9%)",
popover: "hsl(0 0% 100%)",
popoverForeground: "hsl(0 0% 3.9%)",
primary: "hsl(0 0% 9%)",
primaryForeground: "hsl(0 0% 98%)",
secondary: "hsl(0 0% 96.1%)",
secondaryForeground: "hsl(0 0% 9%)",
muted: "hsl(0 0% 96.1%)",
mutedForeground: "hsl(0 0% 45.1%)",
accent: "hsl(0 0% 96.1%)",
accentForeground: "hsl(0 0% 9%)",
destructive: "hsl(0 84.2% 60.2%)",
border: "hsl(0 0% 89.8%)",
input: "hsl(0 0% 89.8%)",
ring: "hsl(0 0% 63%)",
radius: "0.625rem",
chart1: "hsl(12 76% 61%)",
chart2: "hsl(173 58% 39%)",
chart3: "hsl(197 37% 24%)",
chart4: "hsl(43 74% 66%)",
chart5: "hsl(27 87% 67%)",
},
dark: {
background: "hsl(0 0% 3.9%)",
foreground: "hsl(0 0% 98%)",
card: "hsl(0 0% 3.9%)",
cardForeground: "hsl(0 0% 98%)",
popover: "hsl(0 0% 3.9%)",
popoverForeground: "hsl(0 0% 98%)",
primary: "hsl(0 0% 98%)",
primaryForeground: "hsl(0 0% 9%)",
secondary: "hsl(0 0% 14.9%)",
secondaryForeground: "hsl(0 0% 98%)",
muted: "hsl(0 0% 14.9%)",
mutedForeground: "hsl(0 0% 63.9%)",
accent: "hsl(0 0% 14.9%)",
accentForeground: "hsl(0 0% 98%)",
destructive: "hsl(0 70.9% 59.4%)",
border: "hsl(0 0% 14.9%)",
input: "hsl(0 0% 14.9%)",
ring: "hsl(300 0% 45%)",
radius: "0.625rem",
chart1: "hsl(220 70% 50%)",
chart2: "hsl(160 60% 45%)",
chart3: "hsl(30 80% 55%)",
chart4: "hsl(280 65% 60%)",
chart5: "hsl(340 75% 55%)",
},
};
export const NAV_THEME: Record<"light" | "dark", Theme> = {
light: {
...DefaultTheme,
colors: {
background: THEME.light.background,
border: THEME.light.border,
card: THEME.light.card,
notification: THEME.light.destructive,
primary: THEME.light.primary,
text: THEME.light.foreground,
},
},
dark: {
...DarkTheme,
colors: {
background: THEME.dark.background,
border: THEME.dark.border,
card: THEME.dark.card,
notification: THEME.dark.destructive,
primary: THEME.dark.primary,
text: THEME.dark.foreground,
},
},
};

View File

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

View File

@@ -0,0 +1,24 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const path = require("path");
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
/** @type {import('expo/metro-config').MetroConfig} */
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
config.resolver.disableHierarchicalLookup = true;
module.exports = withNativeWind(config, {
input: "./global.css",
inlineRem: 16,
});

View File

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

View File

@@ -0,0 +1,58 @@
{
"name": "aris",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"android": "expo run:android",
"ios": "expo run:ios",
"start": "expo start",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"web": "expo start --web"
},
"dependencies": {
"@aris/ble": "*",
"@expo/vector-icons": "^15.0.2",
"@react-navigation/native": "^7.1.6",
"@rn-primitives/portal": "^1.3.0",
"@rn-primitives/slot": "^1.2.0",
"@rn-primitives/switch": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "^54.0.0",
"expo-clipboard": "~8.0.8",
"expo-calendar": "~14.0.0",
"expo-constants": "~18.0.9",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.10",
"expo-status-bar": "~3.0.8",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.7",
"nativewind": "latest",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.5.1"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.1.10",
"eslint": "^9.25.1",
"eslint-config-expo": "~10.0.0",
"eslint-config-prettier": "^10.1.2",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.5.11",
"typescript": "~5.9.2"
},
"private": true
}

View File

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

View File

@@ -0,0 +1,61 @@
import type { EventSubscription } from "expo-modules-core";
import { create } from "zustand";
import { fullFeedFixture } from "@/lib/ble/fixtures";
import { Ble, defaultBleState } from "@aris/ble";
import type { BleStatePayload } from "@aris/ble";
export type BleStoreState = BleStatePayload & {
isSupported: boolean;
isReady: boolean;
initialize: () => void;
setAdvertisingEnabled: (enabled: boolean) => void;
setWifiRequested: (requested: boolean) => void;
sendFixtureFeedNow: () => void;
};
let subscription: EventSubscription | null = null;
const noopSubscription: EventSubscription = {
remove: () => undefined,
};
export const useBleStore = create<BleStoreState>((set, get) => ({
...defaultBleState,
isSupported: Ble.isSupported,
isReady: false,
initialize: () => {
if (!Ble.isSupported || get().isReady) {
return;
}
const state = Ble.getState();
set({ ...state, isReady: true });
subscription?.remove();
subscription =
Ble.addStateListener?.((nextState) => {
set({ ...nextState });
}) ?? noopSubscription;
Ble.start();
},
setAdvertisingEnabled: (enabled) => {
if (!Ble.isSupported) {
return;
}
set({ advertisingEnabled: enabled });
Ble.setAdvertisingEnabled(enabled);
},
setWifiRequested: (requested) => {
if (!Ble.isSupported) {
return;
}
set({ wifiRequested: requested });
Ble.setWifiRequested(requested);
},
sendFixtureFeedNow: () => {
if (!Ble.isSupported) {
return;
}
const payload = JSON.stringify(fullFeedFixture);
Ble.sendOpaque(payload, 1);
},
}));

View File

@@ -0,0 +1,15 @@
import { create } from 'zustand';
export interface BearState {
bears: number;
increasePopulation: () => void;
removeAllBears: () => void;
updateBears: (newBears: number) => void;
}
export const useStore = create<BearState>((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
updateBears: (newBears) => set({ bears: newBears }),
}));

View File

@@ -0,0 +1,73 @@
const { hairlineWidth } = require("nativewind/theme");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
borderWidth: {
hairline: hairlineWidth(),
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
future: {
hoverOnlyWhenSupported: true,
},
plugins: [require("tailwindcss-animate")],
};

View File

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

2659
aris/bun.lock Normal file

File diff suppressed because it is too large Load Diff

5
aris/package.json Normal file
View File

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

View File

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

View File

@@ -0,0 +1,32 @@
# @aris/ble
BLE peripheral module for the Aris companion app.
# API documentation
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/example.com/)
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/example.com/)
# Installation in managed Expo projects
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects &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

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

View File

@@ -0,0 +1,35 @@
{
"name": "@aris/ble",
"version": "0.1.0",
"description": "BLE peripheral module for the Aris companion app.",
"author": "Iris",
"homepage": "https://example.com",
"main": "build/index.js",
"types": "build/index.d.ts",
"sideEffects": false,
"exports": {
"./package.json": "./package.json",
".": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
}
},
"scripts": {
"build": "expo-module build",
"clean": "expo-module clean",
"lint": "expo-module lint",
"test": "expo-module test",
"prepare": "expo-module prepare",
"prepublishOnly": "expo-module prepublishOnly",
"expo-module": "expo-module"
},
"keywords": ["react-native", "expo", "ble"],
"license": "MIT",
"devDependencies": {
"expo-module-scripts": "^5.0.8"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
}

View File

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

View File

@@ -0,0 +1,90 @@
import { NativeModule, requireNativeModule } from "expo";
import { Platform } from "react-native";
import type { EventSubscription } from "expo-modules-core";
import {
BleBluetoothState,
type BleNativeModuleEvents,
type BleStatePayload,
type BleUuids,
} from "./types";
declare class ArisBleNativeModule extends NativeModule<BleNativeModuleEvents> {
start: () => void;
stop: () => void;
setAdvertisingEnabled: (enabled: boolean) => void;
setWifiRequested: (requested: boolean) => void;
sendOpaque: (payload: string, msgType?: number) => void;
getState: () => BleStatePayload;
serviceUUID: string;
feedTxUUID: string;
controlRxUUID: string;
wifiRequestTxUUID: string;
}
const isSupported = Platform.OS === "ios";
const nativeModule = isSupported
? requireNativeModule<ArisBleNativeModule>("ArisBle")
: null;
export const defaultBleState: BleStatePayload = {
bluetoothState: BleBluetoothState.Unknown,
advertisingEnabled: true,
isAdvertising: false,
isSubscribed: false,
subscribedCount: 0,
lastMsgIdSent: 0,
lastPingAt: null,
lastCommand: null,
notifyQueueDepth: 0,
droppedNotifyPackets: 0,
lastNotifyAt: null,
lastDataAt: null,
wifiRequested: false,
};
const emptyUuids: BleUuids = {
serviceUUID: "",
feedTxUUID: "",
controlRxUUID: "",
wifiRequestTxUUID: "",
};
type BleApi = {
isSupported: boolean;
start: () => void;
stop: () => void;
setAdvertisingEnabled: (enabled: boolean) => void;
setWifiRequested: (requested: boolean) => void;
sendOpaque: (payload: string, msgType?: number) => void;
getState: () => BleStatePayload;
addStateListener: (
listener: (state: BleStatePayload) => void,
) => EventSubscription | undefined;
getUuids: () => BleUuids;
};
export const Ble: BleApi = {
isSupported,
start: () => nativeModule?.start?.(),
stop: () => nativeModule?.stop?.(),
setAdvertisingEnabled: (enabled: boolean) =>
nativeModule?.setAdvertisingEnabled?.(enabled),
setWifiRequested: (requested: boolean) =>
nativeModule?.setWifiRequested?.(requested),
sendOpaque: (payload: string, msgType?: number) =>
nativeModule?.sendOpaque?.(payload, msgType),
getState: () => nativeModule?.getState?.() ?? { ...defaultBleState },
addStateListener: (listener: (state: BleStatePayload) => void) =>
nativeModule?.addListener("onStateChange", listener),
getUuids: (): BleUuids =>
nativeModule
? {
serviceUUID: nativeModule.serviceUUID,
feedTxUUID: nativeModule.feedTxUUID,
controlRxUUID: nativeModule.controlRxUUID,
wifiRequestTxUUID: nativeModule.wifiRequestTxUUID,
}
: emptyUuids,
};

View File

@@ -0,0 +1,39 @@
export const BleBluetoothState = {
Unknown: "Unknown",
Resetting: "Resetting",
Unsupported: "Unsupported",
Unauthorized: "Unauthorized",
PoweredOff: "Powered Off",
PoweredOn: "Powered On",
Other: "Other",
} as const;
export type BleBluetoothState =
(typeof BleBluetoothState)[keyof typeof BleBluetoothState];
export type BleStatePayload = {
bluetoothState: BleBluetoothState;
advertisingEnabled: boolean;
isAdvertising: boolean;
isSubscribed: boolean;
subscribedCount: number;
lastMsgIdSent: number;
lastPingAt: number | null;
lastCommand: string | null;
notifyQueueDepth: number;
droppedNotifyPackets: number;
lastNotifyAt: number | null;
lastDataAt: number | null;
wifiRequested: boolean;
};
export type BleUuids = {
serviceUUID: string;
feedTxUUID: string;
controlRxUUID: string;
wifiRequestTxUUID: string;
};
export type BleNativeModuleEvents = {
onStateChange: (state: BleStatePayload) => void;
};

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
# @aris/data-sources
Data source module for the Aris companion app.
# API documentation
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/example.com/)
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/example.com/)
# Installation in managed Expo projects
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects &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

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

View File

@@ -0,0 +1,38 @@
{
"name": "@aris/data-sources",
"version": "0.1.0",
"description": "Data source module for the Aris companion app.",
"author": "Iris",
"homepage": "https://example.com",
"main": "build/index.js",
"types": "build/index.d.ts",
"sideEffects": false,
"exports": {
"./package.json": "./package.json",
".": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
}
},
"scripts": {
"build": "expo-module build",
"clean": "expo-module clean",
"lint": "expo-module lint",
"test": "expo-module test",
"prepare": "expo-module prepare",
"prepublishOnly": "expo-module prepublishOnly",
"expo-module": "expo-module"
},
"keywords": ["react-native", "expo", "data-sources"],
"license": "MIT",
"dependencies": {
"expo-calendar": "~14.0.0"
},
"devDependencies": {
"expo-module-scripts": "^5.0.8"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
}

View File

@@ -0,0 +1,178 @@
import * as Calendar from "expo-calendar";
import { DataSourceError } from "../common/errors";
import type { Diagnostics } from "../common/types";
import type {
CalendarData,
CalendarDataSource,
CalendarDataSourceConfig,
CalendarEvent,
CalendarRequest,
} from "./types";
const defaultConfig: Required<CalendarDataSourceConfig> = {
lookaheadSec: 2 * 60 * 60,
soonWindowSec: 30 * 60,
maxCandidates: 3,
includeAllDay: false,
includeDeclined: false,
};
const resolveConfig = (
base: CalendarDataSourceConfig,
override?: CalendarDataSourceConfig,
): Required<CalendarDataSourceConfig> => ({
...defaultConfig,
...base,
...(override ?? {}),
});
const ensureCalendarAccess = async (diagnostics: Diagnostics) => {
const permission = await Calendar.getCalendarPermissionsAsync();
let status = permission.status;
diagnostics.auth = status ?? "unknown";
if (status !== "granted") {
const requested = await Calendar.requestCalendarPermissionsAsync();
status = requested.status;
diagnostics.auth = status ?? diagnostics.auth;
}
const granted = status === "granted";
diagnostics.access_granted = granted ? "true" : "false";
if (!granted) {
throw new DataSourceError(
"access_not_granted",
"Calendar access not granted.",
diagnostics,
);
}
};
const shouldIncludeEvent = (
event: Calendar.Event,
config: Required<CalendarDataSourceConfig>,
) => {
if (event.allDay && !config.includeAllDay) {
return false;
}
if (
!config.includeDeclined &&
event.status === Calendar.EventStatus.CANCELED
) {
return false;
}
return true;
};
const toDate = (value?: Date | string | null) => {
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
};
const buildEvents = (
events: Calendar.Event[],
nowDate: Date,
config: Required<CalendarDataSourceConfig>,
) => {
const results: CalendarEvent[] = [];
for (const event of events) {
if (results.length >= config.maxCandidates) {
break;
}
const startDate = toDate(event.startDate);
const endDate = toDate(event.endDate);
if (!startDate || !endDate) {
continue;
}
const isOngoing = startDate <= nowDate && endDate > nowDate;
const startsInSec = Math.floor(
(startDate.getTime() - nowDate.getTime()) / 1000,
);
if (!isOngoing && startsInSec > config.soonWindowSec) {
continue;
}
const startAt = Math.floor(startDate.getTime() / 1000);
const endAt = Math.floor(endDate.getTime() / 1000);
const title = (event.title ?? "Event").trim() || "Event";
results.push({
id: `cal:${event.id}:${startAt}`,
title,
startAt,
endAt,
isAllDay: !!event.allDay,
location: event.location ?? null,
});
}
return results;
};
const fetchCalendarData = async (
request: CalendarRequest,
config: CalendarDataSourceConfig,
): Promise<{ data: CalendarData; diagnostics: Diagnostics }> => {
const resolvedConfig = resolveConfig(config, request.config);
const diagnostics: Diagnostics = {
now: String(request.now),
lookahead_sec: String(resolvedConfig.lookaheadSec),
soon_window_sec: String(resolvedConfig.soonWindowSec),
};
await ensureCalendarAccess(diagnostics);
const nowDate = new Date(request.now * 1000);
const endDate = new Date(
nowDate.getTime() + resolvedConfig.lookaheadSec * 1000,
);
const calendars: Calendar.Calendar[] = await Calendar.getCalendarsAsync(
Calendar.EntityTypes.EVENT,
);
const calendarIds = calendars.map(
(calendar: Calendar.Calendar) => calendar.id,
);
const events: Calendar.Event[] = await Calendar.getEventsAsync(
calendarIds,
nowDate,
endDate,
);
diagnostics.events_matched = String(events.length);
const filtered = events
.filter((event: Calendar.Event) =>
shouldIncludeEvent(event, resolvedConfig),
)
.sort((a: Calendar.Event, b: Calendar.Event) => {
const aStart = toDate(a.startDate)?.getTime() ?? 0;
const bStart = toDate(b.startDate)?.getTime() ?? 0;
return aStart - bStart;
});
diagnostics.events_filtered = String(filtered.length);
const outputEvents = buildEvents(filtered, nowDate, resolvedConfig);
diagnostics.events_output = String(outputEvents.length);
return {
data: {
events: outputEvents,
},
diagnostics,
};
};
export const createCalendarDataSource = (
config: CalendarDataSourceConfig = {},
): CalendarDataSource => ({
dataWithDiagnostics: (request) => fetchCalendarData(request, config),
});

View File

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

View File

@@ -0,0 +1,49 @@
import type { DataSource } from "../common/types";
export type CalendarDataSourceConfig = {
/**
* How far ahead (in seconds) to scan for events.
* Defaults to 7200 (2 hours).
*/
lookaheadSec?: number;
/**
* How soon (in seconds) a future event must start to be included.
* Defaults to 1800 (30 minutes).
*/
soonWindowSec?: number;
/**
* Maximum number of candidate events to return.
* Defaults to 3.
*/
maxCandidates?: number;
/**
* Whether all-day events should be included.
* Defaults to false.
*/
includeAllDay?: boolean;
/**
* Whether declined/canceled events should be included.
* Defaults to false.
*/
includeDeclined?: boolean;
};
export type CalendarEvent = {
id: string;
title: string;
startAt: number;
endAt: number;
isAllDay: boolean;
location?: string | null;
};
export type CalendarData = {
events: CalendarEvent[];
};
export type CalendarRequest = {
now: number;
config?: CalendarDataSourceConfig;
};
export type CalendarDataSource = DataSource<CalendarRequest, CalendarData>;

View File

@@ -0,0 +1,18 @@
import type { Diagnostics } from "./types";
export class DataSourceError extends Error {
readonly code: string;
readonly diagnostics?: Diagnostics;
constructor(code: string, message: string, diagnostics?: Diagnostics) {
super(message);
this.code = code;
this.diagnostics = diagnostics;
}
}
export class UnsupportedError extends DataSourceError {
constructor(message = "Unsupported on this platform") {
super("unsupported", message);
}
}

View File

@@ -0,0 +1,17 @@
export type Diagnostics = Record<string, string>;
export type LocationInput = {
latitude: number;
longitude: number;
horizontalAccuracy?: number | null;
speed?: number | null;
};
export type DataSourceResult<TData> = {
data: TData;
diagnostics: Diagnostics;
};
export interface DataSource<TInput, TData> {
dataWithDiagnostics(input: TInput): Promise<DataSourceResult<TData>>;
}

View File

@@ -0,0 +1,8 @@
export type { DataSource, DataSourceResult, Diagnostics, LocationInput } from "./common/types";
export { DataSourceError, UnsupportedError } from "./common/errors";
export * from "./calendar";
export * from "./poi";
export * from "./stock";
export * from "./tfl";
export * from "./weather";

View File

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

View File

@@ -0,0 +1,8 @@
import { UnsupportedError } from "../common/errors";
import type { PoiDataSource } from "./types";
export const createPoiDataSource = (): PoiDataSource => ({
dataWithDiagnostics: async () => {
throw new UnsupportedError("POI data source is not supported on Android.");
},
});

View File

@@ -0,0 +1,37 @@
import { requireNativeModule } from "expo";
import type {
PoiDataSource,
PoiDataSourceConfig,
PoiRequest,
PoiSnapshot,
} from "./types";
type PoiDataSourceNativeModule = {
getPoiData: (request: PoiRequest) => Promise<PoiSnapshot>;
};
const PoiDataSourceNative =
requireNativeModule<PoiDataSourceNativeModule>("PoiDataSource");
const mergeConfig = (
base: PoiDataSourceConfig,
override?: PoiDataSourceConfig,
) => ({
...base,
...(override ?? {}),
});
export const createPoiDataSource = (
config: PoiDataSourceConfig = {},
): PoiDataSource => ({
dataWithDiagnostics: async (request: PoiRequest) => {
const mergedConfig = mergeConfig(config, request.config);
const resolvedRequest = {
...request,
config:
Object.keys(mergedConfig).length > 0 ? mergedConfig : undefined,
};
const snapshot = await PoiDataSourceNative.getPoiData(resolvedRequest);
return { data: snapshot.data, diagnostics: snapshot.diagnostics };
},
});

View File

@@ -0,0 +1,10 @@
import { Platform } from "react-native";
import type { PoiDataSource, PoiDataSourceConfig } from "./types";
type CreatePoiDataSource = (config?: PoiDataSourceConfig) => PoiDataSource;
const impl: { createPoiDataSource: CreatePoiDataSource } =
Platform.OS === "ios" ? require("./poi.ios") : require("./poi.android");
export const createPoiDataSource = impl.createPoiDataSource;

View File

@@ -0,0 +1,78 @@
import type { DataSource } from "../common/types";
import type { LocationInput } from "../common/types";
export type PoiDataSourceConfig = {
/**
* Maximum number of POIs to return.
* Defaults to 2 on iOS.
*/
maxCandidates?: number;
/**
* Search radius in meters.
* Defaults to 600 on iOS.
*/
searchRadiusMeters?: number;
/**
* Distance in meters within which transit POIs get a score boost.
* Defaults to 200 on iOS.
*/
transitBoostRadiusMeters?: number;
/**
* Assumed walking speed in meters per second.
* Defaults to 1.4 on iOS.
*/
walkingSpeedMps?: number;
/**
* Minimum TTL in seconds for POI items.
* Defaults to 60 on iOS.
*/
minTtlSeconds?: number;
/**
* Maximum TTL in seconds for POI items.
* Defaults to 1200 on iOS.
*/
maxTtlSeconds?: number;
};
export type PoiType =
| "transit"
| "cafe"
| "food"
| "park"
| "shopping"
| "grocery"
| "fitness"
| "entertainment"
| "health"
| "lodging"
| "education"
| "services"
| "other";
export type PoiItem = {
id: string;
name: string;
poiType: PoiType;
distanceMeters: number;
walkingMinutes: number;
ttlSec: number;
confidence: number;
isTransit: boolean;
};
export type PoiData = {
pois: PoiItem[];
};
export type PoiRequest = {
location: LocationInput;
now: number;
config?: PoiDataSourceConfig;
};
export type PoiSnapshot = {
data: PoiData;
diagnostics: Record<string, string>;
};
export type PoiDataSource = DataSource<PoiRequest, PoiData>;

View File

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

View File

@@ -0,0 +1,147 @@
import { DataSourceError } from "../common/errors";
import type { Diagnostics } from "../common/types";
import type {
StockData,
StockDataSource,
StockDataSourceConfig,
StockQuote,
StockRequest,
} from "./types";
type CacheEntry = {
timestamp: number;
data: StockData;
};
const defaultConfig: Required<StockDataSourceConfig> = {
maxSymbols: 5,
cacheValiditySec: 300,
ttlSec: 600,
};
let cache: CacheEntry | null = null;
const resolveConfig = (
base: StockDataSourceConfig,
override?: StockDataSourceConfig,
): Required<StockDataSourceConfig> => ({
...defaultConfig,
...base,
...(override ?? {}),
});
const fetchQuote = async (
symbol: string,
): Promise<StockQuote | null> => {
const encodedSymbol = encodeURIComponent(symbol);
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodedSymbol}?interval=1d&range=1d`;
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
Accept: "application/json",
},
});
if (!response.ok) {
throw new DataSourceError(
"network_failed",
`HTTP ${response.status}`,
);
}
const payload = (await response.json()) as {
chart?: {
result?: Array<{
meta?: {
symbol?: string;
shortName?: string;
regularMarketPrice?: number;
chartPreviousClose?: number;
previousClose?: number;
marketState?: string;
};
}>;
error?: unknown;
};
};
const result = payload.chart?.result?.[0];
const meta = result?.meta;
const price = meta?.regularMarketPrice;
if (!meta || price == null) {
return null;
}
const previousClose = meta.chartPreviousClose ?? meta.previousClose ?? price;
const change = price - previousClose;
const changePercent = previousClose > 0 ? (change / previousClose) * 100 : 0;
return {
symbol: meta.symbol ?? symbol,
shortName: meta.shortName ?? meta.symbol ?? symbol,
price,
change,
changePercent,
marketState: meta.marketState ?? "CLOSED",
};
};
const fetchStockData = async (
request: StockRequest,
config: StockDataSourceConfig,
): Promise<{ data: StockData; diagnostics: Diagnostics }> => {
const resolvedConfig = resolveConfig(config, request.config);
const diagnostics: Diagnostics = {
now: String(request.now),
symbols_requested: request.symbols.join(","),
max_symbols: String(resolvedConfig.maxSymbols),
};
if (request.symbols.length === 0) {
diagnostics.result = "no_symbols";
return { data: { quotes: [] }, diagnostics };
}
const limitedSymbols = request.symbols.slice(0, resolvedConfig.maxSymbols);
diagnostics.symbols_queried = limitedSymbols.join(",");
if (cache && request.now - cache.timestamp < resolvedConfig.cacheValiditySec) {
diagnostics.source = "cache";
diagnostics.cache_age_sec = String(request.now - cache.timestamp);
return { data: cache.data, diagnostics };
}
const quotes: StockQuote[] = [];
const fetchErrors: string[] = [];
for (const symbol of limitedSymbols) {
try {
const quote = await fetchQuote(symbol);
if (quote) {
quotes.push(quote);
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
fetchErrors.push(`${symbol}: ${message}`);
}
}
diagnostics.source = "network";
diagnostics.quotes_returned = String(quotes.length);
if (fetchErrors.length > 0) {
diagnostics.fetch_errors = fetchErrors.join("; ");
}
const data = { quotes };
cache = { timestamp: request.now, data };
return { data, diagnostics };
};
export const createStockDataSource = (
config: StockDataSourceConfig = {},
): StockDataSource => ({
dataWithDiagnostics: (request) => fetchStockData(request, config),
});

View File

@@ -0,0 +1,40 @@
import type { DataSource } from "../common/types";
export type StockDataSourceConfig = {
/**
* Maximum number of symbols to request per fetch.
* Defaults to 5.
*/
maxSymbols?: number;
/**
* In-memory cache validity (seconds) before re-fetching.
* Defaults to 300.
*/
cacheValiditySec?: number;
/**
* Suggested TTL (seconds) for downstream items built from quotes.
* Defaults to 600.
*/
ttlSec?: number;
};
export type StockQuote = {
symbol: string;
shortName: string;
price: number;
change: number;
changePercent: number;
marketState: string;
};
export type StockData = {
quotes: StockQuote[];
};
export type StockRequest = {
symbols: string[];
now: number;
config?: StockDataSourceConfig;
};
export type StockDataSource = DataSource<StockRequest, StockData>;

View File

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

View File

@@ -0,0 +1,165 @@
import { DataSourceError } from "../common/errors";
import type { Diagnostics } from "../common/types";
import type {
TflData,
TflDataSource,
TflDataSourceConfig,
TflDisruption,
TflRequest,
TflStatusSeverity,
} from "./types";
import { TFL_STATUS_SEVERITY } from "./types";
type CacheEntry = {
timestamp: number;
data: TflData;
};
const defaultConfig: Required<TflDataSourceConfig> = {
cacheValiditySec: 120,
ttlSec: 300,
maxDisruptions: 3,
};
const ignoredSeverities = new Set<TflStatusSeverity>([
TFL_STATUS_SEVERITY.PlannedClosure,
TFL_STATUS_SEVERITY.PartClosure,
TFL_STATUS_SEVERITY.GoodService,
]);
const majorSeverities = new Set<TflStatusSeverity>([
TFL_STATUS_SEVERITY.Closed,
TFL_STATUS_SEVERITY.Suspended,
TFL_STATUS_SEVERITY.PartSuspended,
TFL_STATUS_SEVERITY.SevereDelays,
]);
let cache: CacheEntry | null = null;
const resolveConfig = (
base: TflDataSourceConfig,
override?: TflDataSourceConfig,
): Required<TflDataSourceConfig> => ({
...defaultConfig,
...base,
...(override ?? {}),
});
const fetchTflData = async (
request: TflRequest,
config: TflDataSourceConfig,
): Promise<{ data: TflData; diagnostics: Diagnostics }> => {
const resolvedConfig = resolveConfig(config, request.config);
const diagnostics: Diagnostics = {
now: String(request.now),
cache_validity_sec: String(resolvedConfig.cacheValiditySec),
};
if (cache && request.now - cache.timestamp < resolvedConfig.cacheValiditySec) {
diagnostics.source = "cache";
diagnostics.cache_age_sec = String(request.now - cache.timestamp);
return { data: cache.data, diagnostics };
}
const url = "https://api.tfl.gov.uk/Line/Mode/tube,elizabeth-line/Status";
const response = await fetch(url, {
headers: {
Accept: "application/json",
},
});
diagnostics.http_status = String(response.status);
if (!response.ok) {
throw new DataSourceError(
"network_failed",
`HTTP ${response.status}`,
diagnostics,
);
}
const lines = (await response.json()) as Array<{
id: string;
name: string;
lineStatuses?: Array<{
statusSeverity: number;
statusSeverityDescription: string;
reason?: string | null;
disruption?: { description?: string | null } | null;
}>;
}>;
diagnostics.source = "network";
diagnostics.lines_returned = String(lines.length);
const disruptions: TflDisruption[] = [];
const seenLines = new Set<string>();
for (const line of lines) {
if (seenLines.has(line.id)) {
continue;
}
const statuses = line.lineStatuses ?? [];
for (const status of statuses) {
if (ignoredSeverities.has(status.statusSeverity)) {
continue;
}
seenLines.add(line.id);
const isMajor = majorSeverities.has(status.statusSeverity);
disruptions.push({
id: `${line.id}:${status.statusSeverity}`,
lineName: line.name,
lineId: line.id,
severity: status.statusSeverity,
severityDescription: status.statusSeverityDescription,
reason: status.reason ?? status.disruption?.description ?? null,
isMajor,
});
break;
}
}
disruptions.sort((a, b) => a.severity - b.severity);
const limited = disruptions.slice(0, resolvedConfig.maxDisruptions);
diagnostics.disruptions_found = String(disruptions.length);
diagnostics.disruptions_returned = String(limited.length);
const data = { disruptions: limited };
cache = { timestamp: request.now, data };
return { data, diagnostics };
};
export const createTflDataSource = (
config: TflDataSourceConfig = {},
): TflDataSource => ({
dataWithDiagnostics: (request) => fetchTflData(request, config),
});
export const formatTflDisruptionTitle = (disruption: TflDisruption) => {
let name = disruption.lineName;
name = name.replace(" & City", "");
name = name.replace("Hammersmith", "H'smith");
name = name.replace("Metropolitan", "Met");
name = name.replace("Waterloo", "W'loo");
name = name.replace("Elizabeth line", "Eliz.");
let severity = disruption.severityDescription;
severity = severity.replace("Minor Delays", "Delays");
severity = severity.replace("Severe Delays", "Severe");
severity = severity.replace("Part Closure", "Part Closed");
severity = severity.replace("Part Suspended", "Part Susp.");
return `${name}: ${severity}`;
};
export const formatTflDisruptionSubtitle = (disruption: TflDisruption) => {
if (!disruption.reason) {
return "Check TFL for details";
}
const first = disruption.reason
.split(".")
.map((part) => part.trim())
.find((part) => part.length > 0);
return first && first.length > 0 ? first : "Check TFL for details";
};

View File

@@ -0,0 +1,53 @@
import type { DataSource } from "../common/types";
export type TflDataSourceConfig = {
/**
* In-memory cache validity (seconds) before re-fetching.
* Defaults to 120.
*/
cacheValiditySec?: number;
/**
* Suggested TTL (seconds) for downstream disruption items.
* Defaults to 300.
*/
ttlSec?: number;
/**
* Maximum number of disruptions to return.
* Defaults to 3.
*/
maxDisruptions?: number;
};
export const TFL_STATUS_SEVERITY = {
Closed: 1,
Suspended: 2,
PartSuspended: 3,
PlannedClosure: 4,
PartClosure: 5,
SevereDelays: 6,
GoodService: 10,
} as const;
export type TflStatusSeverity =
(typeof TFL_STATUS_SEVERITY)[keyof typeof TFL_STATUS_SEVERITY];
export type TflDisruption = {
id: string;
lineName: string;
lineId: string;
severity: number;
severityDescription: string;
reason?: string | null;
isMajor: boolean;
};
export type TflData = {
disruptions: TflDisruption[];
};
export type TflRequest = {
now: number;
config?: TflDataSourceConfig;
};
export type TflDataSource = DataSource<TflRequest, TflData>;

View File

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

View File

@@ -0,0 +1,78 @@
import type { DataSource } from "../common/types";
import type { LocationInput } from "../common/types";
export type WeatherAlertConfig = {
/**
* Lookahead window (seconds) for rain alerts.
* Defaults to 1200 (20 minutes) on iOS.
*/
rainLookaheadSec?: number;
/**
* Minimum precipitation chance (0-1) to trigger rain alerts.
* Defaults to 0.5 on iOS.
*/
precipitationChanceThreshold?: number;
/**
* Gust threshold in meters per second for wind alerts.
* Defaults to null (disabled) on iOS.
*/
gustThresholdMps?: number | null;
/**
* TTL (seconds) for rain alerts.
* Defaults to 1800 on iOS.
*/
rainTtlSec?: number;
/**
* TTL (seconds) for wind alerts.
* Defaults to 3600 on iOS.
*/
windTtlSec?: number;
};
export type WeatherWarning = {
id: string;
title: string;
subtitle: string;
ttlSec: number;
confidence: number;
};
export type WeatherCurrent = {
temperatureC: number;
feelsLikeC: number;
condition: string;
};
export type WeatherRainSource = "minutely" | "hourlyApprox";
export type WeatherRainSoon = {
startAt: number;
ttlSec: number;
source: WeatherRainSource;
};
export type WeatherWindAlert = {
gustMps: number;
thresholdMps: number;
ttlSec: number;
};
export type WeatherData = {
current?: WeatherCurrent | null;
rainSoon?: WeatherRainSoon | null;
windAlert?: WeatherWindAlert | null;
warnings: WeatherWarning[];
};
export type WeatherRequest = {
location: LocationInput;
now: number;
config?: WeatherAlertConfig;
};
export type WeatherSnapshot = {
data: WeatherData;
diagnostics: Record<string, string>;
};
export type WeatherDataSource = DataSource<WeatherRequest, WeatherData>;

View File

@@ -0,0 +1,8 @@
import { UnsupportedError } from "../common/errors";
import type { WeatherDataSource } from "./types";
export const createWeatherDataSource = (): WeatherDataSource => ({
dataWithDiagnostics: async () => {
throw new UnsupportedError("Weather data source is not supported on Android.");
},
});

View File

@@ -0,0 +1,38 @@
import { requireNativeModule } from "expo";
import type {
WeatherAlertConfig,
WeatherDataSource,
WeatherRequest,
WeatherSnapshot,
} from "./types";
type WeatherDataSourceNativeModule = {
getWeatherData: (request: WeatherRequest) => Promise<WeatherSnapshot>;
};
const WeatherDataSourceNative =
requireNativeModule<WeatherDataSourceNativeModule>("WeatherDataSource");
const mergeConfig = (
base: WeatherAlertConfig,
override?: WeatherAlertConfig,
) => ({
...base,
...(override ?? {}),
});
export const createWeatherDataSource = (
config: WeatherAlertConfig = {},
): WeatherDataSource => ({
dataWithDiagnostics: async (request: WeatherRequest) => {
const mergedConfig = mergeConfig(config, request.config);
const resolvedRequest = {
...request,
config:
Object.keys(mergedConfig).length > 0 ? mergedConfig : undefined,
};
const snapshot =
await WeatherDataSourceNative.getWeatherData(resolvedRequest);
return { data: snapshot.data, diagnostics: snapshot.diagnostics };
},
});

View File

@@ -0,0 +1,14 @@
import { Platform } from "react-native";
import type { WeatherAlertConfig, WeatherDataSource } from "./types";
type CreateWeatherDataSource = (
config?: WeatherAlertConfig,
) => WeatherDataSource;
const impl: { createWeatherDataSource: CreateWeatherDataSource } =
Platform.OS === "ios"
? require("./weather.ios")
: require("./weather.android");
export const createWeatherDataSource = impl.createWeatherDataSource;

View File

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