Commit 8bc05d7e5c
Unsigned
Layout: unified · split
App/Sources/DailySummaryNotifications.swift added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | import SkyEngine | |
| 2 | import SwiftUI | |
| 3 | import UserNotifications | |
| 4 | ||
| 5 | /// Local notifications for the daily summary. The next seven are worked out on the device and | |
| 6 | /// replaced each time the app becomes active or the location or setting changes. | |
| 7 | enum DailySummaryNotifications { | |
| 8 | static let ids = (0..<7).map { "daily-summary-\($0)" } | |
| 9 | static let defaultMinutes = 18 * 60 | |
| 10 | ||
| 11 | @MainActor | |
| 12 | static func reschedule(_ model: SkyModel) async { | |
| 13 | let center = UNUserNotificationCenter.current() | |
| 14 | center.removePendingNotificationRequests(withIdentifiers: ids) | |
| 15 | let defaults = UserDefaults.standard | |
| 16 | guard defaults.bool(forKey: SettingsKey.dailySummary) else { return } | |
| 17 | await model.refreshPasses() | |
| 18 | let observer = model.observer, passes = model.passes | |
| 19 | let minutes = defaults.object(forKey: SettingsKey.dailySummaryMinutes) as? Int ?? defaultMinutes | |
| 20 | let planned = await Task.detached { plan(observer: observer, passes: passes, minutes: minutes) }.value | |
| 21 | let calendar = Calendar.current | |
| 22 | for (id, fire, summary) in planned { | |
| 23 | let content = UNMutableNotificationContent() | |
| 24 | content.title = summary.title | |
| 25 | content.body = summary.body | |
| 26 | let parts = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: fire) | |
| 27 | try? await center.add(UNNotificationRequest(identifier: id, content: content, trigger: UNCalendarNotificationTrigger(dateMatching: parts, repeats: false))) | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | nonisolated static func plan(observer: Observer, passes: [SatellitePass], minutes: Int, now: Date = .now) -> [(String, Date, DailySummary)] { | |
| 32 | let calendar = Calendar.current | |
| 33 | let today = calendar.startOfDay(for: now) | |
| 34 | let events = SkyEngine.events(from: today, days: 8, observer: observer) | |
| 35 | return ids.enumerated().compactMap { i, id in | |
| 36 | guard let day = calendar.date(byAdding: .day, value: i, to: today), | |
| 37 | let fire = calendar.date(bySettingHour: minutes / 60, minute: minutes % 60, second: 0, of: day), | |
| 38 | fire > now, | |
| 39 | let summary = try? DailySummary.make(at: fire, observer: observer, passes: passes, events: events) | |
| 40 | else { return nil } | |
| 41 | return (id, fire, summary) | |
| 42 | } | |
| 43 | } | |
| 44 | } | |
App/Sources/SettingsScreen.swift +43
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import Library |
| 2 | 2 | import SwiftUI |
| 3 | import UserNotifications | |
| 3 | 4 | |
| 4 | 5 | /// UserDefaults keys shared by the settings screen and the models that read them. |
| 5 | 6 | enum SettingsKey { |
| @@ -11,6 +12,9 @@ enum SettingsKey { | ||
| 11 | 12 | static let onboarded = "onboarded" |
| 12 | 13 | /// Whether the Sky tab is currently drawn in night mode (written by the Sky tab). |
| 13 | 14 | static let nightActive = "nightActive" |
| 15 | static let dailySummary = "dailySummary" | |
| 16 | /// Minutes after local midnight. | |
| 17 | static let dailySummaryMinutes = "dailySummaryMinutes" | |
| 14 | 18 | } |
| 15 | 19 | |
| 16 | 20 | struct City: Hashable, Identifiable { |
| @@ -59,6 +63,9 @@ struct SettingsScreen: View { | ||
| 59 | 63 | @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" |
| 60 | 64 | @Environment(\.dismiss) private var dismiss |
| 61 | 65 | @State private var alerts = AuroraAlerts.shared |
| 66 | @AppStorage(SettingsKey.dailySummary) private var dailySummary = false | |
| 67 | @AppStorage(SettingsKey.dailySummaryMinutes) private var dailySummaryMinutes = DailySummaryNotifications.defaultMinutes | |
| 68 | @State private var summaryStatus: String? | |
| 62 | 69 | var onChange: () -> Void |
| 63 | 70 | |
| 64 | 71 | var body: some View { |
| @@ -91,6 +98,20 @@ struct SettingsScreen: View { | ||
| 91 | 98 | } footer: { |
| 92 | 99 | Text("Moonrise notifies you when the aurora may be visible from your area at night. To do this it sends our server this device's notification token, a 5° band of geomagnetic latitude and your time zone offset, not your location.") |
| 93 | 100 | } |
| 101 | Section { | |
| 102 | Toggle("Daily summary", isOn: Binding( | |
| 103 | get: { dailySummary }, | |
| 104 | set: { on in Task { await setDailySummary(on) } } | |
| 105 | )) | |
| 106 | if dailySummary { | |
| 107 | DatePicker("Time", selection: summaryTime, displayedComponents: .hourAndMinute) | |
| 108 | } | |
| 109 | if let summaryStatus { | |
| 110 | Text(summaryStatus).font(.footnote).foregroundStyle(.secondary) | |
| 111 | } | |
| 112 | } footer: { | |
| 113 | Text("A note each day with the Moon, the planets up that night, the best satellite pass and any events. It is worked out on this device; nothing is sent to our server.") | |
| 114 | } | |
| 94 | 115 | Section { |
| 95 | 116 | Picker("Night mode", selection: $nightMode) { |
| 96 | 117 | Text("Off").tag("off") |
| @@ -123,6 +144,28 @@ struct SettingsScreen: View { | ||
| 123 | 144 | .onChange(of: locationMode) { onChange() } |
| 124 | 145 | } |
| 125 | 146 | } |
| 147 | ||
| 148 | private var summaryTime: Binding<Date> { | |
| 149 | Binding( | |
| 150 | get: { Calendar.current.startOfDay(for: .now).addingTimeInterval(Double(dailySummaryMinutes) * 60) }, | |
| 151 | set: { d in | |
| 152 | let c = Calendar.current.dateComponents([.hour, .minute], from: d) | |
| 153 | dailySummaryMinutes = (c.hour ?? 0) * 60 + (c.minute ?? 0) | |
| 154 | } | |
| 155 | ) | |
| 156 | } | |
| 157 | ||
| 158 | private func setDailySummary(_ on: Bool) async { | |
| 159 | if on { | |
| 160 | let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false | |
| 161 | guard granted else { | |
| 162 | summaryStatus = "Notifications are off for Moonrise. Turn them on in the Settings app." | |
| 163 | return | |
| 164 | } | |
| 165 | } | |
| 166 | summaryStatus = nil | |
| 167 | dailySummary = on | |
| 168 | } | |
| 126 | 169 | } |
| 127 | 170 | |
| 128 | 171 | struct CreditsScreen: View { |
App/Sources/SkyScreen.swift +8 −1
| @@ -18,6 +18,7 @@ struct SkyScreen: View { | ||
| 18 | 18 | @AppStorage(SettingsKey.onboarded) private var onboarded = false |
| 19 | 19 | @AppStorage(SettingsKey.nightActive) private var nightActive = false |
| 20 | 20 | @State private var showToday = false |
| 21 | @Environment(\.scenePhase) private var scenePhase | |
| 21 | 22 | @State private var libraryPage: LibraryPage? |
| 22 | 23 | @State private var selection: SkyObject? |
| 23 | 24 | @State private var details: BodyDetails? |
| @@ -72,7 +73,7 @@ struct SkyScreen: View { | ||
| 72 | 73 | .preferredColorScheme(.dark) |
| 73 | 74 | .tint(.blue) |
| 74 | 75 | } |
| 75 | .sheet(isPresented: $showSettings) { | |
| 76 | .sheet(isPresented: $showSettings, onDismiss: rescheduleSummary) { | |
| 76 | 77 | SettingsScreen { model.applyLocationSettings() } |
| 77 | 78 | .preferredColorScheme(.dark) |
| 78 | 79 | .tint(.blue) |
| @@ -88,6 +89,8 @@ struct SkyScreen: View { | ||
| 88 | 89 | .onChange(of: model.offsetHours) { select(selection) } |
| 89 | 90 | .onChange(of: onboarded) { if onboarded { model.start() } } |
| 90 | 91 | .onChange(of: night, initial: true) { nightActive = night } |
| 92 | .onChange(of: model.observer) { rescheduleSummary() } | |
| 93 | .onChange(of: scenePhase) { if scenePhase == .active { rescheduleSummary() } } | |
| 91 | 94 | .task { |
| 92 | 95 | if onboarded { model.start() } |
| 93 | 96 | while !Task.isCancelled { |
| @@ -97,6 +100,10 @@ struct SkyScreen: View { | ||
| 97 | 100 | } |
| 98 | 101 | } |
| 99 | 102 | |
| 103 | private func rescheduleSummary() { | |
| 104 | Task { await DailySummaryNotifications.reschedule(model) } | |
| 105 | } | |
| 106 | ||
| 100 | 107 | private func select(_ object: SkyObject?) { |
| 101 | 108 | selection = object |
| 102 | 109 | details = nil |
Packages/MoonriseKit/Sources/SkyEngine/DailySummary.swift added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// The text of the daily summary notification: tonight's Moon, planets, the best satellite pass and the day's events. | |
| 4 | public struct DailySummary: Sendable, Equatable { | |
| 5 | public var title: String | |
| 6 | public var body: String | |
| 7 | ||
| 8 | public static func phaseName(_ angle: Double) -> String { | |
| 9 | switch angle { | |
| 10 | case ..<10, 350...: "New moon" | |
| 11 | case ..<80: "Waxing crescent" | |
| 12 | case ..<100: "First quarter" | |
| 13 | case ..<170: "Waxing gibbous" | |
| 14 | case ..<190: "Full moon" | |
| 15 | case ..<260: "Waning gibbous" | |
| 16 | case ..<280: "Last quarter" | |
| 17 | default: "Waning crescent" | |
| 18 | } | |
| 19 | } | |
| 20 | ||
| 21 | /// Summary for the night following `date`. `passes` and `events` may cover any span; only those | |
| 22 | /// for this night (passes) and this calendar day or night (events) are used. | |
| 23 | public static func make(at date: Date, observer: Observer, passes: [SatellitePass], events: [SkyEvent], | |
| 24 | locale: Locale = .current, timeZone: TimeZone = .current) throws -> DailySummary { | |
| 25 | let tonight = try Tonight(at: date, from: observer) | |
| 26 | let time = Date.FormatStyle(date: .omitted, time: .shortened, locale: locale, timeZone: timeZone) | |
| 27 | var calendar = Calendar(identifier: .gregorian) | |
| 28 | calendar.timeZone = timeZone | |
| 29 | ||
| 30 | let lit = Int((tonight.moonIllumination * 100).rounded()) | |
| 31 | let title = "Tonight: \(phaseName(tonight.moonPhase).lowercased().replacingOccurrences(of: " moon", with: "")) Moon, \(lit)% lit" | |
| 32 | ||
| 33 | var lines: [String] = [] | |
| 34 | ||
| 35 | let todays = events.filter { calendar.isDate($0.date, inSameDayAs: date) || ($0.date >= tonight.start && $0.date <= tonight.end) } | |
| 36 | if !todays.isEmpty { | |
| 37 | lines.append(todays.map(\.title).joined(separator: ". ") + ".") | |
| 38 | } | |
| 39 | ||
| 40 | let planets = tonight.planets.filter { $0.best != nil }.map { $0.body.rawValue.capitalized } | |
| 41 | if planets.isEmpty { | |
| 42 | lines.append("No bright planets above 10° tonight.") | |
| 43 | } else { | |
| 44 | lines.append("Up tonight: \(ListFormatter.localizedString(byJoining: planets)).") | |
| 45 | } | |
| 46 | ||
| 47 | if let pass = passes | |
| 48 | .filter({ $0.visible && $0.rise >= tonight.start.addingTimeInterval(-3600) && $0.rise <= tonight.end }) | |
| 49 | .max(by: { $0.maxAltitude < $1.maxAltitude }) { | |
| 50 | let when = pass.rise.formatted(time) + (calendar.isDate(pass.rise, inSameDayAs: date) ? "" : " tomorrow") | |
| 51 | lines.append("\(pass.name) passes at \(when), \(Int(pass.maxAltitude.rounded()))° high.") | |
| 52 | } | |
| 53 | ||
| 54 | if let sunset = tonight.sunset { | |
| 55 | lines.append("Sunset \(sunset.formatted(time)).") | |
| 56 | } | |
| 57 | ||
| 58 | return DailySummary(title: title, body: lines.joined(separator: " ")) | |
| 59 | } | |
| 60 | } | |
Packages/MoonriseKit/Tests/SkyEngineTests/DailySummaryTests.swift added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import SkyEngine | |
| 4 | ||
| 5 | private let summaryObserver = Observer(latitude: 41.878, longitude: -87.630) | |
| 6 | private let central = TimeZone(identifier: "America/Chicago")! | |
| 7 | private let gb = Locale(identifier: "en_GB") | |
| 8 | ||
| 9 | @Test func phaseNames() { | |
| 10 | #expect(DailySummary.phaseName(3) == "New moon") | |
| 11 | #expect(DailySummary.phaseName(45) == "Waxing crescent") | |
| 12 | #expect(DailySummary.phaseName(90) == "First quarter") | |
| 13 | #expect(DailySummary.phaseName(180) == "Full moon") | |
| 14 | #expect(DailySummary.phaseName(271) == "Last quarter") | |
| 15 | #expect(DailySummary.phaseName(320) == "Waning crescent") | |
| 16 | } | |
| 17 | ||
| 18 | @Test func summaryForAnEvening() throws { | |
| 19 | // 18:00 CDT, 18 October 2026. | |
| 20 | let date = Date(timeIntervalSince1970: 1_792_364_400) | |
| 21 | let pass = SatellitePass(name: "ISS (ZARYA)", rise: date.addingTimeInterval(5400), set: date.addingTimeInterval(5760), peak: date.addingTimeInterval(5580), maxAltitude: 54.4, riseAzimuth: 240, setAzimuth: 60, visible: true) | |
| 22 | let faint = SatellitePass(name: "HST", rise: date.addingTimeInterval(7200), set: date.addingTimeInterval(7500), peak: date.addingTimeInterval(7350), maxAltitude: 70, riseAzimuth: 200, setAzimuth: 80, visible: false) | |
| 23 | let event = SkyEvent(kind: .meteorShower, date: date.addingTimeInterval(3 * 3600), title: "Orionids peak", detail: "", visibleHere: nil) | |
| 24 | let tomorrow = SkyEvent(kind: .moonPhase, date: date.addingTimeInterval(30 * 3600), title: "Full moon", detail: "", visibleHere: nil) | |
| 25 | ||
| 26 | let s = try DailySummary.make(at: date, observer: summaryObserver, passes: [pass, faint], events: [event, tomorrow], locale: gb, timeZone: central) | |
| 27 | #expect(s.title.hasPrefix("Tonight: ")) | |
| 28 | #expect(s.title.hasSuffix("% lit")) | |
| 29 | #expect(s.body.hasPrefix("Orionids peak. ")) | |
| 30 | #expect(!s.body.contains("Full moon")) | |
| 31 | #expect(s.body.contains("Up tonight: ")) | |
| 32 | #expect(s.body.contains("ISS (ZARYA) passes at 19:30, 54° high.")) | |
| 33 | #expect(!s.body.contains("HST")) | |
| 34 | #expect(s.body.contains("Sunset 18:")) | |
| 35 | } | |
| 36 | ||
| 37 | @Test func morningPassSaysTomorrow() throws { | |
| 38 | let date = Date(timeIntervalSince1970: 1_792_364_400) | |
| 39 | // 05:37 CDT on 19 October, before dawn. | |
| 40 | let pass = SatellitePass(name: "ISS (ZARYA)", rise: date.addingTimeInterval(41_820), set: date.addingTimeInterval(42_180), peak: date.addingTimeInterval(42_000), maxAltitude: 40, riseAzimuth: 240, setAzimuth: 60, visible: true) | |
| 41 | let s = try DailySummary.make(at: date, observer: summaryObserver, passes: [pass], events: [], locale: gb, timeZone: central) | |
| 42 | #expect(s.body.contains("ISS (ZARYA) passes at 5:37 tomorrow, 40° high.")) | |
| 43 | } | |