rename: Jarvis -> Jervis target, scheme, and folder throughout
Some checks are pending
CI / Build · Debug (push) Waiting to run
CI / Build · Release (push) Waiting to run

The bundle-ID and display-name renames weren't enough — the actual
Xcode target/product name was still "Jarvis", which drives CFBundleName,
Xcode's Organizer archive list, the .xcodeproj filename, and the scheme
name. Renamed all the way through:

- project.yml: top-level name, target key, PRODUCT_NAME, source/info paths
- Jarvis/ -> Jervis/ (source folder, git-tracked as renames, no content
  diffs on the moved files)
- .gitignore, CI workflows, README, CONTRIBUTING: Jarvis.xcodeproj /
  scheme Jarvis -> Jervis.xcodeproj / scheme Jervis

Verified: xcodebuild -scheme Jervis succeeds, produces Jervis.app,
CFBundleName/CFBundleExecutable/CFBundleDisplayName all read "Jervis".
CFBundleIdentifier intentionally stays com.kisani.jarvis (per prior
decision — bundle ID isn't user-facing anywhere including Organizer).

Swift type names (JarvisApp, JarvisWordmark, etc.) and the Gitea repo
name/URL are unchanged — pure internal source identifiers, not user or
developer-facing product identity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kutesir
2026-07-13 03:49:31 +03:00
parent 3a6f1bbdd8
commit bd632a1592
46 changed files with 20 additions and 19 deletions

View File

@@ -0,0 +1,201 @@
// NotificationManager.swift
// Jarvis local notifications: morning/evening briefings (weather + top signal
// stories) and breaking-story alerts. Refreshes briefing content on foreground and
// via a background-refresh task.
//
// iOS limit: local notifications carry the content set at *schedule* time. We keep
// them reasonably fresh by rebuilding on foreground + background refresh. Truly live
// pushes while the app is closed would need a push server (APNs / ntfy) see
// docs/BACKLOG.md.
import Foundation
import Combine
import UserNotifications
import BackgroundTasks
enum BriefingPeriod { case morning, evening
var greeting: String { self == .morning ? "Good morning" : "Good evening" }
var requestID: String { self == .morning ? "briefing.morning" : "briefing.evening" }
}
/// Background-refresh task identifier (also declared in Info.plist
/// BGTaskSchedulerPermittedIdentifiers and registered in AppDelegate).
let jarvisBriefingRefreshID = "com.kisani.jarvis.briefing.refresh"
@MainActor
final class NotificationManager: NSObject, ObservableObject {
static let shared = NotificationManager()
@Published var authStatus: UNAuthorizationStatus = .notDetermined
private let center = UNUserNotificationCenter.current()
private let settings = NotificationSettings.shared
private let api = APIClient.shared
private var cancellables = Set<AnyCancellable>()
private override init() { super.init() }
// MARK: - Lifecycle
func bootstrap() async {
center.delegate = self
await refreshAuthStatus()
// Prompt for permission on first launch (.notDetermined).
// Re-requesting after denial is a no-op (iOS ignores it) safe to call every launch.
if authStatus == .notDetermined {
await requestAuthorization()
}
subscribeToBreakingEvents()
await applySettings()
}
// MARK: - Authorization
func refreshAuthStatus() async {
authStatus = await center.notificationSettings().authorizationStatus
}
@discardableResult
func requestAuthorization() async -> Bool {
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
settings.enabled = granted
await refreshAuthStatus()
if granted { await applySettings() }
return granted
}
private var isAuthorized: Bool { authStatus == .authorized || authStatus == .provisional }
// MARK: - Apply settings (reschedule everything)
func applySettings() async {
guard settings.enabled, isAuthorized else {
center.removePendingNotificationRequests(withIdentifiers: [
BriefingPeriod.morning.requestID, BriefingPeriod.evening.requestID])
return
}
await resolveLocationIfNeeded()
await scheduleBriefings()
scheduleBackgroundRefresh()
}
private func resolveLocationIfNeeded() async {
guard settings.weatherEnabled, !settings.locationName.isEmpty,
!settings.hasResolvedLocation else { return }
if let geo = await WeatherService.geocode(settings.locationName) {
settings.latitude = geo.latitude
settings.longitude = geo.longitude
settings.resolvedPlace = [geo.name, geo.country].compactMap { $0 }.joined(separator: ", ")
}
}
// MARK: - Briefings
func scheduleBriefings() async {
center.removePendingNotificationRequests(withIdentifiers: [
BriefingPeriod.morning.requestID, BriefingPeriod.evening.requestID])
if settings.morningEnabled {
await schedule(.morning, hour: settings.morningHour, minute: settings.morningMinute)
}
if settings.eveningEnabled {
await schedule(.evening, hour: settings.eveningHour, minute: settings.eveningMinute)
}
}
private func schedule(_ period: BriefingPeriod, hour: Int, minute: Int) async {
let content = await buildBriefing(period)
var comps = DateComponents(); comps.hour = hour; comps.minute = minute
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
let req = UNNotificationRequest(identifier: period.requestID, content: content, trigger: trigger)
try? await center.add(req)
}
/// Build the "Jarvis" briefing: greeting + weather/rain + top stories to watch.
private func buildBriefing(_ period: BriefingPeriod) async -> UNMutableNotificationContent {
let content = UNMutableNotificationContent()
content.title = period.greeting
content.sound = .default
var lines: [String] = []
if settings.weatherEnabled, let lat = settings.latitude, let lon = settings.longitude,
let w = await WeatherService.forecast(latitude: lat, longitude: lon,
place: settings.resolvedPlace ?? settings.locationName) {
lines.append(w.summary)
}
let top = await topStories(limit: 3)
if !top.isEmpty {
lines.append("Needs your attention:")
for s in top { lines.append("\(s.headline) (\(s.signalScore))") }
} else if lines.isEmpty {
lines.append("No new high-signal stories right now.")
}
content.body = lines.joined(separator: "\n")
if let first = top.first {
content.subtitle = "Top signal · \(Topic.label(first.topic))"
}
return content
}
private func topStories(limit: Int) async -> [StorySummary] {
guard let page = try? await api.fetchStories(limit: 10) else { return [] }
return Array(page.data.sorted { $0.signalScore > $1.signalScore }.prefix(limit))
}
/// Fire a one-off briefing shortly (used by the "Send a test briefing" button).
func sendTestBriefing() async {
guard isAuthorized else { return }
let content = await buildBriefing(.morning)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false)
try? await center.add(UNNotificationRequest(identifier: "briefing.test.\(UUID().uuidString)",
content: content, trigger: trigger))
}
// MARK: - Breaking alerts
private func subscribeToBreakingEvents() {
WebSocketManager.shared.events
.receive(on: DispatchQueue.main)
.sink { [weak self] event in self?.handle(event) }
.store(in: &cancellables)
}
private func handle(_ event: WSEvent) {
guard settings.enabled, settings.breakingEnabled, isAuthorized,
event.type == .storyCreated,
let score = event.signalScore, score >= settings.breakingMinSignal,
let headline = event.headline else { return }
notifyBreaking(headline: headline, topic: event.topic, score: score)
}
func notifyBreaking(headline: String, topic: String?, score: Int) {
let content = UNMutableNotificationContent()
content.title = "🔴 Breaking" + (topic.map { " · \(Topic.label($0))" } ?? "")
content.body = "\(headline) (signal \(score))"
content.sound = .default
let req = UNNotificationRequest(identifier: "breaking.\(UUID().uuidString)",
content: content, trigger: nil)
center.add(req)
}
// MARK: - Background refresh
func scheduleBackgroundRefresh() {
let request = BGAppRefreshTaskRequest(identifier: jarvisBriefingRefreshID)
request.earliestBeginDate = Date(timeIntervalSinceNow: 2 * 3600)
try? BGTaskScheduler.shared.submit(request)
}
}
// MARK: - Foreground presentation
extension NotificationManager: UNUserNotificationCenterDelegate {
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async
-> UNNotificationPresentationOptions {
[.banner, .sound, .list]
}
}

