From 07f75bd766cc6b0861e590ef7ea162609afc0f85 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 27 Jan 2026 13:29:24 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .../App/Localization/Localizable.xcstrings | 12 +++ .../App/Services/ReminderScheduler.swift | 49 +++++++++- Andromida/App/Views/RootView.swift | 1 + .../App/Views/Settings/SettingsView.swift | 8 ++ .../AppIcon.appiconset/Contents.json | 90 +++++++++++++++++++ 5 files changed, 158 insertions(+), 2 deletions(-) diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index d8273be..f821d8c 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -2386,6 +2386,10 @@ "comment" : "A hint that appears when a user taps on a day cell to explain that it will navigate to more details about that day.", "isCommentAutoGenerated" : true }, + "Test Notification" : { + "comment" : "Title of a test notification used to verify notification delivery.", + "isCommentAutoGenerated" : true + }, "Thank someone" : { "comment" : "Habit title for a ritual preset focused on gratitude practice.", "isCommentAutoGenerated" : true @@ -2409,6 +2413,10 @@ "comment" : "A description displayed when a day has no habit completion data.", "isCommentAutoGenerated" : true }, + "This is a test notification to verify delivery." : { + "comment" : "Title and body of a test notification used to verify notification delivery.", + "isCommentAutoGenerated" : true + }, "This ritual is not currently active" : { "comment" : "A message displayed when a user views a past ritual and it is not currently active.", "isCommentAutoGenerated" : true @@ -2506,6 +2514,10 @@ "comment" : "Accessibility label for a trend direction indicating an increase.", "isCommentAutoGenerated" : true }, + "Trigger Test Notification (5s)" : { + "comment" : "Title of a settings option that triggers a test local notification.", + "isCommentAutoGenerated" : true + }, "Try a different search term" : { "comment" : "A description text displayed below the \"No icons found\" message in the icon picker sheet.", "isCommentAutoGenerated" : true diff --git a/Andromida/App/Services/ReminderScheduler.swift b/Andromida/App/Services/ReminderScheduler.swift index 98e2798..482f645 100644 --- a/Andromida/App/Services/ReminderScheduler.swift +++ b/Andromida/App/Services/ReminderScheduler.swift @@ -48,7 +48,7 @@ enum ReminderSlot: String, CaseIterable { /// Only schedules reminders for time slots that have active rituals. @MainActor @Observable -final class ReminderScheduler { +final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate { private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined private(set) var scheduledSlots: Set = [] @@ -69,10 +69,33 @@ final class ReminderScheduler { /// The rituals to base reminders on - set this from RitualStore private var activeRituals: [Ritual] = [] - init() { + override init() { + super.init() + UNUserNotificationCenter.current().delegate = self Task { await refreshAuthorizationStatus() } } + // MARK: - UNUserNotificationCenterDelegate + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Show the notification even when the app is in the foreground + completionHandler([.banner, .list, .sound, .badge]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + // Clear badge when user interacts with notification + clearBadge() + completionHandler() + } + // MARK: - Public API /// Updates the scheduled reminders based on the current active rituals. @@ -116,6 +139,28 @@ final class ReminderScheduler { UNUserNotificationCenter.current().setBadgeCount(0) { _ in } } + /// Schedules a test notification to appear in 5 seconds. + func scheduleTestNotification() { + let content = UNMutableNotificationContent() + content.title = String(localized: "Test Notification") + content.body = String(localized: "This is a test notification to verify delivery.") + content.sound = .default + content.badge = 1 + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) + let request = UNNotificationRequest( + identifier: "rituals.test.notification", + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Failed to schedule test notification: \(error)") + } + } + } + /// Refreshes authorization status and reschedules if enabled. func refreshStatus() async { await refreshAuthorizationStatus() diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift index c921583..22efdb2 100644 --- a/Andromida/App/Views/RootView.swift +++ b/Andromida/App/Views/RootView.swift @@ -72,6 +72,7 @@ struct RootView: View { .background(AppSurface.primary.ignoresSafeArea()) .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { + store.reminderScheduler.clearBadge() refreshCurrentTab() } } diff --git a/Andromida/App/Views/Settings/SettingsView.swift b/Andromida/App/Views/Settings/SettingsView.swift index 5b5e70d..76daa55 100644 --- a/Andromida/App/Views/Settings/SettingsView.swift +++ b/Andromida/App/Views/Settings/SettingsView.swift @@ -154,6 +154,14 @@ struct SettingsView: View { ) { ritualStore.clearAllCompletions() } + + SettingsRow( + systemImage: "bell.badge", + title: String(localized: "Trigger Test Notification (5s)"), + iconColor: AppStatus.info + ) { + ritualStore.reminderScheduler.scheduleTestNotification() + } } } #endif diff --git a/Andromida/Assets.xcassets/AppIcon.appiconset/Contents.json b/Andromida/Assets.xcassets/AppIcon.appiconset/Contents.json index ce8e776..6a6ac99 100644 --- a/Andromida/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Andromida/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -6,6 +6,96 @@ "platform" : "ios", "size" : "1024x1024" }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + }, { "appearances" : [ {