You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

772 lines
25 KiB

<template>
<section
v-if="notificationsSupported"
id="sectionDailyNotifications"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
aria-labelledby="dailyNotificationsHeading"
>
<h2 id="dailyNotificationsHeading" class="mb-2 font-bold">
Daily Notifications
<button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about native notifications"
@click.stop="showNativeNotificationInfo"
>
<font-awesome icon="circle-question" aria-hidden="true" />
</button>
</h2>
<div class="flex items-center justify-between">
<div>Daily Notification</div>
<!-- Toggle switch -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="nativeNotificationEnabled"
:aria-label="
nativeNotificationEnabled
? 'Disable daily notifications'
: 'Enable daily notifications'
"
tabindex="0"
@click="toggleNativeNotification"
>
<!-- input -->
<input
:checked="nativeNotificationEnabled"
type="checkbox"
class="sr-only"
tabindex="-1"
readonly
/>
<!-- line -->
<div
class="block bg-slate-500 w-14 h-8 rounded-full transition"
:class="{
'bg-blue-600': nativeNotificationEnabled,
}"
></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
:class="{
'left-7 bg-white': nativeNotificationEnabled,
}"
></div>
</div>
</div>
<!-- Show "Open Settings" button when permissions are denied -->
<div
v-if="
notificationsSupported &&
notificationStatus &&
notificationStatus.permissions.notifications === 'denied'
"
class="mt-2"
>
<button
class="w-full px-3 py-2 bg-blue-600 text-white rounded text-sm font-medium"
@click="openNotificationSettings"
>
Open Settings
</button>
<p class="text-xs text-slate-500 mt-1 text-center">
Enable notifications in Settings > App info > Notifications
</p>
</div>
<!-- Time input section - show when enabled OR when no time is set -->
<div
v-if="nativeNotificationEnabled || !nativeNotificationTimeStorage"
class="mt-2"
>
<div
v-if="nativeNotificationEnabled"
class="flex items-center justify-between mb-2"
>
<span
>Scheduled for:
<span v-if="nativeNotificationTime">{{
nativeNotificationTime
}}</span>
<span v-else class="text-slate-500">Not set</span></span
>
<button
class="text-blue-500 text-sm"
@click="editNativeNotificationTime"
>
{{ showTimeEdit ? "Cancel" : "Edit Time" }}
</button>
</div>
<!-- Time input (shown when editing or when no time is set) -->
<div v-if="showTimeEdit || !nativeNotificationTimeStorage" class="mt-2">
<label class="block text-sm text-slate-600 mb-1">
Notification Time
</label>
<div class="flex items-center gap-2">
<input
v-model="nativeNotificationTimeStorage"
type="time"
class="rounded border border-slate-400 px-2 py-2"
@change="onTimeChange"
/>
<button
v-if="showTimeEdit || nativeNotificationTimeStorage"
class="px-3 py-2 bg-blue-600 text-white rounded"
@click="saveTimeChange"
>
Save
</button>
</div>
<p
v-if="!nativeNotificationTimeStorage"
class="text-xs text-slate-500 mt-1"
>
Set a time before enabling notifications
</p>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="mt-2 text-sm text-slate-500">Loading...</div>
</section>
</template>
<script lang="ts">
/**
* DailyNotificationSection Component
*
* A self-contained component for managing daily notification scheduling
* in AccountViewView. This component handles platform detection, permission
* requests, scheduling, and state management for daily notifications.
*
* Features:
* - Platform capability detection (hides on unsupported platforms)
* - Permission request flow
* - Schedule/cancel notifications
* - Time editing with HTML5 time input
* - Settings persistence
* - Plugin state synchronization
*
* @author Generated for TimeSafari Daily Notification Integration
* @component
*/
import { Component, Vue } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { logger } from "@/utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import type {
NotificationStatus,
PermissionStatus,
} from "@/services/PlatformService";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import type { NotificationIface } from "@/constants/app";
/**
* Convert 24-hour time format ("09:00") to 12-hour display format ("9:00 AM")
*/
function formatTimeForDisplay(time24: string): string {
if (!time24) return "";
const [hours, minutes] = time24.split(":");
const hourNum = parseInt(hours);
const isPM = hourNum >= 12;
const displayHour =
hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
return `${displayHour}:${minutes} ${isPM ? "PM" : "AM"}`;
}
@Component({
name: "DailyNotificationSection",
mixins: [PlatformServiceMixin],
})
export default class DailyNotificationSection extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
// Component state
notificationsSupported: boolean = false;
nativeNotificationEnabled: boolean = false;
nativeNotificationTime: string = ""; // Display format: "9:00 AM"
nativeNotificationTimeStorage: string = ""; // Plugin format: "09:00"
nativeNotificationTitle: string = "Daily Update";
nativeNotificationMessage: string = "Your daily notification is ready!";
showTimeEdit: boolean = false;
loading: boolean = false;
notificationStatus: NotificationStatus | null = null;
// Notify helpers
private notify!: ReturnType<typeof createNotifyHelpers>;
async created(): Promise<void> {
this.notify = createNotifyHelpers(this.$notify);
}
/**
* Initialize component state on mount
* Checks platform support and syncs with plugin state
*
* **Token Refresh on Mount:**
* - Refreshes native fetcher configuration to ensure plugin has valid token
* - This handles cases where app was closed for extended periods
* - Token refresh happens automatically without user interaction
*
* **App Resume Listener:**
* - Listens for Capacitor 'resume' event to refresh token when app comes to foreground
* - Ensures plugin always has fresh token for background prefetch operations
* - Cleaned up in `beforeDestroy()` lifecycle hook
*/
async mounted(): Promise<void> {
await this.initializeState();
// Refresh native fetcher configuration on mount
// This ensures plugin has valid token even if app was closed for extended periods
await this.refreshNativeFetcherConfig();
// Listen for app resume events to refresh token when app comes to foreground
// This is part of the proactive token refresh strategy
document.addEventListener("resume", this.handleAppResume);
}
/**
* Cleanup on component destroy
*/
beforeDestroy(): void {
document.removeEventListener("resume", this.handleAppResume);
}
/**
* Handle app resume event - refresh native fetcher configuration
*
* This method is called when the app comes to foreground (via Capacitor 'resume' event).
* It proactively refreshes the JWT token to ensure the plugin has valid authentication
* for background prefetch operations.
*
* **Why refresh on resume?**
* - Tokens expire after 72 hours
* - App may have been closed for extended periods
* - Refreshing ensures plugin has valid token for next prefetch cycle
* - No user interaction required - happens automatically
*
* @see {@link refreshNativeFetcherConfig} For implementation details
*/
async handleAppResume(): Promise<void> {
logger.debug(
"[DailyNotificationSection] App resumed, refreshing native fetcher config",
);
await this.refreshNativeFetcherConfig();
}
/**
* Refresh native fetcher configuration with fresh JWT token
*
* This method ensures the daily notification plugin has a valid authentication token
* for background prefetch operations. It's called proactively to prevent token expiration
* issues during offline periods.
*
* **Refresh Triggers:**
* - Component mount (when notification settings page loads)
* - App resume (when app comes to foreground)
* - Notification enabled (when user enables daily notifications)
*
* **Token Refresh Strategy (Hybrid Approach):**
* - Tokens are valid for 72 hours (see `accessTokenForBackground`)
* - Tokens are refreshed proactively when app is already open
* - If token expires while offline, plugin uses cached content
* - Next time app opens, token is automatically refreshed
*
* **Why This Approach?**
* - No app wake-up required (tokens refresh when app is already open)
* - Works offline (72-hour validity supports extended offline periods)
* - Automatic (no user interaction required)
* - Includes starred plans (fetcher receives user's starred plans for prefetch)
* - Graceful degradation (if refresh fails, cached content still works)
*
* **Error Handling:**
* - Errors are logged but not shown to user (background operation)
* - Returns early if notifications not supported or disabled
* - Returns early if API server not configured
* - Failures don't interrupt user experience
*
* @returns Promise that resolves when refresh completes (or fails silently)
*
* @example
* ```typescript
* // Called automatically on mount/resume
* await this.refreshNativeFetcherConfig();
* ```
*
* @see {@link CapacitorPlatformService.configureNativeFetcher} For token generation
* @see {@link accessTokenForBackground} For 72-hour token generation
*/
async refreshNativeFetcherConfig(): Promise<void> {
try {
const platformService = PlatformServiceFactory.getInstance();
// Early return: Only refresh if notifications are supported and enabled
// This prevents unnecessary work when notifications aren't being used
if (!this.notificationsSupported || !this.nativeNotificationEnabled) {
return;
}
// Get settings for API server and starred plans
// API server tells plugin where to fetch content from
// Starred plans tell plugin which plans to prefetch
const settings = await this.$accountSettings();
const apiServer = settings.apiServer || "";
if (!apiServer) {
logger.warn(
"[DailyNotificationSection] No API server configured, skipping native fetcher refresh",
);
return;
}
// Get starred plans from settings
// These are passed to the plugin so it knows which plans to prefetch
const starredPlanHandleIds = settings.starredPlanHandleIds || [];
// Configure native fetcher with fresh token
// The jwt parameter is ignored - configureNativeFetcher generates it automatically
// This ensures we always have a fresh token with current expiration time
await platformService.configureNativeFetcher({
apiServer,
jwt: "", // Will be generated automatically by configureNativeFetcher
starredPlanHandleIds,
});
logger.info(
"[DailyNotificationSection] Native fetcher configuration refreshed",
);
} catch (error) {
// Don't show error to user - this is a background operation
// Failures are logged for debugging but don't interrupt user experience
// If refresh fails, plugin will use existing token (if still valid) or cached content
logger.error(
"[DailyNotificationSection] Failed to refresh native fetcher config:",
error,
);
}
}
/**
* Initialize component state
* Checks platform support and syncs with plugin state
*/
async initializeState(): Promise<void> {
try {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
// Check if notifications are supported on this platform
// This also verifies plugin availability (returns null if plugin unavailable)
const status = await platformService.getDailyNotificationStatus();
if (status === null) {
// Notifications not supported or plugin unavailable - don't initialize
this.notificationsSupported = false;
logger.warn(
"[DailyNotificationSection] Notifications not supported or plugin unavailable",
);
return;
}
this.notificationsSupported = true;
this.notificationStatus = status;
// Plugin state is the source of truth
if (status.isScheduled && status.scheduledTime) {
// Plugin has a scheduled notification - sync UI to match
this.nativeNotificationEnabled = true;
this.nativeNotificationTimeStorage = status.scheduledTime;
this.nativeNotificationTime = formatTimeForDisplay(
status.scheduledTime,
);
} else {
// No plugin schedule - UI defaults to disabled
this.nativeNotificationEnabled = false;
this.nativeNotificationTimeStorage = "";
this.nativeNotificationTime = "";
}
} catch (error) {
logger.error("[DailyNotificationSection] Failed to initialize:", error);
this.notificationsSupported = false;
} finally {
this.loading = false;
}
}
/**
* Toggle notification on/off
*/
async toggleNativeNotification(): Promise<void> {
// Prevent multiple simultaneous toggles
if (this.loading) {
logger.warn(
"[DailyNotificationSection] Toggle ignored - operation in progress",
);
return;
}
logger.info(
`[DailyNotificationSection] Toggling notification: ${this.nativeNotificationEnabled} -> ${!this.nativeNotificationEnabled}`,
);
if (this.nativeNotificationEnabled) {
await this.disableNativeNotification();
} else {
await this.enableNativeNotification();
}
}
/**
* Enable daily notification
*/
async enableNativeNotification(): Promise<void> {
try {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
// Check if we have a time set
if (!this.nativeNotificationTimeStorage) {
this.notify.error(
"Please set a notification time first",
TIMEOUTS.SHORT,
);
this.loading = false;
return;
}
// Check permissions first - this also verifies plugin availability
let permissions: PermissionStatus | null;
try {
permissions = await platformService.checkNotificationPermissions();
logger.info(
`[DailyNotificationSection] Permission check result:`,
permissions,
);
} catch (error) {
// Plugin may not be available or there's an error
logger.error(
"[DailyNotificationSection] Failed to check permissions (plugin may be unavailable):",
error,
);
this.notify.error(
"Unable to check notification permissions. The notification plugin may not be installed.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
if (permissions === null) {
// Platform doesn't support notifications or plugin unavailable
logger.warn(
"[DailyNotificationSection] Notifications not supported or plugin unavailable",
);
this.notify.error(
"Notifications are not supported on this platform or the plugin is not installed.",
TIMEOUTS.SHORT,
);
this.nativeNotificationEnabled = false;
return;
}
logger.info(
`[DailyNotificationSection] Permission state: ${permissions.notifications}`,
);
// If permissions are explicitly denied, don't try to request again
// (this prevents the plugin crash when handling denied permissions)
// Android won't show the dialog again if permissions are permanently denied
if (permissions.notifications === "denied") {
logger.warn(
"[DailyNotificationSection] Permissions already denied, directing user to settings",
);
this.notify.error(
"Notification permissions were denied. Tap 'Open Settings' to enable them.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
// Only request if permissions are in "prompt" state (not denied, not granted)
// This ensures we only call requestPermissions when Android will actually show a dialog
if (permissions.notifications === "prompt") {
logger.info(
"[DailyNotificationSection] Permission state is 'prompt', requesting permissions...",
);
try {
const result = await platformService.requestNotificationPermissions();
logger.info(
`[DailyNotificationSection] Permission request result:`,
result,
);
if (result === null) {
// Plugin unavailable or request failed
logger.error(
"[DailyNotificationSection] Permission request returned null",
);
this.notify.error(
"Unable to request notification permissions. The plugin may not be available.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
if (!result.notifications) {
// Permission request was denied
logger.warn(
"[DailyNotificationSection] Permission request denied by user",
);
this.notify.error(
"Notification permissions are required. Tap 'Open Settings' to enable them.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
// Permissions granted - continue
logger.info(
"[DailyNotificationSection] Permissions granted successfully",
);
} catch (error) {
// Handle permission request errors (including plugin crashes)
logger.error(
"[DailyNotificationSection] Permission request failed:",
error,
);
this.notify.error(
"Unable to request notification permissions. Tap 'Open Settings' to enable them.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
} else if (permissions.notifications !== "granted") {
// Unexpected state - shouldn't happen, but handle gracefully
logger.warn(
`[DailyNotificationSection] Unexpected permission state: ${permissions.notifications}`,
);
this.notify.error(
"Unable to determine notification permission status. Tap 'Open Settings' to check.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
} else {
logger.info("[DailyNotificationSection] Permissions already granted");
}
// Permissions are granted - continue with scheduling
// Schedule notification via PlatformService
await platformService.scheduleDailyNotification({
time: this.nativeNotificationTimeStorage, // "09:00" in local time
title: this.nativeNotificationTitle,
body: this.nativeNotificationMessage,
sound: true,
priority: "high",
});
// Update UI state
this.nativeNotificationEnabled = true;
// Refresh native fetcher configuration with fresh token
// This ensures plugin has valid authentication when notifications are first enabled
// Token will be valid for 72 hours, supporting offline prefetch operations
await this.refreshNativeFetcherConfig();
this.notify.success(
"Daily notification scheduled successfully",
TIMEOUTS.SHORT,
);
} catch (error) {
logger.error(
"[DailyNotificationSection] Failed to enable notification:",
error,
);
this.notify.error(
"Failed to schedule notification. Please try again.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
} finally {
this.loading = false;
}
}
/**
* Disable daily notification
*/
async disableNativeNotification(): Promise<void> {
try {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
// Cancel notification via PlatformService
await platformService.cancelDailyNotification();
// Update UI state
this.nativeNotificationEnabled = false;
this.nativeNotificationTime = "";
this.nativeNotificationTimeStorage = "";
this.showTimeEdit = false;
this.notify.success("Daily notification disabled", TIMEOUTS.SHORT);
} catch (error) {
logger.error(
"[DailyNotificationSection] Failed to disable notification:",
error,
);
this.notify.error(
"Failed to disable notification. Please try again.",
TIMEOUTS.LONG,
);
} finally {
this.loading = false;
}
}
/**
* Show/hide time edit input
*/
editNativeNotificationTime(): void {
this.showTimeEdit = !this.showTimeEdit;
}
/**
* Handle time input change
*/
onTimeChange(): void {
// Time is already in nativeNotificationTimeStorage via v-model
// Just update display format
if (this.nativeNotificationTimeStorage) {
this.nativeNotificationTime = formatTimeForDisplay(
this.nativeNotificationTimeStorage,
);
}
}
/**
* Save time change and update notification schedule
*/
async saveTimeChange(): Promise<void> {
if (!this.nativeNotificationTimeStorage) {
this.notify.error("Please select a time", TIMEOUTS.SHORT);
return;
}
// Update display format
this.nativeNotificationTime = formatTimeForDisplay(
this.nativeNotificationTimeStorage,
);
// If notification is enabled, update the schedule
if (this.nativeNotificationEnabled) {
await this.updateNotificationTime(this.nativeNotificationTimeStorage);
} else {
// Just update local state (time preference stored in component)
this.showTimeEdit = false;
this.notify.success("Notification time saved", TIMEOUTS.SHORT);
}
}
/**
* Update notification time
* If notification is enabled, immediately updates the schedule
*/
async updateNotificationTime(newTime: string): Promise<void> {
// newTime is in "HH:mm" format from HTML5 time input
if (!this.nativeNotificationEnabled) {
// If notification is disabled, just update local state
this.nativeNotificationTimeStorage = newTime;
this.nativeNotificationTime = formatTimeForDisplay(newTime);
this.showTimeEdit = false;
return;
}
// Notification is enabled - update the schedule
try {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
// 1. Cancel existing notification
await platformService.cancelDailyNotification();
// 2. Schedule with new time
await platformService.scheduleDailyNotification({
time: newTime, // "09:00" in local time
title: this.nativeNotificationTitle,
body: this.nativeNotificationMessage,
sound: true,
priority: "high",
});
// 3. Update local state
this.nativeNotificationTimeStorage = newTime;
this.nativeNotificationTime = formatTimeForDisplay(newTime);
this.notify.success(
"Notification time updated successfully",
TIMEOUTS.SHORT,
);
this.showTimeEdit = false;
} catch (error) {
logger.error(
"[DailyNotificationSection] Failed to update notification time:",
error,
);
this.notify.error(
"Failed to update notification time. Please try again.",
TIMEOUTS.LONG,
);
} finally {
this.loading = false;
}
}
/**
* Show info dialog about native notifications
*/
showNativeNotificationInfo(): void {
// TODO: Implement info dialog or navigate to help page
this.notify.info(
"Daily notifications use your device's native notification system. They work even when the app is closed.",
TIMEOUTS.STANDARD,
);
}
/**
* Open app notification settings
*/
async openNotificationSettings(): Promise<void> {
try {
const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.openAppNotificationSettings();
if (result === null) {
this.notify.error(
"Unable to open settings. Please go to Settings > Apps > TimeSafari > Notifications manually.",
TIMEOUTS.LONG,
);
} else {
this.notify.success("Opening notification settings...", TIMEOUTS.SHORT);
}
} catch (error) {
logger.error(
"[DailyNotificationSection] Failed to open notification settings:",
error,
);
this.notify.error(
"Unable to open settings. Please go to Settings > Apps > TimeSafari > Notifications manually.",
TIMEOUTS.LONG,
);
}
}
}
</script>
<style scoped>
.dot {
transition: left 0.2s ease;
}
</style>