From 31dfeb09880324f3f52f0d2d0642365b81f1b369 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Mon, 26 Jan 2026 21:35:20 +0800 Subject: [PATCH] fix(notifications): fix iOS notification scheduling and enable UI for native platforms - Fix permission request to use correct iOS method (requestNotificationPermissions) - Add robust handling for varying permission result formats - Fix cancelDailyReminder to pass object parameter matching Swift plugin expectation - Add notification cancellation before rescheduling to prevent duplicates - Add verification after scheduling to ensure notification was actually scheduled - Fix getStatus to handle both array and object response formats - Enable notifications section in AccountView for native platforms (iOS/Android) - Add edit button to allow users to modify existing notification time and message - Add editReminderNotification method with form pre-population - Add parseTimeTo24Hour helper for time format conversion Fixes issues where: - Notifications were stored but not actually scheduled with UNUserNotificationCenter - cancelDailyReminder failed due to parameter type mismatch - Notification time updates didn't properly cancel old notifications - Users couldn't easily edit existing notification settings The notification section is now visible on native platforms and includes an edit button that opens the notification dialog with current values pre-populated. --- .../NativeNotificationService.ts | 126 ++++++++++++++-- src/views/AccountViewView.vue | 140 +++++++++++++++++- 2 files changed, 245 insertions(+), 21 deletions(-) diff --git a/src/services/notifications/NativeNotificationService.ts b/src/services/notifications/NativeNotificationService.ts index 518adf6e..5e271d7a 100644 --- a/src/services/notifications/NativeNotificationService.ts +++ b/src/services/notifications/NativeNotificationService.ts @@ -12,6 +12,14 @@ */ import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; + +/** + * Extended type for DailyNotification that includes the actual Swift implementation + * signature for cancelDailyReminder (which expects an object, not a string) + */ +interface DailyNotificationWithObjectCancel { + cancelDailyReminder(options: { reminderId: string }): Promise; +} import type { NotificationServiceInterface, DailyNotificationOptions, @@ -50,14 +58,26 @@ export class NativeNotificationService implements NotificationServiceInterface { try { logger.debug("[NativeNotificationService] Requesting permissions"); - const result = await DailyNotification.requestPermissions(); + // Use requestNotificationPermissions() which is the method exposed by iOS + // For Android, this should also work via the plugin bridge + const result = await DailyNotification.requestNotificationPermissions(); - logger.debug("[NativeNotificationService] Permission result:", { - notificationsEnabled: result.notificationsEnabled, - allPermissionsGranted: result.allPermissionsGranted, - }); + logger.debug("[NativeNotificationService] Permission result:", result); - return result.allPermissionsGranted; + // The result may be PermissionStatus (with granted boolean) or PermissionStatusResult + // Handle both formats for compatibility + if ("granted" in result && typeof result.granted === "boolean") { + return result.granted; + } + if ( + "allPermissionsGranted" in result && + typeof result.allPermissionsGranted === "boolean" + ) { + return result.allPermissionsGranted; + } + // Fallback: check status after requesting + const status = await DailyNotification.checkPermissionStatus(); + return status.allPermissionsGranted; } catch (error) { logger.error( "[NativeNotificationService] Permission request failed:", @@ -110,9 +130,31 @@ export class NativeNotificationService implements NotificationServiceInterface { { time: options.time, title: options.title, + body: options.body, }, ); + // Cancel any existing notification with the same ID before scheduling a new one + // This ensures the old notification is removed from iOS notification center + try { + logger.debug( + "[NativeNotificationService] Canceling existing notification before rescheduling", + ); + // The Swift plugin expects an object with reminderId property + // Even though TypeScript definition says string, we need to pass an object + await ( + DailyNotification as unknown as DailyNotificationWithObjectCancel + ).cancelDailyReminder({ + reminderId: this.reminderId, + }); + } catch (cancelError) { + // Ignore errors if notification doesn't exist - that's fine + logger.debug( + "[NativeNotificationService] No existing notification to cancel (or cancel failed):", + cancelError, + ); + } + await DailyNotification.scheduleDailyReminder({ id: this.reminderId, title: options.title, @@ -124,10 +166,57 @@ export class NativeNotificationService implements NotificationServiceInterface { priority: options.priority || "normal", }); - logger.info( - "[NativeNotificationService] Daily notification scheduled successfully", + // Verify the notification was actually scheduled + logger.debug( + "[NativeNotificationService] Verifying notification was scheduled", ); - return true; + const remindersResult = await DailyNotification.getScheduledReminders(); + // Handle both array and object with reminders property + const reminders = Array.isArray(remindersResult) + ? remindersResult + : (remindersResult as { reminders: typeof remindersResult }) + .reminders || []; + const scheduledReminder = reminders.find((r) => r.id === this.reminderId); + + if (scheduledReminder && scheduledReminder.isScheduled) { + // Verify the time matches what we scheduled + if (scheduledReminder.time !== options.time) { + logger.error( + "[NativeNotificationService] Notification time mismatch!", + { + scheduled: scheduledReminder.time, + requested: options.time, + reminderId: this.reminderId, + }, + ); + return false; + } + + logger.info( + "[NativeNotificationService] Daily notification scheduled successfully:", + { + id: scheduledReminder.id, + time: scheduledReminder.time, + requestedTime: options.time, + nextTriggerTime: scheduledReminder.nextTriggerTime, + }, + ); + return true; + } else { + logger.warn( + "[NativeNotificationService] Notification was not found in scheduled reminders after scheduling", + { + reminderId: this.reminderId, + requestedTime: options.time, + allReminders: reminders.map((r) => ({ + id: r.id, + time: r.time, + isScheduled: r.isScheduled, + })), + }, + ); + return false; + } } catch (error) { logger.error("[NativeNotificationService] Schedule failed:", error); return false; @@ -140,7 +229,13 @@ export class NativeNotificationService implements NotificationServiceInterface { async cancelDailyNotification(): Promise { try { logger.info("[NativeNotificationService] Cancelling daily notification"); - await DailyNotification.cancelDailyReminder(this.reminderId); + // The Swift plugin expects an object with reminderId property + // Even though TypeScript definition says string, we need to pass an object + await ( + DailyNotification as unknown as DailyNotificationWithObjectCancel + ).cancelDailyReminder({ + reminderId: this.reminderId, + }); logger.info( "[NativeNotificationService] Daily notification cancelled successfully", ); @@ -155,10 +250,13 @@ export class NativeNotificationService implements NotificationServiceInterface { */ async getStatus(): Promise { try { - const reminders = await DailyNotification.getScheduledReminders(); - const reminder = reminders.reminders.find( - (r) => r.id === this.reminderId, - ); + const remindersResult = await DailyNotification.getScheduledReminders(); + // Handle both array and object with reminders property + const reminders = Array.isArray(remindersResult) + ? remindersResult + : (remindersResult as { reminders: typeof remindersResult }) + .reminders || []; + const reminder = reminders.find((r) => r.id === this.reminderId); if (reminder) { logger.debug("[NativeNotificationService] Found active reminder:", { diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index 5438304f..4130a980 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -76,10 +76,9 @@ /> - +
-
- Message: "{{ notifyingReminderMessage }}" - {{ notifyingReminderTime.replace(" ", " ") }} +
+
+ Message: "{{ notifyingReminderMessage }}" + {{ notifyingReminderTime.replace(" ", " ") }} +
+
+ +
-
+
New Activity Notification @@ -785,6 +796,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView"; import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; import { AccountSettings, isApiError } from "@/interfaces/accountView"; +import { NotificationService } from "@/services/notifications"; // Profile data interface (inlined from ProfileService) interface ProfileData { description: string; @@ -797,6 +809,17 @@ interface UserNameDialogRef { open: (cb: (name?: string) => void) => void; } +interface PushNotificationPermissionRef { + open: ( + title: string, + callback: (success: boolean, timeText: string, message?: string) => void, + ) => void; + hourInput?: string; + minuteInput?: string; + hourAm?: boolean; + messageInput?: string; +} + @Component({ components: { EntityIcon, @@ -1211,6 +1234,109 @@ export default class AccountViewView extends Vue { } } + /** + * Edit existing reminder notification + * Opens the notification dialog to allow editing time and message + */ + async editReminderNotification(): Promise { + const dialog = this.$refs + .pushNotificationPermission as PushNotificationPermission; + + // Open the dialog - it will use the same callback pattern as showReminderNotificationChoice + dialog.open( + DIRECT_PUSH_TITLE, + async (success: boolean, timeText: string, message?: string) => { + if (success) { + // Cancel the old notification before scheduling the new one + const service = NotificationService.getInstance(); + await service.cancelDailyNotification(); + + // Schedule the updated notification + const time24h = this.parseTimeTo24Hour(timeText); + const title = "Daily Reminder"; + const body = + message || + this.notifyingReminderMessage || + "Click to share some gratitude with the world -- even if they're unnamed."; + + const scheduleSuccess = await service.scheduleDailyNotification({ + time: time24h, + title, + body, + priority: "normal", + }); + + if (scheduleSuccess) { + await this.$saveSettings({ + notifyingReminderMessage: message, + notifyingReminderTime: timeText, + }); + this.notifyingReminderMessage = message || ""; + this.notifyingReminderTime = timeText; + } + } + }, + ); + + // Pre-populate the form with current values after dialog opens + // Use setTimeout to ensure the dialog is fully rendered + setTimeout(() => { + // Parse the current time string (e.g., "5:22 PM") + const timeMatch = this.notifyingReminderTime.match( + /(\d+):(\d+)\s*(AM|PM)/i, + ); + if (timeMatch) { + let hour = parseInt(timeMatch[1], 10); + const minute = timeMatch[2]; + const isAm = timeMatch[3].toUpperCase() === "AM"; + + // Convert to 12-hour format for the input + if (hour === 12) { + hour = 12; + } else if (hour > 12) { + hour = hour - 12; + } + + // Set the component's properties directly + // Note: We need to access the component's internal properties + // This is a workaround but necessary to pre-populate + const dialogComponent = + dialog as unknown as PushNotificationPermissionRef; + if (dialogComponent) { + dialogComponent.hourInput = hour.toString(); + dialogComponent.minuteInput = minute; + dialogComponent.hourAm = isAm; + dialogComponent.messageInput = + this.notifyingReminderMessage || + "Click to share some gratitude with the world -- even if they're unnamed."; + } + } + }, 150); + } + + /** + * Parse time string (e.g., "5:22 PM") to 24-hour format (e.g., "17:22") + */ + private parseTimeTo24Hour(timeStr: string): string { + const timeMatch = timeStr.match(/(\d+):(\d+)\s*(AM|PM)/i); + if (!timeMatch) { + return "09:00"; // Default fallback + } + + let hour = parseInt(timeMatch[1], 10); + const minute = timeMatch[2]; + const isAm = timeMatch[3].toUpperCase() === "AM"; + + // Convert to 24-hour format + if (isAm && hour === 12) { + hour = 0; + } else if (!isAm && hour !== 12) { + hour = hour + 12; + } + + return `${hour.toString().padStart(2, "0")}:${minute}`; + } + public async toggleHideRegisterPromptOnNewContact(): Promise { const newSetting = !this.hideRegisterPromptOnNewContact; await this.$saveSettings({