View File

@@ -0,0 +1,75 @@
// NotificationSettings.swift
// Jarvis persisted notification preferences: breaking alerts + morning/evening
// briefings with weather.
import Foundation
@MainActor
final class NotificationSettings: ObservableObject {
static let shared = NotificationSettings()
private let key = "jarvis_notifications_v1"
// Master intent (actual OS permission tracked by NotificationManager).
@Published var enabled: Bool { didSet { persist() } }
// Breaking / new-story alerts
@Published var breakingEnabled: Bool { didSet { persist() } }
@Published var breakingMinSignal: Int { didSet { persist() } }
// Morning briefing
@Published var morningEnabled: Bool { didSet { persist() } }
@Published var morningHour: Int { didSet { persist() } }
@Published var morningMinute: Int { didSet { persist() } }
// Evening briefing
@Published var eveningEnabled: Bool { didSet { persist() } }
@Published var eveningHour: Int { didSet { persist() } }
@Published var eveningMinute: Int { didSet { persist() } }
// Weather
@Published var weatherEnabled: Bool { didSet { persist() } }
@Published var locationName: String { didSet { persist() } }
@Published var latitude: Double? { didSet { persist() } }
@Published var longitude: Double? { didSet { persist() } }
@Published var resolvedPlace: String? { didSet { persist() } }
private struct Stored: Codable {
var enabled, breakingEnabled, morningEnabled, eveningEnabled, weatherEnabled: Bool
var breakingMinSignal, morningHour, morningMinute, eveningHour, eveningMinute: Int
var locationName: String
var latitude, longitude: Double?
var resolvedPlace: String?
}
private init() {
if let data = UserDefaults.standard.data(forKey: key),
let s = try? JSONDecoder().decode(Stored.self, from: data) {
enabled = s.enabled
breakingEnabled = s.breakingEnabled; breakingMinSignal = s.breakingMinSignal
morningEnabled = s.morningEnabled; morningHour = s.morningHour; morningMinute = s.morningMinute
eveningEnabled = s.eveningEnabled; eveningHour = s.eveningHour; eveningMinute = s.eveningMinute
weatherEnabled = s.weatherEnabled; locationName = s.locationName
latitude = s.latitude; longitude = s.longitude; resolvedPlace = s.resolvedPlace
} else {
enabled = true
breakingEnabled = true; breakingMinSignal = 65
morningEnabled = true; morningHour = 7; morningMinute = 0
eveningEnabled = true; eveningHour = 18; eveningMinute = 0
weatherEnabled = true; locationName = ""
latitude = nil; longitude = nil; resolvedPlace = nil
}
}
private func persist() {
let s = Stored(enabled: enabled, breakingEnabled: breakingEnabled,
morningEnabled: morningEnabled, eveningEnabled: eveningEnabled,
weatherEnabled: weatherEnabled, breakingMinSignal: breakingMinSignal,
morningHour: morningHour, morningMinute: morningMinute,
eveningHour: eveningHour, eveningMinute: eveningMinute,
locationName: locationName, latitude: latitude, longitude: longitude,
resolvedPlace: resolvedPlace)
if let data = try? JSONEncoder().encode(s) { UserDefaults.standard.set(data, forKey: key) }
}
var hasResolvedLocation: Bool { latitude != nil && longitude != nil }
}

