Add alert indicator before reminder

This commit is contained in:
Hariharan Mudaliar 2025-11-11 00:03:17 +05:30
commit 929b15ba45
3 changed files with 66 additions and 41 deletions

View file

@ -474,7 +474,7 @@ struct ContentView: View {
GeometryReader { geo in GeometryReader { geo in
MarqueeText( MarqueeText(
.constant(reminderSneakPeekText(for: reminder)), .constant(reminderSneakPeekText(for: reminder)),
textColor: reminderColor(for: reminder.event), textColor: reminderColor(for: reminder, now: reminderManager.currentDate),
minDuration: 1, minDuration: 1,
frameWidth: geo.size.width frameWidth: geo.size.width
) )
@ -522,8 +522,13 @@ struct ContentView: View {
} }
} }
private func reminderColor(for event: EventModel) -> Color { private func reminderColor(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Color {
Color(nsColor: event.calendar.color).ensureMinimumBrightness(factor: 0.7) let window = TimeInterval(Defaults[.reminderSneakPeekDuration])
let remaining = reminder.event.start.timeIntervalSince(now)
if window > 0 && remaining > 0 && remaining <= window {
return .red
}
return Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
} }
private func reminderSneakPeekText(for entry: ReminderLiveActivityManager.ReminderEntry) -> String { private func reminderSneakPeekText(for entry: ReminderLiveActivityManager.ReminderEntry) -> String {

View file

@ -42,7 +42,7 @@ struct ReminderLiveActivity: View {
Color.clear Color.clear
.frame(width: leftWingWidth, height: notchContentHeight) .frame(width: leftWingWidth, height: notchContentHeight)
.background(alignment: .leading) { .background(alignment: .leading) {
iconSection(for: reminder) iconSection(for: reminder, now: now)
.padding(.leading, wingPadding / 2) .padding(.leading, wingPadding / 2)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
} }
@ -62,11 +62,11 @@ struct ReminderLiveActivity: View {
.frame(height: notchContentHeight, alignment: .center) .frame(height: notchContentHeight, alignment: .center)
} }
private func iconSection(for reminder: ReminderLiveActivityManager.ReminderEntry) -> some View { private func iconSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View {
let diameter = iconDiameter let diameter = iconDiameter
let accent = accentColor(for: reminder) let accent = accentColor(for: reminder, now: now)
return Image(systemName: glyphName(for: reminder.event)) return Image(systemName: iconName(for: reminder, now: now))
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundStyle(accent) .foregroundStyle(accent)
.frame(width: diameter, height: diameter) .frame(width: diameter, height: diameter)
@ -75,49 +75,49 @@ struct ReminderLiveActivity: View {
@ViewBuilder @ViewBuilder
private func rightSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View { private func rightSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View {
let accent = accentColor(for: reminder) let accent = accentColor(for: reminder, now: now)
switch presentationStyle { switch presentationStyle {
case .ringCountdown: case .ringCountdown:
ringCountdownSection(for: reminder, now: now, accent: accent) ringCountdownSection(for: reminder, now: now, accent: accent)
case .digital: case .digital:
digitalSection(for: reminder, now: now) digitalSection(for: reminder, now: now, accent: accent)
case .minutes: case .minutes:
minutesSection(for: reminder, now: now) minutesSection(for: reminder, now: now, accent: accent)
} }
} }
private func ringCountdownSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date, accent: Color) -> some View { private func ringCountdownSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date, accent: Color) -> some View {
let progress = progress(for: reminder, now: now) let progressValue = progress(for: reminder, now: now)
let diameter = ringDiameter let diameter = ringDiameter
return ZStack { return ZStack {
Circle() Circle()
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth) .stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
Circle() Circle()
.trim(from: 0, to: progress) .trim(from: 0, to: progressValue)
.stroke(accent, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round)) .stroke(accent, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.25), value: progress) .animation(.smooth(duration: 0.25), value: progressValue)
} }
.frame(width: diameter, height: diameter) .frame(width: diameter, height: diameter)
.frame(height: notchContentHeight, alignment: .center) .frame(height: notchContentHeight, alignment: .center)
} }
private func digitalSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View { private func digitalSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date, accent: Color) -> some View {
let countdown = digitalCountdown(for: reminder, now: now) let countdown = digitalCountdown(for: reminder, now: now)
return Text(countdown) return Text(countdown)
.font(.system(size: 16, weight: .semibold, design: .monospaced)) .font(.system(size: 16, weight: .semibold, design: .monospaced))
.foregroundColor(.white) .foregroundColor(accent)
.contentTransition(.numericText()) .contentTransition(.numericText())
.animation(.smooth(duration: 0.25), value: countdown) .animation(.smooth(duration: 0.25), value: countdown)
.frame(height: notchContentHeight, alignment: .center) .frame(height: notchContentHeight, alignment: .center)
} }
private func minutesSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View { private func minutesSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date, accent: Color) -> some View {
VStack(alignment: .trailing, spacing: 2) { VStack(alignment: .trailing, spacing: 2) {
Text(minutesCountdown(for: reminder, now: now)) Text(minutesCountdown(for: reminder, now: now))
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
.foregroundColor(.white) .foregroundColor(accent)
} }
.frame(height: notchContentHeight, alignment: .center) .frame(height: notchContentHeight, alignment: .center)
} }
@ -144,19 +144,11 @@ struct ReminderLiveActivity: View {
return minutes == 1 ? "in 1 min" : "in \(minutes) min" return minutes == 1 ? "in 1 min" : "in \(minutes) min"
} }
private func glyphName(for event: EventModel) -> String { private func accentColor(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Color {
switch event.type { if isCritical(for: reminder, now: now) {
case .birthday: return .red
return "gift.fill"
case .reminder(let completed):
return completed ? "checkmark.circle" : "bell.fill"
case .event:
return event.isMeeting ? "person.2.fill" : "calendar"
} }
} return Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
private func accentColor(for reminder: ReminderLiveActivityManager.ReminderEntry) -> Color {
Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
} }
private var leftWingWidth: CGFloat { private var leftWingWidth: CGFloat {
@ -216,4 +208,15 @@ struct ReminderLiveActivity: View {
return UIFont.monospacedDigitSystemFont(ofSize: size, weight: weight) return UIFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
#endif #endif
} }
private func iconName(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String {
isCritical(for: reminder, now: now) ? ReminderLiveActivityManager.criticalIconName : ReminderLiveActivityManager.standardIconName
}
private func isCritical(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Bool {
let window = TimeInterval(Defaults[.reminderSneakPeekDuration])
guard window > 0 else { return false }
let remaining = reminder.event.start.timeIntervalSince(now)
return remaining > 0 && remaining <= window
}
} }

View file

@ -19,6 +19,8 @@ final class ReminderLiveActivityManager: ObservableObject {
} }
static let shared = ReminderLiveActivityManager() static let shared = ReminderLiveActivityManager()
static let standardIconName = "calendar.badge.clock"
static let criticalIconName = "calendar.badge.exclamationmark"
@Published private(set) var activeReminder: ReminderEntry? @Published private(set) var activeReminder: ReminderEntry?
@Published private(set) var currentDate: Date = Date() @Published private(set) var currentDate: Date = Date()
@ -36,6 +38,7 @@ final class ReminderLiveActivityManager: ObservableObject {
private let minimumRefreshInterval: TimeInterval = 10 private let minimumRefreshInterval: TimeInterval = 10
private let refreshDebounceInterval: TimeInterval = 0.3 private let refreshDebounceInterval: TimeInterval = 0.3
private var refreshTaskToken = UUID() private var refreshTaskToken = UUID()
private var hasShownCriticalSneakPeek = false
private let calendarService: CalendarServiceProviding private let calendarService: CalendarServiceProviding
private let calendarManager = CalendarManager.shared private let calendarManager = CalendarManager.shared
@ -110,6 +113,7 @@ final class ReminderLiveActivityManager: ObservableObject {
pendingRefreshForce = false pendingRefreshForce = false
refreshTask?.cancel() refreshTask?.cancel()
refreshTask = nil refreshTask = nil
hasShownCriticalSneakPeek = false
} }
private func deactivateReminder() { private func deactivateReminder() {
@ -254,6 +258,7 @@ final class ReminderLiveActivityManager: ObservableObject {
private func handleEntrySelection(_ entry: ReminderEntry?, referenceDate: Date) { private func handleEntrySelection(_ entry: ReminderEntry?, referenceDate: Date) {
fallbackRefreshTask?.cancel() fallbackRefreshTask?.cancel()
nextReminder = entry nextReminder = entry
hasShownCriticalSneakPeek = false
Task { await self.evaluateCurrentState(at: referenceDate) } Task { await self.evaluateCurrentState(at: referenceDate) }
} }
@ -317,6 +322,7 @@ final class ReminderLiveActivityManager: ObservableObject {
activeReminder = nil activeReminder = nil
nextReminder = nil nextReminder = nil
stopTicker() stopTicker()
hasShownCriticalSneakPeek = false
scheduleRefresh(force: true) scheduleRefresh(force: true)
return return
} }
@ -333,8 +339,28 @@ final class ReminderLiveActivityManager: ObservableObject {
type: .reminder, type: .reminder,
duration: Defaults[.reminderSneakPeekDuration], duration: Defaults[.reminderSneakPeekDuration],
value: 0, value: 0,
icon: glyphName(for: entry.event) icon: ReminderLiveActivityManager.standardIconName
) )
hasShownCriticalSneakPeek = false
}
let criticalWindow = TimeInterval(Defaults[.reminderSneakPeekDuration])
let timeRemaining = entry.event.start.timeIntervalSince(date)
if criticalWindow > 0 && timeRemaining > 0 {
if timeRemaining <= criticalWindow {
if !hasShownCriticalSneakPeek {
DynamicIslandViewCoordinator.shared.toggleSneakPeek(
status: true,
type: .reminder,
duration: Defaults[.reminderSneakPeekDuration],
value: 0,
icon: ReminderLiveActivityManager.criticalIconName
)
hasShownCriticalSneakPeek = true
}
} else {
hasShownCriticalSneakPeek = false
}
} }
startTickerIfNeeded() startTickerIfNeeded()
} else { } else {
@ -342,6 +368,7 @@ final class ReminderLiveActivityManager: ObservableObject {
activeReminder = nil activeReminder = nil
} }
stopTicker() stopTicker()
hasShownCriticalSneakPeek = false
scheduleEvaluation(at: entry.triggerDate) scheduleEvaluation(at: entry.triggerDate)
} }
} }
@ -355,14 +382,4 @@ final class ReminderLiveActivityManager: ObservableObject {
await evaluateCurrentState(at: now) await evaluateCurrentState(at: now)
} }
private func glyphName(for event: EventModel) -> String {
switch event.type {
case .birthday:
return "gift.fill"
case .reminder(let completed):
return completed ? "checkmark.circle" : "bell.fill"
case .event:
return event.isMeeting ? "person.2.fill" : "calendar"
}
}
} }