Initial release of Pommedoro
A macOS Pomodoro timer built on the thesis that transitions — not hard cuts — are the only way breaks actually happen. Includes escalating edge gradients, actionable wellness suggestions, self-reflection, and a compiled DMG. Licensed CC BY 4.0. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
cc23058b1d
|
|
@ -0,0 +1,4 @@
|
||||||
|
.build/
|
||||||
|
.DS_Store
|
||||||
|
*.o
|
||||||
|
Package.resolved
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
Creative Commons Attribution 4.0 International (CC BY 4.0)
|
||||||
|
|
||||||
|
Copyright (c) 2026 Colin Knapp — https://colinknapp.com
|
||||||
|
|
||||||
|
You are free to:
|
||||||
|
|
||||||
|
Share — copy and redistribute the material in any medium or format for
|
||||||
|
any purpose, even commercially.
|
||||||
|
|
||||||
|
Adapt — remix, transform, and build upon the material for any purpose,
|
||||||
|
even commercially.
|
||||||
|
|
||||||
|
Under the following terms:
|
||||||
|
|
||||||
|
Attribution — You must give appropriate credit, provide a link to the
|
||||||
|
license, and indicate if changes were made. You may do so in any
|
||||||
|
reasonable manner, but not in any way that suggests the licensor
|
||||||
|
endorses you or your use.
|
||||||
|
|
||||||
|
No additional restrictions — You may not apply legal terms or
|
||||||
|
technological measures that legally restrict others from doing anything
|
||||||
|
the license permits.
|
||||||
|
|
||||||
|
Full license text: https://creativecommons.org/licenses/by/4.0/legalcode
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
APP_NAME = Pommedoro
|
||||||
|
BUILD_DIR = .build
|
||||||
|
RELEASE_BIN = $(BUILD_DIR)/arm64-apple-macosx/release/$(APP_NAME)
|
||||||
|
APP_BUNDLE = $(BUILD_DIR)/$(APP_NAME).app
|
||||||
|
CONTENTS = $(APP_BUNDLE)/Contents
|
||||||
|
MACOS_DIR = $(CONTENTS)/MacOS
|
||||||
|
RESOURCES_DIR = $(CONTENTS)/Resources
|
||||||
|
|
||||||
|
LAUNCH_AGENT_LABEL = com.pommedoro.app
|
||||||
|
LAUNCH_AGENT_PLIST = $(HOME)/Library/LaunchAgents/$(LAUNCH_AGENT_LABEL).plist
|
||||||
|
|
||||||
|
.PHONY: all build bundle install clean run icons install-agent uninstall-agent dmg
|
||||||
|
|
||||||
|
all: bundle
|
||||||
|
|
||||||
|
build:
|
||||||
|
swift build -c release
|
||||||
|
|
||||||
|
icons:
|
||||||
|
@echo "Generating icons from SVG..."
|
||||||
|
@mkdir -p Resources/Pommedoro.iconset
|
||||||
|
@for size in 16 32 64 128 256 512 1024; do \
|
||||||
|
rsvg-convert -w $$size -h $$size Resources/boot-logo.svg \
|
||||||
|
-o Resources/Pommedoro.iconset/icon_$${size}x$${size}.png; \
|
||||||
|
done
|
||||||
|
@cp Resources/Pommedoro.iconset/icon_32x32.png Resources/Pommedoro.iconset/icon_16x16@2x.png
|
||||||
|
@cp Resources/Pommedoro.iconset/icon_64x64.png Resources/Pommedoro.iconset/icon_32x32@2x.png
|
||||||
|
@cp Resources/Pommedoro.iconset/icon_256x256.png Resources/Pommedoro.iconset/icon_128x128@2x.png
|
||||||
|
@cp Resources/Pommedoro.iconset/icon_512x512.png Resources/Pommedoro.iconset/icon_256x256@2x.png
|
||||||
|
@cp Resources/Pommedoro.iconset/icon_1024x1024.png Resources/Pommedoro.iconset/icon_512x512@2x.png
|
||||||
|
@rm -f Resources/Pommedoro.iconset/icon_64x64.png Resources/Pommedoro.iconset/icon_1024x1024.png
|
||||||
|
@iconutil -c icns Resources/Pommedoro.iconset -o Resources/Pommedoro.icns
|
||||||
|
@rm -rf Resources/Pommedoro.iconset
|
||||||
|
@echo "Icon generated: Resources/Pommedoro.icns"
|
||||||
|
|
||||||
|
bundle: build
|
||||||
|
@echo "Creating $(APP_NAME).app bundle..."
|
||||||
|
@rm -rf $(APP_BUNDLE)
|
||||||
|
@mkdir -p $(MACOS_DIR)
|
||||||
|
@mkdir -p $(RESOURCES_DIR)
|
||||||
|
@cp $(RELEASE_BIN) $(MACOS_DIR)/$(APP_NAME)
|
||||||
|
@cp Resources/Info.plist $(CONTENTS)/Info.plist
|
||||||
|
@cp Resources/$(APP_NAME).icns $(RESOURCES_DIR)/$(APP_NAME).icns
|
||||||
|
@echo "Built $(APP_BUNDLE)"
|
||||||
|
|
||||||
|
install: bundle
|
||||||
|
@echo "Installing to /Applications..."
|
||||||
|
@rm -rf /Applications/$(APP_NAME).app
|
||||||
|
@cp -R $(APP_BUNDLE) /Applications/$(APP_NAME).app
|
||||||
|
@echo "Installed /Applications/$(APP_NAME).app"
|
||||||
|
|
||||||
|
run: bundle
|
||||||
|
@open $(APP_BUNDLE)
|
||||||
|
|
||||||
|
install-agent: install
|
||||||
|
@echo "Installing LaunchAgent..."
|
||||||
|
@mkdir -p $(HOME)/Library/LaunchAgents
|
||||||
|
@/usr/libexec/PlistBuddy -c "Clear dict" /dev/null 2>/dev/null; \
|
||||||
|
printf '<?xml version="1.0" encoding="UTF-8"?>\n\
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n\
|
||||||
|
<plist version="1.0">\n\
|
||||||
|
<dict>\n\
|
||||||
|
\t<key>Label</key>\n\
|
||||||
|
\t<string>$(LAUNCH_AGENT_LABEL)</string>\n\
|
||||||
|
\t<key>Program</key>\n\
|
||||||
|
\t<string>/Applications/$(APP_NAME).app/Contents/MacOS/$(APP_NAME)</string>\n\
|
||||||
|
\t<key>RunAtLoad</key>\n\
|
||||||
|
\t<true/>\n\
|
||||||
|
\t<key>KeepAlive</key>\n\
|
||||||
|
\t<false/>\n\
|
||||||
|
</dict>\n\
|
||||||
|
</plist>\n' > $(LAUNCH_AGENT_PLIST)
|
||||||
|
@echo "LaunchAgent installed: $(LAUNCH_AGENT_PLIST)"
|
||||||
|
|
||||||
|
uninstall-agent:
|
||||||
|
@echo "Removing LaunchAgent..."
|
||||||
|
@rm -f $(LAUNCH_AGENT_PLIST)
|
||||||
|
@echo "LaunchAgent removed"
|
||||||
|
|
||||||
|
dmg: bundle
|
||||||
|
@echo "Creating DMG..."
|
||||||
|
@rm -f $(BUILD_DIR)/$(APP_NAME).dmg
|
||||||
|
@mkdir -p $(BUILD_DIR)/dmg-staging
|
||||||
|
@cp -R $(APP_BUNDLE) $(BUILD_DIR)/dmg-staging/
|
||||||
|
@ln -s /Applications $(BUILD_DIR)/dmg-staging/Applications
|
||||||
|
@hdiutil create -volname "$(APP_NAME)" \
|
||||||
|
-srcfolder $(BUILD_DIR)/dmg-staging \
|
||||||
|
-ov -format UDZO \
|
||||||
|
$(BUILD_DIR)/$(APP_NAME).dmg
|
||||||
|
@rm -rf $(BUILD_DIR)/dmg-staging
|
||||||
|
@echo "Created $(BUILD_DIR)/$(APP_NAME).dmg"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
swift package clean
|
||||||
|
rm -rf $(APP_BUNDLE)
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "Pommedoro",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "8.56.0")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "Pommedoro",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Sentry", package: "sentry-cocoa")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
# Pommedoro
|
||||||
|
|
||||||
|
**A Pomodoro timer that respects how your brain actually works.**
|
||||||
|
|
||||||
|
[Download the latest DMG](https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Problem with Traditional Pomodoro Timers
|
||||||
|
|
||||||
|
Every Pomodoro app does the same thing: a timer counts down, an alarm fires, and a dialog box demands your attention. You dismiss it. You always dismiss it. The break never happens.
|
||||||
|
|
||||||
|
This isn't a willpower failure. It's a design failure.
|
||||||
|
|
||||||
|
When you're deep in flow — writing, coding, designing — your mind is in a groove. A sudden alarm is a context switch, and human beings are wired to reject context switches. The instinctive response to an interruption during focus is to kill the interruption, not to obey it. So you click "dismiss" and keep working, and the entire purpose of the break is defeated.
|
||||||
|
|
||||||
|
Traditional timers treat breaks as binary events: you're working, then you're not. But that's not how attention works. Attention is a continuum. You can't jump from full focus to full rest without a bridge. **Transitions, not hard cuts, are the only way successful breaks will actually occur.**
|
||||||
|
|
||||||
|
## How Pommedoro Works
|
||||||
|
|
||||||
|
Pommedoro is built around the idea that your timer should *ease* you toward a break rather than ambush you with one.
|
||||||
|
|
||||||
|
### Phase 1: Escalating Awareness
|
||||||
|
|
||||||
|
During the last five minutes of a work session, Pommedoro begins painting soft teal gradients along the edges of all your screens. These are gentle — barely noticeable at first. They fade in and out slowly, like breathing.
|
||||||
|
|
||||||
|
As time goes on, the blinks become more frequent and more intense:
|
||||||
|
|
||||||
|
- **5:00 – 3:00 remaining**: A brief flash every 60 seconds. Subtle. A whisper.
|
||||||
|
- **3:00 – 1:00 remaining**: Every 30 seconds. You start to notice.
|
||||||
|
- **1:00 – 0:30 remaining**: Every 10 seconds. The edges are becoming familiar.
|
||||||
|
- **0:30 – 0:10 remaining**: Every 5 seconds. Your peripheral vision has accepted what's coming.
|
||||||
|
- **0:10 – 0:05 remaining**: Every 2 seconds. A steady pulse.
|
||||||
|
- **Last 5 seconds**: The edges hold solid. A countdown pill appears at the bottom of the screen.
|
||||||
|
|
||||||
|
The intensity of the gradient also escalates over this period — from a faint 15% opacity to a vivid 75%. Your subconscious has been processing this for five full minutes before the break screen ever appears.
|
||||||
|
|
||||||
|
### Phase 2: The Break Screen
|
||||||
|
|
||||||
|
When the timer reaches zero, the screen transitions to a full teal overlay. This isn't an alert dialog you reflexively dismiss — it *is* your screen now. It carries a simple suggestion:
|
||||||
|
|
||||||
|
> *"Stretch your neck and shoulders"*
|
||||||
|
> *"Step outside for a minute of fresh air"*
|
||||||
|
> *"Unclench your jaw and relax your shoulders"*
|
||||||
|
|
||||||
|
These are real, physical actions. Not platitudes. Not "take a break!" — that's meaningless. Pommedoro tells you *what to do* with your break, and if a suggestion doesn't resonate, you can dismiss it and it won't come back.
|
||||||
|
|
||||||
|
A 5-minute break countdown runs in the background. You can mark the suggestion as completed ("Success!"), skip it ("Next Time"), or permanently dismiss suggestions that don't suit you ("Don't Suggest").
|
||||||
|
|
||||||
|
### Phase 3: Reflection
|
||||||
|
|
||||||
|
After completing a suggestion, Pommedoro asks one question:
|
||||||
|
|
||||||
|
> *"How did that impact your readiness for the day?"*
|
||||||
|
|
||||||
|
Three options: **Feeling Great**, **About the Same**, **Not Really**. No journaling. No friction. Just a moment of honest self-assessment before you return to work.
|
||||||
|
|
||||||
|
### Phase 4: Ready When You Are
|
||||||
|
|
||||||
|
The final screen says "Ready when you are" and offers **Pause** or **Continue**. There's no urgency. The break timer is still visible but the message is clear: *you* decide when to go back. The next 25-minute cycle starts when you press Continue.
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
The escalating gradient approach works because it operates on the same channel as your focus: your visual field. Rather than competing with your attention via an auditory alarm or a modal dialog, Pommedoro gradually *joins* your visual environment. By the time the break arrives, your brain has already been transitioning for five minutes. The break screen isn't an interruption — it's the natural conclusion of a process you've been subconsciously participating in.
|
||||||
|
|
||||||
|
This is the difference between someone tapping you on the shoulder while you're reading versus slowly turning up the lights in the room. One triggers a startle response and gets dismissed. The other lets your eyes adjust naturally.
|
||||||
|
|
||||||
|
The actionable suggestions matter too. "Take a break" is an instruction with no substance. "Do some pushups" or "Drink some water" gives your body and mind a concrete thing to switch to. The context switch succeeds because there's a real context to switch *into*.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **25-minute work sessions** with 5-minute escalating visual transitions
|
||||||
|
- **Edge gradient warnings** that increase in frequency and intensity
|
||||||
|
- **Full-screen break overlay** with actionable wellness suggestions
|
||||||
|
- **5-minute break timer** with self-assessment reflection
|
||||||
|
- **Menu bar timer** with pause/resume support
|
||||||
|
- **Debug mode** for testing (1-minute cycles)
|
||||||
|
- **Launch at Login** via macOS LaunchAgent
|
||||||
|
- **Native macOS app** — no Electron, no web views, just AppKit
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Download the latest DMG directly:
|
||||||
|
|
||||||
|
**[Pommedoro.dmg](https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg)**
|
||||||
|
|
||||||
|
Open the DMG and drag Pommedoro to your Applications folder.
|
||||||
|
|
||||||
|
## Build from Source
|
||||||
|
|
||||||
|
Requires macOS 13+ and Swift 5.9+.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
To install to `/Applications`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
To build the DMG:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dmg
|
||||||
|
```
|
||||||
|
|
||||||
|
To run directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- macOS 13+
|
||||||
|
- Swift 5.9+
|
||||||
|
- Apple Silicon or Intel Mac
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
**Colin Knapp** — [colinknapp.com](https://colinknapp.com)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This work is licensed under [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/).
|
||||||
|
|
||||||
|
You are free to share and adapt this work for any purpose, including commercially, as long as you give appropriate credit.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>Pommedoro</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>Pommedoro</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.pommedoro.app</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Pommedoro</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Pommedoro</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,2 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 218.21 218.21"><defs><style>.cls-1{fill:#1d3557;}.cls-2{fill:#457b9d;}.cls-3{fill:#e63946;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M137.19,18.06A3.74,3.74,0,0,0,140.86,15l-3.47-3.46c-.3-.3-.61-.58-.91-.87a3.73,3.73,0,0,0,.71,7.4Z"/><path class="cls-1" d="M140.48,53.17h7.24a17.34,17.34,0,0,1,17.33,17.32v7.25H175a7.24,7.24,0,0,0,5.12-2.12l10.7-10.7-45-45a10.37,10.37,0,0,1-5.33,4.14Z"/><path class="cls-1" d="M175,84.31h-9.91v7.47h14.27a3.29,3.29,0,0,1,0,6.57H165.05v7.47h52.88a2.4,2.4,0,0,1,.28,0,39.79,39.79,0,0,0-11.52-25L195.43,69.56l-10.7,10.7A13.73,13.73,0,0,1,175,84.31Z"/><path class="cls-1" d="M112.39,53.16h7.48V38.9a3.28,3.28,0,1,1,6.56,0V53.16h7.48V24.09a10.3,10.3,0,0,1-3.08-17.86A40,40,0,0,0,112.36,0a2.4,2.4,0,0,1,0,.28Z"/><path class="cls-2" d="M14.32,84.76A3.74,3.74,0,0,0,15,77.35l-3.47,3.47c-.3.3-.58.61-.87.92A3.73,3.73,0,0,0,14.32,84.76Z"/><path class="cls-2" d="M53.16,105.82V98.35H38.9a3.29,3.29,0,1,1,0-6.57H53.16V84.31H24.09A10.3,10.3,0,0,1,6.23,87.39,40,40,0,0,0,0,105.85a2.4,2.4,0,0,1,.28,0Z"/><path class="cls-1" d="M84.31,43.26v9.9h7.47V38.9a3.29,3.29,0,1,1,6.57,0V53.16h7.47V.28a2.4,2.4,0,0,1,0-.28,39.8,39.8,0,0,0-25,11.53L69.56,22.79l10.7,10.7A13.73,13.73,0,0,1,84.31,43.26Z"/><path class="cls-2" d="M81,200.16a3.74,3.74,0,0,0-3.67,3.06l3.47,3.47c.3.3.61.58.92.87a3.73,3.73,0,0,0-.72-7.4Z"/><path class="cls-2" d="M105.82,165.05H98.35v14.27a3.29,3.29,0,0,1-6.57,0V165.05H84.31v29.08A10.29,10.29,0,0,1,87.39,212a39.84,39.84,0,0,0,18.46,6.23,2.4,2.4,0,0,1,0-.28Z"/><path class="cls-1" d="M203.89,133.46a3.73,3.73,0,0,0-.67,7.4l3.47-3.47c.3-.3.58-.61.87-.91A3.74,3.74,0,0,0,203.89,133.46Z"/><path class="cls-3" d="M194.13,140.48H165.05v7.24a17,17,0,0,1-.55,4.33,3.28,3.28,0,0,1-3.17,2.47,3.24,3.24,0,0,1-.82-.11,3.29,3.29,0,0,1-2.37-4,10.5,10.5,0,0,0,.34-2.69V70.49a10.78,10.78,0,0,0-10.76-10.76H70.49a11,11,0,0,0-2.69.34,3.28,3.28,0,0,1-1.64-6.36,17.54,17.54,0,0,1,4.33-.55h7.25v-9.9a7.24,7.24,0,0,0-2.12-5.13l-10.7-10.7-45,45a10.43,10.43,0,0,1,4.14,5.34H53.17V70.49a17.53,17.53,0,0,1,.54-4.33,3.28,3.28,0,1,1,6.36,1.64,11,11,0,0,0-.34,2.69v77.23a10.78,10.78,0,0,0,10.76,10.76h77.23a10.63,10.63,0,0,0,2.7-.34,3.28,3.28,0,0,1,1.63,6.36,17,17,0,0,1-4.33.55h-7.24V175a7.19,7.19,0,0,0,2.12,5.12l10.7,10.7,45-45A10.27,10.27,0,0,1,194.13,140.48Zm-46.46-30.77L140.21,117l-4.5-1.94,1.83-4.75Zm-14.46,4.84-10.43-3.84,2.34-2.67-3.84-10.13L147,85.49Zm-.8,1.54-2.58,5.71H112l9.64-9.63Zm-4.08-38.27-.46,14.71-6.34,2.71-3.25-6.58ZM119,96.5l-9.59,4.41-9.5-4.41,9.5-26.94Zm-10.26,5.75v18.84l-13-13.42,3.41-9.76Zm-8.08-13.43-2.92,6.42L91.26,92l-.41-14Zm-3.34,8.43-4.17,10.92,2.59,2.42L85.59,114,71.92,85.24ZM81.67,110.09l1.67,4.54-4.71,2-7.46-7.13Zm15.68,23.3-30-10.67,17.3-6.09,3.73,7,18.58.07Zm-8-11.59-3-6,10.68-3.84,10,9.88Zm21,26.85h-1.59V123.3h1.59Zm0-46.48,9.42-3.84,3.5,9.34-12.92,13.42Zm11.42,31.22-10.09-10,19.29.17,3.16-6.74,16.74,5.9Z"/><path class="cls-2" d="M133.91,175v-9.91h-7.48v14.27a3.29,3.29,0,0,1-6.57,0V165.05h-7.47v52.88a2.4,2.4,0,0,1,0,.28,39.76,39.76,0,0,0,25-11.52l11.26-11.26L138,184.73A13.72,13.72,0,0,1,133.91,175Z"/><path class="cls-2" d="M43.26,133.91h9.9v-7.48H38.9a3.29,3.29,0,1,1,0-6.57H53.16v-7.47H.28a2.4,2.4,0,0,1-.28,0,39.75,39.75,0,0,0,11.53,25l11.26,11.26L33.49,138A13.72,13.72,0,0,1,43.26,133.91Z"/><path class="cls-1" d="M165.05,112.39v7.47h14.27a3.29,3.29,0,0,1,0,6.57H165.05v7.48h29.08A10.29,10.29,0,0,1,212,130.83a39.88,39.88,0,0,0,6.23-18.47,2.4,2.4,0,0,1-.28,0Z"/><path class="cls-2" d="M77.74,165.05H70.49a17.34,17.34,0,0,1-17.32-17.33v-7.24H43.26a7.2,7.2,0,0,0-5.13,2.12l-10.7,10.7,45,45a10.33,10.33,0,0,1,5.34-4.14Z"/></g></g></svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
|
|
@ -0,0 +1,163 @@
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
// Configuration
|
||||||
|
var isDebugMode: Bool = false
|
||||||
|
var workTimerDuration: Int {
|
||||||
|
return isDebugMode ? 60 : 1500 // Debug: 1 min, Normal: 25 min
|
||||||
|
}
|
||||||
|
var countdownDuration: Int = 300 // 5 minutes of escalating flashes
|
||||||
|
|
||||||
|
// Status bar
|
||||||
|
var statusItem: NSStatusItem!
|
||||||
|
var statusMenuItem: NSMenuItem!
|
||||||
|
var debugMenuItem: NSMenuItem!
|
||||||
|
var launchAtLoginMenuItem: NSMenuItem!
|
||||||
|
|
||||||
|
// Overlay windows
|
||||||
|
var overlayWindows: [NSWindow] = []
|
||||||
|
var countdownWindows: [NSWindow] = []
|
||||||
|
var countdownLabels: [NSTextField] = []
|
||||||
|
|
||||||
|
// Timers
|
||||||
|
var countdownTimer: Timer?
|
||||||
|
var breakTimer: Timer?
|
||||||
|
|
||||||
|
// State
|
||||||
|
var remainingSeconds: Int = 0
|
||||||
|
var isSolid = false
|
||||||
|
var isPaused = false
|
||||||
|
var currentSuggestion = ""
|
||||||
|
var breakRemainingSeconds: Int = 300
|
||||||
|
var breakCountdownLabels: [NSTextField] = []
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
remainingSeconds = workTimerDuration
|
||||||
|
setupStatusBar()
|
||||||
|
setupWindows()
|
||||||
|
startCountdown()
|
||||||
|
NotificationManager.shared.requestPermission()
|
||||||
|
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startCountdown() {
|
||||||
|
updateStatusBar()
|
||||||
|
|
||||||
|
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
|
||||||
|
guard let self = self else { timer.invalidate(); return }
|
||||||
|
if self.isPaused { return }
|
||||||
|
self.remainingSeconds -= 1
|
||||||
|
self.updateStatusBar()
|
||||||
|
|
||||||
|
if self.remainingSeconds <= self.countdownDuration && self.remainingSeconds > 0 {
|
||||||
|
let s = self.remainingSeconds
|
||||||
|
|
||||||
|
if s > 60 {
|
||||||
|
// 5:00 to 3:00 -> every 60 seconds, 3:00 to 1:00 -> every 30 seconds
|
||||||
|
let shouldBlink: Bool
|
||||||
|
if s > 180 {
|
||||||
|
shouldBlink = (s % 60 == 0)
|
||||||
|
} else {
|
||||||
|
shouldBlink = (s % 30 == 0)
|
||||||
|
}
|
||||||
|
if shouldBlink { self.triggerBlink() }
|
||||||
|
} else if s > 5 {
|
||||||
|
// Last minute: same as debug mode escalation
|
||||||
|
let shouldBlink: Bool
|
||||||
|
if s > 30 {
|
||||||
|
shouldBlink = (s % 10 == 0)
|
||||||
|
} else if s > 10 {
|
||||||
|
shouldBlink = (s % 5 == 0)
|
||||||
|
} else {
|
||||||
|
shouldBlink = (s % 2 == 0)
|
||||||
|
}
|
||||||
|
if shouldBlink { self.triggerBlink() }
|
||||||
|
} else {
|
||||||
|
if !self.isSolid { self.makeSolid() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if s <= 10 {
|
||||||
|
self.showCountdownPills()
|
||||||
|
self.updateCountdownLabels()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.remainingSeconds <= 0 {
|
||||||
|
self.showFullOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func togglePause() {
|
||||||
|
isPaused.toggle()
|
||||||
|
updateStatusBar()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func pauseFromOverlay() {
|
||||||
|
isPaused = true
|
||||||
|
breakTimer?.invalidate()
|
||||||
|
updateStatusBar()
|
||||||
|
resetOverlayToGradient()
|
||||||
|
breakCountdownLabels.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func toggleDebugMode() {
|
||||||
|
isDebugMode.toggle()
|
||||||
|
debugMenuItem.state = isDebugMode ? .on : .off
|
||||||
|
|
||||||
|
countdownTimer?.invalidate()
|
||||||
|
breakTimer?.invalidate()
|
||||||
|
breakCountdownLabels.removeAll()
|
||||||
|
remainingSeconds = workTimerDuration
|
||||||
|
isSolid = false
|
||||||
|
isPaused = false
|
||||||
|
|
||||||
|
resetOverlayToGradient()
|
||||||
|
hideCountdownPills()
|
||||||
|
for window in countdownWindows { window.orderFrontRegardless() }
|
||||||
|
|
||||||
|
startCountdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func toggleLaunchAtLogin() {
|
||||||
|
if LaunchAgent.isInstalled() {
|
||||||
|
LaunchAgent.uninstall()
|
||||||
|
launchAtLoginMenuItem.state = .off
|
||||||
|
} else {
|
||||||
|
LaunchAgent.install()
|
||||||
|
launchAtLoginMenuItem.state = .on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func closePommedoro() {
|
||||||
|
countdownTimer?.invalidate()
|
||||||
|
breakTimer?.invalidate()
|
||||||
|
for window in overlayWindows { window.orderOut(nil) }
|
||||||
|
for window in countdownWindows { window.orderOut(nil) }
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func resumePommedoro() {
|
||||||
|
isPaused = false
|
||||||
|
remainingSeconds = workTimerDuration
|
||||||
|
isSolid = false
|
||||||
|
breakTimer?.invalidate()
|
||||||
|
breakCountdownLabels.removeAll()
|
||||||
|
|
||||||
|
resetOverlayToGradient()
|
||||||
|
hideCountdownPills()
|
||||||
|
for window in countdownWindows { window.orderFrontRegardless() }
|
||||||
|
|
||||||
|
startCountdown()
|
||||||
|
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func dismissSuggestion() {
|
||||||
|
SuggestionManager.shared.dismiss(suggestion: currentSuggestion)
|
||||||
|
resumePommedoro()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
extension AppDelegate {
|
||||||
|
|
||||||
|
func showFullOverlay() {
|
||||||
|
countdownTimer?.invalidate()
|
||||||
|
currentSuggestion = SuggestionManager.shared.next()
|
||||||
|
statusItem.button?.title = "🍅 Time's Up!"
|
||||||
|
|
||||||
|
for window in countdownWindows { window.orderOut(nil) }
|
||||||
|
|
||||||
|
breakCountdownLabels.removeAll()
|
||||||
|
breakRemainingSeconds = 300
|
||||||
|
startBreakTimer()
|
||||||
|
|
||||||
|
for (i, window) in overlayWindows.enumerated() {
|
||||||
|
window.ignoresMouseEvents = false
|
||||||
|
buildSuggestionScreen(window: window, isPrimary: i == 0)
|
||||||
|
|
||||||
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
ctx.duration = 0.3
|
||||||
|
window.animator().alphaValue = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startBreakTimer() {
|
||||||
|
breakTimer?.invalidate()
|
||||||
|
breakTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
|
||||||
|
guard let self = self else { timer.invalidate(); return }
|
||||||
|
self.breakRemainingSeconds -= 1
|
||||||
|
self.updateBreakCountdownLabels()
|
||||||
|
if self.breakRemainingSeconds <= 0 {
|
||||||
|
timer.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateBreakCountdownLabels() {
|
||||||
|
let minutes = breakRemainingSeconds / 60
|
||||||
|
let seconds = breakRemainingSeconds % 60
|
||||||
|
let text = String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
for label in breakCountdownLabels {
|
||||||
|
label.stringValue = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSuggestionScreen(window: NSWindow, isPrimary: Bool) {
|
||||||
|
let size = window.frame.size
|
||||||
|
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
||||||
|
view.wantsLayer = true
|
||||||
|
view.layer?.backgroundColor = NSColor.systemTeal.withAlphaComponent(0.85).cgColor
|
||||||
|
|
||||||
|
let countdownLabel = NSTextField(labelWithString: "05:00")
|
||||||
|
countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 72, weight: .bold)
|
||||||
|
countdownLabel.textColor = .white
|
||||||
|
countdownLabel.alignment = .center
|
||||||
|
countdownLabel.sizeToFit()
|
||||||
|
countdownLabel.frame.origin = CGPoint(
|
||||||
|
x: (size.width - countdownLabel.frame.width) / 2,
|
||||||
|
y: (size.height / 2) + 80
|
||||||
|
)
|
||||||
|
view.addSubview(countdownLabel)
|
||||||
|
breakCountdownLabels.append(countdownLabel)
|
||||||
|
|
||||||
|
let suggestion = NSTextField(labelWithString: currentSuggestion)
|
||||||
|
suggestion.font = NSFont.systemFont(ofSize: 48, weight: .medium)
|
||||||
|
suggestion.textColor = NSColor.white.withAlphaComponent(0.9)
|
||||||
|
suggestion.alignment = .center
|
||||||
|
suggestion.sizeToFit()
|
||||||
|
suggestion.frame.origin = CGPoint(
|
||||||
|
x: (size.width - suggestion.frame.width) / 2,
|
||||||
|
y: (size.height / 2) - 10
|
||||||
|
)
|
||||||
|
view.addSubview(suggestion)
|
||||||
|
|
||||||
|
if isPrimary {
|
||||||
|
let closeBtn = NSButton(frame: NSRect(x: size.width - 140, y: size.height - 60, width: 120, height: 36))
|
||||||
|
closeBtn.title = "Close"
|
||||||
|
closeBtn.bezelStyle = .rounded
|
||||||
|
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
|
closeBtn.target = self
|
||||||
|
closeBtn.action = #selector(closePommedoro)
|
||||||
|
view.addSubview(closeBtn)
|
||||||
|
|
||||||
|
let continueWorkingBtn = NSButton(frame: NSRect(x: (size.width - 200) / 2, y: size.height / 2 - 50, width: 200, height: 50))
|
||||||
|
continueWorkingBtn.title = "Continue Working"
|
||||||
|
continueWorkingBtn.bezelStyle = .rounded
|
||||||
|
continueWorkingBtn.font = NSFont.systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
continueWorkingBtn.target = self
|
||||||
|
continueWorkingBtn.action = #selector(resumePommedoro)
|
||||||
|
view.addSubview(continueWorkingBtn)
|
||||||
|
|
||||||
|
let btnY = size.height / 2 - 110
|
||||||
|
let btnW: CGFloat = 160
|
||||||
|
let btnH: CGFloat = 44
|
||||||
|
let gap: CGFloat = 16
|
||||||
|
let totalW = btnW * 3 + gap * 2
|
||||||
|
let startX = (size.width - totalW) / 2
|
||||||
|
|
||||||
|
let successBtn = NSButton(frame: NSRect(x: startX, y: btnY, width: btnW, height: btnH))
|
||||||
|
successBtn.title = "Success!"
|
||||||
|
successBtn.bezelStyle = .rounded
|
||||||
|
successBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
successBtn.target = self
|
||||||
|
successBtn.action = #selector(didSuccess)
|
||||||
|
view.addSubview(successBtn)
|
||||||
|
|
||||||
|
let nextTimeBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: btnY, width: btnW, height: btnH))
|
||||||
|
nextTimeBtn.title = "Next Time"
|
||||||
|
nextTimeBtn.bezelStyle = .rounded
|
||||||
|
nextTimeBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
nextTimeBtn.target = self
|
||||||
|
nextTimeBtn.action = #selector(resumePommedoro)
|
||||||
|
view.addSubview(nextTimeBtn)
|
||||||
|
|
||||||
|
let dontSuggestBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: btnY, width: btnW, height: btnH))
|
||||||
|
dontSuggestBtn.title = "Don't Suggest"
|
||||||
|
dontSuggestBtn.bezelStyle = .rounded
|
||||||
|
dontSuggestBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
dontSuggestBtn.target = self
|
||||||
|
dontSuggestBtn.action = #selector(dismissSuggestion)
|
||||||
|
view.addSubview(dontSuggestBtn)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.contentView = view
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func didSuccess() {
|
||||||
|
breakCountdownLabels.removeAll()
|
||||||
|
for (i, window) in overlayWindows.enumerated() {
|
||||||
|
let size = window.frame.size
|
||||||
|
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
||||||
|
view.wantsLayer = true
|
||||||
|
view.layer?.backgroundColor = NSColor.systemTeal.withAlphaComponent(0.85).cgColor
|
||||||
|
|
||||||
|
let countdownLabel = NSTextField(labelWithString: String(format: "%02d:%02d", breakRemainingSeconds / 60, breakRemainingSeconds % 60))
|
||||||
|
countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
|
||||||
|
countdownLabel.textColor = NSColor.white.withAlphaComponent(0.7)
|
||||||
|
countdownLabel.alignment = .center
|
||||||
|
countdownLabel.sizeToFit()
|
||||||
|
countdownLabel.frame.origin = CGPoint(
|
||||||
|
x: (size.width - countdownLabel.frame.width) / 2,
|
||||||
|
y: size.height - 80
|
||||||
|
)
|
||||||
|
view.addSubview(countdownLabel)
|
||||||
|
breakCountdownLabels.append(countdownLabel)
|
||||||
|
|
||||||
|
let congrats = NSTextField(labelWithString: "Nice work!")
|
||||||
|
congrats.font = NSFont.systemFont(ofSize: 72, weight: .bold)
|
||||||
|
congrats.textColor = .white
|
||||||
|
congrats.alignment = .center
|
||||||
|
congrats.sizeToFit()
|
||||||
|
congrats.frame.origin = CGPoint(
|
||||||
|
x: (size.width - congrats.frame.width) / 2,
|
||||||
|
y: (size.height / 2) + 80
|
||||||
|
)
|
||||||
|
view.addSubview(congrats)
|
||||||
|
|
||||||
|
let prompt = NSTextField(labelWithString: "How did that impact your readiness for the day?")
|
||||||
|
prompt.font = NSFont.systemFont(ofSize: 36, weight: .medium)
|
||||||
|
prompt.textColor = NSColor.white.withAlphaComponent(0.9)
|
||||||
|
prompt.alignment = .center
|
||||||
|
prompt.sizeToFit()
|
||||||
|
prompt.frame.origin = CGPoint(
|
||||||
|
x: (size.width - prompt.frame.width) / 2,
|
||||||
|
y: (size.height / 2) + 10
|
||||||
|
)
|
||||||
|
view.addSubview(prompt)
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
let closeBtn = NSButton(frame: NSRect(x: size.width - 140, y: size.height - 60, width: 120, height: 36))
|
||||||
|
closeBtn.title = "Close"
|
||||||
|
closeBtn.bezelStyle = .rounded
|
||||||
|
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
|
closeBtn.target = self
|
||||||
|
closeBtn.action = #selector(closePommedoro)
|
||||||
|
view.addSubview(closeBtn)
|
||||||
|
|
||||||
|
let reflY = size.height / 2 - 60
|
||||||
|
let btnW: CGFloat = 180
|
||||||
|
let btnH: CGFloat = 44
|
||||||
|
let gap: CGFloat = 16
|
||||||
|
let totalW = btnW * 3 + gap * 2
|
||||||
|
let startX = (size.width - totalW) / 2
|
||||||
|
|
||||||
|
let greatBtn = NSButton(frame: NSRect(x: startX, y: reflY, width: btnW, height: btnH))
|
||||||
|
greatBtn.title = "Feeling Great"
|
||||||
|
greatBtn.bezelStyle = .rounded
|
||||||
|
greatBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium)
|
||||||
|
greatBtn.target = self
|
||||||
|
greatBtn.action = #selector(reflectionDone)
|
||||||
|
view.addSubview(greatBtn)
|
||||||
|
|
||||||
|
let okBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: reflY, width: btnW, height: btnH))
|
||||||
|
okBtn.title = "About the Same"
|
||||||
|
okBtn.bezelStyle = .rounded
|
||||||
|
okBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium)
|
||||||
|
okBtn.target = self
|
||||||
|
okBtn.action = #selector(reflectionDone)
|
||||||
|
view.addSubview(okBtn)
|
||||||
|
|
||||||
|
let notBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: reflY, width: btnW, height: btnH))
|
||||||
|
notBtn.title = "Not Really"
|
||||||
|
notBtn.bezelStyle = .rounded
|
||||||
|
notBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium)
|
||||||
|
notBtn.target = self
|
||||||
|
notBtn.action = #selector(reflectionDone)
|
||||||
|
view.addSubview(notBtn)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.contentView = view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func reflectionDone() {
|
||||||
|
breakCountdownLabels.removeAll()
|
||||||
|
for (i, window) in overlayWindows.enumerated() {
|
||||||
|
let size = window.frame.size
|
||||||
|
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
||||||
|
view.wantsLayer = true
|
||||||
|
view.layer?.backgroundColor = NSColor.systemTeal.withAlphaComponent(0.85).cgColor
|
||||||
|
|
||||||
|
let countdownLabel = NSTextField(labelWithString: String(format: "%02d:%02d", breakRemainingSeconds / 60, breakRemainingSeconds % 60))
|
||||||
|
countdownLabel.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
|
||||||
|
countdownLabel.textColor = NSColor.white.withAlphaComponent(0.7)
|
||||||
|
countdownLabel.alignment = .center
|
||||||
|
countdownLabel.sizeToFit()
|
||||||
|
countdownLabel.frame.origin = CGPoint(
|
||||||
|
x: (size.width - countdownLabel.frame.width) / 2,
|
||||||
|
y: size.height - 80
|
||||||
|
)
|
||||||
|
view.addSubview(countdownLabel)
|
||||||
|
breakCountdownLabels.append(countdownLabel)
|
||||||
|
|
||||||
|
let msg = NSTextField(labelWithString: "Ready when you are")
|
||||||
|
msg.font = NSFont.systemFont(ofSize: 56, weight: .bold)
|
||||||
|
msg.textColor = .white
|
||||||
|
msg.alignment = .center
|
||||||
|
msg.sizeToFit()
|
||||||
|
msg.frame.origin = CGPoint(
|
||||||
|
x: (size.width - msg.frame.width) / 2,
|
||||||
|
y: (size.height / 2) + 40
|
||||||
|
)
|
||||||
|
view.addSubview(msg)
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
let closeBtn = NSButton(frame: NSRect(x: size.width - 140, y: size.height - 60, width: 120, height: 36))
|
||||||
|
closeBtn.title = "Close"
|
||||||
|
closeBtn.bezelStyle = .rounded
|
||||||
|
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
|
closeBtn.target = self
|
||||||
|
closeBtn.action = #selector(closePommedoro)
|
||||||
|
view.addSubview(closeBtn)
|
||||||
|
|
||||||
|
let btnW: CGFloat = 180
|
||||||
|
let btnH: CGFloat = 50
|
||||||
|
let gap: CGFloat = 24
|
||||||
|
let totalW = btnW * 2 + gap
|
||||||
|
let startX = (size.width - totalW) / 2
|
||||||
|
let btnY = size.height / 2 - 50
|
||||||
|
|
||||||
|
let pauseBtn = NSButton(frame: NSRect(x: startX, y: btnY, width: btnW, height: btnH))
|
||||||
|
pauseBtn.title = "Pause"
|
||||||
|
pauseBtn.bezelStyle = .rounded
|
||||||
|
pauseBtn.font = NSFont.systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
pauseBtn.target = self
|
||||||
|
pauseBtn.action = #selector(pauseFromOverlay)
|
||||||
|
view.addSubview(pauseBtn)
|
||||||
|
|
||||||
|
let continueBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: btnY, width: btnW, height: btnH))
|
||||||
|
continueBtn.title = "Continue"
|
||||||
|
continueBtn.bezelStyle = .rounded
|
||||||
|
continueBtn.font = NSFont.systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
continueBtn.target = self
|
||||||
|
continueBtn.action = #selector(resumePommedoro)
|
||||||
|
view.addSubview(continueBtn)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.contentView = view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import Foundation
|
||||||
|
import Sentry
|
||||||
|
|
||||||
|
enum BugReporting {
|
||||||
|
static let dsn = "https://5ef8c516e95244bc88e859fc428ec5a0@bugsink.nixc.us/5"
|
||||||
|
|
||||||
|
static func start() {
|
||||||
|
SentrySDK.start { options in
|
||||||
|
options.dsn = dsn
|
||||||
|
options.debug = false
|
||||||
|
options.enableCrashHandler = true
|
||||||
|
options.enableAutoSessionTracking = true
|
||||||
|
options.sendDefaultPii = false
|
||||||
|
|
||||||
|
// Attach stack traces to all logged errors
|
||||||
|
options.attachStacktrace = true
|
||||||
|
|
||||||
|
// Sample rate for regular events (1.0 = send everything)
|
||||||
|
options.sampleRate = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture a non-fatal error manually
|
||||||
|
static func capture(_ error: Error) {
|
||||||
|
SentrySDK.capture(error: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture a message manually
|
||||||
|
static func capture(message: String) {
|
||||||
|
SentrySDK.capture(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
class EdgeGradientView: NSView {
|
||||||
|
var intensity: CGFloat = 0.4
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
|
||||||
|
let w = bounds.width, h = bounds.height, edge: CGFloat = 220
|
||||||
|
let color = NSColor.systemTeal
|
||||||
|
let colors = [color.withAlphaComponent(intensity).cgColor, color.withAlphaComponent(0.0).cgColor] as CFArray
|
||||||
|
guard let grad = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors, locations: [0, 1]) else { return }
|
||||||
|
|
||||||
|
ctx.saveGState(); ctx.clip(to: CGRect(x: 0, y: h - edge, width: w, height: edge))
|
||||||
|
ctx.drawLinearGradient(grad, start: CGPoint(x: w/2, y: h), end: CGPoint(x: w/2, y: h - edge), options: []); ctx.restoreGState()
|
||||||
|
|
||||||
|
ctx.saveGState(); ctx.clip(to: CGRect(x: 0, y: 0, width: w, height: edge))
|
||||||
|
ctx.drawLinearGradient(grad, start: CGPoint(x: w/2, y: 0), end: CGPoint(x: w/2, y: edge), options: []); ctx.restoreGState()
|
||||||
|
|
||||||
|
ctx.saveGState(); ctx.clip(to: CGRect(x: 0, y: 0, width: edge, height: h))
|
||||||
|
ctx.drawLinearGradient(grad, start: CGPoint(x: 0, y: h/2), end: CGPoint(x: edge, y: h/2), options: []); ctx.restoreGState()
|
||||||
|
|
||||||
|
ctx.saveGState(); ctx.clip(to: CGRect(x: w - edge, y: 0, width: edge, height: h))
|
||||||
|
ctx.drawLinearGradient(grad, start: CGPoint(x: w, y: h/2), end: CGPoint(x: w - edge, y: h/2), options: []); ctx.restoreGState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum LaunchAgent {
|
||||||
|
static let label = "com.pommedoro.app"
|
||||||
|
static let plistName = "\(label).plist"
|
||||||
|
|
||||||
|
static var plistURL: URL {
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
return home.appendingPathComponent("Library/LaunchAgents/\(plistName)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var appPath: String {
|
||||||
|
return "/Applications/Pommedoro.app/Contents/MacOS/Pommedoro"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isInstalled() -> Bool {
|
||||||
|
return FileManager.default.fileExists(atPath: plistURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func install() {
|
||||||
|
let plist: [String: Any] = [
|
||||||
|
"Label": label,
|
||||||
|
"Program": appPath,
|
||||||
|
"RunAtLoad": true,
|
||||||
|
"KeepAlive": false
|
||||||
|
]
|
||||||
|
|
||||||
|
let launchAgentsDir = plistURL.deletingLastPathComponent()
|
||||||
|
try? FileManager.default.createDirectory(at: launchAgentsDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let data = try? PropertyListSerialization.data(
|
||||||
|
fromPropertyList: plist,
|
||||||
|
format: .xml,
|
||||||
|
options: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if let data = data {
|
||||||
|
try? data.write(to: plistURL)
|
||||||
|
NSLog("Pommedoro: LaunchAgent installed at \(plistURL.path)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func uninstall() {
|
||||||
|
try? FileManager.default.removeItem(at: plistURL)
|
||||||
|
NSLog("Pommedoro: LaunchAgent removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
|
||||||
|
static let shared = NotificationManager()
|
||||||
|
|
||||||
|
/// UNUserNotificationCenter requires a valid .app bundle.
|
||||||
|
/// When running the bare binary (e.g. via build-test.sh) we skip notifications.
|
||||||
|
private var isInsideBundle: Bool {
|
||||||
|
return Bundle.main.bundleIdentifier != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
if isInsideBundle {
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestPermission() {
|
||||||
|
guard isInsideBundle else {
|
||||||
|
NSLog("Pommedoro: skipping notification permission (not running from .app bundle)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
|
||||||
|
if let error = error {
|
||||||
|
NSLog("Pommedoro: notification permission error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postCycleStarted(durationMinutes: Int) {
|
||||||
|
guard isInsideBundle else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Pommedoro"
|
||||||
|
content.body = "Cycle started — \(durationMinutes) minutes"
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "pommedoro-cycle-start-\(UUID().uuidString)",
|
||||||
|
content: content,
|
||||||
|
trigger: trigger
|
||||||
|
)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { error in
|
||||||
|
if let error = error {
|
||||||
|
NSLog("Pommedoro: failed to post notification: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler([.banner, .sound])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
extension AppDelegate {
|
||||||
|
|
||||||
|
func setupWindows() {
|
||||||
|
for screen in NSScreen.screens {
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: screen.frame,
|
||||||
|
styleMask: .borderless,
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.level = .screenSaver
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.isOpaque = false
|
||||||
|
window.hasShadow = false
|
||||||
|
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
|
window.ignoresMouseEvents = true
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
|
||||||
|
let gradientView = EdgeGradientView(frame: NSRect(origin: .zero, size: screen.frame.size))
|
||||||
|
gradientView.autoresizingMask = [.width, .height]
|
||||||
|
window.contentView = gradientView
|
||||||
|
|
||||||
|
window.alphaValue = 0.0
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
overlayWindows.append(window)
|
||||||
|
|
||||||
|
let pillW: CGFloat = 200
|
||||||
|
let pillH: CGFloat = 80
|
||||||
|
let pillX = screen.frame.origin.x + (screen.frame.width - pillW) / 2
|
||||||
|
let pillY = screen.frame.origin.y + 40
|
||||||
|
|
||||||
|
let pillWindow = NSWindow(
|
||||||
|
contentRect: NSRect(x: pillX, y: pillY, width: pillW, height: pillH),
|
||||||
|
styleMask: .borderless,
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
pillWindow.level = .screenSaver
|
||||||
|
pillWindow.backgroundColor = .clear
|
||||||
|
pillWindow.isOpaque = false
|
||||||
|
pillWindow.hasShadow = false
|
||||||
|
pillWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
|
pillWindow.ignoresMouseEvents = true
|
||||||
|
pillWindow.isReleasedWhenClosed = false
|
||||||
|
|
||||||
|
let pillView = NSView(frame: NSRect(x: 0, y: 0, width: pillW, height: pillH))
|
||||||
|
pillView.wantsLayer = true
|
||||||
|
pillView.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.6).cgColor
|
||||||
|
pillView.layer?.cornerRadius = pillH / 2
|
||||||
|
|
||||||
|
let label = NSTextField(labelWithString: "00:10")
|
||||||
|
label.font = NSFont.monospacedSystemFont(ofSize: 36, weight: .bold)
|
||||||
|
label.textColor = .systemTeal
|
||||||
|
label.alignment = .center
|
||||||
|
label.frame = NSRect(x: 0, y: 14, width: pillW, height: 50)
|
||||||
|
pillView.addSubview(label)
|
||||||
|
|
||||||
|
pillWindow.contentView = pillView
|
||||||
|
pillWindow.alphaValue = 0.0
|
||||||
|
pillWindow.orderFrontRegardless()
|
||||||
|
|
||||||
|
countdownWindows.append(pillWindow)
|
||||||
|
countdownLabels.append(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showCountdownPills() {
|
||||||
|
for window in countdownWindows {
|
||||||
|
if window.alphaValue < 1.0 {
|
||||||
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
ctx.duration = 0.2
|
||||||
|
window.animator().alphaValue = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hideCountdownPills() {
|
||||||
|
for window in countdownWindows { window.alphaValue = 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCountdownLabels() {
|
||||||
|
let seconds = max(remainingSeconds, 0)
|
||||||
|
let text = String(format: "00:%02d", seconds)
|
||||||
|
for label in countdownLabels { label.stringValue = text }
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerBlink() {
|
||||||
|
// Intensity scales across the effective countdown window (capped to work timer)
|
||||||
|
let effectiveCountdown = min(countdownDuration, workTimerDuration)
|
||||||
|
let countdownElapsed = Double(effectiveCountdown - remainingSeconds)
|
||||||
|
let countdownTotal = Double(effectiveCountdown)
|
||||||
|
let progress = min(max(countdownElapsed / countdownTotal, 0.0), 1.0)
|
||||||
|
let intensity = 0.15 + (0.60 * progress) // 0.15 at start, up to 0.75 at 0:00
|
||||||
|
|
||||||
|
let fadeIn: Double
|
||||||
|
let hold: Double
|
||||||
|
let fadeOut: Double
|
||||||
|
if remainingSeconds > 60 {
|
||||||
|
// 5:00 to 1:00: gentle, slow flashes
|
||||||
|
fadeIn = 1.0; hold = 0.8; fadeOut = 1.0
|
||||||
|
} else if remainingSeconds > 30 {
|
||||||
|
fadeIn = 0.8; hold = 0.6; fadeOut = 0.8
|
||||||
|
} else if remainingSeconds > 10 {
|
||||||
|
fadeIn = 0.5; hold = 0.4; fadeOut = 0.6
|
||||||
|
} else {
|
||||||
|
fadeIn = 0.3; hold = 0.3; fadeOut = 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
for window in overlayWindows {
|
||||||
|
guard let view = window.contentView as? EdgeGradientView else { continue }
|
||||||
|
view.intensity = CGFloat(intensity)
|
||||||
|
view.needsDisplay = true
|
||||||
|
|
||||||
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
ctx.duration = fadeIn
|
||||||
|
window.animator().alphaValue = 1.0
|
||||||
|
} completionHandler: {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + hold) {
|
||||||
|
NSAnimationContext.runAnimationGroup({ ctx in
|
||||||
|
ctx.duration = fadeOut
|
||||||
|
window.animator().alphaValue = 0.0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSolid() {
|
||||||
|
isSolid = true
|
||||||
|
for window in overlayWindows {
|
||||||
|
guard let view = window.contentView as? EdgeGradientView else { continue }
|
||||||
|
view.intensity = 0.75
|
||||||
|
view.needsDisplay = true
|
||||||
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
ctx.duration = 0.3
|
||||||
|
window.animator().alphaValue = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetOverlayToGradient() {
|
||||||
|
for window in overlayWindows {
|
||||||
|
window.ignoresMouseEvents = true
|
||||||
|
let size = window.frame.size
|
||||||
|
let gradientView = EdgeGradientView(frame: NSRect(origin: .zero, size: size))
|
||||||
|
gradientView.autoresizingMask = [.width, .height]
|
||||||
|
window.contentView = gradientView
|
||||||
|
window.alphaValue = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
extension AppDelegate {
|
||||||
|
|
||||||
|
func setupStatusBar() {
|
||||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
|
if let button = statusItem.button {
|
||||||
|
let minutes = workTimerDuration / 60
|
||||||
|
let seconds = workTimerDuration % 60
|
||||||
|
button.title = String(format: "🍅 %02d:%02d", minutes, seconds)
|
||||||
|
button.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
let menu = NSMenu()
|
||||||
|
statusMenuItem = NSMenuItem(title: "Pommedoro: Running", action: nil, keyEquivalent: "")
|
||||||
|
statusMenuItem.isEnabled = false
|
||||||
|
menu.addItem(statusMenuItem)
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
menu.addItem(NSMenuItem(title: "Pause", action: #selector(togglePause), keyEquivalent: "p"))
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
debugMenuItem = NSMenuItem(title: "Debug Mode", action: #selector(toggleDebugMode), keyEquivalent: "d")
|
||||||
|
debugMenuItem.state = isDebugMode ? .on : .off
|
||||||
|
menu.addItem(debugMenuItem)
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
launchAtLoginMenuItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLaunchAtLogin), keyEquivalent: "l")
|
||||||
|
launchAtLoginMenuItem.state = LaunchAgent.isInstalled() ? .on : .off
|
||||||
|
menu.addItem(launchAtLoginMenuItem)
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
menu.addItem(NSMenuItem(title: "Quit Pommedoro", action: #selector(closePommedoro), keyEquivalent: "q"))
|
||||||
|
|
||||||
|
for item in menu.items {
|
||||||
|
item.target = self
|
||||||
|
}
|
||||||
|
|
||||||
|
statusItem.menu = menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateStatusBar() {
|
||||||
|
let minutes = remainingSeconds / 60
|
||||||
|
let seconds = remainingSeconds % 60
|
||||||
|
let time = String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
let prefix = isPaused ? "⏸" : "🍅"
|
||||||
|
statusItem.button?.title = "\(prefix) \(time)"
|
||||||
|
statusMenuItem.title = isPaused ? "Pommedoro: Paused" : "Pommedoro: Running"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class SuggestionManager {
|
||||||
|
static let shared = SuggestionManager()
|
||||||
|
|
||||||
|
private var suggestions: [String] = [
|
||||||
|
"Breathe for a few",
|
||||||
|
"Get up and walk around a minute",
|
||||||
|
"Do some pushups or something",
|
||||||
|
"Bodyweight squats, let's go",
|
||||||
|
"Maybe some crunches",
|
||||||
|
"Drink some water",
|
||||||
|
"Stretch your neck and shoulders",
|
||||||
|
"Rest your eyes, look at something far away",
|
||||||
|
"Stand up and touch your toes",
|
||||||
|
"Roll your wrists and ankles",
|
||||||
|
"Take a few deep breaths",
|
||||||
|
"Go get some water",
|
||||||
|
"Do some lunges",
|
||||||
|
"Stretch your hip flexors",
|
||||||
|
"Give your eyes a break",
|
||||||
|
"Straighten your posture, your back will thank you",
|
||||||
|
"Smile — it actually helps your mood",
|
||||||
|
"Clear your desk, clear your mind",
|
||||||
|
"Take a moment to appreciate how far you've come",
|
||||||
|
"Eat something fresh and nourishing today",
|
||||||
|
"Step outside for a minute of fresh air",
|
||||||
|
"Unclench your jaw and relax your shoulders"
|
||||||
|
]
|
||||||
|
|
||||||
|
private var dismissed: Set<Int> = []
|
||||||
|
private var currentIndex: Int = 0
|
||||||
|
|
||||||
|
func next() -> String {
|
||||||
|
var attempts = 0
|
||||||
|
while dismissed.contains(currentIndex) && attempts < suggestions.count {
|
||||||
|
currentIndex = (currentIndex + 1) % suggestions.count
|
||||||
|
attempts += 1
|
||||||
|
}
|
||||||
|
if attempts >= suggestions.count {
|
||||||
|
dismissed.removeAll()
|
||||||
|
}
|
||||||
|
let suggestion = suggestions[currentIndex]
|
||||||
|
currentIndex = (currentIndex + 1) % suggestions.count
|
||||||
|
return suggestion
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismiss(suggestion: String) {
|
||||||
|
if let idx = suggestions.firstIndex(of: suggestion) {
|
||||||
|
dismissed.insert(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
BugReporting.start()
|
||||||
|
|
||||||
|
let app = NSApplication.shared
|
||||||
|
app.setActivationPolicy(.accessory)
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
app.delegate = delegate
|
||||||
|
app.run()
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "==> Stopping any running Pommedoro..."
|
||||||
|
pkill -9 -f Pommedoro 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
echo "==> Cleaning previous build..."
|
||||||
|
rm -rf .build/Pommedoro.app .build/Pommedoro.dmg
|
||||||
|
|
||||||
|
echo "==> Building DMG..."
|
||||||
|
make dmg
|
||||||
|
|
||||||
|
echo "==> Installing to /Applications..."
|
||||||
|
make install
|
||||||
|
|
||||||
|
echo "==> Launching Pommedoro.app..."
|
||||||
|
open /Applications/Pommedoro.app
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==> Done. DMG at: .build/Pommedoro.dmg"
|
||||||
Binary file not shown.
Loading…
Reference in New Issue