Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion Matinee/App/MatineeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,38 @@ import SwiftUI

@main
struct MatineeApp: App {
@State private var timer = FocusTimer()
@State private var timer: FocusTimer
@State private var updates = UpdateController()
@State private var caffeine: CaffeineMode
@State private var reminders: ReminderSettings
@State private var overlay: ReminderOverlay

init() {
let timer = FocusTimer()
let caffeine = CaffeineMode()
let reminders = ReminderSettings()
let overlay = ReminderOverlay()

caffeine.follow(timer)
timer.onPhaseFinished = { finished in
guard let reminder = reminders.reminder(after: finished) else { return }
overlay.show(reminder) { timer.start() }
}

_timer = State(initialValue: timer)
_caffeine = State(initialValue: caffeine)
_reminders = State(initialValue: reminders)
_overlay = State(initialValue: overlay)
}

var body: some Scene {
MenuBarExtra {
TimerPopover()
.environment(timer)
.environment(updates)
.environment(caffeine)
.environment(reminders)
.environment(overlay)
} label: {
MenuBarLabel(timer: timer, updates: updates)
}
Expand Down
51 changes: 51 additions & 0 deletions Matinee/Features/Caffeine/CaffeineMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation
import Observation

/// When on, keeps the Mac and its display from sleeping while the timer is running, and lets
/// them sleep normally again as soon as the timer pauses, stops or finishes.
@MainActor
@Observable
final class CaffeineMode {
private static let isEnabledKey = "caffeine.isEnabled"

var isEnabled: Bool {
didSet {
defaults.set(isEnabled, forKey: Self.isEnabledKey)
refresh()
}
}

private(set) var isKeepingAwake = false

@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private let assertion = PowerAssertion(reason: "Matinee timer is running")
@ObservationIgnored private var timerIsRunning = false

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
isEnabled = defaults.bool(forKey: Self.isEnabledKey)
}

static func shouldKeepAwake(isEnabled: Bool, timerIsRunning: Bool) -> Bool {
isEnabled && timerIsRunning
}

/// Follows `timer.isRunning` for the life of the app.
func follow(_ timer: FocusTimer) {
timerIsRunning = withObservationTracking {
timer.isRunning
} onChange: {
Task { @MainActor in self.follow(timer) }
}
refresh()
}

private func refresh() {
if Self.shouldKeepAwake(isEnabled: isEnabled, timerIsRunning: timerIsRunning) {
assertion.hold()
} else {
assertion.release()
}
isKeepingAwake = assertion.isHeld
}
}
34 changes: 34 additions & 0 deletions Matinee/Features/Caffeine/CaffeineToggle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import SwiftUI

struct CaffeineToggle: View {
@Environment(CaffeineMode.self) private var caffeine

var body: some View {
@Bindable var caffeine = caffeine

Toggle(isOn: $caffeine.isEnabled) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Caffeine")
Text(caption)
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: caffeine.isKeepingAwake ? "cup.and.heat.waves.fill" : "cup.and.heat.waves")
}
}
.toggleStyle(.switch)
.help("Keep the Mac and screen awake until the timer ends")
}

private var caption: String {
if caffeine.isKeepingAwake {
"Keeping your Mac awake"
} else if caffeine.isEnabled {
"Keeps your Mac awake while the timer runs"
} else {
"Off"
}
}
}
36 changes: 36 additions & 0 deletions Matinee/Features/Caffeine/PowerAssertion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation
import IOKit.pwr_mgt

/// An IOKit power assertion that stops idle display sleep, which also stops idle system sleep.
/// The system drops it automatically if Matinee quits or crashes.
@MainActor
final class PowerAssertion {
private let reason: String
private var assertionID: IOPMAssertionID?

var isHeld: Bool { assertionID != nil }

init(reason: String) {
self.reason = reason
}

func hold() {
guard assertionID == nil else { return }
var newID = IOPMAssertionID(0)
let result = IOPMAssertionCreateWithName(
"PreventUserIdleDisplaySleep" as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
reason as CFString,
&newID
)
if result == kIOReturnSuccess {
assertionID = newID
}
}

func release() {
guard let assertionID else { return }
IOPMAssertionRelease(assertionID)
self.assertionID = nil
}
}
81 changes: 81 additions & 0 deletions Matinee/Features/Reminders/Reminder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Foundation

/// What the full-screen overlay says when a phase ends.
struct Reminder: Equatable, Sendable {
let title: String
let detail: String
let systemImage: String
/// Label of the button that starts the next phase.
let actionTitle: String
}

enum ReminderChoice: String, CaseIterable, Identifiable, Sendable {
case standUp
case drinkWater
case restEyes
case stretch
case custom

var id: Self { self }

var menuTitle: String {
switch self {
case .standUp: "Stand up"
case .drinkWater: "Drink water"
case .restEyes: "Rest your eyes"
case .stretch: "Stretch"
case .custom: "Custom…"
}
}

/// The overlay for the end of a focus session. A blank custom text falls back to "Stand up".
func reminder(customText: String) -> Reminder {
let breakAction = "Start Break"
switch self {
case .standUp:
return Reminder(
title: "Stand up",
detail: "Get up and move around for a minute.",
systemImage: "figure.stand",
actionTitle: breakAction
)
case .drinkWater:
return Reminder(
title: "Drink some water",
detail: "Refill your glass before the next session.",
systemImage: "drop.fill",
actionTitle: breakAction
)
case .restEyes:
return Reminder(
title: "Rest your eyes",
detail: "Look at something far away for 20 seconds.",
systemImage: "eye",
actionTitle: breakAction
)
case .stretch:
return Reminder(
title: "Stretch",
detail: "Roll your shoulders and stretch your back.",
systemImage: "figure.flexibility",
actionTitle: breakAction
)
case .custom:
let text = customText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return ReminderChoice.standUp.reminder(customText: "") }
return Reminder(
title: text,
detail: "Your focus session is done.",
systemImage: "bell.fill",
actionTitle: breakAction
)
}
}

static let breakOver = Reminder(
title: "Break's over",
detail: "Ready for the next focus session?",
systemImage: "brain.head.profile",
actionTitle: "Start Focus"
)
}
70 changes: 70 additions & 0 deletions Matinee/Features/Reminders/ReminderOverlay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import AppKit
import Observation
import SwiftUI

/// Covers every screen with the reminder until the user dismisses it or starts the next phase.
@MainActor
@Observable
final class ReminderOverlay {
@ObservationIgnored private var panels: [NSPanel] = []

var isShowing: Bool { !panels.isEmpty }

func show(_ reminder: Reminder, onAction: @escaping @MainActor () -> Void) {
dismiss()

// Activate first so the panel made key below actually receives Esc and Return.
NSApp.activate()

for screen in NSScreen.screens {
let view = ReminderOverlayView(
reminder: reminder,
onAction: { [weak self] in
self?.dismiss()
onAction()
},
onDismiss: { [weak self] in self?.dismiss() }
)
let panel = makePanel(covering: screen, content: view)
panel.orderFrontRegardless()
panels.append(panel)
}

let keyPanel = panels.first { $0.screen == NSScreen.main } ?? panels.first
keyPanel?.makeKeyAndOrderFront(nil)
}

func dismiss() {
panels.forEach { $0.orderOut(nil) }
panels.removeAll()
}

private func makePanel(covering screen: NSScreen, content: ReminderOverlayView) -> NSPanel {
let panel = OverlayPanel(
contentRect: screen.frame,
styleMask: [.borderless],
backing: .buffered,
defer: false
)
panel.level = .screenSaver
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
// Panels hide when the app deactivates by default; the reminder must stay until dismissed.
panel.hidesOnDeactivate = false
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
panel.isReleasedWhenClosed = false

let hostingView = NSHostingView(rootView: content)
hostingView.sizingOptions = []
panel.contentView = hostingView
panel.setFrame(screen.frame, display: true)
return panel
}
}

/// Borderless panels can't take keyboard focus by default; the overlay needs it for Esc and Return.
private final class OverlayPanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
}
52 changes: 52 additions & 0 deletions Matinee/Features/Reminders/ReminderOverlayView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import SwiftUI

/// Full-screen glass that blurs whatever is underneath, with the reminder in the middle.
struct ReminderOverlayView: View {
let reminder: Reminder
let onAction: () -> Void
let onDismiss: () -> Void

var body: some View {
VStack(spacing: 20) {
Image(systemName: reminder.systemImage)
.font(.system(size: 72))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.tint)

Text(reminder.title)
.font(.system(size: 48, weight: .bold, design: .rounded))
.multilineTextAlignment(.center)

Text(reminder.detail)
.font(.title2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)

GlassEffectContainer(spacing: 12) {
HStack(spacing: 12) {
Button("Dismiss", action: onDismiss)
.buttonStyle(.glass)
.keyboardShortcut(.cancelAction)
Button(reminder.actionTitle, action: onAction)
.buttonStyle(.glassProminent)
.keyboardShortcut(.defaultAction)
}
.controlSize(.extraLarge)
}
.padding(.top, 12)
}
.padding(48)
.frame(maxWidth: 720)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
// The dim keeps the reminder readable over any wallpaper; the glass blurs it.
Rectangle()
.fill(.black.opacity(0.35))
.glassEffect(.regular, in: Rectangle())
.ignoresSafeArea()
}
.environment(\.colorScheme, .dark)
.accessibilityElement(children: .contain)
.accessibilityAddTraits(.isModal)
}
}
Loading