diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..5bc9d48 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..d29ccab Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..7cff6ca --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,30 @@ +{ + "name": "Quiet Thanks", + "short_name": "Quiet Thanks", + "description": "A calm, private gratitude and mood log", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "categories": ["lifestyle", "health"], + "id": "quiet-thanks-pwa" +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..49cf59a --- /dev/null +++ b/public/sw.js @@ -0,0 +1,94 @@ +// Service Worker for Quiet Thanks PWA +const CACHE_NAME = 'quiet-thanks-v1'; + +// Install event - cache essential assets +self.addEventListener('install', (event) => { + console.log('[SW] Installing service worker...'); + self.skipWaiting(); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[SW] Activating service worker...'); + event.waitUntil(clients.claim()); +}); + +// Push event - handle incoming push notifications +self.addEventListener('push', (event) => { + console.log('[SW] Push event received'); + + let data = { + title: 'Quiet Thanks', + body: 'Take a moment to reflect on what you\'re grateful for today.', + icon: '/icons/icon.svg', + badge: '/icons/icon.svg', + tag: 'daily-reminder', + }; + + if (event.data) { + try { + const payload = event.data.json(); + data = { ...data, ...payload }; + } catch (e) { + data.body = event.data.text(); + } + } + + const options = { + body: data.body, + icon: data.icon, + badge: data.badge, + tag: data.tag, + vibrate: [100, 50, 100], + data: { + url: '/', + }, + actions: [ + { action: 'open', title: 'Open app' }, + { action: 'dismiss', title: 'Dismiss' }, + ], + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +// Notification click handler +self.addEventListener('notificationclick', (event) => { + console.log('[SW] Notification clicked'); + event.notification.close(); + + if (event.action === 'dismiss') { + return; + } + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // Focus existing window if available + for (const client of clientList) { + if (client.url.includes(self.registration.scope) && 'focus' in client) { + return client.focus(); + } + } + // Open new window + if (clients.openWindow) { + return clients.openWindow('/'); + } + }) + ); +}); + +// Periodic sync for background reminders (when supported) +self.addEventListener('periodicsync', (event) => { + if (event.tag === 'daily-reminder') { + event.waitUntil(checkAndShowReminder()); + } +}); + +async function checkAndShowReminder() { + // This would check the reminder time and show notification if appropriate + // For now, we rely on the client-side scheduler + console.log('[SW] Periodic sync triggered'); +} diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000..2a95766 --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,26 @@ +import sharp from "sharp"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import { readFileSync, writeFileSync } from "fs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const publicDir = join(__dirname, "..", "public", "icons"); + +const svgPath = join(publicDir, "icon.svg"); +const svgContent = readFileSync(svgPath, "utf-8"); + +async function generateIcons() { + const sizes = [192, 512]; + + for (const size of sizes) { + const outputPath = join(publicDir, `icon-${size}.png`); + await sharp(Buffer.from(svgContent)) + .resize(size, size) + .png() + .toFile(outputPath); + console.log(`Generated ${outputPath}`); + } +} + +generateIcons().catch(console.error); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2c27cea..0f2c6a1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( -
- - - -+ Service worker loading... Notifications may be limited. +
+ )} {notificationPermission === "denied" && (- Notifications are blocked. Please enable them in your browser settings. + Notifications are blocked. Please enable them in your browser/device settings. +
+ )} + {settings.reminderEnabled && notificationPermission === "default" && ( ++ Notification permission not yet granted. Toggle reminders off and on to request permission.
)} diff --git a/src/components/ServiceWorkerProvider.tsx b/src/components/ServiceWorkerProvider.tsx new file mode 100644 index 0000000..8346f4a --- /dev/null +++ b/src/components/ServiceWorkerProvider.tsx @@ -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