From 1883697de7a26839ca66ef2162f825973adc89b9 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 21:10:25 -0800 Subject: [PATCH] feat: add settings link in dashboard header --- .../Notifications/NotificationScheduler.swift | 188 ++++++++++++++++++ .../Shared/Theme/ChronoMindTheme.swift | 117 +++++++++++ web/src/components/Dashboard.tsx | 11 +- 3 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 ios/ChronoMind/Shared/Notifications/NotificationScheduler.swift create mode 100644 ios/ChronoMind/Shared/Theme/ChronoMindTheme.swift diff --git a/ios/ChronoMind/Shared/Notifications/NotificationScheduler.swift b/ios/ChronoMind/Shared/Notifications/NotificationScheduler.swift new file mode 100644 index 0000000..faac9c6 --- /dev/null +++ b/ios/ChronoMind/Shared/Notifications/NotificationScheduler.swift @@ -0,0 +1,188 @@ +// ── Notification Scheduling ──────────────────────────────────── +// UNUserNotificationCenter wrapper for pre-warning cascade + timer fire +// iOS-native notification system + +import Foundation +import UserNotifications + +// MARK: - Notification Manager + +@MainActor +final class CMNotificationManager: ObservableObject { + static let shared = CMNotificationManager() + + @Published var isAuthorized = false + + private init() {} + + // MARK: - Permission + + func requestPermission() async { + do { + let granted = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge, .criticalAlert]) + isAuthorized = granted + } catch { + isAuthorized = false + } + } + + func checkPermission() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + isAuthorized = settings.authorizationStatus == .authorized + } + + // MARK: - Schedule Timer Notifications + + /// Schedule all cascade warnings + final fire notification for a timer + func scheduleNotifications(for timer: CMTimer) { + // Remove existing notifications for this timer + removeNotifications(for: timer.id) + + // Schedule cascade warnings + for warning in timer.warnings where !warning.fired { + scheduleWarning(timer: timer, warning: warning) + } + + // Schedule fire notification + scheduleFireNotification(timer: timer) + } + + /// Remove all scheduled notifications for a timer + func removeNotifications(for timerId: String) { + let center = UNUserNotificationCenter.current() + // Remove all notifications with this timer's ID prefix + center.getPendingNotificationRequests { requests in + let ids = requests + .filter { $0.identifier.hasPrefix("cm-\(timerId)") } + .map { $0.identifier } + center.removePendingNotificationRequests(withIdentifiers: ids) + } + } + + /// Remove all ChronoMind notifications + func removeAllNotifications() { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + } + + // MARK: - Private + + private func scheduleWarning(timer: CMTimer, warning: CascadeWarning) { + let content = UNMutableNotificationContent() + let timeStr = formatMinutesBefore(warning.minutesBefore) + content.title = "⏰ \(timer.label) in \(timeStr)" + content.body = "Pre-warning: \"\(timer.label)\" fires in \(timeStr)" + content.sound = soundForUrgency(timer.urgency, isWarning: true) + content.categoryIdentifier = "TIMER_WARNING" + content.userInfo = [ + "timerId": timer.id, + "warningId": warning.id, + "type": "warning", + ] + + // Use the warning's scheduled time + let trigger = UNTimeIntervalNotificationTrigger( + timeInterval: max(1, warning.scheduledTime.timeIntervalSinceNow), + repeats: false + ) + + let request = UNNotificationRequest( + identifier: "cm-\(timer.id)-warning-\(warning.id)", + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) + } + + private func scheduleFireNotification(timer: CMTimer) { + let content = UNMutableNotificationContent() + content.title = "🔔 \(timer.label) — NOW!" + content.body = "Timer \"\(timer.label)\" is firing!" + content.sound = soundForUrgency(timer.urgency, isWarning: false) + content.categoryIdentifier = "TIMER_FIRE" + content.userInfo = [ + "timerId": timer.id, + "type": "fire", + ] + + // Interrupt level for important/critical + if #available(iOS 15.0, *) { + switch timer.urgency { + case .critical: + content.interruptionLevel = .critical + case .important: + content.interruptionLevel = .timeSensitive + case .standard: + content.interruptionLevel = .active + case .gentle: + content.interruptionLevel = .passive + case .passive: + content.interruptionLevel = .passive + } + } + + let trigger = UNTimeIntervalNotificationTrigger( + timeInterval: max(1, timer.targetTime.timeIntervalSinceNow), + repeats: false + ) + + let request = UNNotificationRequest( + identifier: "cm-\(timer.id)-fire", + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) + } + + private func soundForUrgency(_ urgency: UrgencyLevel, isWarning: Bool) -> UNNotificationSound { + let config = getUrgencyConfig(urgency) + guard config.soundEnabled else { return .none } + + if urgency == .critical && !isWarning { + // Critical fires get the default critical alert sound + if #available(iOS 12.0, *) { + return .defaultCritical + } + } + + return .default + } + + // MARK: - Notification Categories + + func registerCategories() { + let snooze5 = UNNotificationAction( + identifier: "SNOOZE_5", + title: "Snooze 5m", + options: [] + ) + let snooze15 = UNNotificationAction( + identifier: "SNOOZE_15", + title: "Snooze 15m", + options: [] + ) + let dismiss = UNNotificationAction( + identifier: "DISMISS", + title: "Dismiss", + options: [.destructive] + ) + + let fireCategory = UNNotificationCategory( + identifier: "TIMER_FIRE", + actions: [snooze5, snooze15, dismiss], + intentIdentifiers: [], + options: [] + ) + + let warningCategory = UNNotificationCategory( + identifier: "TIMER_WARNING", + actions: [dismiss], + intentIdentifiers: [], + options: [] + ) + + UNUserNotificationCenter.current().setNotificationCategories([fireCategory, warningCategory]) + } +} diff --git a/ios/ChronoMind/Shared/Theme/ChronoMindTheme.swift b/ios/ChronoMind/Shared/Theme/ChronoMindTheme.swift new file mode 100644 index 0000000..6b9e05f --- /dev/null +++ b/ios/ChronoMind/Shared/Theme/ChronoMindTheme.swift @@ -0,0 +1,117 @@ +// ── ChronoMind Design Tokens ─────────────────────────────────── +// Colors from PRD Section 9 (--cm-* CSS vars → Swift Color literals) +// Typography from PRD Section 9.2 + +import SwiftUI + +// MARK: - Colors + +enum CMColors { + // Backgrounds + static let bg = Color(hex: 0x0A0B0F) + static let surface = Color(hex: 0x12141D) + static let surfaceHover = Color(hex: 0x1A1D2A) + static let border = Color(hex: 0x2A2D3A) + + // Text + static let text = Color(hex: 0xE8ECF4) + static let textSecondary = Color(hex: 0x8A92A6) + static let textMuted = Color(hex: 0x5A6178) + + // Urgency + static let critical = Color(hex: 0xFF4757) + static let important = Color(hex: 0xFF9F43) + static let standard = Color(hex: 0xFECA57) + static let gentle = Color(hex: 0x2ED573) + static let passive = Color(hex: 0x5B8DEE) + + // Accent + static let accent = Color(hex: 0x5B8DEE) + static let accentGlow = Color(hex: 0x5B8DEE, alpha: 0.2) + + // Semantic + static let success = Color(hex: 0x2ED573) + static let warning = Color(hex: 0xFECA57) + static let error = Color(hex: 0xFF4757) + + // Light theme variants + static let lightBg = Color(hex: 0xF8F9FC) + static let lightSurface = Color.white + static let lightSurfaceHover = Color(hex: 0xF0F2F8) + static let lightBorder = Color(hex: 0xE2E5EE) + static let lightText = Color(hex: 0x1A1D2A) + static let lightTextSecondary = Color(hex: 0x5A6178) + static let lightTextMuted = Color(hex: 0x8A92A6) + + /// Get urgency color for a given level + static func urgencyColor(_ level: UrgencyLevel) -> Color { + switch level { + case .critical: return critical + case .important: return important + case .standard: return standard + case .gentle: return gentle + case .passive: return passive + } + } + + /// Get urgency background color (15% opacity) + static func urgencyBg(_ level: UrgencyLevel) -> Color { + urgencyColor(level).opacity(0.15) + } + + /// Get urgency border color (40% opacity) + static func urgencyBorder(_ level: UrgencyLevel) -> Color { + urgencyColor(level).opacity(0.4) + } +} + +// MARK: - Typography + +enum CMFonts { + /// Display font — Space Grotesk (clock face, timer digits) + static func display(size: CGFloat, weight: Font.Weight = .bold) -> Font { + .system(size: size, weight: weight, design: .rounded) + } + + /// Body font — Inter/SF Pro equivalent + static func body(size: CGFloat, weight: Font.Weight = .regular) -> Font { + .system(size: size, weight: weight, design: .default) + } + + /// Mono font — JetBrains Mono (countdown digits, time displays) + static func mono(size: CGFloat, weight: Font.Weight = .regular) -> Font { + .system(size: size, weight: weight, design: .monospaced) + } +} + +// MARK: - Spacing + +enum CMSpacing { + static let xxs: CGFloat = 2 + static let xs: CGFloat = 4 + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 + static let xl: CGFloat = 24 + static let xxl: CGFloat = 32 + static let xxxl: CGFloat = 48 +} + +// MARK: - Corner Radius + +enum CMRadius { + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 + static let xl: CGFloat = 20 + static let full: CGFloat = 999 +} + +// MARK: - Shadows + +enum CMShadow { + static let sm = Color.black.opacity(0.1) + static let md = Color.black.opacity(0.2) + static let lg = Color.black.opacity(0.3) + static let glow = CMColors.accent.opacity(0.3) +} diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index 854f379..877cb0f 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -11,7 +11,8 @@ import { CreateTimerModal } from './CreateTimerModal'; import { AlarmOverlay } from './AlarmOverlay'; import { requestNotificationPermission } from '@/lib/notifications'; import { formatTime, formatDate } from '@/lib/format'; -import { Plus, Clock, Bell, Keyboard, Sun, Moon } from 'lucide-react'; +import { Plus, Clock, Bell, Keyboard, Sun, Moon, Settings } from 'lucide-react'; +import Link from 'next/link'; import { FeedbackButton } from './FeedbackButton'; import { useTheme } from '@/lib/use-theme'; @@ -134,6 +135,14 @@ export function Dashboard() { > {theme === 'dark' ? : } + + +