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";
|
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 {
|
import type {
|
||||||
NotificationServiceInterface,
|
NotificationServiceInterface,
|
||||||
DailyNotificationOptions,
|
DailyNotificationOptions,
|
||||||
@@ -50,14 +58,26 @@ export class NativeNotificationService implements NotificationServiceInterface {
|
|||||||
try {
|
try {
|
||||||
logger.debug("[NativeNotificationService] Requesting permissions");
|
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:", {
|
logger.debug("[NativeNotificationService] Permission result:", result);
|
||||||
notificationsEnabled: result.notificationsEnabled,
|
|
||||||
allPermissionsGranted: 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;
|
return result.allPermissionsGranted;
|
||||||
|
}
|
||||||
|
// Fallback: check status after requesting
|
||||||
|
const status = await DailyNotification.checkPermissionStatus();
|
||||||
|
return status.allPermissionsGranted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
"[NativeNotificationService] Permission request failed:",
|
"[NativeNotificationService] Permission request failed:",
|
||||||
@@ -110,9 +130,31 @@ export class NativeNotificationService implements NotificationServiceInterface {
|
|||||||
{
|
{
|
||||||
time: options.time,
|
time: options.time,
|
||||||
title: options.title,
|
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({
|
await DailyNotification.scheduleDailyReminder({
|
||||||
id: this.reminderId,
|
id: this.reminderId,
|
||||||
title: options.title,
|
title: options.title,
|
||||||
@@ -124,10 +166,57 @@ export class NativeNotificationService implements NotificationServiceInterface {
|
|||||||
priority: options.priority || "normal",
|
priority: options.priority || "normal",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Verify the notification was actually scheduled
|
||||||
|
logger.debug(
|
||||||
|
"[NativeNotificationService] Verifying notification was scheduled",
|
||||||
|
);
|
||||||
|
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(
|
logger.info(
|
||||||
"[NativeNotificationService] Daily notification scheduled successfully",
|
"[NativeNotificationService] Daily notification scheduled successfully:",
|
||||||
|
{
|
||||||
|
id: scheduledReminder.id,
|
||||||
|
time: scheduledReminder.time,
|
||||||
|
requestedTime: options.time,
|
||||||
|
nextTriggerTime: scheduledReminder.nextTriggerTime,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return true;
|
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) {
|
} catch (error) {
|
||||||
logger.error("[NativeNotificationService] Schedule failed:", error);
|
logger.error("[NativeNotificationService] Schedule failed:", error);
|
||||||
return false;
|
return false;
|
||||||
@@ -140,7 +229,13 @@ export class NativeNotificationService implements NotificationServiceInterface {
|
|||||||
async cancelDailyNotification(): Promise<void> {
|
async cancelDailyNotification(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.info("[NativeNotificationService] Cancelling daily notification");
|
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(
|
logger.info(
|
||||||
"[NativeNotificationService] Daily notification cancelled successfully",
|
"[NativeNotificationService] Daily notification cancelled successfully",
|
||||||
);
|
);
|
||||||
@@ -155,10 +250,13 @@ export class NativeNotificationService implements NotificationServiceInterface {
|
|||||||
*/
|
*/
|
||||||
async getStatus(): Promise<NotificationStatus> {
|
async getStatus(): Promise<NotificationStatus> {
|
||||||
try {
|
try {
|
||||||
const reminders = await DailyNotification.getScheduledReminders();
|
const remindersResult = await DailyNotification.getScheduledReminders();
|
||||||
const reminder = reminders.reminders.find(
|
// Handle both array and object with reminders property
|
||||||
(r) => r.id === this.reminderId,
|
const reminders = Array.isArray(remindersResult)
|
||||||
);
|
? remindersResult
|
||||||
|
: (remindersResult as { reminders: typeof remindersResult })
|
||||||
|
.reminders || [];
|
||||||
|
const reminder = reminders.find((r) => r.id === this.reminderId);
|
||||||
|
|
||||||
if (reminder) {
|
if (reminder) {
|
||||||
logger.debug("[NativeNotificationService] Found active reminder:", {
|
logger.debug("[NativeNotificationService] Found active reminder:", {
|
||||||
|
|||||||
@@ -76,10 +76,9 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<!-- Currently disabled because it doesn't work, even on Chrome.
|
<!-- Enabled for native platforms (iOS/Android) -->
|
||||||
If restored, make sure it works or doesn't show on mobile/electron. -->
|
|
||||||
<section
|
<section
|
||||||
v-if="false"
|
v-if="isNativePlatform"
|
||||||
id="sectionNotifications"
|
id="sectionNotifications"
|
||||||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||||||
aria-labelledby="notificationsHeading"
|
aria-labelledby="notificationsHeading"
|
||||||
@@ -117,11 +116,23 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="notifyingReminder" class="w-full flex justify-between">
|
<div v-if="notifyingReminder" class="w-full">
|
||||||
<span class="ml-8 mr-8">Message: "{{ notifyingReminderMessage }}"</span>
|
<div class="flex justify-between mb-2">
|
||||||
|
<span class="ml-8 mr-8"
|
||||||
|
>Message: "{{ notifyingReminderMessage }}"</span
|
||||||
|
>
|
||||||
<span>{{ notifyingReminderTime.replace(" ", " ") }}</span>
|
<span>{{ notifyingReminderTime.replace(" ", " ") }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-center justify-between">
|
<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 v-if="false" class="mt-2 flex items-center justify-between">
|
||||||
<!-- label -->
|
<!-- label -->
|
||||||
<div>
|
<div>
|
||||||
New Activity Notification
|
New Activity Notification
|
||||||
@@ -785,6 +796,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
|||||||
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
||||||
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
||||||
import { AccountSettings, isApiError } from "@/interfaces/accountView";
|
import { AccountSettings, isApiError } from "@/interfaces/accountView";
|
||||||
|
import { NotificationService } from "@/services/notifications";
|
||||||
// Profile data interface (inlined from ProfileService)
|
// Profile data interface (inlined from ProfileService)
|
||||||
interface ProfileData {
|
interface ProfileData {
|
||||||
description: string;
|
description: string;
|
||||||
@@ -797,6 +809,17 @@ interface UserNameDialogRef {
|
|||||||
open: (cb: (name?: string) => void) => void;
|
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({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
EntityIcon,
|
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> {
|
public async toggleHideRegisterPromptOnNewContact(): Promise<void> {
|
||||||
const newSetting = !this.hideRegisterPromptOnNewContact;
|
const newSetting = !this.hideRegisterPromptOnNewContact;
|
||||||
await this.$saveSettings({
|
await this.$saveSettings({
|
||||||
|
|||||||
Reference in New Issue
Block a user