mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-24 21:31:41 +08:00
Add PWA infrastructure for iOS notification support
- Add manifest.json for proper PWA installation - Add service worker (sw.js) for push notification handling - Add ServiceWorkerProvider to register SW and manage notifications - Generate PNG icons (192x192, 512x512) for iOS compatibility - Update settings to show notification status and test button - Use service worker showNotification for iOS Safari compatibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,20 @@ import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
import { AuthProvider } from "@/components/AuthProvider";
|
||||
import { ServiceWorkerProvider } from "@/components/ServiceWorkerProvider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: APP_NAME,
|
||||
description: "A calm, private gratitude and mood log",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: "/icons/icon.svg",
|
||||
apple: "/icons/icon.svg",
|
||||
apple: "/icons/icon-192.png",
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: APP_NAME,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,12 +34,10 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body className="antialiased min-h-screen">
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useServiceWorker } from "@/components/ServiceWorkerProvider";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key, ChevronDown } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -29,11 +30,13 @@ const PROVIDERS = [
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const { isSupported: swSupported, isRegistered: swRegistered, showNotification } = useServiceWorker();
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showLlmSetup, setShowLlmSetup] = useState(false);
|
||||
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>("default");
|
||||
const [isPWA, setIsPWA] = useState(false);
|
||||
|
||||
// LLM setup state
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>("");
|
||||
@@ -68,6 +71,11 @@ export default function SettingsPage() {
|
||||
if ("Notification" in window) {
|
||||
setNotificationPermission(Notification.permission);
|
||||
}
|
||||
|
||||
// Detect if running as PWA (standalone mode)
|
||||
const isStandalone = window.matchMedia("(display-mode: standalone)").matches ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
setIsPWA(isStandalone);
|
||||
}, []);
|
||||
|
||||
const updateSetting = async (key: string, value: unknown) => {
|
||||
@@ -204,27 +212,35 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Reminder check interval
|
||||
// Reminder check interval - uses service worker for iOS compatibility
|
||||
useEffect(() => {
|
||||
if (!settings?.reminderEnabled || !settings?.reminderTime) return;
|
||||
|
||||
const checkReminder = () => {
|
||||
// Track if we've shown the notification this minute
|
||||
let lastShownMinute = -1;
|
||||
|
||||
const checkReminder = async () => {
|
||||
const now = new Date();
|
||||
const [hours, minutes] = settings.reminderTime.split(":").map(Number);
|
||||
const currentMinute = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
if (now.getHours() === hours && now.getMinutes() === minutes) {
|
||||
if (now.getHours() === hours && now.getMinutes() === minutes && currentMinute !== lastShownMinute) {
|
||||
lastShownMinute = currentMinute;
|
||||
if (Notification.permission === "granted") {
|
||||
new Notification(APP_NAME, {
|
||||
await showNotification(APP_NAME, {
|
||||
body: "Take a moment to reflect on what you're grateful for today.",
|
||||
icon: "/icon.png",
|
||||
icon: "/icons/icon.svg",
|
||||
tag: "daily-reminder",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately and then every minute
|
||||
checkReminder();
|
||||
const interval = setInterval(checkReminder, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [settings?.reminderEnabled, settings?.reminderTime]);
|
||||
}, [settings?.reminderEnabled, settings?.reminderTime, showNotification]);
|
||||
|
||||
const getProviderName = (id: string) => PROVIDERS.find(p => p.id === id)?.name || id;
|
||||
|
||||
@@ -302,9 +318,37 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{settings.reminderEnabled && notificationPermission === "granted" && swRegistered && (
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-green-500">
|
||||
<Check size={14} />
|
||||
<span>Notifications ready{isPWA ? " (PWA mode)" : ""}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => showNotification(APP_NAME, {
|
||||
body: "Test notification - reminders are working!",
|
||||
icon: "/icons/icon.svg",
|
||||
tag: "test-notification",
|
||||
})}
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{settings.reminderEnabled && notificationPermission === "granted" && !swRegistered && (
|
||||
<p className="mt-2 text-sm text-amber-400">
|
||||
Service worker loading... Notifications may be limited.
|
||||
</p>
|
||||
)}
|
||||
{notificationPermission === "denied" && (
|
||||
<p className="mt-2 text-sm text-red-400">
|
||||
Notifications are blocked. Please enable them in your browser settings.
|
||||
Notifications are blocked. Please enable them in your browser/device settings.
|
||||
</p>
|
||||
)}
|
||||
{settings.reminderEnabled && notificationPermission === "default" && (
|
||||
<p className="mt-2 text-sm text-amber-400">
|
||||
Notification permission not yet granted. Toggle reminders off and on to request permission.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
99
src/components/ServiceWorkerProvider.tsx
Normal file
99
src/components/ServiceWorkerProvider.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, createContext, useContext, useState, useCallback, ReactNode } from "react";
|
||||
|
||||
interface ServiceWorkerContextValue {
|
||||
isSupported: boolean;
|
||||
isRegistered: boolean;
|
||||
registration: ServiceWorkerRegistration | null;
|
||||
showNotification: (title: string, options?: NotificationOptions) => Promise<void>;
|
||||
}
|
||||
|
||||
const ServiceWorkerContext = createContext<ServiceWorkerContextValue>({
|
||||
isSupported: false,
|
||||
isRegistered: false,
|
||||
registration: null,
|
||||
showNotification: async () => {},
|
||||
});
|
||||
|
||||
export function useServiceWorker() {
|
||||
return useContext(ServiceWorkerContext);
|
||||
}
|
||||
|
||||
interface ServiceWorkerProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ServiceWorkerProvider({ children }: ServiceWorkerProviderProps) {
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
const [isRegistered, setIsRegistered] = useState(false);
|
||||
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if service workers are supported
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
console.log("[SW Provider] Service workers not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSupported(true);
|
||||
|
||||
// Register the service worker
|
||||
async function registerServiceWorker() {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register("/sw.js", {
|
||||
scope: "/",
|
||||
});
|
||||
console.log("[SW Provider] Service worker registered:", reg.scope);
|
||||
setRegistration(reg);
|
||||
setIsRegistered(true);
|
||||
|
||||
// Check for updates
|
||||
reg.addEventListener("updatefound", () => {
|
||||
console.log("[SW Provider] Service worker update found");
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[SW Provider] Service worker registration failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
registerServiceWorker();
|
||||
}, []);
|
||||
|
||||
const showNotification = useCallback(
|
||||
async (title: string, options?: NotificationOptions) => {
|
||||
if (!registration) {
|
||||
console.log("[SW Provider] No registration, falling back to Notification API");
|
||||
if ("Notification" in window && Notification.permission === "granted") {
|
||||
new Notification(title, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await registration.showNotification(title, options);
|
||||
console.log("[SW Provider] Notification shown via service worker");
|
||||
} catch (error) {
|
||||
console.error("[SW Provider] Failed to show notification:", error);
|
||||
// Fallback to regular Notification API
|
||||
if ("Notification" in window && Notification.permission === "granted") {
|
||||
new Notification(title, options);
|
||||
}
|
||||
}
|
||||
},
|
||||
[registration]
|
||||
);
|
||||
|
||||
return (
|
||||
<ServiceWorkerContext.Provider
|
||||
value={{
|
||||
isSupported,
|
||||
isRegistered,
|
||||
registration,
|
||||
showNotification,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ServiceWorkerContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user