add poi type to feed item shape
This commit is contained in:
73
AGENTS.md
Normal file
73
AGENTS.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# agents.md
|
||||||
|
|
||||||
|
This monorepo contains two cooperating apps that together provide a “Google Now on Glass” experience. The system is deliberately split so that heavy computation and data access live on the phone, while Glass remains a lightweight, glance-first client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IrisCompanion (iOS)
|
||||||
|
|
||||||
|
**Role:** Context engine and system brain.
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
- Collect user context (location, time, calendar, environment).
|
||||||
|
- Generate a small, ranked set of “Now” cards.
|
||||||
|
- Decide what matters *right now* and suppress noise.
|
||||||
|
- Act as the source of truth for card content and ordering.
|
||||||
|
- Deliver updates to Glass over a low-power Bluetooth channel.
|
||||||
|
|
||||||
|
**Design principles:**
|
||||||
|
- Phone does the thinking; Glass does the displaying.
|
||||||
|
- Strong throttling and expiry to avoid notification fatigue.
|
||||||
|
- Cards are ephemeral and contextual, not a permanent feed.
|
||||||
|
- Battery-conscious: push updates only when context changes.
|
||||||
|
|
||||||
|
**What it is not:**
|
||||||
|
- Not a UI-heavy app.
|
||||||
|
- Not a timeline renderer.
|
||||||
|
- Not dependent on Glass-specific UI concepts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IrisGlass (Google Glass Explorer)
|
||||||
|
|
||||||
|
**Role:** Glanceable display and interaction surface.
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
- Maintain a lightweight connection to IrisCompanion.
|
||||||
|
- Receive and cache the current “Now” state.
|
||||||
|
- Present the most relevant card as a persistent Glass surface.
|
||||||
|
- Recover automatically from disconnects.
|
||||||
|
- Optionally expose limited interaction (dismiss, snooze).
|
||||||
|
|
||||||
|
**Design principles:**
|
||||||
|
- One primary surface, always available.
|
||||||
|
- Fast to glance, minimal user effort.
|
||||||
|
- No local decision-making about importance.
|
||||||
|
- Prefer replacement over accumulation of cards.
|
||||||
|
|
||||||
|
**What it is not:**
|
||||||
|
- Not a launcher replacement.
|
||||||
|
- Not a full feed manager.
|
||||||
|
- Not a context engine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System Philosophy
|
||||||
|
|
||||||
|
- **Single source of truth:** IrisCompanion decides; IrisGlass renders.
|
||||||
|
- **Low noise:** Fewer cards, higher confidence.
|
||||||
|
- **Glass-native:** Respect the timeline and LiveCard patterns.
|
||||||
|
- **Battery first:** Bluetooth over Wi-Fi; minimal background work.
|
||||||
|
- **Ephemeral by default:** Cards expire and disappear naturally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Evolution Notes
|
||||||
|
|
||||||
|
- Early development may use fixture data and diagnostic UIs.
|
||||||
|
- Additional card types can be added incrementally.
|
||||||
|
- More advanced ranking or summarisation may be introduced later.
|
||||||
|
- Timeline publishing should remain limited and intentional.
|
||||||
|
|
||||||
|
End of file.
|
||||||
|
|
||||||
@@ -415,7 +415,11 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = iris/Info.plist;
|
INFOPLIST_FILE = iris/Info.plist;
|
||||||
|
INFOPLIST_KEY_NSAppleMusicUsageDescription = "Allow access to your Now Playing information to show music on Glass.";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Allow Bluetooth to send context updates to Glass.";
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Allow Bluetooth to send context updates to Glass.";
|
||||||
|
INFOPLIST_KEY_NSCalendarsUsageDescription = "Allow calendar access to show upcoming events on Glass.";
|
||||||
|
INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Allow background location access to keep Glass context updated while moving.";
|
||||||
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Allow location access to compute context cards for Glass.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -446,7 +450,11 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = iris/Info.plist;
|
INFOPLIST_FILE = iris/Info.plist;
|
||||||
|
INFOPLIST_KEY_NSAppleMusicUsageDescription = "Allow access to your Now Playing information to show music on Glass.";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Allow Bluetooth to send context updates to Glass.";
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Allow Bluetooth to send context updates to Glass.";
|
||||||
|
INFOPLIST_KEY_NSCalendarsUsageDescription = "Allow calendar access to show upcoming events on Glass.";
|
||||||
|
INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Allow background location access to keep Glass context updated while moving.";
|
||||||
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Allow location access to compute context cards for Glass.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|||||||
@@ -7,17 +7,46 @@
|
|||||||
|
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import MapKit
|
||||||
|
|
||||||
struct POIDataSourceConfig: Sendable {
|
struct POIDataSourceConfig: Sendable {
|
||||||
var maxCandidates: Int = 3
|
var maxCandidates: Int = 2
|
||||||
|
var searchRadiusMeters: CLLocationDistance = 600
|
||||||
|
var transitBoostRadiusMeters: CLLocationDistance = 200
|
||||||
|
var walkingSpeedMps: Double = 1.4
|
||||||
|
var minTTLSeconds: Int = 60
|
||||||
|
var maxTTLSeconds: Int = 20 * 60
|
||||||
init() {}
|
init() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Placeholder POI source. Hook point for MapKit / local cache / server-driven POIs.
|
/// MapKit-backed POI source.
|
||||||
final class POIDataSource {
|
final class POIDataSource {
|
||||||
|
enum POIType: String, Codable, CaseIterable, Sendable {
|
||||||
|
case transit = "TRANSIT"
|
||||||
|
case cafe = "CAFE"
|
||||||
|
case food = "FOOD"
|
||||||
|
case park = "PARK"
|
||||||
|
case shopping = "SHOPPING"
|
||||||
|
case grocery = "GROCERY"
|
||||||
|
case fitness = "FITNESS"
|
||||||
|
case entertainment = "ENTERTAINMENT"
|
||||||
|
case health = "HEALTH"
|
||||||
|
case lodging = "LODGING"
|
||||||
|
case education = "EDUCATION"
|
||||||
|
case services = "SERVICES"
|
||||||
|
case other = "OTHER"
|
||||||
|
}
|
||||||
|
|
||||||
struct POI: Sendable, Equatable {
|
struct POI: Sendable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
|
let category: String
|
||||||
|
let poiType: POIType
|
||||||
|
let distanceMeters: CLLocationDistance
|
||||||
|
let walkingMinutes: Int
|
||||||
|
let ttlSec: Int
|
||||||
|
let confidence: Double
|
||||||
|
let isTransit: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
private let config: POIDataSourceConfig
|
private let config: POIDataSourceConfig
|
||||||
@@ -27,11 +56,216 @@ final class POIDataSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func data(for location: CLLocation, now: Int) async throws -> [POI] {
|
func data(for location: CLLocation, now: Int) async throws -> [POI] {
|
||||||
// Phase 1 stub: return nothing.
|
print("finding pois for location \(location)")
|
||||||
// (Still async/throws so the orchestrator can treat it uniformly with real implementations later.)
|
|
||||||
_ = config
|
|
||||||
_ = location
|
|
||||||
_ = now
|
_ = now
|
||||||
return []
|
|
||||||
|
let request = MKLocalPointsOfInterestRequest(
|
||||||
|
center: location.coordinate,
|
||||||
|
radius: config.searchRadiusMeters
|
||||||
|
)
|
||||||
|
request.pointOfInterestFilter = .includingAll
|
||||||
|
|
||||||
|
let items = try await search(request: request)
|
||||||
|
|
||||||
|
print(items)
|
||||||
|
|
||||||
|
var results: [POI] = []
|
||||||
|
results.reserveCapacity(min(items.count, config.maxCandidates))
|
||||||
|
var seen: Set<String> = []
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !name.isEmpty else { continue }
|
||||||
|
|
||||||
|
let coordinate = item.placemark.coordinate
|
||||||
|
let itemLocation = item.placemark.location ?? CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
|
||||||
|
let distance = location.distance(from: itemLocation)
|
||||||
|
guard distance <= config.searchRadiusMeters else { continue }
|
||||||
|
|
||||||
|
let mkCategory = item.pointOfInterestCategory
|
||||||
|
let categoryRaw = mkCategory?.rawValue
|
||||||
|
let category = categoryLabel(from: categoryRaw)
|
||||||
|
let poiType = poiType(for: mkCategory, categoryRaw: categoryRaw, name: name)
|
||||||
|
let isTransit = poiType == .transit
|
||||||
|
let walkingMinutes = walkingMinutes(for: distance)
|
||||||
|
let ttlSec = ttlSeconds(radius: config.searchRadiusMeters, speed: location.speed)
|
||||||
|
let confidence = confidenceScore(distance: distance, isTransit: isTransit)
|
||||||
|
|
||||||
|
let id = stableId(name: name, coordinate: coordinate, category: poiType.rawValue)
|
||||||
|
guard !seen.contains(id) else { continue }
|
||||||
|
seen.insert(id)
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
POI(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
category: category,
|
||||||
|
poiType: poiType,
|
||||||
|
distanceMeters: distance,
|
||||||
|
walkingMinutes: walkingMinutes,
|
||||||
|
ttlSec: ttlSec,
|
||||||
|
confidence: confidence,
|
||||||
|
isTransit: isTransit
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort {
|
||||||
|
if $0.confidence == $1.confidence {
|
||||||
|
return $0.distanceMeters < $1.distanceMeters
|
||||||
|
}
|
||||||
|
return $0.confidence > $1.confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
if results.count > config.maxCandidates {
|
||||||
|
return Array(results.prefix(config.maxCandidates))
|
||||||
|
}
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func search(request: MKLocalPointsOfInterestRequest) async throws -> [MKMapItem] {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let search = MKLocalSearch(request: request)
|
||||||
|
search.start { response, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: response?.mapItems ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stableId(name: String, coordinate: CLLocationCoordinate2D, category: String) -> String {
|
||||||
|
let lat = String(format: "%.5f", coordinate.latitude)
|
||||||
|
let lon = String(format: "%.5f", coordinate.longitude)
|
||||||
|
let rawCategory = category.lowercased()
|
||||||
|
let raw = "poi:\(rawCategory):\(name):\(lat):\(lon)"
|
||||||
|
return raw
|
||||||
|
.replacingOccurrences(of: " ", with: "_")
|
||||||
|
.replacingOccurrences(of: "\n", with: "_")
|
||||||
|
.replacingOccurrences(of: "\t", with: "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func walkingMinutes(for distanceMeters: CLLocationDistance) -> Int {
|
||||||
|
let speed = max(config.walkingSpeedMps, 0.5)
|
||||||
|
let seconds = distanceMeters / speed
|
||||||
|
let minutes = Int(ceil(seconds / 60.0))
|
||||||
|
return max(1, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ttlSeconds(radius: CLLocationDistance, speed: CLLocationSpeed) -> Int {
|
||||||
|
let safeSpeed = max(speed, 0.5)
|
||||||
|
let secondsToExit = radius / safeSpeed
|
||||||
|
let ttl = Int(secondsToExit.rounded())
|
||||||
|
return min(max(ttl, config.minTTLSeconds), config.maxTTLSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confidenceScore(distance: CLLocationDistance, isTransit: Bool) -> Double {
|
||||||
|
let normalized = max(0.0, min(1.0, 1.0 - (distance / max(config.searchRadiusMeters, 1))))
|
||||||
|
let base = isTransit ? 0.55 : 0.25
|
||||||
|
let range = isTransit ? 0.25 : 0.2
|
||||||
|
let transitBoost = (isTransit && distance <= config.transitBoostRadiusMeters) ? 0.1 : 0.0
|
||||||
|
return min(max(base + (range * normalized) + transitBoost, 0.0), 0.9)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func categoryLabel(from raw: String?) -> String {
|
||||||
|
guard var raw = raw else { return "Place" }
|
||||||
|
if raw.hasPrefix("MKPOICategory") {
|
||||||
|
raw = String(raw.dropFirst("MKPOICategory".count))
|
||||||
|
}
|
||||||
|
raw = raw.replacingOccurrences(of: "_", with: " ").replacingOccurrences(of: "-", with: " ")
|
||||||
|
if raw.isEmpty { return "Place" }
|
||||||
|
|
||||||
|
var result = ""
|
||||||
|
for scalar in raw.unicodeScalars {
|
||||||
|
if CharacterSet.uppercaseLetters.contains(scalar),
|
||||||
|
let last = result.unicodeScalars.last,
|
||||||
|
!CharacterSet.whitespaces.contains(last) {
|
||||||
|
result.append(" ")
|
||||||
|
}
|
||||||
|
result.append(String(scalar))
|
||||||
|
}
|
||||||
|
let trimmed = result.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? "Place" : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isTransitCategory(categoryRaw: String?, name: String) -> Bool {
|
||||||
|
let haystack = ([categoryRaw, name].compactMap { $0 }.joined(separator: " ")).lowercased()
|
||||||
|
let tokens = ["bus", "train", "rail", "subway", "metro", "tram", "ferry", "transit", "transport"]
|
||||||
|
return tokens.contains { haystack.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func poiType(for category: MKPointOfInterestCategory?, categoryRaw: String?, name: String) -> POIType {
|
||||||
|
if let category {
|
||||||
|
switch category {
|
||||||
|
case .publicTransport, .airport:
|
||||||
|
return .transit
|
||||||
|
case .cafe:
|
||||||
|
return .cafe
|
||||||
|
case .restaurant, .bakery, .brewery, .winery:
|
||||||
|
return .food
|
||||||
|
case .foodMarket:
|
||||||
|
return .grocery
|
||||||
|
case .park, .nationalPark, .beach, .campground, .marina:
|
||||||
|
return .park
|
||||||
|
case .fitnessCenter:
|
||||||
|
return .fitness
|
||||||
|
case .hotel:
|
||||||
|
return .lodging
|
||||||
|
case .school, .university, .library:
|
||||||
|
return .education
|
||||||
|
case .hospital, .pharmacy:
|
||||||
|
return .health
|
||||||
|
case .store:
|
||||||
|
return .shopping
|
||||||
|
case .movieTheater, .museum, .nightlife, .amusementPark, .aquarium, .stadium, .theater, .zoo:
|
||||||
|
return .entertainment
|
||||||
|
case .atm, .bank, .postOffice, .police, .fireStation, .parking, .laundry, .carRental, .evCharger, .restroom:
|
||||||
|
return .services
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTransitCategory(categoryRaw: categoryRaw, name: name) {
|
||||||
|
return .transit
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = name.lowercased()
|
||||||
|
if lower.contains("cafe") || lower.contains("coffee") {
|
||||||
|
return .cafe
|
||||||
|
}
|
||||||
|
if lower.contains("restaurant") || lower.contains("pizza") || lower.contains("food") {
|
||||||
|
return .food
|
||||||
|
}
|
||||||
|
if lower.contains("market") || lower.contains("grocery") {
|
||||||
|
return .grocery
|
||||||
|
}
|
||||||
|
if lower.contains("park") {
|
||||||
|
return .park
|
||||||
|
}
|
||||||
|
if lower.contains("gym") || lower.contains("fitness") {
|
||||||
|
return .fitness
|
||||||
|
}
|
||||||
|
if lower.contains("museum") || lower.contains("theater") || lower.contains("cinema") || lower.contains("zoo") {
|
||||||
|
return .entertainment
|
||||||
|
}
|
||||||
|
if lower.contains("school") || lower.contains("university") || lower.contains("college") {
|
||||||
|
return .education
|
||||||
|
}
|
||||||
|
if lower.contains("hotel") {
|
||||||
|
return .lodging
|
||||||
|
}
|
||||||
|
if lower.contains("hospital") || lower.contains("pharmacy") {
|
||||||
|
return .health
|
||||||
|
}
|
||||||
|
if lower.contains("mall") || lower.contains("store") || lower.contains("shop") {
|
||||||
|
return .shopping
|
||||||
|
}
|
||||||
|
|
||||||
|
return .other
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>MKDirectionsApplicationSupportedModes</key>
|
||||||
<string>Allow Bluetooth to send context updates to Glass.</string>
|
<array/>
|
||||||
<key>NSCalendarsUsageDescription</key>
|
|
||||||
<string>Allow calendar access to show upcoming events on Glass.</string>
|
|
||||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||||
<string>Allow full calendar access to show upcoming events on Glass.</string>
|
<string>Allow full calendar access to show upcoming events on Glass.</string>
|
||||||
<key>NSAppleMusicUsageDescription</key>
|
|
||||||
<string>Allow access to your Now Playing information to show music on Glass.</string>
|
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
|
||||||
<string>Allow location access to compute context cards for Glass.</string>
|
|
||||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
|
||||||
<string>Allow background location access to keep Glass context updated while moving.</string>
|
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>bluetooth-peripheral</string>
|
<string>bluetooth-peripheral</string>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ struct FeedItem: Codable, Equatable {
|
|||||||
let ttlSec: Int
|
let ttlSec: Int
|
||||||
let condition: WeatherKit.WeatherCondition?
|
let condition: WeatherKit.WeatherCondition?
|
||||||
let startsAt: Int?
|
let startsAt: Int?
|
||||||
|
let poiType: POIDataSource.POIType?
|
||||||
let bucket: Bucket
|
let bucket: Bucket
|
||||||
let actions: [String]
|
let actions: [String]
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ struct FeedItem: Codable, Equatable {
|
|||||||
case ttlSec = "ttl_sec"
|
case ttlSec = "ttl_sec"
|
||||||
case condition
|
case condition
|
||||||
case startsAt = "starts_at"
|
case startsAt = "starts_at"
|
||||||
|
case poiType = "poi_type"
|
||||||
case bucket
|
case bucket
|
||||||
case actions
|
case actions
|
||||||
}
|
}
|
||||||
@@ -60,6 +62,7 @@ struct FeedItem: Codable, Equatable {
|
|||||||
ttlSec: Int,
|
ttlSec: Int,
|
||||||
condition: WeatherKit.WeatherCondition? = nil,
|
condition: WeatherKit.WeatherCondition? = nil,
|
||||||
startsAt: Int? = nil,
|
startsAt: Int? = nil,
|
||||||
|
poiType: POIDataSource.POIType? = nil,
|
||||||
bucket: Bucket,
|
bucket: Bucket,
|
||||||
actions: [String]) {
|
actions: [String]) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -70,6 +73,7 @@ struct FeedItem: Codable, Equatable {
|
|||||||
self.ttlSec = ttlSec
|
self.ttlSec = ttlSec
|
||||||
self.condition = condition
|
self.condition = condition
|
||||||
self.startsAt = startsAt
|
self.startsAt = startsAt
|
||||||
|
self.poiType = poiType
|
||||||
self.bucket = bucket
|
self.bucket = bucket
|
||||||
self.actions = actions
|
self.actions = actions
|
||||||
}
|
}
|
||||||
@@ -85,6 +89,11 @@ struct FeedItem: Codable, Equatable {
|
|||||||
bucket = try container.decode(Bucket.self, forKey: .bucket)
|
bucket = try container.decode(Bucket.self, forKey: .bucket)
|
||||||
actions = try container.decode([String].self, forKey: .actions)
|
actions = try container.decode([String].self, forKey: .actions)
|
||||||
startsAt = try container.decodeIfPresent(Int.self, forKey: .startsAt)
|
startsAt = try container.decodeIfPresent(Int.self, forKey: .startsAt)
|
||||||
|
if let raw = try container.decodeIfPresent(String.self, forKey: .poiType) {
|
||||||
|
poiType = POIDataSource.POIType(rawValue: raw) ?? .other
|
||||||
|
} else {
|
||||||
|
poiType = nil
|
||||||
|
}
|
||||||
|
|
||||||
if let encoded = try container.decodeIfPresent(String.self, forKey: .condition) {
|
if let encoded = try container.decodeIfPresent(String.self, forKey: .condition) {
|
||||||
condition = WeatherKit.WeatherCondition.irisDecode(encoded)
|
condition = WeatherKit.WeatherCondition.irisDecode(encoded)
|
||||||
@@ -104,6 +113,9 @@ struct FeedItem: Codable, Equatable {
|
|||||||
try container.encode(bucket, forKey: .bucket)
|
try container.encode(bucket, forKey: .bucket)
|
||||||
try container.encode(actions, forKey: .actions)
|
try container.encode(actions, forKey: .actions)
|
||||||
try container.encodeIfPresent(startsAt, forKey: .startsAt)
|
try container.encodeIfPresent(startsAt, forKey: .startsAt)
|
||||||
|
if let poiType {
|
||||||
|
try container.encode(poiType.rawValue, forKey: .poiType)
|
||||||
|
}
|
||||||
if let condition {
|
if let condition {
|
||||||
try container.encode(condition.irisScreamingCase(), forKey: .condition)
|
try container.encode(condition.irisScreamingCase(), forKey: .condition)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ final class FeedStore {
|
|||||||
ttlSec: ttl,
|
ttlSec: ttl,
|
||||||
condition: card.condition,
|
condition: card.condition,
|
||||||
startsAt: card.startsAt,
|
startsAt: card.startsAt,
|
||||||
|
poiType: card.poiType,
|
||||||
bucket: card.bucket,
|
bucket: card.bucket,
|
||||||
actions: card.actions
|
actions: card.actions
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ final class HeuristicRanker {
|
|||||||
ttlSec: max(1, best.item.ttlSec),
|
ttlSec: max(1, best.item.ttlSec),
|
||||||
condition: best.item.condition,
|
condition: best.item.condition,
|
||||||
startsAt: best.item.startsAt,
|
startsAt: best.item.startsAt,
|
||||||
|
poiType: best.item.poiType,
|
||||||
bucket: .rightNow,
|
bucket: .rightNow,
|
||||||
actions: ["DISMISS"]
|
actions: ["DISMISS"]
|
||||||
)
|
)
|
||||||
|
|||||||
84
IrisCompanion/iris/Models/POIType.swift
Normal file
84
IrisCompanion/iris/Models/POIType.swift
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// POIType.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
// Created by Codex.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum POIType: String, Codable, CaseIterable, Sendable {
|
||||||
|
case animalService = "MKPOICategoryAnimalService"
|
||||||
|
case airport = "MKPOICategoryAirport"
|
||||||
|
case amusementPark = "MKPOICategoryAmusementPark"
|
||||||
|
case aquarium = "MKPOICategoryAquarium"
|
||||||
|
case atm = "MKPOICategoryATM"
|
||||||
|
case automotiveRepair = "MKPOICategoryAutomotiveRepair"
|
||||||
|
case bakery = "MKPOICategoryBakery"
|
||||||
|
case bank = "MKPOICategoryBank"
|
||||||
|
case baseball = "MKPOICategoryBaseball"
|
||||||
|
case basketball = "MKPOICategoryBasketball"
|
||||||
|
case beach = "MKPOICategoryBeach"
|
||||||
|
case beauty = "MKPOICategoryBeauty"
|
||||||
|
case bowling = "MKPOICategoryBowling"
|
||||||
|
case brewery = "MKPOICategoryBrewery"
|
||||||
|
case cafe = "MKPOICategoryCafe"
|
||||||
|
case campground = "MKPOICategoryCampground"
|
||||||
|
case carRental = "MKPOICategoryCarRental"
|
||||||
|
case castle = "MKPOICategoryCastle"
|
||||||
|
case conventionCenter = "MKPOICategoryConventionCenter"
|
||||||
|
case distillery = "MKPOICategoryDistillery"
|
||||||
|
case evCharger = "MKPOICategoryEVCharger"
|
||||||
|
case fairground = "MKPOICategoryFairground"
|
||||||
|
case fireStation = "MKPOICategoryFireStation"
|
||||||
|
case fishing = "MKPOICategoryFishing"
|
||||||
|
case fitnessCenter = "MKPOICategoryFitnessCenter"
|
||||||
|
case foodMarket = "MKPOICategoryFoodMarket"
|
||||||
|
case fortress = "MKPOICategoryFortress"
|
||||||
|
case gasStation = "MKPOICategoryGasStation"
|
||||||
|
case golf = "MKPOICategoryGolf"
|
||||||
|
case goKart = "MKPOICategoryGoKart"
|
||||||
|
case hiking = "MKPOICategoryHiking"
|
||||||
|
case hospital = "MKPOICategoryHospital"
|
||||||
|
case hotel = "MKPOICategoryHotel"
|
||||||
|
case kayaking = "MKPOICategoryKayaking"
|
||||||
|
case landmark = "MKPOICategoryLandmark"
|
||||||
|
case laundry = "MKPOICategoryLaundry"
|
||||||
|
case library = "MKPOICategoryLibrary"
|
||||||
|
case mailbox = "MKPOICategoryMailbox"
|
||||||
|
case marina = "MKPOICategoryMarina"
|
||||||
|
case miniGolf = "MKPOICategoryMiniGolf"
|
||||||
|
case movieTheater = "MKPOICategoryMovieTheater"
|
||||||
|
case museum = "MKPOICategoryMuseum"
|
||||||
|
case musicVenue = "MKPOICategoryMusicVenue"
|
||||||
|
case nationalMonument = "MKPOICategoryNationalMonument"
|
||||||
|
case nationalPark = "MKPOICategoryNationalPark"
|
||||||
|
case nightlife = "MKPOICategoryNightlife"
|
||||||
|
case park = "MKPOICategoryPark"
|
||||||
|
case parking = "MKPOICategoryParking"
|
||||||
|
case pharmacy = "MKPOICategoryPharmacy"
|
||||||
|
case planetarium = "MKPOICategoryPlanetarium"
|
||||||
|
case police = "MKPOICategoryPolice"
|
||||||
|
case postOffice = "MKPOICategoryPostOffice"
|
||||||
|
case publicTransport = "MKPOICategoryPublicTransport"
|
||||||
|
case restaurant = "MKPOICategoryRestaurant"
|
||||||
|
case restroom = "MKPOICategoryRestroom"
|
||||||
|
case rockClimbing = "MKPOICategoryRockClimbing"
|
||||||
|
case rvPark = "MKPOICategoryRVPark"
|
||||||
|
case school = "MKPOICategorySchool"
|
||||||
|
case skatePark = "MKPOICategorySkatePark"
|
||||||
|
case skating = "MKPOICategorySkating"
|
||||||
|
case skiing = "MKPOICategorySkiing"
|
||||||
|
case soccer = "MKPOICategorySoccer"
|
||||||
|
case spa = "MKPOICategorySpa"
|
||||||
|
case stadium = "MKPOICategoryStadium"
|
||||||
|
case store = "MKPOICategoryStore"
|
||||||
|
case surfing = "MKPOICategorySurfing"
|
||||||
|
case swimming = "MKPOICategorySwimming"
|
||||||
|
case tennis = "MKPOICategoryTennis"
|
||||||
|
case theater = "MKPOICategoryTheater"
|
||||||
|
case university = "MKPOICategoryUniversity"
|
||||||
|
case winery = "MKPOICategoryWinery"
|
||||||
|
case volleyball = "MKPOICategoryVolleyball"
|
||||||
|
case zoo = "MKPOICategoryZoo"
|
||||||
|
}
|
||||||
@@ -162,18 +162,23 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
return (true, "initial")
|
return (true, "initial")
|
||||||
}
|
}
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if let last = lastRecomputeAt, now.timeIntervalSince(last) > 15 * 60 {
|
let speed = max(0, location.speed)
|
||||||
return (true, "timer_15m")
|
let moving = speed >= 1.0
|
||||||
|
let maxInterval: TimeInterval = moving ? 5 * 60 : 15 * 60
|
||||||
|
if let last = lastRecomputeAt, now.timeIntervalSince(last) > maxInterval {
|
||||||
|
return (true, moving ? "timer_5m_moving" : "timer_15m")
|
||||||
}
|
}
|
||||||
if let lastLoc = lastRecomputeLocation {
|
if let lastLoc = lastRecomputeLocation {
|
||||||
let dist = location.distance(from: lastLoc)
|
let dist = location.distance(from: lastLoc)
|
||||||
if dist > 250 {
|
let distanceThreshold: CLLocationDistance = moving ? 100 : 250
|
||||||
return (true, "moved_250m")
|
if dist > distanceThreshold {
|
||||||
|
return (true, moving ? "moved_100m_moving" : "moved_250m")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let lastAcc = lastRecomputeAccuracy, location.horizontalAccuracy > 0, lastAcc > 0 {
|
if let lastAcc = lastRecomputeAccuracy, location.horizontalAccuracy > 0, lastAcc > 0 {
|
||||||
if lastAcc - location.horizontalAccuracy > 50 {
|
let improvementThreshold: CLLocationAccuracy = moving ? 30 : 50
|
||||||
return (true, "accuracy_improved_50m")
|
if lastAcc - location.horizontalAccuracy > improvementThreshold {
|
||||||
|
return (true, moving ? "accuracy_improved_30m" : "accuracy_improved_50m")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (false, "no_trigger")
|
return (false, "no_trigger")
|
||||||
@@ -217,6 +222,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
|
|
||||||
var rightNowCandidates: [HeuristicRanker.Ranked] = []
|
var rightNowCandidates: [HeuristicRanker.Ranked] = []
|
||||||
var calendarItems: [FeedItem] = []
|
var calendarItems: [FeedItem] = []
|
||||||
|
var poiItems: [FeedItem] = []
|
||||||
var weatherNowItem: FeedItem? = nil
|
var weatherNowItem: FeedItem? = nil
|
||||||
var fetchFailed = false
|
var fetchFailed = false
|
||||||
var wxDiagnostics: [String: String] = [:]
|
var wxDiagnostics: [String: String] = [:]
|
||||||
@@ -317,8 +323,29 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch poiRes {
|
switch poiRes {
|
||||||
case .success:
|
case .success(let pois):
|
||||||
break
|
if pois.isEmpty {
|
||||||
|
logger.info("no points of interests found")
|
||||||
|
}
|
||||||
|
for poi in pois.prefix(2) {
|
||||||
|
let subtitle = poiSubtitle(for: poi)
|
||||||
|
let confidence = min(max(poi.confidence, 0.0), 1.0)
|
||||||
|
let item = FeedItem(
|
||||||
|
id: poi.id,
|
||||||
|
type: .poiNearby,
|
||||||
|
title: poi.name.truncated(maxLength: TextConstraints.titleMax),
|
||||||
|
subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||||
|
priority: confidence,
|
||||||
|
ttlSec: max(1, poi.ttlSec),
|
||||||
|
condition: nil,
|
||||||
|
startsAt: nil,
|
||||||
|
poiType: poi.poiType,
|
||||||
|
bucket: .fyi,
|
||||||
|
actions: ["DISMISS"]
|
||||||
|
)
|
||||||
|
poiItems.append(item)
|
||||||
|
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true))
|
||||||
|
}
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
fetchFailed = true
|
fetchFailed = true
|
||||||
logger.error("poi fetch failed: \(String(describing: error), privacy: .public)")
|
logger.error("poi fetch failed: \(String(describing: error), privacy: .public)")
|
||||||
@@ -364,7 +391,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
lastWeatherDiagnostics = wxDiagnostics
|
lastWeatherDiagnostics = wxDiagnostics
|
||||||
lastCalendarDiagnostics = calDiagnostics
|
lastCalendarDiagnostics = calDiagnostics
|
||||||
|
|
||||||
logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
|
logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) poi_items=\(poiItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
|
||||||
|
|
||||||
if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil {
|
if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil {
|
||||||
let fallbackFeed = store.getFeed(now: nowEpoch)
|
let fallbackFeed = store.getFeed(now: nowEpoch)
|
||||||
@@ -399,6 +426,28 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
ttlSec: max(1, item.ttlSec),
|
ttlSec: max(1, item.ttlSec),
|
||||||
condition: item.condition,
|
condition: item.condition,
|
||||||
startsAt: item.startsAt,
|
startsAt: item.startsAt,
|
||||||
|
poiType: item.poiType,
|
||||||
|
bucket: .fyi,
|
||||||
|
actions: ["DISMISS"]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
let fyiPOI = poiItems
|
||||||
|
.filter { $0.id != winnerItem.id }
|
||||||
|
.filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) }
|
||||||
|
.prefix(2)
|
||||||
|
|
||||||
|
fyi.append(contentsOf: fyiPOI.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,
|
bucket: .fyi,
|
||||||
actions: ["DISMISS"]
|
actions: ["DISMISS"]
|
||||||
)
|
)
|
||||||
@@ -467,6 +516,22 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
meta: FeedMeta(winnerId: base.meta.winnerId, unreadCount: cards.count)
|
meta: FeedMeta(winnerId: base.meta.winnerId, unreadCount: cards.count)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func poiSubtitle(for poi: POIDataSource.POI) -> String {
|
||||||
|
let distance = distanceText(meters: poi.distanceMeters)
|
||||||
|
let walk = "\(poi.walkingMinutes) min walk"
|
||||||
|
let parts = [poi.category, distance, walk]
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
return parts.joined(separator: " • ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func distanceText(meters: CLLocationDistance) -> String {
|
||||||
|
if meters >= 1000 {
|
||||||
|
return String(format: "%.1f km", meters / 1000.0)
|
||||||
|
}
|
||||||
|
return "\(Int(meters.rounded())) m"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ContextOrchestrator: CLLocationManagerDelegate {
|
extension ContextOrchestrator: CLLocationManagerDelegate {
|
||||||
|
|||||||
Reference in New Issue
Block a user