diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1d8ad70d..3aa15877 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -45,4 +45,15 @@ + + + + + + + + + + + diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json index 721bea0d..d89dc7dd 100644 --- a/android/app/src/main/assets/capacitor.plugins.json +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -35,6 +35,10 @@ "pkg": "@capawesome/capacitor-file-picker", "classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin" }, + { + "pkg": "@timesafari/daily-notification-plugin", + "classpath": "com.timesafari.dailynotification.DailyNotificationPlugin" + }, { "pkg": "@timesafari/daily-notification-plugin", "classpath": "com.timesafari.dailynotification.DailyNotificationPlugin" diff --git a/src/components/notifications/DailyNotificationSection.vue b/src/components/notifications/DailyNotificationSection.vue new file mode 100644 index 00000000..48533a95 --- /dev/null +++ b/src/components/notifications/DailyNotificationSection.vue @@ -0,0 +1,676 @@ + + + + + diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index 493e4596..0af24058 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -53,6 +53,11 @@ export type Settings = { notifyingReminderMessage?: string; // set to their chosen message for a daily reminder notifyingReminderTime?: string; // set to their chosen time for a daily reminder + // Native notification settings (Capacitor only) + nativeNotificationTime?: string; // "09:00" format (24-hour) - scheduled time for daily notification + nativeNotificationTitle?: string; // Default: "Daily Update" - notification title + nativeNotificationMessage?: string; // Default message - notification body text + partnerApiServer?: string; // partner server API URL passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes diff --git a/src/services/PlatformService.ts b/src/services/PlatformService.ts index a8ae9ee7..6db1c04d 100644 --- a/src/services/PlatformService.ts +++ b/src/services/PlatformService.ts @@ -32,6 +32,68 @@ export interface PlatformCapabilities { isNativeApp: boolean; } +/** + * Permission status for notifications + */ +export interface PermissionStatus { + /** Notification permission status */ + notifications: "granted" | "denied" | "prompt"; + /** Exact alarms permission status (Android only) */ + exactAlarms?: "granted" | "denied" | "prompt"; +} + +/** + * Result of permission request + */ +export interface PermissionResult { + /** Whether notification permission was granted */ + notifications: boolean; + /** Whether exact alarms permission was granted (Android only) */ + exactAlarms?: boolean; +} + +/** + * Status of scheduled daily notifications + */ +export interface NotificationStatus { + /** Whether a notification is currently scheduled */ + isScheduled: boolean; + /** Scheduled time in "HH:mm" format (24-hour) */ + scheduledTime?: string; + /** Last time the notification was triggered (ISO string) */ + lastTriggered?: string; + /** Current permission status */ + permissions: PermissionStatus; +} + +/** + * Options for scheduling a daily notification + */ +export interface ScheduleOptions { + /** Time in "HH:mm" format (24-hour) in local time */ + time: string; + /** Notification title */ + title: string; + /** Notification body text */ + body: string; + /** Whether to play sound (default: true) */ + sound?: boolean; + /** Notification priority */ + priority?: "high" | "normal" | "low"; +} + +/** + * Configuration for native fetcher background operations + */ +export interface NativeFetcherConfig { + /** API server URL */ + apiServer: string; + /** JWT token for authentication */ + jwt: string; + /** Array of starred plan handle IDs */ + starredPlanHandleIds: string[]; +} + /** * Platform-agnostic interface for handling platform-specific operations. * Provides a common API for file system operations, camera interactions, @@ -209,6 +271,58 @@ export interface PlatformService { */ retrieveSettingsForActiveAccount(): Promise | null>; + // Daily notification operations + /** + * Get the status of scheduled daily notifications + * @returns Promise resolving to notification status, or null if not supported + */ + getDailyNotificationStatus(): Promise; + + /** + * Check notification permissions + * @returns Promise resolving to permission status, or null if not supported + */ + checkNotificationPermissions(): Promise; + + /** + * Request notification permissions + * @returns Promise resolving to permission result, or null if not supported + */ + requestNotificationPermissions(): Promise; + + /** + * Schedule a daily notification + * @param options - Notification scheduling options + * @returns Promise that resolves when scheduled, or rejects if not supported + */ + scheduleDailyNotification(options: ScheduleOptions): Promise; + + /** + * Cancel scheduled daily notification + * @returns Promise that resolves when cancelled, or rejects if not supported + */ + cancelDailyNotification(): Promise; + + /** + * Configure native fetcher for background operations + * @param config - Native fetcher configuration + * @returns Promise that resolves when configured, or null if not supported + */ + configureNativeFetcher(config: NativeFetcherConfig): Promise; + + /** + * Update starred plans for background fetcher + * @param plans - Starred plan IDs + * @returns Promise that resolves when updated, or null if not supported + */ + updateStarredPlans(plans: { planIds: string[] }): Promise; + + /** + * Open the app's notification settings in the system settings + * @returns Promise that resolves when the settings page is opened, or null if not supported + */ + openAppNotificationSettings(): Promise; + // --- PWA/Web-only methods (optional, only implemented on web) --- /** * Registers the service worker for PWA support (web only) diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 51fb9ce5..30b8d8b4 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -20,6 +20,11 @@ import { ImageResult, PlatformService, PlatformCapabilities, + NotificationStatus, + PermissionStatus, + PermissionResult, + ScheduleOptions, + NativeFetcherConfig, } from "../PlatformService"; import { logger } from "../../utils/logger"; import { BaseDatabaseService } from "./BaseDatabaseService"; @@ -1333,6 +1338,359 @@ export class CapacitorPlatformService // --- PWA/Web-only methods (no-op for Capacitor) --- public registerServiceWorker(): void {} + // Daily notification operations + /** + * Get the status of scheduled daily notifications + * @see PlatformService.getDailyNotificationStatus + */ + async getDailyNotificationStatus(): Promise { + try { + // Dynamic import to avoid build issues if plugin unavailable + const { DailyNotification } = await import( + "@timesafari/daily-notification-plugin" + ); + + const pluginStatus = await DailyNotification.getNotificationStatus(); + + // Get permissions separately + const permissions = await DailyNotification.checkPermissions(); + + // Map plugin PermissionState to our PermissionStatus format + const notificationsPermission = permissions.notifications; + let notifications: "granted" | "denied" | "prompt"; + + if (notificationsPermission === "granted") { + notifications = "granted"; + } else if (notificationsPermission === "denied") { + notifications = "denied"; + } else { + notifications = "prompt"; + } + + // Handle lastNotificationTime which can be a Promise + let lastTriggered: string | undefined; + const lastNotificationTime = pluginStatus.lastNotificationTime; + if (lastNotificationTime) { + const timeValue = await Promise.resolve(lastNotificationTime); + if (typeof timeValue === "number") { + lastTriggered = new Date(timeValue).toISOString(); + } + } + + return { + isScheduled: pluginStatus.isScheduled ?? false, + scheduledTime: pluginStatus.settings?.time, + lastTriggered, + permissions: { + notifications, + exactAlarms: undefined, // Plugin doesn't expose this in status + }, + }; + } catch (error) { + logger.error( + "[CapacitorPlatformService] Failed to get notification status:", + error, + ); + return null; + } + } + + /** + * Check notification permissions + * @see PlatformService.checkNotificationPermissions + */ + async checkNotificationPermissions(): Promise { + try { + const { DailyNotification } = await import( + "@timesafari/daily-notification-plugin" + ); + + const permissions = await DailyNotification.checkPermissions(); + + // Log the raw permission state for debugging + logger.info( + `[CapacitorPlatformService] Raw permission state from plugin:`, + permissions, + ); + + // Map plugin PermissionState to our PermissionStatus format + const notificationsPermission = permissions.notifications; + let notifications: "granted" | "denied" | "prompt"; + + // Handle all possible PermissionState values + if (notificationsPermission === "granted") { + notifications = "granted"; + } else if ( + notificationsPermission === "denied" || + notificationsPermission === "ephemeral" + ) { + notifications = "denied"; + } else { + // Treat "prompt", "prompt-with-rationale", "unknown", "provisional" as "prompt" + // This allows Android to show the permission dialog + notifications = "prompt"; + } + + logger.info( + `[CapacitorPlatformService] Mapped permission state: ${notifications} (from ${notificationsPermission})`, + ); + + return { + notifications, + exactAlarms: undefined, // Plugin doesn't expose this directly + }; + } catch (error) { + logger.error( + "[CapacitorPlatformService] Failed to check permissions:", + error, + ); + return null; + } + } + + /** + * Request notification permissions + * @see PlatformService.requestNotificationPermissions + */ + async requestNotificationPermissions(): Promise { + try { + const { DailyNotification } = await import( + "@timesafari/daily-notification-plugin" + ); + + logger.info( + `[CapacitorPlatformService] Requesting notification permissions...`, + ); + + const result = await DailyNotification.requestPermissions(); + + logger.info( + `[CapacitorPlatformService] Permission request result:`, + result, + ); + + // Map plugin PermissionState to boolean + const notificationsGranted = result.notifications === "granted"; + + logger.info( + `[CapacitorPlatformService] Mapped permission result: ${notificationsGranted} (from ${result.notifications})`, + ); + + return { + notifications: notificationsGranted, + exactAlarms: undefined, // Plugin doesn't expose this directly + }; + } catch (error) { + logger.error( + "[CapacitorPlatformService] Failed to request permissions:", + error, + ); + return null; + } + } + + /** + * Schedule a daily notification + * @see PlatformService.scheduleDailyNotification + */ + async scheduleDailyNotification(options: ScheduleOptions): Promise { + try { + const { DailyNotification } = await import( + "@timesafari/daily-notification-plugin" + ); + + await DailyNotification.scheduleDailyNotification({ + time: options.time, + title: options.title, + body: options.body, + sound: options.sound ?? true, + priority: options.priority ?? "high", + }); + + logger.info( + `[CapacitorPlatformService] Scheduled daily notification for ${options.time}`, + ); + } catch (error) { + logger.error( + "[CapacitorPlatformService] Failed to schedule notification:", + error, + ); + throw error; + } + } + + /** + * Cancel scheduled daily notification + * @see PlatformService.cancelDailyNotification + */ + async cancelDailyNotification(): Promise { + try { + const { DailyNotification } = await import( + "@timesafari/daily-notification-plugin" + ); + + await DailyNotification.cancelAllNotifications(); + + logger.info("[CapacitorPlatformService] Cancelled daily notification"); + } catch (error) { + logger.error( + "[CapacitorPlatformService] Failed to cancel notification:", + error, + ); + throw error; + } + } + + /** + * Configure native fetcher for background operations + * @see PlatformService.configureNativeFetcher + */ + async configureNativeFetcher( + config: NativeFetcherConfig, + ): Promise { + try { + const { DailyNotification } = await import( + "@timesafari/daily-notification-plugin" + ); + + // Plugin expects apiBaseUrl, activeDid, and jwtToken + // We'll need to get activeDid from somewhere - for now pass empty string + // Components should provide activeDid when calling this + await DailyNotification.configureNativeFetcher({ + apiBaseUrl: config.apiServer, + activeDid: "", // Should be provided by caller + jwtToken: config.jwt, + }); + + logger.info("[CapacitorPlatformService] Configured native fetcher"); + } catch (error) { + logger.error( + "[CapacitorPlatformService] Failed to configure native fetcher:", + error, + ); + return null; + } + } + + /** + * Update starred plans for background fetcher + * @see PlatformService.updateStarredPlans + */ + async updateStarredPlans(plans: { planIds: string[] }): Promise { + try { + const { DailyNotification } = await import( + "@timesafari/daily-notification-plugin" + ); + + await DailyNotification.updateStarredPlans({ + planIds: plans.planIds, + }); + + logger.info( + `[CapacitorPlatformService] Updated starred plans: ${plans.planIds.length} plans`, + ); + } catch (error) { + logger.error( + "[CapacitorPlatformService] Failed to update starred plans:", + error, + ); + return null; + } + } + + /** + * Open the app's notification settings in the system settings + * @see PlatformService.openAppNotificationSettings + */ + async openAppNotificationSettings(): Promise { + try { + const platform = Capacitor.getPlatform(); + + if (platform === "android") { + // Android: Open app details settings page + // From there, users can navigate to "Notifications" section + // This is more reliable than trying to open notification settings directly + const packageName = "app.timesafari.app"; // Full application ID from build.gradle + + // Use APPLICATION_DETAILS_SETTINGS which opens the app's settings page + // Users can then navigate to "Notifications" section + // Try multiple URL formats to ensure compatibility + const intentUrl1 = `intent:#Intent;action=android.settings.APPLICATION_DETAILS_SETTINGS;data=package:${packageName};end`; + const intentUrl2 = `intent://settings/app_detail?package=${packageName}#Intent;scheme=android-app;end`; + + logger.info( + `[CapacitorPlatformService] Opening Android app settings for ${packageName}`, + ); + + // Log current permission state before opening settings + try { + const currentPerms = await this.checkNotificationPermissions(); + logger.info( + `[CapacitorPlatformService] Current permission state before opening settings:`, + currentPerms, + ); + } catch (e) { + logger.warn( + `[CapacitorPlatformService] Could not check permissions before opening settings:`, + e, + ); + } + + // Try multiple approaches to ensure it works + try { + // Method 1: Direct window.location.href (most reliable) + window.location.href = intentUrl1; + + // Method 2: Fallback with window.open + setTimeout(() => { + try { + window.open(intentUrl1, "_blank"); + } catch (e) { + logger.warn( + "[CapacitorPlatformService] window.open fallback failed:", + e, + ); + } + }, 100); + + // Method 3: Alternative format + setTimeout(() => { + try { + window.location.href = intentUrl2; + } catch (e) { + logger.warn( + "[CapacitorPlatformService] Alternative format failed:", + e, + ); + } + }, 200); + } catch (e) { + logger.error( + "[CapacitorPlatformService] Failed to open intent URL:", + e, + ); + } + } else if (platform === "ios") { + // iOS: Use app settings URL scheme + const settingsUrl = `app-settings:`; + window.location.href = settingsUrl; + + logger.info("[CapacitorPlatformService] Opening iOS app settings"); + } else { + logger.warn( + `[CapacitorPlatformService] Cannot open settings on platform: ${platform}`, + ); + return null; + } + } catch (error) { + logger.error( + "[CapacitorPlatformService] Failed to open app notification settings:", + error, + ); + return null; + } + } + // Database utility methods - inherited from BaseDatabaseService // generateInsertStatement, updateDefaultSettings, updateActiveDid, // getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings, diff --git a/src/services/platforms/ElectronPlatformService.ts b/src/services/platforms/ElectronPlatformService.ts index 1a077c65..f7350b12 100644 --- a/src/services/platforms/ElectronPlatformService.ts +++ b/src/services/platforms/ElectronPlatformService.ts @@ -22,6 +22,13 @@ import { CapacitorPlatformService } from "./CapacitorPlatformService"; import { logger } from "../../utils/logger"; +import { + NotificationStatus, + PermissionStatus, + PermissionResult, + ScheduleOptions, + NativeFetcherConfig, +} from "../PlatformService"; /** * Electron-specific platform service implementation. @@ -166,4 +173,88 @@ export class ElectronPlatformService extends CapacitorPlatformService { // --- PWA/Web-only methods (no-op for Electron) --- public registerServiceWorker(): void {} + + // Daily notification operations + // Override CapacitorPlatformService methods to return null/throw errors + // since Electron doesn't support native daily notifications + + /** + * Get the status of scheduled daily notifications + * @see PlatformService.getDailyNotificationStatus + * @returns null - notifications not supported on Electron platform + */ + async getDailyNotificationStatus(): Promise { + return null; + } + + /** + * Check notification permissions + * @see PlatformService.checkNotificationPermissions + * @returns null - notifications not supported on Electron platform + */ + async checkNotificationPermissions(): Promise { + return null; + } + + /** + * Request notification permissions + * @see PlatformService.requestNotificationPermissions + * @returns null - notifications not supported on Electron platform + */ + async requestNotificationPermissions(): Promise { + return null; + } + + /** + * Schedule a daily notification + * @see PlatformService.scheduleDailyNotification + * @throws Error - notifications not supported on Electron platform + */ + async scheduleDailyNotification(_options: ScheduleOptions): Promise { + throw new Error( + "Daily notifications are not supported on Electron platform", + ); + } + + /** + * Cancel scheduled daily notification + * @see PlatformService.cancelDailyNotification + * @throws Error - notifications not supported on Electron platform + */ + async cancelDailyNotification(): Promise { + throw new Error( + "Daily notifications are not supported on Electron platform", + ); + } + + /** + * Configure native fetcher for background operations + * @see PlatformService.configureNativeFetcher + * @returns null - native fetcher not supported on Electron platform + */ + async configureNativeFetcher( + _config: NativeFetcherConfig, + ): Promise { + return null; + } + + /** + * Update starred plans for background fetcher + * @see PlatformService.updateStarredPlans + * @returns null - native fetcher not supported on Electron platform + */ + async updateStarredPlans(_plans: { + planIds: string[]; + }): Promise { + return null; + } + + /** + * Open the app's notification settings in the system settings + * @see PlatformService.openAppNotificationSettings + * @returns null - not supported on Electron platform + */ + async openAppNotificationSettings(): Promise { + return null; + } } diff --git a/src/services/platforms/WebPlatformService.ts b/src/services/platforms/WebPlatformService.ts index da573837..5b4f83b5 100644 --- a/src/services/platforms/WebPlatformService.ts +++ b/src/services/platforms/WebPlatformService.ts @@ -2,6 +2,11 @@ import { ImageResult, PlatformService, PlatformCapabilities, + NotificationStatus, + PermissionStatus, + PermissionResult, + ScheduleOptions, + NativeFetcherConfig, } from "../PlatformService"; import { logger } from "../../utils/logger"; import { QueryExecResult } from "@/interfaces/database"; @@ -677,4 +682,81 @@ export class WebPlatformService // generateInsertStatement, updateDefaultSettings, updateActiveDid, // getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings, // retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService + + // Daily notification operations + /** + * Get the status of scheduled daily notifications + * @see PlatformService.getDailyNotificationStatus + * @returns null - notifications not supported on web platform + */ + async getDailyNotificationStatus(): Promise { + return null; + } + + /** + * Check notification permissions + * @see PlatformService.checkNotificationPermissions + * @returns null - notifications not supported on web platform + */ + async checkNotificationPermissions(): Promise { + return null; + } + + /** + * Request notification permissions + * @see PlatformService.requestNotificationPermissions + * @returns null - notifications not supported on web platform + */ + async requestNotificationPermissions(): Promise { + return null; + } + + /** + * Schedule a daily notification + * @see PlatformService.scheduleDailyNotification + * @throws Error - notifications not supported on web platform + */ + async scheduleDailyNotification(_options: ScheduleOptions): Promise { + throw new Error("Daily notifications are not supported on web platform"); + } + + /** + * Cancel scheduled daily notification + * @see PlatformService.cancelDailyNotification + * @throws Error - notifications not supported on web platform + */ + async cancelDailyNotification(): Promise { + throw new Error("Daily notifications are not supported on web platform"); + } + + /** + * Configure native fetcher for background operations + * @see PlatformService.configureNativeFetcher + * @returns null - native fetcher not supported on web platform + */ + async configureNativeFetcher( + _config: NativeFetcherConfig, + ): Promise { + return null; + } + + /** + * Update starred plans for background fetcher + * @see PlatformService.updateStarredPlans + * @returns null - native fetcher not supported on web platform + */ + async updateStarredPlans(_plans: { + planIds: string[]; + }): Promise { + return null; + } + + /** + * Open the app's notification settings in the system settings + * @see PlatformService.openAppNotificationSettings + * @returns null - not supported on web platform + */ + async openAppNotificationSettings(): Promise { + return null; + } } diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index 9b7efd3e..ab107e62 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -161,6 +161,9 @@ + + +