Skip to content

Commit

Permalink
Merge pull request #27 from peterklingelhofer/feature/Screen_tint_button
Browse files Browse the repository at this point in the history
feat: Screen tint button, stop works as expected
  • Loading branch information
peterklingelhofer authored Jun 7, 2024
2 parents 4225d09 + b37c881 commit 168772b
Show file tree
Hide file tree
Showing 8 changed files with 186 additions and 86 deletions.
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Each of these implementations allows users to set an inhale, inhale hold, exhale

The information and guidance provided by this breathing app are intended for general informational purposes only and should not be construed as medical advice, diagnosis, or treatment. The creator of this app is not a medical professional, and the app is not a substitute for professional medical advice or consultation with a qualified healthcare provider. Always seek the advice of a physician or other qualified healthcare provider with any questions you may have regarding a medical condition or health objectives. Do not disregard or delay seeking professional medical advice because of the information or suggestions provided by this app. In the event of a medical emergency, call your doctor or dial your local emergency number immediately. Use of this app is at your own risk, and the creator assumes no responsibility for any adverse effects or consequences resulting from its use.

## Download
## Download

You can download the build for your respective operating system on the [Releases](https://github.com/peterklingelhofer/exhale/releases) page. Using the latest release is recommended, but if you run into issues you could try a previous release to see if that yields better results. If you do encounter a problem, please [document the issue you encountered](https://github.com/peterklingelhofer/exhale/issues/new).

Expand All @@ -24,9 +24,9 @@ You can download the build for your respective operating system on the [Releases

Note: This is built natively in Swift.

To launch the app on Catalina or newer, you may have to right click and select "Open" instead of double clicking on it. That's Apple's take on "security" for non-notarized binaries, or if you are not connected to the Internet.
To launch the app on Catalina or newer for the first time, you may have to right click and select "Open" instead of double clicking on it, and you may need to do this twice. That's Apple's take on "security" for non-notarized binaries, or if you are not connected to the Internet.

You can use <kbd>Ctrl</kbd> + <kbd>,</kbd> to toggle settings open and closed. The **Pause** feature can be used to tint your screen or make your screen darker than otherwise possible for nighttime work (which can compound with both [Night Shift](https://support.apple.com/en-us/102191) and [f.lux](https://justgetflux.com/).
You can use <kbd>Ctrl</kbd> + <kbd>,</kbd> to toggle settings open and closed. The **Tint** feature can be used to tint your screen the color of your selected background color, or make your screen darker than otherwise possible for nighttime work (which can compound with both [Night Shift](https://support.apple.com/en-us/102191) and [f.lux](https://justgetflux.com/).

```sh
git clone https://github.com/peterklingelhofer/exhale.git
Expand All @@ -41,7 +41,6 @@ xed .
![exhaleElectronCircular](https://user-images.githubusercontent.com/60944077/224865780-0e61721e-2345-49aa-830d-0e157b6f4366.gif)
<img width="912" alt="Screenshot 2024-06-01 at 1 35 36 PM" src="https://github.com/peterklingelhofer/exhale/assets/60944077/b2eb9450-8dcf-4934-b6c9-08328ef6a167">


Note: This implementation is built with TypeScript & Electron. The macOS will build but it is not very performant and is far more CPU-intensive than the native Swift build, and as a result the Swift build is recommended for macOS users.

```sh
Expand Down
8 changes: 4 additions & 4 deletions swift/exhale.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@
CODE_SIGN_ENTITLEMENTS = exhale/exhale.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 150;
CURRENT_PROJECT_VERSION = 151;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"exhale/Preview Content\"";
DEVELOPMENT_TEAM = VZCHHV7VNW;
Expand All @@ -459,7 +459,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.5.0;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = peterklingelhofer.exhale;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = macosx;
Expand All @@ -478,7 +478,7 @@
CODE_SIGN_ENTITLEMENTS = exhale/exhale.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 150;
CURRENT_PROJECT_VERSION = 151;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"exhale/Preview Content\"";
DEVELOPMENT_TEAM = VZCHHV7VNW;
Expand All @@ -491,7 +491,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.5.0;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = peterklingelhofer.exhale;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = macosx;
Expand Down
Binary file not shown.
78 changes: 52 additions & 26 deletions swift/exhale/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,59 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
var isAnimatingSubscription: AnyCancellable?
var subscriptions = Set<AnyCancellable>()
var statusItem: NSStatusItem!

func setUpStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
button.image = NSImage(named: "StatusBarIcon")
button.action = #selector(statusBarButtonClicked(sender:))
}

let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Preferences...", action: #selector(toggleSettings(_:)), keyEquivalent: ","))

let startStopMenuItem = NSMenuItem(title: settingsModel.isAnimating ? "Stop" : "Start", action: #selector(toggleAnimating(_:)), keyEquivalent: "s")
menu.addItem(startStopMenuItem)
let startMenuItem = NSMenuItem(title: "Start", action: #selector(startAnimating(_:)), keyEquivalent: "s")
let tintMenuItem = NSMenuItem(title: "Tint", action: #selector(pauseAnimating(_:)), keyEquivalent: "p")
let stopMenuItem = NSMenuItem(title: "Stop", action: #selector(stopAnimating(_:)), keyEquivalent: "x")

menu.addItem(startMenuItem)
menu.addItem(stopMenuItem)
menu.addItem(tintMenuItem)
menu.addItem(NSMenuItem(title: "Quit exhale", action: #selector(terminateApp(_:)), keyEquivalent: "q"))

settingsModel.$isAnimating
.sink { isAnimating in
startStopMenuItem.title = isAnimating ? "Stop" : "Start"
.sink { [weak self] isAnimating in
guard let self = self else { return }
startMenuItem.isEnabled = !isAnimating
stopMenuItem.isEnabled = isAnimating || self.settingsModel.isPaused
tintMenuItem.isEnabled = !isAnimating && !self.settingsModel.isPaused
}
.store(in: &subscriptions)

settingsModel.$isPaused
.sink { [weak self] isPaused in
guard let self = self else { return }
tintMenuItem.isEnabled = !self.settingsModel.isAnimating && !isPaused
}
.store(in: &subscriptions)

statusItem.menu = menu
}

@objc func toggleAnimating(_ sender: Any?) {
settingsModel.isAnimating.toggle()
@objc func startAnimating(_ sender: Any?) {
settingsModel.start()
}

@objc func stopAnimating(_ sender: Any?) {
settingsModel.stop()
}

@objc func pauseAnimating(_ sender: Any?) {
if settingsModel.isPaused {
settingsModel.unpause()
} else {
settingsModel.pause()
}
}

@objc func statusBarButtonClicked(sender: NSStatusBarButton) {
Expand All @@ -49,7 +75,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
@objc func terminateApp(_ sender: Any?) {
NSApp.terminate(nil)
}

func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
settingsModel = SettingsModel()
Expand All @@ -62,15 +88,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
backing: .buffered,
defer: false
)

window.contentView = NSHostingView(rootView: ContentView().environmentObject(settingsModel))
window.makeKeyAndOrderFront(nil)
window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.mainMenuWindow)) + 1) // Window level in front of the menu bar
window.alphaValue = CGFloat(settingsModel.overlayOpacity)
window.isOpaque = false
window.ignoresMouseEvents = true
window.setFrame(screen.frame, display: true)

windows.append(window)
}

Expand All @@ -79,33 +105,33 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
window.backgroundColor = NSColor(newColor)
}
}

exhaleColorSubscription = settingsModel.$exhaleColor.sink { [unowned self] newColor in
for window in self.windows {
window.backgroundColor = NSColor(newColor)
}
}

overlayOpacitySubscription = settingsModel.$overlayOpacity.sink { [unowned self] newOpacity in
for window in self.windows {
window.alphaValue = CGFloat(newOpacity)
}
}

// Reload content view when any setting changes
settingsModel.objectWillChange.sink { [unowned self] in
self.reloadContentView()
}.store(in: &subscriptions)

reloadContentView()

settingsWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 200),
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
backing: .buffered,
defer: false
)

settingsWindow.delegate = self
settingsWindow.contentView = NSHostingView(rootView: SettingsView(
showSettings: .constant(false),
Expand All @@ -120,31 +146,31 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
drift: Binding(get: { self.settingsModel.drift }, set: { self.settingsModel.drift = $0 }),
overlayOpacity: Binding(get: { self.settingsModel.overlayOpacity }, set: { self.settingsModel.overlayOpacity = $0 }),
shape: Binding<AnimationShape>(get: { self.settingsModel.shape }, set: { self.settingsModel.shape = $0 }),
animationMode: Binding<AnimationMode>(get: { self.settingsModel.animationMode }, set: { self.settingsModel.animationMode = $0 }),
animationMode: Binding(get: { self.settingsModel.animationMode }, set: { self.settingsModel.animationMode = $0 }),
randomizedTimingInhale: Binding(get: { self.settingsModel.randomizedTimingInhale }, set: { self.settingsModel.randomizedTimingInhale = $0 }),
randomizedTimingPostInhaleHold: Binding(get: { self.settingsModel.randomizedTimingPostInhaleHold }, set: { self.settingsModel.randomizedTimingPostInhaleHold = $0 }),
randomizedTimingExhale: Binding(get: { self.settingsModel.randomizedTimingExhale }, set: { self.settingsModel.randomizedTimingExhale = $0 }),
randomizedTimingPostExhaleHold: Binding(get: { self.settingsModel.randomizedTimingPostExhaleHold }, set: { self.settingsModel.randomizedTimingPostExhaleHold = $0 }),
isAnimating: Binding(get: { self.settingsModel.isAnimating }, set: { self.settingsModel.isAnimating = $0 })
).environmentObject(settingsModel))

settingsWindow.title = "exhale"
toggleSettings(nil)
setUpStatusItem()

isAnimatingSubscription = settingsModel.$isAnimating.sink { [unowned self] isAnimating in
if !isAnimating {
if !isAnimating && !self.settingsModel.isPaused {
for window in self.windows {
window.backgroundColor = NSColor.clear
}
}
}
}

func applicationWillTerminate(_ notification: Notification) {
// Insert code here to tear down your application
}

@objc func toggleSettings(_ sender: Any?) {
if settingsWindow.isVisible {
settingsWindow.orderOut(nil)
Expand All @@ -154,14 +180,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
settingsWindow.level = .floating
}
}

func reloadContentView() {
let contentView = ContentView().environmentObject(settingsModel)
for window in windows {
window.contentView = NSHostingView(rootView: contentView)
}
}

func windowShouldClose(_ sender: NSWindow) -> Bool {
if sender == settingsWindow {
settingsWindow.orderOut(sender)
Expand Down
55 changes: 41 additions & 14 deletions swift/exhale/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,21 @@ struct ContentView: View {
@State private var overlayOpacity: Double = 0.1
@State private var showSettings = false
@State private var cycleCount: Int = 0

var maxCircleScale: CGFloat {
guard let screen = NSScreen.main else { return settingsModel.colorFillGradient == .on ? 2 : 1 }
let screenWidth = screen.frame.width
let screenHeight = screen.frame.height
let maxDimension = max(screenWidth, screenHeight)
return maxDimension / min(screenWidth, screenHeight)
}

var body: some View {
ZStack {
GeometryReader { geometry in
ZStack {
if !settingsModel.isAnimating {
Color.clear.edgesIgnoringSafeArea(.all)
if !settingsModel.isAnimating && !settingsModel.isPaused {
Color.clear.edgesIgnoringSafeArea(.all)
} else {
settingsModel.backgroundColor.edgesIgnoringSafeArea(.all)

Expand Down Expand Up @@ -134,21 +134,28 @@ struct ContentView: View {
resetAnimation()
}
}
.onChange(of: settingsModel.isPaused) { newValue in
if newValue {
stopCurrentAnimation()
} else if settingsModel.isAnimating {
resumeBreathingCycle()
}
}
.onChange(of: settingsModel.resetAnimation) { newValue in
if newValue {
resetAnimation()
startBreathingCycle()
}
}
}

func startBreathingCycle() {
cycleCount = 0
inhale()
}

func inhale() {
guard settingsModel.isAnimating else { return resetAnimation() }
guard settingsModel.isAnimating && !settingsModel.isPaused else { return }
var duration = settingsModel.inhaleDuration * pow(settingsModel.drift, Double(cycleCount))
if settingsModel.randomizedTimingInhale > 0 {
duration += Double.random(in: -settingsModel.randomizedTimingInhale...settingsModel.randomizedTimingInhale)
Expand All @@ -168,9 +175,9 @@ struct ContentView: View {
holdAfterInhale()
}
}

func holdAfterInhale() {
guard settingsModel.isAnimating else { return resetAnimation() }
guard settingsModel.isAnimating && !settingsModel.isPaused else { return }
var duration = settingsModel.postInhaleHoldDuration * pow(settingsModel.drift, Double(cycleCount))
if settingsModel.randomizedTimingPostInhaleHold > 0 {
duration += Double.random(in: -settingsModel.randomizedTimingPostInhaleHold...settingsModel.randomizedTimingPostInhaleHold)
Expand All @@ -181,9 +188,9 @@ struct ContentView: View {
exhale()
}
}

func exhale() {
guard settingsModel.isAnimating else { return resetAnimation() }
guard settingsModel.isAnimating && !settingsModel.isPaused else { return }
var duration = settingsModel.exhaleDuration * pow(settingsModel.drift, Double(cycleCount))
if settingsModel.randomizedTimingExhale > 0 {
duration += Double.random(in: -settingsModel.randomizedTimingExhale...settingsModel.randomizedTimingExhale)
Expand All @@ -200,9 +207,9 @@ struct ContentView: View {
holdAfterExhale()
}
}

func holdAfterExhale() {
guard settingsModel.isAnimating else { return resetAnimation() }
guard settingsModel.isAnimating && !settingsModel.isPaused else { return }
var duration = settingsModel.postExhaleHoldDuration * pow(settingsModel.drift, Double(cycleCount))
if settingsModel.randomizedTimingPostExhaleHold > 0 {
duration += Double.random(in: -settingsModel.randomizedTimingPostExhaleHold...settingsModel.randomizedTimingPostExhaleHold)
Expand All @@ -216,12 +223,32 @@ struct ContentView: View {
self.inhale()
}
}

func resetAnimation() {
cycleCount = 0
animationProgress = 0.0
breathingPhase = .inhale
}

func stopCurrentAnimation() {
// Stop the current animation
cycleCount = 0
animationProgress = 0.0
}

func resumeBreathingCycle() {
// Resume the breathing cycle
switch breathingPhase {
case .inhale:
inhale()
case .holdAfterInhale:
holdAfterInhale()
case .exhale:
exhale()
case .holdAfterExhale:
holdAfterExhale()
}
}
}

struct ContentView_Previews: PreviewProvider {
Expand Down
Loading

0 comments on commit 168772b

Please sign in to comment.