forked from trent_larson/crowd-funder-for-time-pwa
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.
This commit is contained in:
@@ -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<void>;
|
||||
}
|
||||
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<void> {
|
||||
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<NotificationStatus> {
|
||||
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:", {
|
||||
|
||||
@@ -76,10 +76,9 @@
|
||||
/>
|
||||
|
||||
<!-- Notifications -->
|
||||
<!-- Currently disabled because it doesn't work, even on Chrome.
|
||||
If restored, make sure it works or doesn't show on mobile/electron. -->
|
||||
<!-- Enabled for native platforms (iOS/Android) -->
|
||||
<section
|
||||
v-if="false"
|
||||
v-if="isNativePlatform"
|
||||
id="sectionNotifications"
|
||||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||||
aria-labelledby="notificationsHeading"
|
||||
@@ -117,11 +116,23 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="notifyingReminder" class="w-full flex justify-between">
|
||||
<span class="ml-8 mr-8">Message: "{{ notifyingReminderMessage }}"</span>
|
||||
<span>{{ notifyingReminderTime.replace(" ", " ") }}</span>
|
||||
<div v-if="notifyingReminder" class="w-full">
|
||||
<div class="flex justify-between mb-2">
|
||||
<span class="ml-8 mr-8"
|
||||
>Message: "{{ notifyingReminderMessage }}"</span
|
||||
>
|
||||
<span>{{ notifyingReminderTime.replace(" ", " ") }}</span>
|
||||
</div>
|
||||
<div class="mt-2 text-center">
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
@click="editReminderNotification"
|
||||
>
|
||||
Edit Notification Details…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<div v-if="false" class="mt-2 flex items-center justify-between">
|
||||
<!-- label -->
|
||||
<div>
|
||||
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<void> {
|
||||
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<void> {
|
||||
const newSetting = !this.hideRegisterPromptOnNewContact;
|
||||
await this.$saveSettings({
|
||||
|
||||
Reference in New Issue
Block a user