feat: add settings link in dashboard header

This commit is contained in:
saravanakumardb1 2026-02-27 21:10:25 -08:00
parent cad95be62a
commit 1883697de7
3 changed files with 315 additions and 1 deletions

View File

@ -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])
}
}

View File

@ -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)
}

View File

@ -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' ? <Sun size={18} /> : <Moon size={18} />}
</button>
<Link
href="/settings"
className="p-2 rounded-lg transition-colors"
style={{ color: 'var(--cm-text-tertiary)' }}
title="Settings"
>
<Settings size={18} />
</Link>
<button
onClick={() => setShowShortcuts((p) => !p)}
className="p-2 rounded-lg transition-colors cursor-pointer"