View File

@@ -0,0 +1,93 @@
// WeatherService.swift
// Jarvis weather via Open-Meteo (free, no API key, no entitlement). Used to put
// a "will it rain today" line in the morning/evening briefing.
import Foundation
struct DailyWeather {
let place: String
let tempMax: Double
let tempMin: Double
let precipProbability: Int // %
let code: Int // WMO weather code
var willRain: Bool { precipProbability >= 40 }
/// e.g. "Kampala 26°/18°, light rain · 70% take an umbrella "
var summary: String {
let temps = "\(Int(tempMax.rounded()))°/\(Int(tempMin.rounded()))°"
let cond = WeatherService.codeText(code)
let rain = "\(precipProbability)% rain"
let tail = willRain ? " — take an umbrella ☔️" : ""
return "\(place) \(temps), \(cond) · \(rain)\(tail)"
}
}
enum WeatherService {
struct GeoResult: Decodable { let latitude: Double; let longitude: Double; let name: String; let country: String? }
private struct GeoResponse: Decodable { let results: [GeoResult]? }
private struct ForecastResponse: Decodable {
struct Daily: Decodable {
let temperature_2m_max: [Double]
let temperature_2m_min: [Double]
let precipitation_probability_max: [Int?]
let weathercode: [Int]
}
let daily: Daily
}
private static let session = URLSession(configuration: .default)
/// Geocode a free-text place to coordinates.
static func geocode(_ query: String) async -> GeoResult? {
let q = query.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty,
var c = URLComponents(string: "https://geocoding-api.open-meteo.com/v1/search")
else { return nil }
c.queryItems = [.init(name: "name", value: q), .init(name: "count", value: "1"),
.init(name: "language", value: "en"), .init(name: "format", value: "json")]
guard let url = c.url else { return nil }
do {
let (data, _) = try await session.data(from: url)
return try JSONDecoder().decode(GeoResponse.self, from: data).results?.first
} catch { return nil }
}
/// Today's forecast for coordinates.
static func forecast(latitude: Double, longitude: Double, place: String) async -> DailyWeather? {
guard var c = URLComponents(string: "https://api.open-meteo.com/v1/forecast") else { return nil }
c.queryItems = [
.init(name: "latitude", value: "\(latitude)"),
.init(name: "longitude", value: "\(longitude)"),
.init(name: "daily", value: "temperature_2m_max,temperature_2m_min,precipitation_probability_max,weathercode"),
.init(name: "timezone", value: "auto"),
.init(name: "forecast_days", value: "1"),
]
guard let url = c.url else { return nil }
do {
let (data, _) = try await session.data(from: url)
let r = try JSONDecoder().decode(ForecastResponse.self, from: data)
guard let tmax = r.daily.temperature_2m_max.first,
let tmin = r.daily.temperature_2m_min.first,
let code = r.daily.weathercode.first else { return nil }
let prob = r.daily.precipitation_probability_max.first.flatMap { $0 } ?? 0
return DailyWeather(place: place, tempMax: tmax, tempMin: tmin, precipProbability: prob, code: code)
} catch { return nil }
}
/// Coarse WMO weather-code words.
static func codeText(_ code: Int) -> String {
switch code {
case 0: return "clear"
case 1, 2: return "partly cloudy"
case 3: return "overcast"
case 45, 48: return "fog"
case 51, 53, 55, 56, 57: return "drizzle"
case 61, 63, 65, 66, 67: return "rain"
case 71, 73, 75, 77: return "snow"
case 80, 81, 82: return "rain showers"
case 95, 96, 99: return "thunderstorms"
default: return "mixed"
}
}
}