Add work log, pause/resume UX, countdown schedule, timer persistence, suggestion improvements, and ad-hoc code signing
- Work log with CSV persistence and SwiftUI history view - Pause/resume support with timer state persistence across app restarts - Countdown pulse schedule: 5→3min at 1/min, 3→1min every 30s - Intent pre-fill from suggestions with improved suggestion matching - Overlay window and break screen refinements - Ad-hoc code signing with runtime hardening and entitlements - DMG includes Install.command that strips quarantine on install - README install instructions for non-technical users with Gatekeeper workaround - build-test.sh simulates download quarantine to verify fix - CHANGELOG and README updates Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b170202033
commit
0d2ef2eedc
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Pommedoro are documented here.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Work log** — Today's work log view (menu: View Work Log) with intervals, intent, reflection, and suggestion feedback. Permanent log file at `~/Documents/pommedoro/worklog.log`; in-app view shows today only. Copy and "Back to Work" from the log screen.
|
||||||
|
- **Intent pre-fill** — On the "Ready when you are" reflection/planning screen, the "What do you intend to work on next?" field is pre-filled with your current or last intent from today's log.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Pause / Resume** — Menu bar item toggles to "Resume" when paused; state is visible at a glance.
|
||||||
|
- **Back to work** — "Pause" and "Continue" from the break/ready overlay no longer reset the countdown when you're mid-interval; remaining time is preserved.
|
||||||
|
- **Countdown flash schedule** — From 5 minutes: one flash per minute until 3 minutes, then every 30 seconds until 1 minute, then the existing last-minute pattern (every 10s → 5s → 2s, last 5s solid). See [README.md](README.md#phase-1-escalating-awareness).
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Countdown pulse logic moved into `CountdownPulseSchedule`; shared by work and break.
|
||||||
|
- `KeyableWindow` for overlay text input (reflection/intent fields).
|
||||||
|
- Break flow: suggestion feedback, wellness reflection, then reflection + intent screen with Pause | Finish Day | Continue.
|
||||||
8
Makefile
8
Makefile
|
|
@ -41,12 +41,18 @@ bundle: build
|
||||||
@cp $(RELEASE_BIN) $(MACOS_DIR)/$(APP_NAME)
|
@cp $(RELEASE_BIN) $(MACOS_DIR)/$(APP_NAME)
|
||||||
@cp Resources/Info.plist $(CONTENTS)/Info.plist
|
@cp Resources/Info.plist $(CONTENTS)/Info.plist
|
||||||
@cp Resources/$(APP_NAME).icns $(RESOURCES_DIR)/$(APP_NAME).icns
|
@cp Resources/$(APP_NAME).icns $(RESOURCES_DIR)/$(APP_NAME).icns
|
||||||
|
@echo "Ad-hoc signing $(APP_BUNDLE)..."
|
||||||
|
@codesign --force --deep --sign - \
|
||||||
|
--entitlements Resources/Pommedoro.entitlements \
|
||||||
|
--options runtime \
|
||||||
|
$(APP_BUNDLE)
|
||||||
@echo "Built $(APP_BUNDLE)"
|
@echo "Built $(APP_BUNDLE)"
|
||||||
|
|
||||||
install: bundle
|
install: bundle
|
||||||
@echo "Installing to /Applications..."
|
@echo "Installing to /Applications..."
|
||||||
@rm -rf /Applications/$(APP_NAME).app
|
@rm -rf /Applications/$(APP_NAME).app
|
||||||
@cp -R $(APP_BUNDLE) /Applications/$(APP_NAME).app
|
@cp -R $(APP_BUNDLE) /Applications/$(APP_NAME).app
|
||||||
|
@xattr -cr /Applications/$(APP_NAME).app
|
||||||
@echo "Installed /Applications/$(APP_NAME).app"
|
@echo "Installed /Applications/$(APP_NAME).app"
|
||||||
|
|
||||||
run: bundle
|
run: bundle
|
||||||
|
|
@ -83,6 +89,8 @@ dmg: bundle
|
||||||
@mkdir -p $(BUILD_DIR)/dmg-staging
|
@mkdir -p $(BUILD_DIR)/dmg-staging
|
||||||
@cp -R $(APP_BUNDLE) $(BUILD_DIR)/dmg-staging/
|
@cp -R $(APP_BUNDLE) $(BUILD_DIR)/dmg-staging/
|
||||||
@ln -s /Applications $(BUILD_DIR)/dmg-staging/Applications
|
@ln -s /Applications $(BUILD_DIR)/dmg-staging/Applications
|
||||||
|
@cp Resources/install.command $(BUILD_DIR)/dmg-staging/Install.command
|
||||||
|
@chmod +x $(BUILD_DIR)/dmg-staging/Install.command
|
||||||
@cp Resources/$(APP_NAME).icns $(BUILD_DIR)/dmg-staging/.VolumeIcon.icns
|
@cp Resources/$(APP_NAME).icns $(BUILD_DIR)/dmg-staging/.VolumeIcon.icns
|
||||||
@SetFile -a C $(BUILD_DIR)/dmg-staging
|
@SetFile -a C $(BUILD_DIR)/dmg-staging
|
||||||
@hdiutil create -volname "$(APP_NAME)" \
|
@hdiutil create -volname "$(APP_NAME)" \
|
||||||
|
|
|
||||||
25
README.md
25
README.md
|
|
@ -78,13 +78,34 @@ The actionable suggestions matter too. "Take a break" is an instruction with no
|
||||||
- **Launch at Login** via macOS LaunchAgent
|
- **Launch at Login** via macOS LaunchAgent
|
||||||
- **Native macOS app** — no Electron, no web views, just AppKit
|
- **Native macOS app** — no Electron, no web views, just AppKit
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
See [CHANGELOG.md](CHANGELOG.md) for release history and recent changes.
|
||||||
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Download the latest DMG directly:
|
### 1. Download
|
||||||
|
|
||||||
|
Go to [colinknapp.com/stories/pommedoro.html](https://colinknapp.com/stories/pommedoro.html) or download the DMG directly:
|
||||||
|
|
||||||
**[Pommedoro.dmg](https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg)**
|
**[Pommedoro.dmg](https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg)**
|
||||||
|
|
||||||
Open the DMG and drag Pommedoro to your Applications folder.
|
### 2. Install
|
||||||
|
|
||||||
|
In Finder, open your Downloads folder and double-click **Pommedoro.dmg**. When the disk image opens, drag **Pommedoro** into the **Applications** folder (or use the shortcut arrow if the window shows one). Then eject the disk image (right-click the "Pommedoro" volume in the Finder sidebar and choose Eject, or drag it to the Trash).
|
||||||
|
|
||||||
|
### 3. If macOS says "Pommedoro can't be opened" or "move to Trash"
|
||||||
|
|
||||||
|
Don't trash it. This happens because the app is distributed outside the Mac App Store.
|
||||||
|
|
||||||
|
1. Open **System Settings** → **Privacy & Security**.
|
||||||
|
2. Scroll down to the message about "Pommedoro was blocked…" (or "from an unidentified developer").
|
||||||
|
3. Click **Open Anyway** and confirm.
|
||||||
|
|
||||||
|
### 4. Open the app
|
||||||
|
|
||||||
|
Open **Applications** (or use Spotlight: **Cmd+Space**, type "Pommedoro") and double-click **Pommedoro**. It runs as a menu bar app — look for the timer icon in your menu bar.
|
||||||
|
|
||||||
## Build from Source
|
## Build from Source
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?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>com.apple.security.app-sandbox</key>
|
||||||
|
<false/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Pommedoro Installer
|
||||||
|
# Double-click this file to install Pommedoro to /Applications
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_NAME="Pommedoro"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
APP_SRC="${SCRIPT_DIR}/${APP_NAME}.app"
|
||||||
|
APP_DST="/Applications/${APP_NAME}.app"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Installing ${APP_NAME}..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ ! -d "${APP_SRC}" ]; then
|
||||||
|
echo " ERROR: ${APP_NAME}.app not found next to this installer."
|
||||||
|
echo " Make sure the DMG is mounted and try again."
|
||||||
|
echo ""
|
||||||
|
read -n 1 -s -r -p " Press any key to close..."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop running instance if any
|
||||||
|
pkill -9 -f "${APP_NAME}" 2>/dev/null || true
|
||||||
|
sleep 0.3
|
||||||
|
|
||||||
|
# Copy to /Applications
|
||||||
|
rm -rf "${APP_DST}"
|
||||||
|
cp -R "${APP_SRC}" "${APP_DST}"
|
||||||
|
|
||||||
|
# Strip quarantine attribute (fixes "app is damaged" on downloaded DMGs)
|
||||||
|
xattr -cr "${APP_DST}"
|
||||||
|
|
||||||
|
echo " Installed to ${APP_DST}"
|
||||||
|
echo " Launching ${APP_NAME}..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
open "${APP_DST}"
|
||||||
|
|
||||||
|
echo " Done!"
|
||||||
|
echo ""
|
||||||
|
|
@ -13,6 +13,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
var statusMenuItem: NSMenuItem!
|
var statusMenuItem: NSMenuItem!
|
||||||
var debugMenuItem: NSMenuItem!
|
var debugMenuItem: NSMenuItem!
|
||||||
var launchAtLoginMenuItem: NSMenuItem!
|
var launchAtLoginMenuItem: NSMenuItem!
|
||||||
|
var pauseMenuItem: NSMenuItem!
|
||||||
var settingsMenuBuilder: SettingsMenuBuilder!
|
var settingsMenuBuilder: SettingsMenuBuilder!
|
||||||
|
|
||||||
// Overlay windows
|
// Overlay windows
|
||||||
|
|
@ -32,11 +33,31 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
var breakRemainingSeconds: Int = 300
|
var breakRemainingSeconds: Int = 300
|
||||||
var breakCountdownLabels: [NSTextField] = []
|
var breakCountdownLabels: [NSTextField] = []
|
||||||
|
|
||||||
|
// Work log tracking
|
||||||
|
var currentIntervalStartDate: Date?
|
||||||
|
var currentIntent: String?
|
||||||
|
var intervalCompleted: Bool = false
|
||||||
|
var currentSuggestionFeedback: SuggestionFeedback?
|
||||||
|
var currentWellnessReflection: WellnessReflection?
|
||||||
|
|
||||||
|
// Text fields on ready screen (held for reading values)
|
||||||
|
var reflectionField: NSTextField?
|
||||||
|
var intentField: NSTextField?
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
WorkLogStore.shared.clearOldEntries()
|
||||||
|
if let saved = TimerPersistence.restore() {
|
||||||
|
remainingSeconds = min(saved.remainingSeconds, workTimerDuration)
|
||||||
|
isPaused = saved.isPaused
|
||||||
|
currentIntervalStartDate = saved.intervalStartDate
|
||||||
|
} else {
|
||||||
remainingSeconds = workTimerDuration
|
remainingSeconds = workTimerDuration
|
||||||
|
currentIntervalStartDate = Date()
|
||||||
|
}
|
||||||
setupStatusBar()
|
setupStatusBar()
|
||||||
setupWindows()
|
setupWindows()
|
||||||
startCountdown()
|
startCountdown()
|
||||||
|
updateStatusBar()
|
||||||
NotificationManager.shared.requestPermission()
|
NotificationManager.shared.requestPermission()
|
||||||
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
||||||
|
|
||||||
|
|
@ -46,6 +67,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
TimerPersistence.save(remainingSeconds: remainingSeconds, isPaused: isPaused, intervalStartDate: currentIntervalStartDate)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func settingsDidChange() {
|
@objc private func settingsDidChange() {
|
||||||
let color = Settings.shared.gradientColor
|
let color = Settings.shared.gradientColor
|
||||||
for label in countdownLabels {
|
for label in countdownLabels {
|
||||||
|
|
@ -67,38 +92,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
self.remainingSeconds -= 1
|
self.remainingSeconds -= 1
|
||||||
self.updateStatusBar()
|
self.updateStatusBar()
|
||||||
|
|
||||||
if self.remainingSeconds <= self.countdownDuration && self.remainingSeconds > 0 {
|
if CountdownPulseSchedule.isInCountdownPhase(remainingSeconds: self.remainingSeconds, countdownDuration: self.countdownDuration, workTimerDuration: self.workTimerDuration) {
|
||||||
let s = self.remainingSeconds
|
let effective = min(self.countdownDuration, self.workTimerDuration)
|
||||||
|
self.applyPulseSchedule(remainingSeconds: self.remainingSeconds, totalSeconds: effective)
|
||||||
if s > 60 {
|
if self.remainingSeconds <= 10 {
|
||||||
let shouldBlink: Bool
|
|
||||||
if s > 180 {
|
|
||||||
shouldBlink = (s % 60 == 0)
|
|
||||||
} else {
|
|
||||||
shouldBlink = (s % 30 == 0)
|
|
||||||
}
|
|
||||||
if shouldBlink { self.triggerBlink() }
|
|
||||||
} else if s > 5 {
|
|
||||||
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.showCountdownPills()
|
||||||
self.updateCountdownLabels()
|
self.updateCountdownLabels()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.remainingSeconds <= 0 {
|
if self.remainingSeconds <= 0 {
|
||||||
|
self.intervalCompleted = true
|
||||||
self.showFullOverlay()
|
self.showFullOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -109,12 +113,40 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
updateStatusBar()
|
updateStatusBar()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func restartTimer() {
|
||||||
|
countdownTimer?.invalidate()
|
||||||
|
remainingSeconds = workTimerDuration
|
||||||
|
currentIntervalStartDate = Date()
|
||||||
|
intervalCompleted = false
|
||||||
|
isPaused = false
|
||||||
|
isSolid = false
|
||||||
|
resetOverlayToGradient()
|
||||||
|
hideCountdownPills()
|
||||||
|
for window in countdownWindows { window.orderFrontRegardless() }
|
||||||
|
startCountdown()
|
||||||
|
updateStatusBar()
|
||||||
|
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
||||||
|
}
|
||||||
|
|
||||||
@objc func pauseFromOverlay() {
|
@objc func pauseFromOverlay() {
|
||||||
|
saveCurrentInterval()
|
||||||
isPaused = true
|
isPaused = true
|
||||||
breakTimer?.invalidate()
|
breakTimer?.invalidate()
|
||||||
updateStatusBar()
|
|
||||||
resetOverlayToGradient()
|
|
||||||
breakCountdownLabels.removeAll()
|
breakCountdownLabels.removeAll()
|
||||||
|
// Only reset to full duration if the interval actually completed;
|
||||||
|
// otherwise preserve remaining time so the user can resume mid-countdown.
|
||||||
|
if remainingSeconds <= 0 {
|
||||||
|
remainingSeconds = workTimerDuration
|
||||||
|
currentIntervalStartDate = Date()
|
||||||
|
currentIntent = nil
|
||||||
|
intervalCompleted = false
|
||||||
|
}
|
||||||
|
isSolid = false
|
||||||
|
resetOverlayToGradient()
|
||||||
|
hideCountdownPills()
|
||||||
|
for window in countdownWindows { window.orderFrontRegardless() }
|
||||||
|
startCountdown()
|
||||||
|
updateStatusBar()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func toggleDebugMode() {
|
@objc func toggleDebugMode() {
|
||||||
|
|
@ -128,6 +160,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
isSolid = false
|
isSolid = false
|
||||||
isPaused = false
|
isPaused = false
|
||||||
|
|
||||||
|
currentIntervalStartDate = Date()
|
||||||
|
currentIntent = nil
|
||||||
|
intervalCompleted = false
|
||||||
|
|
||||||
resetOverlayToGradient()
|
resetOverlayToGradient()
|
||||||
hideCountdownPills()
|
hideCountdownPills()
|
||||||
for window in countdownWindows { window.orderFrontRegardless() }
|
for window in countdownWindows { window.orderFrontRegardless() }
|
||||||
|
|
@ -146,6 +182,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func closePommedoro() {
|
@objc func closePommedoro() {
|
||||||
|
saveCurrentInterval()
|
||||||
countdownTimer?.invalidate()
|
countdownTimer?.invalidate()
|
||||||
breakTimer?.invalidate()
|
breakTimer?.invalidate()
|
||||||
for window in overlayWindows { window.orderOut(nil) }
|
for window in overlayWindows { window.orderOut(nil) }
|
||||||
|
|
@ -154,12 +191,26 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func resumePommedoro() {
|
@objc func resumePommedoro() {
|
||||||
|
saveCurrentInterval()
|
||||||
|
|
||||||
isPaused = false
|
isPaused = false
|
||||||
remainingSeconds = workTimerDuration
|
|
||||||
isSolid = false
|
isSolid = false
|
||||||
breakTimer?.invalidate()
|
breakTimer?.invalidate()
|
||||||
breakCountdownLabels.removeAll()
|
breakCountdownLabels.removeAll()
|
||||||
|
|
||||||
|
// Only reset to full duration if the interval completed;
|
||||||
|
// otherwise preserve remaining time so the user resumes mid-countdown.
|
||||||
|
if remainingSeconds <= 0 || intervalCompleted {
|
||||||
|
remainingSeconds = workTimerDuration
|
||||||
|
intervalCompleted = false
|
||||||
|
currentIntervalStartDate = Date()
|
||||||
|
// Carry forward the next intent
|
||||||
|
let nextIntent = intentField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
currentIntent = (nextIntent?.isEmpty == false) ? nextIntent : nil
|
||||||
|
}
|
||||||
|
reflectionField = nil
|
||||||
|
intentField = nil
|
||||||
|
|
||||||
resetOverlayToGradient()
|
resetOverlayToGradient()
|
||||||
hideCountdownPills()
|
hideCountdownPills()
|
||||||
for window in countdownWindows { window.orderFrontRegardless() }
|
for window in countdownWindows { window.orderFrontRegardless() }
|
||||||
|
|
@ -169,10 +220,82 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func dismissSuggestion() {
|
@objc func dismissSuggestion() {
|
||||||
|
currentSuggestionFeedback = .dismissed
|
||||||
SuggestionManager.shared.dismiss(suggestion: currentSuggestion)
|
SuggestionManager.shared.dismiss(suggestion: currentSuggestion)
|
||||||
resumePommedoro()
|
resumePommedoro()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Work Log Helpers
|
||||||
|
|
||||||
|
func saveCurrentInterval() {
|
||||||
|
guard let startDate = currentIntervalStartDate else { return }
|
||||||
|
let elapsed = Int(Date().timeIntervalSince(startDate))
|
||||||
|
let reflection = reflectionField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
let entry = WorkLogEntry(
|
||||||
|
date: startDate,
|
||||||
|
durationSeconds: elapsed,
|
||||||
|
intent: currentIntent,
|
||||||
|
completed: intervalCompleted,
|
||||||
|
reflection: (reflection?.isEmpty == false) ? reflection : nil,
|
||||||
|
additionalNotes: nil,
|
||||||
|
suggestion: currentSuggestion.isEmpty ? nil : currentSuggestion,
|
||||||
|
suggestionFeedback: currentSuggestionFeedback,
|
||||||
|
wellnessReflection: currentWellnessReflection
|
||||||
|
)
|
||||||
|
WorkLogStore.shared.addEntry(entry)
|
||||||
|
currentIntervalStartDate = nil
|
||||||
|
currentSuggestionFeedback = nil
|
||||||
|
currentWellnessReflection = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func showWorkLog() {
|
||||||
|
// Only save if meaningful work was done; skip trivial 0-second partials
|
||||||
|
// so that viewing the log doesn't create throwaway entries.
|
||||||
|
if let start = currentIntervalStartDate, Int(Date().timeIntervalSince(start)) >= 5 {
|
||||||
|
saveCurrentInterval()
|
||||||
|
}
|
||||||
|
countdownTimer?.invalidate()
|
||||||
|
breakTimer?.invalidate()
|
||||||
|
breakCountdownLabels.removeAll()
|
||||||
|
|
||||||
|
for (i, window) in overlayWindows.enumerated() {
|
||||||
|
window.ignoresMouseEvents = false
|
||||||
|
WorkLogViewBuilder.build(in: window, isPrimary: i == 0, delegate: self)
|
||||||
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
ctx.duration = 0.3
|
||||||
|
window.animator().alphaValue = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func dismissWorkLogAndResume() {
|
||||||
|
isPaused = false
|
||||||
|
remainingSeconds = workTimerDuration
|
||||||
|
isSolid = false
|
||||||
|
intervalCompleted = false
|
||||||
|
breakTimer?.invalidate()
|
||||||
|
breakCountdownLabels.removeAll()
|
||||||
|
|
||||||
|
// Preserve currentIntent so it carries into the next interval
|
||||||
|
currentIntervalStartDate = Date()
|
||||||
|
reflectionField = nil
|
||||||
|
intentField = nil
|
||||||
|
|
||||||
|
resetOverlayToGradient()
|
||||||
|
hideCountdownPills()
|
||||||
|
for window in countdownWindows { window.orderFrontRegardless() }
|
||||||
|
|
||||||
|
startCountdown()
|
||||||
|
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func copyWorkLog() {
|
||||||
|
let text = WorkLogStore.shared.todayLogAsText()
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(text, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ extension AppDelegate {
|
||||||
for (i, window) in overlayWindows.enumerated() {
|
for (i, window) in overlayWindows.enumerated() {
|
||||||
window.ignoresMouseEvents = false
|
window.ignoresMouseEvents = false
|
||||||
buildSuggestionScreen(window: window, isPrimary: i == 0)
|
buildSuggestionScreen(window: window, isPrimary: i == 0)
|
||||||
|
|
||||||
NSAnimationContext.runAnimationGroup { ctx in
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
ctx.duration = 0.3
|
ctx.duration = 0.3
|
||||||
window.animator().alphaValue = 1.0
|
window.animator().alphaValue = 1.0
|
||||||
|
|
@ -49,6 +48,19 @@ extension AppDelegate {
|
||||||
Settings.shared.gradientColor.withAlphaComponent(0.85)
|
Settings.shared.gradientColor.withAlphaComponent(0.85)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces overlay with suggestion UI. Called when break countdown reaches 0 (same pulse schedule as work).
|
||||||
|
func showSuggestionScreen() {
|
||||||
|
for window in countdownWindows { window.orderOut(nil) }
|
||||||
|
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 buildSuggestionScreen(window: NSWindow, isPrimary: Bool) {
|
func buildSuggestionScreen(window: NSWindow, isPrimary: Bool) {
|
||||||
let size = window.frame.size
|
let size = window.frame.size
|
||||||
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
||||||
|
|
@ -84,7 +96,7 @@ extension AppDelegate {
|
||||||
closeBtn.bezelStyle = .rounded
|
closeBtn.bezelStyle = .rounded
|
||||||
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
closeBtn.target = self
|
closeBtn.target = self
|
||||||
closeBtn.action = #selector(closePommedoro)
|
closeBtn.action = #selector(showWorkLog)
|
||||||
view.addSubview(closeBtn)
|
view.addSubview(closeBtn)
|
||||||
|
|
||||||
let continueWorkingBtn = NSButton(frame: NSRect(x: (size.width - 200) / 2, y: size.height / 2 - 50, width: 200, height: 50))
|
let continueWorkingBtn = NSButton(frame: NSRect(x: (size.width - 200) / 2, y: size.height / 2 - 50, width: 200, height: 50))
|
||||||
|
|
@ -115,7 +127,7 @@ extension AppDelegate {
|
||||||
nextTimeBtn.bezelStyle = .rounded
|
nextTimeBtn.bezelStyle = .rounded
|
||||||
nextTimeBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
|
nextTimeBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
|
||||||
nextTimeBtn.target = self
|
nextTimeBtn.target = self
|
||||||
nextTimeBtn.action = #selector(resumePommedoro)
|
nextTimeBtn.action = #selector(skipSuggestion)
|
||||||
view.addSubview(nextTimeBtn)
|
view.addSubview(nextTimeBtn)
|
||||||
|
|
||||||
let dontSuggestBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: btnY, width: btnW, height: btnH))
|
let dontSuggestBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: btnY, width: btnW, height: btnH))
|
||||||
|
|
@ -130,7 +142,13 @@ extension AppDelegate {
|
||||||
window.contentView = view
|
window.contentView = view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func skipSuggestion() {
|
||||||
|
currentSuggestionFeedback = .skipped
|
||||||
|
resumePommedoro()
|
||||||
|
}
|
||||||
|
|
||||||
@objc func didSuccess() {
|
@objc func didSuccess() {
|
||||||
|
currentSuggestionFeedback = .didIt
|
||||||
breakCountdownLabels.removeAll()
|
breakCountdownLabels.removeAll()
|
||||||
let bgColor = breakScreenColor
|
let bgColor = breakScreenColor
|
||||||
|
|
||||||
|
|
@ -180,7 +198,7 @@ extension AppDelegate {
|
||||||
closeBtn.bezelStyle = .rounded
|
closeBtn.bezelStyle = .rounded
|
||||||
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
closeBtn.target = self
|
closeBtn.target = self
|
||||||
closeBtn.action = #selector(closePommedoro)
|
closeBtn.action = #selector(showWorkLog)
|
||||||
view.addSubview(closeBtn)
|
view.addSubview(closeBtn)
|
||||||
|
|
||||||
let reflY = size.height / 2 - 60
|
let reflY = size.height / 2 - 60
|
||||||
|
|
@ -195,7 +213,7 @@ extension AppDelegate {
|
||||||
greatBtn.bezelStyle = .rounded
|
greatBtn.bezelStyle = .rounded
|
||||||
greatBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium)
|
greatBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium)
|
||||||
greatBtn.target = self
|
greatBtn.target = self
|
||||||
greatBtn.action = #selector(reflectionDone)
|
greatBtn.action = #selector(reflectionGreat)
|
||||||
view.addSubview(greatBtn)
|
view.addSubview(greatBtn)
|
||||||
|
|
||||||
let okBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: reflY, width: btnW, height: btnH))
|
let okBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: reflY, width: btnW, height: btnH))
|
||||||
|
|
@ -203,7 +221,7 @@ extension AppDelegate {
|
||||||
okBtn.bezelStyle = .rounded
|
okBtn.bezelStyle = .rounded
|
||||||
okBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium)
|
okBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium)
|
||||||
okBtn.target = self
|
okBtn.target = self
|
||||||
okBtn.action = #selector(reflectionDone)
|
okBtn.action = #selector(reflectionSame)
|
||||||
view.addSubview(okBtn)
|
view.addSubview(okBtn)
|
||||||
|
|
||||||
let notBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: reflY, width: btnW, height: btnH))
|
let notBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: reflY, width: btnW, height: btnH))
|
||||||
|
|
@ -211,7 +229,7 @@ extension AppDelegate {
|
||||||
notBtn.bezelStyle = .rounded
|
notBtn.bezelStyle = .rounded
|
||||||
notBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium)
|
notBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium)
|
||||||
notBtn.target = self
|
notBtn.target = self
|
||||||
notBtn.action = #selector(reflectionDone)
|
notBtn.action = #selector(reflectionNotReally)
|
||||||
view.addSubview(notBtn)
|
view.addSubview(notBtn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,8 +237,14 @@ extension AppDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func reflectionGreat() { currentWellnessReflection = .great; reflectionDone() }
|
||||||
|
@objc func reflectionSame() { currentWellnessReflection = .same; reflectionDone() }
|
||||||
|
@objc func reflectionNotReally() { currentWellnessReflection = .notReally; reflectionDone() }
|
||||||
|
|
||||||
@objc func reflectionDone() {
|
@objc func reflectionDone() {
|
||||||
breakCountdownLabels.removeAll()
|
breakCountdownLabels.removeAll()
|
||||||
|
reflectionField = nil
|
||||||
|
intentField = nil
|
||||||
let bgColor = breakScreenColor
|
let bgColor = breakScreenColor
|
||||||
|
|
||||||
for (i, window) in overlayWindows.enumerated() {
|
for (i, window) in overlayWindows.enumerated() {
|
||||||
|
|
@ -248,7 +272,7 @@ extension AppDelegate {
|
||||||
msg.sizeToFit()
|
msg.sizeToFit()
|
||||||
msg.frame.origin = CGPoint(
|
msg.frame.origin = CGPoint(
|
||||||
x: (size.width - msg.frame.width) / 2,
|
x: (size.width - msg.frame.width) / 2,
|
||||||
y: (size.height / 2) + 40
|
y: (size.height / 2) + 120
|
||||||
)
|
)
|
||||||
view.addSubview(msg)
|
view.addSubview(msg)
|
||||||
|
|
||||||
|
|
@ -258,15 +282,95 @@ extension AppDelegate {
|
||||||
closeBtn.bezelStyle = .rounded
|
closeBtn.bezelStyle = .rounded
|
||||||
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
closeBtn.target = self
|
closeBtn.target = self
|
||||||
closeBtn.action = #selector(closePommedoro)
|
closeBtn.action = #selector(showWorkLog)
|
||||||
view.addSubview(closeBtn)
|
view.addSubview(closeBtn)
|
||||||
|
|
||||||
let btnW: CGFloat = 180
|
// Reflection text field
|
||||||
|
let fieldW: CGFloat = 560
|
||||||
|
let fieldH: CGFloat = 48
|
||||||
|
|
||||||
|
let reflLabel = NSTextField(labelWithString: "What did you accomplish this interval?")
|
||||||
|
reflLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium)
|
||||||
|
reflLabel.textColor = NSColor.white.withAlphaComponent(0.9)
|
||||||
|
reflLabel.alignment = .center
|
||||||
|
reflLabel.sizeToFit()
|
||||||
|
reflLabel.frame.origin = CGPoint(
|
||||||
|
x: (size.width - reflLabel.frame.width) / 2,
|
||||||
|
y: (size.height / 2) + 76
|
||||||
|
)
|
||||||
|
view.addSubview(reflLabel)
|
||||||
|
|
||||||
|
let reflField = NSTextField(frame: NSRect(
|
||||||
|
x: (size.width - fieldW) / 2,
|
||||||
|
y: (size.height / 2) + 24,
|
||||||
|
width: fieldW,
|
||||||
|
height: fieldH
|
||||||
|
))
|
||||||
|
reflField.placeholderString = "Optional — jot down what you worked on"
|
||||||
|
reflField.font = NSFont.systemFont(ofSize: 20)
|
||||||
|
reflField.alignment = .center
|
||||||
|
reflField.bezelStyle = .roundedBezel
|
||||||
|
reflField.isBordered = true
|
||||||
|
reflField.drawsBackground = true
|
||||||
|
reflField.backgroundColor = NSColor.textBackgroundColor
|
||||||
|
reflField.textColor = NSColor.labelColor
|
||||||
|
reflField.wantsLayer = true
|
||||||
|
reflField.layer?.cornerRadius = 10
|
||||||
|
reflField.focusRingType = .default
|
||||||
|
reflField.isEditable = true
|
||||||
|
reflField.isSelectable = true
|
||||||
|
view.addSubview(reflField)
|
||||||
|
self.reflectionField = reflField
|
||||||
|
|
||||||
|
// Intent text field
|
||||||
|
let intentLabel = NSTextField(labelWithString: "What do you intend to work on next?")
|
||||||
|
intentLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium)
|
||||||
|
intentLabel.textColor = NSColor.white.withAlphaComponent(0.9)
|
||||||
|
intentLabel.alignment = .center
|
||||||
|
intentLabel.sizeToFit()
|
||||||
|
intentLabel.frame.origin = CGPoint(
|
||||||
|
x: (size.width - intentLabel.frame.width) / 2,
|
||||||
|
y: (size.height / 2) - 10
|
||||||
|
)
|
||||||
|
view.addSubview(intentLabel)
|
||||||
|
|
||||||
|
let intField = NSTextField(frame: NSRect(
|
||||||
|
x: (size.width - fieldW) / 2,
|
||||||
|
y: (size.height / 2) - 62,
|
||||||
|
width: fieldW,
|
||||||
|
height: fieldH
|
||||||
|
))
|
||||||
|
intField.placeholderString = "Optional — set your intention for the next interval"
|
||||||
|
intField.font = NSFont.systemFont(ofSize: 20)
|
||||||
|
intField.alignment = .center
|
||||||
|
intField.bezelStyle = .roundedBezel
|
||||||
|
intField.isBordered = true
|
||||||
|
intField.drawsBackground = true
|
||||||
|
intField.backgroundColor = NSColor.textBackgroundColor
|
||||||
|
intField.textColor = NSColor.labelColor
|
||||||
|
intField.wantsLayer = true
|
||||||
|
intField.layer?.cornerRadius = 10
|
||||||
|
intField.focusRingType = .default
|
||||||
|
intField.isEditable = true
|
||||||
|
intField.isSelectable = true
|
||||||
|
view.addSubview(intField)
|
||||||
|
self.intentField = intField
|
||||||
|
|
||||||
|
// Pre-fill with the current or most recent intent from today
|
||||||
|
if let intent = self.currentIntent, !intent.isEmpty {
|
||||||
|
intField.stringValue = intent
|
||||||
|
} else if let lastIntent = WorkLogStore.shared.lastTodayIntent() {
|
||||||
|
intField.stringValue = lastIntent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons row: Pause | Finish Day | Continue
|
||||||
|
let btnW: CGFloat = 160
|
||||||
let btnH: CGFloat = 50
|
let btnH: CGFloat = 50
|
||||||
let gap: CGFloat = 24
|
let gap: CGFloat = 20
|
||||||
let totalW = btnW * 2 + gap
|
let btnCount: CGFloat = 3
|
||||||
|
let totalW = btnW * btnCount + gap * (btnCount - 1)
|
||||||
let startX = (size.width - totalW) / 2
|
let startX = (size.width - totalW) / 2
|
||||||
let btnY = size.height / 2 - 50
|
let btnY = size.height / 2 - 140
|
||||||
|
|
||||||
let pauseBtn = NSButton(frame: NSRect(x: startX, y: btnY, width: btnW, height: btnH))
|
let pauseBtn = NSButton(frame: NSRect(x: startX, y: btnY, width: btnW, height: btnH))
|
||||||
pauseBtn.title = "Pause"
|
pauseBtn.title = "Pause"
|
||||||
|
|
@ -276,7 +380,15 @@ extension AppDelegate {
|
||||||
pauseBtn.action = #selector(pauseFromOverlay)
|
pauseBtn.action = #selector(pauseFromOverlay)
|
||||||
view.addSubview(pauseBtn)
|
view.addSubview(pauseBtn)
|
||||||
|
|
||||||
let continueBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: btnY, width: btnW, height: btnH))
|
let finishBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: btnY, width: btnW, height: btnH))
|
||||||
|
finishBtn.title = "Finish Day"
|
||||||
|
finishBtn.bezelStyle = .rounded
|
||||||
|
finishBtn.font = NSFont.systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
finishBtn.target = self
|
||||||
|
finishBtn.action = #selector(showWorkLog)
|
||||||
|
view.addSubview(finishBtn)
|
||||||
|
|
||||||
|
let continueBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: btnY, width: btnW, height: btnH))
|
||||||
continueBtn.title = "Continue"
|
continueBtn.title = "Continue"
|
||||||
continueBtn.bezelStyle = .rounded
|
continueBtn.bezelStyle = .rounded
|
||||||
continueBtn.font = NSFont.systemFont(ofSize: 18, weight: .semibold)
|
continueBtn.font = NSFont.systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
|
@ -286,6 +398,13 @@ extension AppDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
window.contentView = view
|
window.contentView = view
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
if let field = self.reflectionField {
|
||||||
|
window.makeFirstResponder(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Single source of truth for countdown pulse timing. Used by both default and debug modes
|
||||||
|
/// 5 min→3 min: once per minute; 3 min→1 min: every 30s; last minute: every 10s→5s→2s; last 5s: solid.
|
||||||
|
enum CountdownPulseSchedule {
|
||||||
|
|
||||||
|
/// Whether we are in the "flashing" phase (remaining within countdown window).
|
||||||
|
static func isInCountdownPhase(remainingSeconds: Int, countdownDuration: Int, workTimerDuration: Int) -> Bool {
|
||||||
|
let effective = min(countdownDuration, workTimerDuration)
|
||||||
|
return remainingSeconds <= effective && remainingSeconds > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to trigger a blink this second.
|
||||||
|
/// 5 min→3 min: once per minute; 3 min→1 min: every 30s; last minute: every 10s, then 5s, then 2s; last 5s: solid.
|
||||||
|
static func shouldBlink(remainingSeconds: Int) -> Bool {
|
||||||
|
let s = remainingSeconds
|
||||||
|
if s <= 5 { return false } // last 5: solid
|
||||||
|
if s <= 15 { return s % 2 == 0 } // 6–15: every 2s
|
||||||
|
if s <= 30 { return s % 5 == 0 } // 16–30: every 5s
|
||||||
|
if s <= 60 { return s % 10 == 0 } // 31–60: every 10s (last minute)
|
||||||
|
if s <= 180 { return s % 30 == 0 } // 61–180: every 30s (last 3 min)
|
||||||
|
return s % 60 == 0 // 181–300: once per minute (5 min → 3 min)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When remaining <= 5, show solid overlay instead of blinking.
|
||||||
|
static func shouldBeSolid(remainingSeconds: Int) -> Bool {
|
||||||
|
return remainingSeconds > 0 && remainingSeconds <= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blink animation durations (seconds). Single place so overlay timing matches schedule.
|
||||||
|
struct BlinkDurations {
|
||||||
|
var fadeIn: Double
|
||||||
|
var hold: Double
|
||||||
|
var fadeOut: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
static func blinkDurations(remainingSeconds: Int, durationScale: Double) -> BlinkDurations {
|
||||||
|
let scale = durationScale
|
||||||
|
if remainingSeconds > 60 {
|
||||||
|
return BlinkDurations(fadeIn: 1.0 * scale, hold: 0.8 * scale, fadeOut: 1.0 * scale)
|
||||||
|
}
|
||||||
|
if remainingSeconds > 30 {
|
||||||
|
return BlinkDurations(fadeIn: 0.8 * scale, hold: 0.6 * scale, fadeOut: 0.8 * scale)
|
||||||
|
}
|
||||||
|
if remainingSeconds > 10 {
|
||||||
|
return BlinkDurations(fadeIn: 0.5 * scale, hold: 0.4 * scale, fadeOut: 0.6 * scale)
|
||||||
|
}
|
||||||
|
return BlinkDurations(fadeIn: 0.3 * scale, hold: 0.3 * scale, fadeOut: 0.4 * scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
|
/// Borderless NSWindow subclass that can become key, allowing text field input.
|
||||||
|
class KeyableWindow: NSWindow {
|
||||||
|
override var canBecomeKey: Bool { true }
|
||||||
|
override var canBecomeMain: Bool { true }
|
||||||
|
}
|
||||||
|
|
||||||
extension AppDelegate {
|
extension AppDelegate {
|
||||||
|
|
||||||
func setupWindows() {
|
func setupWindows() {
|
||||||
for screen in NSScreen.screens {
|
for screen in NSScreen.screens {
|
||||||
let window = NSWindow(
|
let window = KeyableWindow(
|
||||||
contentRect: screen.frame,
|
contentRect: screen.frame,
|
||||||
styleMask: .borderless,
|
styleMask: .borderless,
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
|
|
@ -87,31 +93,30 @@ extension AppDelegate {
|
||||||
for label in countdownLabels { label.stringValue = text }
|
for label in countdownLabels { label.stringValue = text }
|
||||||
}
|
}
|
||||||
|
|
||||||
func triggerBlink() {
|
/// Single entry point for pulse schedule. Used by both work countdown and break.
|
||||||
|
func applyPulseSchedule(remainingSeconds: Int, totalSeconds: Int) {
|
||||||
|
if CountdownPulseSchedule.shouldBeSolid(remainingSeconds: remainingSeconds) {
|
||||||
|
if !isSolid { makeSolid() }
|
||||||
|
} else if CountdownPulseSchedule.shouldBlink(remainingSeconds: remainingSeconds) {
|
||||||
|
triggerBlink(remainingSeconds: remainingSeconds, totalSeconds: totalSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerBlink(remainingSeconds: Int, totalSeconds: Int) {
|
||||||
let settings = Settings.shared
|
let settings = Settings.shared
|
||||||
let scale = CGFloat(settings.intensityScale)
|
let scale = CGFloat(settings.intensityScale)
|
||||||
let durationScale = settings.blinkDurationScale
|
let durationScale = settings.blinkDurationScale
|
||||||
|
|
||||||
// Intensity scales across the effective countdown window (capped to work timer)
|
let countdownElapsed = Double(totalSeconds - remainingSeconds)
|
||||||
let effectiveCountdown = min(countdownDuration, workTimerDuration)
|
let countdownTotal = Double(totalSeconds)
|
||||||
let countdownElapsed = Double(effectiveCountdown - remainingSeconds)
|
|
||||||
let countdownTotal = Double(effectiveCountdown)
|
|
||||||
let progress = min(max(countdownElapsed / countdownTotal, 0.0), 1.0)
|
let progress = min(max(countdownElapsed / countdownTotal, 0.0), 1.0)
|
||||||
let baseIntensity = 0.15 + (0.60 * progress) // 0.15 at start, up to 0.75 at 0:00
|
let baseIntensity = 0.15 + (0.60 * progress) // 0.15 at start, up to 0.75 at 0:00
|
||||||
let intensity = min(baseIntensity * Double(scale), 1.0)
|
let intensity = min(baseIntensity * Double(scale), 1.0)
|
||||||
|
|
||||||
let fadeIn: Double
|
let d = CountdownPulseSchedule.blinkDurations(remainingSeconds: remainingSeconds, durationScale: durationScale)
|
||||||
let hold: Double
|
let fadeIn = d.fadeIn
|
||||||
let fadeOut: Double
|
let hold = d.hold
|
||||||
if remainingSeconds > 60 {
|
let fadeOut = d.fadeOut
|
||||||
fadeIn = 1.0 * durationScale; hold = 0.8 * durationScale; fadeOut = 1.0 * durationScale
|
|
||||||
} else if remainingSeconds > 30 {
|
|
||||||
fadeIn = 0.8 * durationScale; hold = 0.6 * durationScale; fadeOut = 0.8 * durationScale
|
|
||||||
} else if remainingSeconds > 10 {
|
|
||||||
fadeIn = 0.5 * durationScale; hold = 0.4 * durationScale; fadeOut = 0.6 * durationScale
|
|
||||||
} else {
|
|
||||||
fadeIn = 0.3 * durationScale; hold = 0.3 * durationScale; fadeOut = 0.4 * durationScale
|
|
||||||
}
|
|
||||||
|
|
||||||
let color = settings.gradientColor
|
let color = settings.gradientColor
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,17 @@ extension AppDelegate {
|
||||||
statusMenuItem.isEnabled = false
|
statusMenuItem.isEnabled = false
|
||||||
menu.addItem(statusMenuItem)
|
menu.addItem(statusMenuItem)
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
menu.addItem(NSMenuItem(title: "Pause", action: #selector(togglePause), keyEquivalent: "p"))
|
pauseMenuItem = NSMenuItem(title: "Pause", action: #selector(togglePause), keyEquivalent: "p")
|
||||||
|
menu.addItem(pauseMenuItem)
|
||||||
|
menu.addItem(NSMenuItem(title: "Restart Timer", action: #selector(restartTimer), keyEquivalent: "r"))
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
// Settings submenu with inline sliders and color picker
|
// Settings submenu with inline sliders and color picker
|
||||||
settingsMenuBuilder = SettingsMenuBuilder()
|
settingsMenuBuilder = SettingsMenuBuilder()
|
||||||
menu.addItem(settingsMenuBuilder.buildSettingsSubmenu())
|
menu.addItem(settingsMenuBuilder.buildSettingsSubmenu())
|
||||||
|
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
menu.addItem(NSMenuItem(title: "View Work Log", action: #selector(showWorkLog), keyEquivalent: "w"))
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
debugMenuItem = NSMenuItem(title: "Debug Mode", action: #selector(toggleDebugMode), keyEquivalent: "d")
|
debugMenuItem = NSMenuItem(title: "Debug Mode", action: #selector(toggleDebugMode), keyEquivalent: "d")
|
||||||
debugMenuItem.state = isDebugMode ? .on : .off
|
debugMenuItem.state = isDebugMode ? .on : .off
|
||||||
|
|
@ -50,5 +54,6 @@ extension AppDelegate {
|
||||||
let prefix = isPaused ? "⏸" : "🍅"
|
let prefix = isPaused ? "⏸" : "🍅"
|
||||||
statusItem.button?.title = "\(prefix) \(time)"
|
statusItem.button?.title = "\(prefix) \(time)"
|
||||||
statusMenuItem.title = isPaused ? "Pommedoro: Paused" : "Pommedoro: Running"
|
statusMenuItem.title = isPaused ? "Pommedoro: Paused" : "Pommedoro: Running"
|
||||||
|
pauseMenuItem.title = isPaused ? "Resume" : "Pause"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,19 @@ import Foundation
|
||||||
class SuggestionManager {
|
class SuggestionManager {
|
||||||
static let shared = SuggestionManager()
|
static let shared = SuggestionManager()
|
||||||
|
|
||||||
private var suggestions: [String] = [
|
// MARK: - UserDefaults keys
|
||||||
|
|
||||||
|
private enum Keys {
|
||||||
|
static let dismissed = "pommedoro.suggestions.dismissed"
|
||||||
|
static let currentIndex = "pommedoro.suggestions.currentIndex"
|
||||||
|
static let lastShown = "pommedoro.suggestions.lastShown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
// MARK: - Data
|
||||||
|
|
||||||
|
private let suggestions: [String] = [
|
||||||
"Breathe for a few",
|
"Breathe for a few",
|
||||||
"Get up and walk around a minute",
|
"Get up and walk around a minute",
|
||||||
"Do some pushups or something",
|
"Do some pushups or something",
|
||||||
|
|
@ -30,24 +42,71 @@ class SuggestionManager {
|
||||||
|
|
||||||
private var dismissed: Set<Int> = []
|
private var dismissed: Set<Int> = []
|
||||||
private var currentIndex: Int = 0
|
private var currentIndex: Int = 0
|
||||||
|
private var lastShown: String = ""
|
||||||
|
|
||||||
|
// MARK: - Init (restore persisted state)
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
if let savedDismissed = defaults.array(forKey: Keys.dismissed) as? [Int] {
|
||||||
|
dismissed = Set(savedDismissed)
|
||||||
|
}
|
||||||
|
currentIndex = defaults.integer(forKey: Keys.currentIndex)
|
||||||
|
lastShown = defaults.string(forKey: Keys.lastShown) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence helpers
|
||||||
|
|
||||||
|
private func persist() {
|
||||||
|
defaults.set(Array(dismissed), forKey: Keys.dismissed)
|
||||||
|
defaults.set(currentIndex, forKey: Keys.currentIndex)
|
||||||
|
defaults.set(lastShown, forKey: Keys.lastShown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
func next() -> String {
|
func next() -> String {
|
||||||
var attempts = 0
|
let available = availableIndices()
|
||||||
while dismissed.contains(currentIndex) && attempts < suggestions.count {
|
|
||||||
currentIndex = (currentIndex + 1) % suggestions.count
|
// If nothing is available after filtering, reset dismissed pool
|
||||||
attempts += 1
|
if available.isEmpty {
|
||||||
}
|
|
||||||
if attempts >= suggestions.count {
|
|
||||||
dismissed.removeAll()
|
dismissed.removeAll()
|
||||||
|
return next()
|
||||||
}
|
}
|
||||||
let suggestion = suggestions[currentIndex]
|
|
||||||
currentIndex = (currentIndex + 1) % suggestions.count
|
// Pick the next available index that isn't the last-shown suggestion
|
||||||
|
var chosen: Int? = nil
|
||||||
|
var idx = currentIndex
|
||||||
|
for _ in 0..<suggestions.count {
|
||||||
|
if available.contains(idx) && suggestions[idx] != lastShown {
|
||||||
|
chosen = idx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
idx = (idx + 1) % suggestions.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// If every available suggestion equals lastShown (only 1 left), allow repeat
|
||||||
|
if chosen == nil {
|
||||||
|
chosen = available.first!
|
||||||
|
}
|
||||||
|
|
||||||
|
let suggestion = suggestions[chosen!]
|
||||||
|
currentIndex = (chosen! + 1) % suggestions.count
|
||||||
|
lastShown = suggestion
|
||||||
|
persist()
|
||||||
return suggestion
|
return suggestion
|
||||||
}
|
}
|
||||||
|
|
||||||
func dismiss(suggestion: String) {
|
func dismiss(suggestion: String) {
|
||||||
if let idx = suggestions.firstIndex(of: suggestion) {
|
if let idx = suggestions.firstIndex(of: suggestion) {
|
||||||
dismissed.insert(idx)
|
dismissed.insert(idx)
|
||||||
|
persist()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func availableIndices() -> Set<Int> {
|
||||||
|
let all = Set(0..<suggestions.count)
|
||||||
|
return all.subtracting(dismissed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Timer state persistence across relaunches
|
||||||
|
|
||||||
|
enum TimerPersistence {
|
||||||
|
private static let keyRemaining = "pommedoro.persistedRemainingSeconds"
|
||||||
|
private static let keyPaused = "pommedoro.persistedIsPaused"
|
||||||
|
private static let keyIntervalStart = "pommedoro.persistedIntervalStart"
|
||||||
|
|
||||||
|
private static let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
/// Save work timer state. Call on terminate. Only saves when remainingSeconds > 0.
|
||||||
|
static func save(remainingSeconds: Int, isPaused: Bool, intervalStartDate: Date?) {
|
||||||
|
guard remainingSeconds > 0 else {
|
||||||
|
clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defaults.set(remainingSeconds, forKey: keyRemaining)
|
||||||
|
defaults.set(isPaused, forKey: keyPaused)
|
||||||
|
defaults.set(intervalStartDate?.timeIntervalSince1970, forKey: keyIntervalStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore saved state. Returns nil if nothing valid saved (e.g. timer had finished).
|
||||||
|
static func restore() -> (remainingSeconds: Int, isPaused: Bool, intervalStartDate: Date?)? {
|
||||||
|
let remaining = defaults.integer(forKey: keyRemaining)
|
||||||
|
guard remaining > 0 else {
|
||||||
|
clear()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let paused = defaults.bool(forKey: keyPaused)
|
||||||
|
let start: Date? = {
|
||||||
|
let t = defaults.double(forKey: keyIntervalStart)
|
||||||
|
return t > 0 ? Date(timeIntervalSince1970: t) : nil
|
||||||
|
}()
|
||||||
|
return (remainingSeconds: remaining, isPaused: paused, intervalStartDate: start)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func clear() {
|
||||||
|
defaults.removeObject(forKey: keyRemaining)
|
||||||
|
defaults.removeObject(forKey: keyPaused)
|
||||||
|
defaults.removeObject(forKey: keyIntervalStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Suggestion Feedback
|
||||||
|
|
||||||
|
enum SuggestionFeedback: String, Codable {
|
||||||
|
case didIt // "Success!" then any reflection
|
||||||
|
case skipped // "Next Time"
|
||||||
|
case dismissed // "Don't Suggest" (removed from pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WellnessReflection: String, Codable {
|
||||||
|
case great // "Feeling Great"
|
||||||
|
case same // "About the Same"
|
||||||
|
case notReally // "Not Really"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Work Log Entry
|
||||||
|
|
||||||
|
struct WorkLogEntry: Codable, Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let date: Date
|
||||||
|
let durationSeconds: Int
|
||||||
|
let intent: String?
|
||||||
|
let completed: Bool
|
||||||
|
let reflection: String?
|
||||||
|
let additionalNotes: String?
|
||||||
|
let suggestion: String?
|
||||||
|
let suggestionFeedback: SuggestionFeedback?
|
||||||
|
let wellnessReflection: WellnessReflection?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
date: Date,
|
||||||
|
durationSeconds: Int,
|
||||||
|
intent: String?,
|
||||||
|
completed: Bool,
|
||||||
|
reflection: String?,
|
||||||
|
additionalNotes: String?,
|
||||||
|
suggestion: String? = nil,
|
||||||
|
suggestionFeedback: SuggestionFeedback? = nil,
|
||||||
|
wellnessReflection: WellnessReflection? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.date = date
|
||||||
|
self.durationSeconds = durationSeconds
|
||||||
|
self.intent = intent
|
||||||
|
self.completed = completed
|
||||||
|
self.reflection = reflection
|
||||||
|
self.additionalNotes = additionalNotes
|
||||||
|
self.suggestion = suggestion
|
||||||
|
self.suggestionFeedback = suggestionFeedback
|
||||||
|
self.wellnessReflection = wellnessReflection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Work Log Store
|
||||||
|
|
||||||
|
class WorkLogStore {
|
||||||
|
static let shared = WorkLogStore()
|
||||||
|
|
||||||
|
private let key = "pommedoro.workLog"
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
private var lastLoggedDay: String?
|
||||||
|
|
||||||
|
private(set) var entries: [WorkLogEntry] = []
|
||||||
|
|
||||||
|
private static var workLogFileURL: URL {
|
||||||
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Documents", isDirectory: true)
|
||||||
|
.appendingPathComponent("pommedoro", isDirectory: true)
|
||||||
|
.appendingPathComponent("worklog.log", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
load()
|
||||||
|
clearOldEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addEntry(_ entry: WorkLogEntry) {
|
||||||
|
entries.append(entry)
|
||||||
|
save()
|
||||||
|
appendEntryToFile(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func todayEntries() -> [WorkLogEntry] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
return entries.filter { calendar.isDateInToday($0.date) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastTodayIntent() -> String? {
|
||||||
|
return todayEntries().last(where: { $0.intent != nil && !$0.intent!.isEmpty })?.intent
|
||||||
|
}
|
||||||
|
|
||||||
|
func todaySummary() -> (intervals: Int, totalSeconds: Int) {
|
||||||
|
let today = todayEntries()
|
||||||
|
let total = today.reduce(0) { $0 + $1.durationSeconds }
|
||||||
|
return (intervals: today.count, totalSeconds: total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func todayLogAsText() -> String {
|
||||||
|
formatEntriesAsText(todayEntries(), includeDaySeparator: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard let data = try? encoder.encode(entries) else { return }
|
||||||
|
defaults.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
guard let data = defaults.data(forKey: key),
|
||||||
|
let decoded = try? decoder.decode([WorkLogEntry].self, from: data) else {
|
||||||
|
entries = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearOldEntries() {
|
||||||
|
let cutoff = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date()
|
||||||
|
let before = entries.count
|
||||||
|
entries.removeAll { $0.date < cutoff }
|
||||||
|
if entries.count != before { save() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureWorkLogDirectory() {
|
||||||
|
let dir = Self.workLogFileURL.deletingLastPathComponent()
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let isoFormatter: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
f.timeZone = TimeZone.current
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let dayFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
f.timeZone = TimeZone.current
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func appendEntryToFile(_ entry: WorkLogEntry) {
|
||||||
|
ensureWorkLogDirectory()
|
||||||
|
let dayStr = Self.dayFormatter.string(from: entry.date)
|
||||||
|
var lines: [String] = []
|
||||||
|
|
||||||
|
if lastLoggedDay != dayStr {
|
||||||
|
if lastLoggedDay != nil { lines.append("") }
|
||||||
|
lines.append("--- \(dayStr) ---")
|
||||||
|
lastLoggedDay = dayStr
|
||||||
|
}
|
||||||
|
|
||||||
|
let iso = Self.isoFormatter.string(from: entry.date)
|
||||||
|
let durMins = entry.durationSeconds / 60
|
||||||
|
let status = entry.completed ? "completed" : "partial"
|
||||||
|
let intentPart = entry.intent.map { " intent: \(flattenForLog($0))" } ?? ""
|
||||||
|
let reflPart = entry.reflection.map { " reflection: \(flattenForLog($0))" } ?? ""
|
||||||
|
let suggPart = entry.suggestion.map { " suggestion: \(flattenForLog($0))" } ?? ""
|
||||||
|
let fbPart = entry.suggestionFeedback.map { " feedback: \($0.rawValue)" } ?? ""
|
||||||
|
let wellPart = entry.wellnessReflection.map { " wellness: \($0.rawValue)" } ?? ""
|
||||||
|
lines.append("\(iso) \(durMins)m \(status)\(intentPart)\(reflPart)\(suggPart)\(fbPart)\(wellPart)")
|
||||||
|
|
||||||
|
guard let data = (lines.joined(separator: "\n") + "\n").data(using: .utf8) else { return }
|
||||||
|
if let handle = try? FileHandle(forWritingTo: Self.workLogFileURL) {
|
||||||
|
handle.seekToEndOfFile()
|
||||||
|
handle.write(data)
|
||||||
|
try? handle.close()
|
||||||
|
} else {
|
||||||
|
try? data.write(to: Self.workLogFileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flattenForLog(_ s: String) -> String {
|
||||||
|
s.replacingOccurrences(of: "\n", with: " ")
|
||||||
|
.replacingOccurrences(of: "\t", with: " ")
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatEntriesAsText(_ entries: [WorkLogEntry], includeDaySeparator: Bool) -> String {
|
||||||
|
guard !entries.isEmpty else {
|
||||||
|
return "No intervals logged yet today."
|
||||||
|
}
|
||||||
|
var parts: [String] = []
|
||||||
|
var currentDay: String?
|
||||||
|
for entry in entries {
|
||||||
|
let dayStr = Self.dayFormatter.string(from: entry.date)
|
||||||
|
if includeDaySeparator, currentDay != dayStr {
|
||||||
|
if currentDay != nil { parts.append("") }
|
||||||
|
parts.append("--- \(dayStr) ---")
|
||||||
|
currentDay = dayStr
|
||||||
|
} else if !includeDaySeparator {
|
||||||
|
currentDay = dayStr
|
||||||
|
}
|
||||||
|
let iso = Self.isoFormatter.string(from: entry.date)
|
||||||
|
let durMins = entry.durationSeconds / 60
|
||||||
|
let status = entry.completed ? "completed" : "partial"
|
||||||
|
var line = "\(iso) \(durMins)m \(status)"
|
||||||
|
if let i = entry.intent, !i.isEmpty { line += " intent: \(flattenForLog(i))" }
|
||||||
|
if let r = entry.reflection, !r.isEmpty { line += " reflection: \(flattenForLog(r))" }
|
||||||
|
if let s = entry.suggestion, !s.isEmpty { line += " suggestion: \(flattenForLog(s))" }
|
||||||
|
if let fb = entry.suggestionFeedback { line += " feedback: \(fb.rawValue)" }
|
||||||
|
if let w = entry.wellnessReflection { line += " wellness: \(w.rawValue)" }
|
||||||
|
parts.append(line)
|
||||||
|
}
|
||||||
|
return parts.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// MARK: - Work Log View Builder
|
||||||
|
|
||||||
|
enum WorkLogViewBuilder {
|
||||||
|
|
||||||
|
static func build(in window: NSWindow, isPrimary: Bool, delegate: AppDelegate) {
|
||||||
|
let size = window.frame.size
|
||||||
|
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
||||||
|
view.wantsLayer = true
|
||||||
|
let bgColor = Settings.shared.gradientColor.withAlphaComponent(0.92)
|
||||||
|
view.layer?.backgroundColor = bgColor.cgColor
|
||||||
|
|
||||||
|
let entries = WorkLogStore.shared.todayEntries()
|
||||||
|
let summary = WorkLogStore.shared.todaySummary()
|
||||||
|
|
||||||
|
// Title
|
||||||
|
let title = NSTextField(labelWithString: "Today's Work Log")
|
||||||
|
title.font = NSFont.systemFont(ofSize: 48, weight: .bold)
|
||||||
|
title.textColor = .white
|
||||||
|
title.alignment = .center
|
||||||
|
title.sizeToFit()
|
||||||
|
title.frame.origin = CGPoint(
|
||||||
|
x: (size.width - title.frame.width) / 2,
|
||||||
|
y: size.height - 120
|
||||||
|
)
|
||||||
|
view.addSubview(title)
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
let hours = summary.totalSeconds / 3600
|
||||||
|
let mins = (summary.totalSeconds % 3600) / 60
|
||||||
|
let timeStr = hours > 0
|
||||||
|
? String(format: "%dh %dm", hours, mins)
|
||||||
|
: String(format: "%dm", mins)
|
||||||
|
let completedCount = entries.filter { $0.completed }.count
|
||||||
|
let summaryText = "\(summary.intervals) intervals | \(completedCount) completed | \(timeStr) focused"
|
||||||
|
let summaryLabel = NSTextField(labelWithString: summaryText)
|
||||||
|
summaryLabel.font = NSFont.systemFont(ofSize: 22, weight: .medium)
|
||||||
|
summaryLabel.textColor = NSColor.white.withAlphaComponent(0.85)
|
||||||
|
summaryLabel.alignment = .center
|
||||||
|
summaryLabel.sizeToFit()
|
||||||
|
summaryLabel.frame.origin = CGPoint(
|
||||||
|
x: (size.width - summaryLabel.frame.width) / 2,
|
||||||
|
y: size.height - 165
|
||||||
|
)
|
||||||
|
view.addSubview(summaryLabel)
|
||||||
|
|
||||||
|
if isPrimary {
|
||||||
|
// Close button
|
||||||
|
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 = delegate
|
||||||
|
closeBtn.action = #selector(AppDelegate.dismissWorkLogAndResume)
|
||||||
|
view.addSubview(closeBtn)
|
||||||
|
|
||||||
|
// Scrollable entry list
|
||||||
|
let scrollW: CGFloat = min(700, size.width - 100)
|
||||||
|
let scrollH: CGFloat = size.height - 300
|
||||||
|
let scrollX = (size.width - scrollW) / 2
|
||||||
|
let scrollY: CGFloat = 120
|
||||||
|
|
||||||
|
let scrollView = NSScrollView(frame: NSRect(x: scrollX, y: scrollY, width: scrollW, height: scrollH))
|
||||||
|
scrollView.hasVerticalScroller = true
|
||||||
|
scrollView.drawsBackground = false
|
||||||
|
scrollView.autohidesScrollers = true
|
||||||
|
|
||||||
|
let contentView = buildEntryList(entries: entries, width: scrollW)
|
||||||
|
scrollView.documentView = contentView
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
|
||||||
|
// Bottom buttons: Back to Work | Quit
|
||||||
|
let btnW: CGFloat = 180
|
||||||
|
let btnH: CGFloat = 44
|
||||||
|
let gap: CGFloat = 24
|
||||||
|
let totalW = btnW * 3 + gap * 2
|
||||||
|
let startX = (size.width - totalW) / 2
|
||||||
|
let btnY: CGFloat = 50
|
||||||
|
|
||||||
|
let copyBtn = NSButton(frame: NSRect(x: startX, y: btnY, width: btnW, height: btnH))
|
||||||
|
copyBtn.title = "Copy"
|
||||||
|
copyBtn.bezelStyle = .rounded
|
||||||
|
copyBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
copyBtn.target = delegate
|
||||||
|
copyBtn.action = #selector(AppDelegate.copyWorkLog)
|
||||||
|
view.addSubview(copyBtn)
|
||||||
|
|
||||||
|
let resumeBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: btnY, width: btnW, height: btnH))
|
||||||
|
|
||||||
|
resumeBtn.title = "Back to Work"
|
||||||
|
resumeBtn.bezelStyle = .rounded
|
||||||
|
resumeBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
resumeBtn.target = delegate
|
||||||
|
resumeBtn.action = #selector(AppDelegate.dismissWorkLogAndResume)
|
||||||
|
view.addSubview(resumeBtn)
|
||||||
|
|
||||||
|
let quitBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: btnY, width: btnW, height: btnH))
|
||||||
|
quitBtn.title = "Quit Pommedoro"
|
||||||
|
quitBtn.bezelStyle = .rounded
|
||||||
|
quitBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
quitBtn.target = delegate
|
||||||
|
quitBtn.action = #selector(AppDelegate.closePommedoro)
|
||||||
|
view.addSubview(quitBtn)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.contentView = view
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entry List
|
||||||
|
|
||||||
|
private static func buildEntryList(entries: [WorkLogEntry], width: CGFloat) -> NSView {
|
||||||
|
let rowHeight: CGFloat = 86
|
||||||
|
let padding: CGFloat = 12
|
||||||
|
let totalHeight = max(CGFloat(entries.count) * (rowHeight + padding) + padding, 100)
|
||||||
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: totalHeight))
|
||||||
|
|
||||||
|
if entries.isEmpty {
|
||||||
|
let empty = NSTextField(labelWithString: "No intervals logged yet today.\nStart a work cycle and your log will appear here.")
|
||||||
|
empty.font = NSFont.systemFont(ofSize: 18, weight: .medium)
|
||||||
|
empty.textColor = NSColor.white.withAlphaComponent(0.7)
|
||||||
|
empty.alignment = .center
|
||||||
|
empty.maximumNumberOfLines = 0
|
||||||
|
empty.preferredMaxLayoutWidth = width - 40
|
||||||
|
empty.sizeToFit()
|
||||||
|
empty.frame.origin = CGPoint(
|
||||||
|
x: (width - empty.frame.width) / 2,
|
||||||
|
y: (totalHeight - empty.frame.height) / 2
|
||||||
|
)
|
||||||
|
container.addSubview(empty)
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "h:mm a"
|
||||||
|
|
||||||
|
for (index, entry) in entries.enumerated() {
|
||||||
|
let y = totalHeight - CGFloat(index + 1) * (rowHeight + padding)
|
||||||
|
let row = NSView(frame: NSRect(x: 0, y: y, width: width, height: rowHeight))
|
||||||
|
row.wantsLayer = true
|
||||||
|
row.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.12).cgColor
|
||||||
|
row.layer?.cornerRadius = 8
|
||||||
|
|
||||||
|
// Time and duration
|
||||||
|
let timeStr = formatter.string(from: entry.date)
|
||||||
|
let durMins = entry.durationSeconds / 60
|
||||||
|
let status = entry.completed ? "completed" : "partial"
|
||||||
|
let header = "\(timeStr) — \(durMins)m (\(status))"
|
||||||
|
let headerLabel = NSTextField(labelWithString: header)
|
||||||
|
headerLabel.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .semibold)
|
||||||
|
headerLabel.textColor = NSColor.white.withAlphaComponent(0.9)
|
||||||
|
headerLabel.frame = NSRect(x: 16, y: rowHeight - 24, width: width - 32, height: 18)
|
||||||
|
row.addSubview(headerLabel)
|
||||||
|
|
||||||
|
// Intent (always show line; use "—" when nil or empty)
|
||||||
|
let rawIntent = entry.intent?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let intentText = (rawIntent?.isEmpty != false) ? "—" : (rawIntent ?? "—")
|
||||||
|
let intentLabel = NSTextField(labelWithString: "Intent: \(intentText)")
|
||||||
|
intentLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular)
|
||||||
|
intentLabel.textColor = NSColor.white.withAlphaComponent(0.75)
|
||||||
|
intentLabel.frame = NSRect(x: 16, y: rowHeight - 44, width: width - 32, height: 16)
|
||||||
|
intentLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
if let intent = rawIntent, !intent.isEmpty { intentLabel.toolTip = intent }
|
||||||
|
row.addSubview(intentLabel)
|
||||||
|
|
||||||
|
// Reflection
|
||||||
|
let reflText = entry.reflection ?? "—"
|
||||||
|
let reflLabel = NSTextField(labelWithString: "Reflection: \(reflText)")
|
||||||
|
reflLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular)
|
||||||
|
reflLabel.textColor = NSColor.white.withAlphaComponent(0.75)
|
||||||
|
reflLabel.frame = NSRect(x: 16, y: rowHeight - 62, width: width - 32, height: 16)
|
||||||
|
reflLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
row.addSubview(reflLabel)
|
||||||
|
|
||||||
|
// Suggestion feedback
|
||||||
|
let suggText = formatSuggestionFeedback(entry)
|
||||||
|
let suggLabel = NSTextField(labelWithString: suggText)
|
||||||
|
suggLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular)
|
||||||
|
suggLabel.textColor = NSColor.white.withAlphaComponent(0.65)
|
||||||
|
suggLabel.frame = NSRect(x: 16, y: rowHeight - 80, width: width - 32, height: 16)
|
||||||
|
suggLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
row.addSubview(suggLabel)
|
||||||
|
|
||||||
|
container.addSubview(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func formatSuggestionFeedback(_ entry: WorkLogEntry) -> String {
|
||||||
|
guard let suggestion = entry.suggestion, !suggestion.isEmpty else {
|
||||||
|
return "Wellness: —"
|
||||||
|
}
|
||||||
|
let fbStr: String
|
||||||
|
switch entry.suggestionFeedback {
|
||||||
|
case .didIt:
|
||||||
|
let wellStr: String
|
||||||
|
switch entry.wellnessReflection {
|
||||||
|
case .great: wellStr = " — felt great"
|
||||||
|
case .same: wellStr = " — about the same"
|
||||||
|
case .notReally: wellStr = " — not really"
|
||||||
|
case .none: wellStr = ""
|
||||||
|
}
|
||||||
|
fbStr = "did it\(wellStr)"
|
||||||
|
case .skipped:
|
||||||
|
fbStr = "skipped"
|
||||||
|
case .dismissed:
|
||||||
|
fbStr = "removed from pool"
|
||||||
|
case .none:
|
||||||
|
fbStr = "—"
|
||||||
|
}
|
||||||
|
return "Wellness: \(suggestion) (\(fbStr))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,11 +13,36 @@ rm -rf .build/Pommedoro.app .build/Pommedoro.dmg
|
||||||
echo "==> Building DMG..."
|
echo "==> Building DMG..."
|
||||||
make dmg
|
make dmg
|
||||||
|
|
||||||
echo "==> Installing to /Applications..."
|
echo "==> Simulating download quarantine on DMG..."
|
||||||
make install
|
xattr -w com.apple.quarantine "0081;67890abc;Safari;12345678-1234-1234-1234-123456789012" .build/Pommedoro.dmg
|
||||||
|
|
||||||
echo "==> Launching Pommedoro.app..."
|
echo "==> Mounting quarantined DMG..."
|
||||||
open /Applications/Pommedoro.app
|
hdiutil attach .build/Pommedoro.dmg -nobrowse -quiet
|
||||||
|
|
||||||
|
echo "==> Running Install.command from DMG..."
|
||||||
|
bash /Volumes/Pommedoro/Install.command
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo "==> Verifying app is running..."
|
||||||
|
if pgrep -x Pommedoro > /dev/null; then
|
||||||
|
echo "==> SUCCESS: Pommedoro is running."
|
||||||
|
else
|
||||||
|
echo "==> FAIL: Pommedoro did not launch."
|
||||||
|
hdiutil detach /Volumes/Pommedoro -quiet 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Verifying quarantine is stripped..."
|
||||||
|
if xattr /Applications/Pommedoro.app 2>&1 | grep -q "com.apple.quarantine"; then
|
||||||
|
echo "==> FAIL: quarantine attribute still present."
|
||||||
|
hdiutil detach /Volumes/Pommedoro -quiet 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "==> OK: no quarantine attribute."
|
||||||
|
fi
|
||||||
|
|
||||||
|
hdiutil detach /Volumes/Pommedoro -quiet 2>/dev/null || true
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "==> Done. DMG at: .build/Pommedoro.dmg"
|
echo "==> Done. DMG at: .build/Pommedoro.dmg"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue