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
20 changes: 20 additions & 0 deletions Breathe.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
3D5C2A81D6145203D7A5797A /* BreathingSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF9ED589A2C4F42EFCE2B2E /* BreathingSessionView.swift */; };
4077731034C1E423CED83406 /* OverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB12CE5791E853603437E14 /* OverlayWindowController.swift */; };
4912BBD0A491D3206D865C5D /* CalmTextTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D086A97B4FDD0B60CA1E69 /* CalmTextTransition.swift */; };
4CDA61380C595E7FEC166205 /* SettingsPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F7E0E3AA058063CC5EA6991 /* SettingsPanelController.swift */; };
53687B0C82A46947913E1238 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8D044CA32E077584B90D839D /* Assets.xcassets */; };
684975E0462301CAB5E7F4AA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B23E3984A58F1CAB0205BBB2 /* AppDelegate.swift */; };
6B65B14E84887AFCF2298127 /* LaunchAtLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8786FC1BD4B88DD31750597 /* LaunchAtLoginManager.swift */; };
6BA81C85E864FCD0B026B999 /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFE71121F4A6C3D689439E9 /* WindowAccessor.swift */; };
75F752BB337D1B6CD28007BD /* NotchOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64005923AB45FB4CEE171F7F /* NotchOverlayView.swift */; };
77E048467B637BD118C69BC9 /* VisualEffectBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF3AC2654B689CC62477FF4 /* VisualEffectBackground.swift */; };
85068ABB69C09477A2B7B15D /* ShapeTunerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB4D7952E6B2D4F33ADEBD2E /* ShapeTunerView.swift */; };
Expand All @@ -30,10 +32,13 @@
9A219EDCF617E0797E200B5B /* SessionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78D5CD41BCA547E138315674 /* SessionSection.swift */; };
9B497F887ADCBAB9B4815327 /* BreathingPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C7A544CB2F11FFBE1CC438B /* BreathingPreset.swift */; };
9D481505063C2A250FE4C627 /* NotchShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9430A38A6528B825252E088D /* NotchShape.swift */; };
9E5528F75CF11B6BB5BEB7EB /* CustomizeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209B859704E842D11A11267C /* CustomizeSection.swift */; };
A973FDAAB5E5744B3A24EB29 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC8CFA465EA29841BEC6813E /* UserSettings.swift */; };
AF64AEA51A6A58E330976273 /* NotchDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE401A31A3213804ED9C5100 /* NotchDetector.swift */; };
CE51310F076EEDEF2C100518 /* BreathingSessionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D30ADD9745540D40BBA1D45 /* BreathingSessionViewModel.swift */; };
D988C344D5DFEB07798620E8 /* HomeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07453CB7DCAF94EC86CE71A6 /* HomeSection.swift */; };
DEC3FDD48795793C9A0C627C /* SquarePathView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAFA4A49D78545C9AB4C780 /* SquarePathView.swift */; };
E0499196EED5919D3C34F197 /* SettingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B101FA316EE7171EFE8D4CAE /* SettingsCard.swift */; };
ED31AE6164D9B7470B162FA2 /* ReminderFrequency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83094133F2122B3CAF178FAC /* ReminderFrequency.swift */; };
/* End PBXBuildFile section */

Expand All @@ -49,12 +54,16 @@

/* Begin PBXFileReference section */
0704D508BAE98872AA0E6ED0 /* SessionProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProgressView.swift; sourceTree = "<group>"; };
07453CB7DCAF94EC86CE71A6 /* HomeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSection.swift; sourceTree = "<group>"; };
1AFC4FFF986105E4A6DDB4D4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
1F7E0E3AA058063CC5EA6991 /* SettingsPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPanelController.swift; sourceTree = "<group>"; };
209B859704E842D11A11267C /* CustomizeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeSection.swift; sourceTree = "<group>"; };
2EB12CE5791E853603437E14 /* OverlayWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayWindowController.swift; sourceTree = "<group>"; };
362C35629E26BAB6016F4E3D /* ReminderScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderScheduler.swift; sourceTree = "<group>"; };
3C7A544CB2F11FFBE1CC438B /* BreathingPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreathingPreset.swift; sourceTree = "<group>"; };
3D6B12BAFCCA6B92AF300E73 /* BreatheTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BreatheTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
4D9A52436FCC13142808DC34 /* GeneralSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSection.swift; sourceTree = "<group>"; };
4EFE71121F4A6C3D689439E9 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = "<group>"; };
64005923AB45FB4CEE171F7F /* NotchOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOverlayView.swift; sourceTree = "<group>"; };
6D30ADD9745540D40BBA1D45 /* BreathingSessionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreathingSessionViewModel.swift; sourceTree = "<group>"; };
74697DE978286BFC4A32F2A4 /* NSScreen+Notch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+Notch.swift"; sourceTree = "<group>"; };
Expand All @@ -69,6 +78,7 @@
A2D84C5978BA6877BD62B1EB /* Breathe.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Breathe.entitlements; sourceTree = "<group>"; };
AA9A29583A42C1061A6092CD /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = "<group>"; };
AC8CFA465EA29841BEC6813E /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = "<group>"; };
B101FA316EE7171EFE8D4CAE /* SettingsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCard.swift; sourceTree = "<group>"; };
B23E3984A58F1CAB0205BBB2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
B770107CE44875E387B6B2DA /* RemindersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersSection.swift; sourceTree = "<group>"; };
B83F40FE94185F659B573C07 /* BreathingPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreathingPhase.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -162,7 +172,9 @@
children = (
99D086A97B4FDD0B60CA1E69 /* CalmTextTransition.swift */,
9430A38A6528B825252E088D /* NotchShape.swift */,
B101FA316EE7171EFE8D4CAE /* SettingsCard.swift */,
9FF3AC2654B689CC62477FF4 /* VisualEffectBackground.swift */,
4EFE71121F4A6C3D689439E9 /* WindowAccessor.swift */,
);
path = Shared;
sourceTree = "<group>";
Expand All @@ -189,6 +201,7 @@
CE401A31A3213804ED9C5100 /* NotchDetector.swift */,
2EB12CE5791E853603437E14 /* OverlayWindowController.swift */,
362C35629E26BAB6016F4E3D /* ReminderScheduler.swift */,
1F7E0E3AA058063CC5EA6991 /* SettingsPanelController.swift */,
);
path = Services;
sourceTree = "<group>";
Expand Down Expand Up @@ -231,7 +244,9 @@
DE727F92F5859963E63BB9BD /* Settings */ = {
isa = PBXGroup;
children = (
209B859704E842D11A11267C /* CustomizeSection.swift */,
4D9A52436FCC13142808DC34 /* GeneralSection.swift */,
07453CB7DCAF94EC86CE71A6 /* HomeSection.swift */,
B770107CE44875E387B6B2DA /* RemindersSection.swift */,
78D5CD41BCA547E138315674 /* SessionSection.swift */,
9009A267051D0C02E8E16866 /* SettingsView.swift */,
Expand Down Expand Up @@ -342,7 +357,9 @@
3D5C2A81D6145203D7A5797A /* BreathingSessionView.swift in Sources */,
CE51310F076EEDEF2C100518 /* BreathingSessionViewModel.swift in Sources */,
4912BBD0A491D3206D865C5D /* CalmTextTransition.swift in Sources */,
9E5528F75CF11B6BB5BEB7EB /* CustomizeSection.swift in Sources */,
2E6A4D3CE9946941A8317A05 /* GeneralSection.swift in Sources */,
D988C344D5DFEB07798620E8 /* HomeSection.swift in Sources */,
6B65B14E84887AFCF2298127 /* LaunchAtLoginManager.swift in Sources */,
08840B5EF000F3B3488D1184 /* MenuBarView.swift in Sources */,
1D2DED5CEC12037E73479DAB /* NSScreen+Notch.swift in Sources */,
Expand All @@ -355,12 +372,15 @@
078BE261CE5C615F8526ACB4 /* RemindersSection.swift in Sources */,
39B7FE214F905CEA448E7A8D /* SessionProgressView.swift in Sources */,
9A219EDCF617E0797E200B5B /* SessionSection.swift in Sources */,
E0499196EED5919D3C34F197 /* SettingsCard.swift in Sources */,
4CDA61380C595E7FEC166205 /* SettingsPanelController.swift in Sources */,
92B914C8F8583C88B0943BF9 /* SettingsView.swift in Sources */,
85068ABB69C09477A2B7B15D /* ShapeTunerView.swift in Sources */,
DEC3FDD48795793C9A0C627C /* SquarePathView.swift in Sources */,
0ACC488CD79BCA73AEE02755 /* TimingPresetsSection.swift in Sources */,
A973FDAAB5E5744B3A24EB29 /* UserSettings.swift in Sources */,
77E048467B637BD118C69BC9 /* VisualEffectBackground.swift in Sources */,
6BA81C85E864FCD0B026B999 /* WindowAccessor.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
8 changes: 7 additions & 1 deletion Breathe/App/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import AppKit
import UserNotifications

final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
let userSettings = UserSettings()
let overlayController = OverlayWindowController()
let reminderScheduler = ReminderScheduler()
let launchAtLoginManager = LaunchAtLoginManager()
lazy var settingsPanelController = SettingsPanelController(settings: userSettings)

func applicationDidFinishLaunching(_ notification: Notification) {
UNUserNotificationCenter.current().delegate = self
reminderScheduler.configure(with: userSettings)
launchAtLoginManager.syncState(with: userSettings)
}

func showSettings() {
settingsPanelController.show()
}

// MARK: - Notification Delegate

/// Handle notification tap -- start a breathing session
Expand Down
10 changes: 3 additions & 7 deletions Breathe/App/BreatheApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,13 @@ struct BreatheApp: App {
MenuBarExtra {
MenuBarView(
overlayController: appDelegate.overlayController,
settings: appDelegate.userSettings
settings: appDelegate.userSettings,
onOpenSettings: appDelegate.showSettings
)
} label: {
Label("Breathe", systemImage: "wind")
}
.menuBarExtraStyle(.window)

Settings {
SettingsView()
.environment(appDelegate.userSettings)
}
.menuBarExtraStyle(.menu)

Window("Shape Tuner", id: "shape-tuner") {
ShapeTunerView()
Expand Down
123 changes: 123 additions & 0 deletions Breathe/Services/SettingsPanelController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import AppKit
import SwiftUI

@MainActor
final class SettingsPanelController: NSObject, NSWindowDelegate {
private let settings: UserSettings
private var panel: NSPanel?

/// Shift traffic lights right so they line up with the sidebar icon column (titlebar accessory does not move them with a unified toolbar).
private static let trafficLightLeadingInset: CGFloat = 11

private var didShiftTrafficLights = false

init(settings: UserSettings) {
self.settings = settings
}

func show() {
let panel = panel ?? makePanel()
self.panel = panel

NSApplication.shared.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil)

scheduleTrafficLightLeadingShift(for: panel)
}

func windowWillClose(_ notification: Notification) {
panel?.orderOut(nil)
}

private func makePanel() -> NSPanel {
let frame = NSRect(x: 0, y: 0, width: 680, height: 380)
let panel = NSPanel(
contentRect: frame,
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView, .resizable],
backing: .buffered,
defer: false
)

panel.title = ""
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
panel.isMovableByWindowBackground = false
panel.isReleasedWhenClosed = false
panel.hidesOnDeactivate = false
panel.delegate = self
panel.minSize = NSSize(width: 680, height: 380)
panel.maxSize = NSSize(width: 680, height: 10_000)
if #available(macOS 15.0, *) {
panel.titlebarSeparatorStyle = .none
}

let toolbar = NSToolbar(identifier: "settingsToolbar")
toolbar.showsBaselineSeparator = false
panel.toolbar = toolbar
panel.toolbarStyle = .unifiedCompact

let visualEffect = NSVisualEffectView(frame: frame)
visualEffect.material = .underWindowBackground
visualEffect.blendingMode = .behindWindow
visualEffect.state = .active
visualEffect.wantsLayer = true
visualEffect.layer?.cornerRadius = 16
visualEffect.layer?.masksToBounds = true

let hostingView = NSHostingView(
rootView: SettingsView()
.environment(settings)
)
hostingView.translatesAutoresizingMaskIntoConstraints = false

visualEffect.addSubview(hostingView)
NSLayoutConstraint.activate([
hostingView.topAnchor.constraint(equalTo: visualEffect.topAnchor),
hostingView.bottomAnchor.constraint(equalTo: visualEffect.bottomAnchor),
hostingView.leadingAnchor.constraint(equalTo: visualEffect.leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: visualEffect.trailingAnchor),
])

panel.contentView = visualEffect
panel.standardWindowButton(.zoomButton)?.isHidden = true
panel.center()

return panel
}

/// Unified toolbar lays out traffic lights after the first frame; retry on later run-loop turns until we see real frames.
private func scheduleTrafficLightLeadingShift(for panel: NSPanel) {
func attempt(_ pass: Int) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
panel.layoutIfNeeded()
if self.shiftTrafficLightsRightIfNeeded(in: panel) {
return
}
if pass < 8 {
attempt(pass + 1)
}
}
}
attempt(0)
}

@discardableResult
private func shiftTrafficLightsRightIfNeeded(in panel: NSPanel) -> Bool {
guard !didShiftTrafficLights else { return true }
guard let close = panel.standardWindowButton(.closeButton), close.frame.width > 0 else { return false }

let inset = Self.trafficLightLeadingInset
for type in [NSWindow.ButtonType.closeButton, NSWindow.ButtonType.miniaturizeButton] {
guard let btn = panel.standardWindowButton(type) else { continue }
var f = btn.frame
f.origin.x += inset
btn.frame = f
}
didShiftTrafficLights = true
return true
}
}
96 changes: 27 additions & 69 deletions Breathe/Views/MenuBar/MenuBarView.swift
Original file line number Diff line number Diff line change
@@ -1,86 +1,44 @@
import SwiftUI

struct MenuBarView: View {
@Environment(\.openWindow) private var openWindow
let overlayController: OverlayWindowController
let settings: UserSettings
let onOpenSettings: () -> Void

var body: some View {
VStack(spacing: 8) {
HStack {
Image(systemName: "lungs.fill")
.font(.system(size: 16))
.foregroundStyle(.secondary)
Text("Breathe")
.font(.headline)
}
.padding(.top, 4)
Button(action: startBreathing) {
Label("Start Breathing", systemImage: "wind")
}
.keyboardShortcut("b", modifiers: [.command])

Divider()

Divider()
Button(action: openSettings) {
Label("Settings…", systemImage: "gear")
}
.keyboardShortcut(",", modifiers: [.command])

// Quick start
Button {
overlayController.show(settings: settings)
} label: {
HStack {
Image(systemName: "play.fill")
.font(.system(size: 10))
Text("Start Breathing")
}
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.keyboardShortcut("b", modifiers: [.command])
Divider()

// Current preset info
HStack {
Text("Preset:")
.foregroundStyle(.secondary)
.font(.caption)
Text(settings.currentPreset.name)
.font(.caption)
.fontWeight(.medium)
Spacer()
Text("\(Int(settings.currentPreset.inhale))s cycle")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 4)
Text("Version 1.0.0")

Divider()
Button(action: quit) {
Label("Quit Breathe", systemImage: "xmark.square")
}
.keyboardShortcut("q", modifiers: [.command])
}

// Settings
SettingsLink {
HStack {
Image(systemName: "gear")
.font(.system(size: 11))
Text("Settings")
Spacer()
Text("\u{2318},")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
// MARK: - Actions

Button {
openWindow(id: "shape-tuner")
} label: {
HStack {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 11))
Text("Shape Tuner")
Spacer()
}
}
private func startBreathing() {
overlayController.show(settings: settings)
}

Divider()
private func openSettings() {
onOpenSettings()
}

Button("Quit Breathe") {
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("q", modifiers: [.command])
}
.padding(10)
.frame(width: 220)
private func quit() {
NSApplication.shared.terminate(nil)
}
}
Loading
Loading