feat: add settings link in dashboard header
This commit is contained in:
parent
cad95be62a
commit
1883697de7
188
ios/ChronoMind/Shared/Notifications/NotificationScheduler.swift
Normal file
188
ios/ChronoMind/Shared/Notifications/NotificationScheduler.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
117
ios/ChronoMind/Shared/Theme/ChronoMindTheme.swift
Normal file
117
ios/ChronoMind/Shared/Theme/ChronoMindTheme.swift
Normal 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)
|
||||||
|
}
|
||||||
@ -11,7 +11,8 @@ import { CreateTimerModal } from './CreateTimerModal';
|
|||||||
import { AlarmOverlay } from './AlarmOverlay';
|
import { AlarmOverlay } from './AlarmOverlay';
|
||||||
import { requestNotificationPermission } from '@/lib/notifications';
|
import { requestNotificationPermission } from '@/lib/notifications';
|
||||||
import { formatTime, formatDate } from '@/lib/format';
|
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 { FeedbackButton } from './FeedbackButton';
|
||||||
import { useTheme } from '@/lib/use-theme';
|
import { useTheme } from '@/lib/use-theme';
|
||||||
|
|
||||||
@ -134,6 +135,14 @@ export function Dashboard() {
|
|||||||
>
|
>
|
||||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
</button>
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="p-2 rounded-lg transition-colors"
|
||||||
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings size={18} />
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowShortcuts((p) => !p)}
|
onClick={() => setShowShortcuts((p) => !p)}
|
||||||
className="p-2 rounded-lg transition-colors cursor-pointer"
|
className="p-2 rounded-lg transition-colors cursor-pointer"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user