commit 959580c55e806885bd937aa9b7a0238e5d4274ec Author: 徐翔宇 Date: Mon Apr 27 20:21:12 2026 +0800 v0.1.0: initial 摸鱼侠 — Worker Toolkit plugin for MioIsland Office-survival toolkit. Five tabs in the notch: - 🍅 番茄钟: 25/5 focus/break with circular ring timer + tally. - 🪑 久坐: count-up since last break, dock-bounce alert at 45min. - 💧 喝水: animated cup, daily goal tracking with progress dots. - 🏃 下班: H:M:S countdown to clock-out hour with gradient bar. - 🎉 周末: 天/时/分 to next Saturday + rotating 打工语录. 100% local. UNUserNotificationCenter for alerts, dock-bounce fallback when authorization denied. UserDefaults persistence. Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33f779a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +.DS_Store +*.swiftmodule +*.dSYM +.build/ diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..ec79107 --- /dev/null +++ b/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleExecutable + WorkerPlugin + CFBundleIdentifier + com.mioisland.plugin.worker + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + 摸鱼侠 + CFBundlePackageType + BNDL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + NSPrincipalClass + WorkerPlugin.WorkerPlugin + + MioPluginPreferredWidth + 380 + MioPluginPreferredHeight + 620 + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a44fc63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MioMioOS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6320863 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# 摸鱼侠 — Worker Toolkit for MioIsland + +Office-survival toolkit in your notch. Five tabs cover the everyday +rhythm of a desk job: pomodoro focus, sit-up reminder, water +hydration tracker, clock-out countdown, and weekend countdown. + +100% local. No network, no API keys, no telemetry. + +## Features + +- **🍅 番茄钟** — 25/5 minute focus/break cycles, big circular ring + timer, pause/resume/reset, today's pomodoro tally + focus minutes, + ±5min and ±1min steppers. +- **🪑 久坐提醒** — count-up "已坐 X 分钟" since you last broke, + configurable trigger interval (default 45 min), red status pill + + dock bounce when you cross the threshold. +- **💧 喝水追踪** — animated water-level cup glyph, today's tally + with progress dots, 加一杯 / 撤销 buttons, configurable goal + (default 8 cups). +- **🏃 下班倒计时** — live H:M:S countdown to your clock-out hour, + gradient bar fills 0→1 across a 9-hour anchor, in-panel hour and + minute steppers. +- **🎉 周末倒数** — 天/时/分 to next Saturday 00:00 with a rotating + 打工语录 that's stable per day. + +## Notifications + +Uses `UNUserNotificationCenter` for sit-up and pomodoro alerts. If +notification authorization is denied, falls back to +`NSApp.requestUserAttention(.criticalRequest)` — your dock icon +bounces and the menu bar status dot flips red, so the reminder +never silently misses. + +## Persistence + +All preferences stored in `UserDefaults(suiteName: "com.mioisland.plugin.worker")`: +- Pomodoro count history (per-date dict) +- Focus / break minute settings +- Sit-up start timestamp + trigger interval +- Water cups today + daily goal +- Clock-out time (HH:mm) + +## Requirements + +- macOS 15.0+ +- MioIsland v2.2.0+ + +## Building from source + +```bash +./build.sh # produce build/worker.bundle + build/worker.zip +./build.sh install # build + copy to ~/.config/codeisland/plugins/ +``` + +Restart MioIsland (Cmd+Q + reopen) to load the new build. + +## Structure + +``` +Sources/ +├── MioPlugin.swift # protocol (verbatim from host) +├── WorkerPlugin.swift # principal class +├── ui/ +│ ├── ExpandedView.swift # 380×620 panel + 5-tab pill strip +│ ├── PomodoroView.swift # circular ring timer +│ ├── SitView.swift # count-up + threshold alert +│ ├── WaterView.swift # animated cup with water fill +│ ├── ClockoutView.swift # gradient countdown bar +│ ├── WeekendView.swift # purple hero countdown +│ └── Theme.swift # design tokens +└── engine/ + ├── WorkerStore.swift # @MainActor state surface, 1Hz tick + ├── NotificationCenter.swift # UN wrapper + dock bounce fallback + └── WorkerDebugLog.swift # /tmp/worker-plugin.log +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/Sources/MioPlugin.swift b/Sources/MioPlugin.swift new file mode 100644 index 0000000..a28cba2 --- /dev/null +++ b/Sources/MioPlugin.swift @@ -0,0 +1,18 @@ +// +// MioPlugin.swift +// Mio Island Plugin SDK (verbatim copy from host). +// Runtime conformance is by ObjC selector, not module identity. +// + +import AppKit + +@objc protocol MioPlugin: AnyObject { + var id: String { get } + var name: String { get } + var icon: String { get } + var version: String { get } + func activate() + func deactivate() + func makeView() -> NSView + @objc optional func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? +} diff --git a/Sources/WorkerPlugin.swift b/Sources/WorkerPlugin.swift new file mode 100644 index 0000000..3eac182 --- /dev/null +++ b/Sources/WorkerPlugin.swift @@ -0,0 +1,38 @@ +// +// WorkerPlugin.swift +// Mio Island plugin: 摸鱼侠 +// +// Principal class. Module = WorkerPlugin, Class = WorkerPlugin → +// NSPrincipalClass = "WorkerPlugin.WorkerPlugin". +// + +import AppKit +import SwiftUI + +final class WorkerPlugin: NSObject, MioPlugin { + var id: String { "worker" } + var name: String { "摸鱼侠" } + var icon: String { "fish.fill" } + var version: String { "0.1.0" } + + func activate() { + WorkerDebugLog.write("plugin activate") + Task { @MainActor in + WorkerStore.shared.start() + WorkerNotificationCenter.shared.requestAuthorizationIfNeeded() + } + } + + func deactivate() { + WorkerDebugLog.write("plugin deactivate") + Task { @MainActor in + WorkerStore.shared.stop() + } + } + + func makeView() -> NSView { + let view = NSHostingView(rootView: ExpandedView()) + view.autoresizingMask = [.width, .height] + return view + } +} diff --git a/Sources/engine/NotificationCenter.swift b/Sources/engine/NotificationCenter.swift new file mode 100644 index 0000000..e0d49b8 --- /dev/null +++ b/Sources/engine/NotificationCenter.swift @@ -0,0 +1,77 @@ +// +// NotificationCenter.swift +// 摸鱼侠 plugin v0.1 +// +// Thin wrapper around UNUserNotificationCenter. Plugin bundles inherit +// the host app's bundle identifier for notification authorization, so +// the host (Mio Island) needs to have notifications allowed in System +// Settings. We request once on activate; if it's denied we fall back +// to the in-panel red-dot pulse pattern. +// + +import Foundation +@preconcurrency import UserNotifications +import AppKit + +@MainActor +final class WorkerNotificationCenter { + static let shared = WorkerNotificationCenter() + + private(set) var isAuthorized: Bool = false + private var didRequest: Bool = false + + private init() {} + + /// Asks the user once. Subsequent calls are no-ops. + func requestAuthorizationIfNeeded() { + guard !didRequest else { return } + didRequest = true + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + let status = settings.authorizationStatus + switch status { + case .authorized, .provisional, .ephemeral: + Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = true } + case .denied: + Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = false } + WorkerDebugLog.write("notifications denied — falling back to in-panel dot") + case .notDetermined: + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in + if let error = error { + WorkerDebugLog.write("notif auth error: \(error)") + } + Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = granted } + WorkerDebugLog.write("notif auth granted=\(granted)") + } + @unknown default: + Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = false } + } + } + } + + /// Fire-and-forget local notification. Returns true if scheduled + /// (best effort — auth status may flip between scheduling and firing). + @discardableResult + func notify(title: String, body: String, identifier: String = UUID().uuidString) -> Bool { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + // Trigger immediately. nil means deliver right away. We use a + // 0.1s threshold trigger because some macOS builds are flaky + // with truly-instant local notifications from plugin bundles. + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false) + let req = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + UNUserNotificationCenter.current().add(req) { err in + if let err = err { + WorkerDebugLog.write("notif schedule error: \(err)") + } + } + + // Always bounce the dock as a backup signal — works even when + // notifications are denied. + NSApp.requestUserAttention(.criticalRequest) + return isAuthorized + } +} diff --git a/Sources/engine/WorkerDebugLog.swift b/Sources/engine/WorkerDebugLog.swift new file mode 100644 index 0000000..8d1ef51 --- /dev/null +++ b/Sources/engine/WorkerDebugLog.swift @@ -0,0 +1,44 @@ +// +// WorkerDebugLog.swift +// 摸鱼侠 plugin +// +// Plugin-bundle NSLog calls don't reliably surface in `log show` from +// the host process. This helper writes timestamped lines to a known +// file so we can `tail -f` it during debugging. +// + +import Foundation + +enum WorkerDebugLog { + static let path = "/tmp/worker-plugin.log" + private static let queue = DispatchQueue(label: "worker.debug.log") + private static let dateFmt: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone.current + f.dateFormat = "HH:mm:ss.SSS" + return f + }() + + static func write(_ message: String) { + let stamp = dateFmt.string(from: Date()) + let line = "[\(stamp)] \(message)\n" + queue.async { + if let data = line.data(using: .utf8) { + if FileManager.default.fileExists(atPath: path) { + if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) { + do { + try handle.seekToEnd() + try handle.write(contentsOf: data) + try handle.close() + } catch { + // best-effort log: ignore write failures. + } + } + } else { + try? data.write(to: URL(fileURLWithPath: path)) + } + } + } + } +} diff --git a/Sources/engine/WorkerStore.swift b/Sources/engine/WorkerStore.swift new file mode 100644 index 0000000..ef501ea --- /dev/null +++ b/Sources/engine/WorkerStore.swift @@ -0,0 +1,440 @@ +// +// WorkerStore.swift +// 摸鱼侠 plugin v0.1 +// +// Single source of truth for all five tabs. Holds: +// - pomodoro state machine (idle / focus / break / paused) +// - sit-timer countup (start timestamp) +// - water tally (cups today) +// - clockout target time (HH:mm) +// - weekend countdown (computed) +// +// All persisted to UserDefaults with suite "com.mioisland.plugin.worker". +// A single 1Hz Timer drives all derived "now" values. +// + +import Combine +import Foundation +import SwiftUI + +// MARK: - Persistence keys + +private enum K { + static let pomodoroHistory = "pomodoro.history" // [String: Int] (yyyy-MM-dd → count) + static let pomodoroFocusMin = "pomodoro.focusMin" // Int, default 25 + static let pomodoroBreakMin = "pomodoro.breakMin" // Int, default 5 + static let sitStart = "sit.start" // Double (timeIntervalSince1970), 0 = idle + static let sitTriggerMin = "sit.triggerMin" // Int, default 45 + static let sitLastNotified = "sit.lastNotified" // Double — last fired notification ts, to dedupe + static let waterHistory = "water.history" // [String: Int] (date → cups) + static let waterGoal = "water.goal" // Int, default 8 + static let clockoutHHmm = "clockout.hhmm" // String "18:00" +} + +private let suiteName = "com.mioisland.plugin.worker" + +// MARK: - Pomodoro phase + +enum PomodoroPhase: String { + case idle // not running + case focus // 25 min focus going + case rest // 5 min break going + case paused // paused mid-phase + + var label: String { + switch self { + case .idle: return "准备开始" + case .focus: return "专注中" + case .rest: return "休息中" + case .paused: return "已暂停" + } + } +} + +// MARK: - Store + +@MainActor +final class WorkerStore: ObservableObject { + static let shared = WorkerStore() + + private let defaults: UserDefaults + + // MARK: Published state + + /// Pomodoro + @Published var pomodoroPhase: PomodoroPhase = .idle + @Published var pomodoroRemaining: Int = 25 * 60 // seconds + @Published var pomodoroFocusMin: Int = 25 + @Published var pomodoroBreakMin: Int = 5 + @Published var pomodoroTodayCount: Int = 0 + /// Pre-pause snapshot so resume can restore the underlying phase. + private var pausedPhase: PomodoroPhase = .focus + private var pausedRemaining: Int = 25 * 60 + + /// Sit + @Published var sitStart: Date? = nil // nil = not seated yet + @Published var sitTriggerMin: Int = 45 + @Published var sitElapsedSec: Int = 0 // computed each tick + + /// Water + @Published var waterCupsToday: Int = 0 + @Published var waterGoal: Int = 8 + + /// Clockout + @Published var clockoutHour: Int = 18 + @Published var clockoutMinute: Int = 0 + + /// Weekend (derived) + @Published var weekendDays: Int = 0 + @Published var weekendHours: Int = 0 + @Published var weekendMinutes: Int = 0 + + /// Notification status surfaced to UI for the in-panel fallback. + @Published var notificationsAuthorized: Bool = false + + // MARK: Private + + private var tick: Timer? + private let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.locale = Locale(identifier: "zh_CN") + c.timeZone = TimeZone.current + return c + }() + + // MARK: - Init + + private init() { + // UserDefaults(suiteName:) returns nil only when the suite name + // is invalid (e.g. the global suite). For a normal bundle ID it + // always succeeds, but fall back to .standard defensively. + self.defaults = UserDefaults(suiteName: suiteName) ?? .standard + loadPersisted() + } + + // MARK: - Lifecycle + + func start() { + WorkerDebugLog.write("store start") + recomputeAll() + tick?.invalidate() + tick = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in self?.tickFire() } + } + } + + func stop() { + WorkerDebugLog.write("store stop") + tick?.invalidate() + tick = nil + } + + // MARK: - Tick (1Hz) + + private func tickFire() { + // 1. Pomodoro countdown + if pomodoroPhase == .focus || pomodoroPhase == .rest { + if pomodoroRemaining > 0 { + pomodoroRemaining -= 1 + } + if pomodoroRemaining <= 0 { + pomodoroPhaseEnded() + } + } + + // 2. Sit countup + threshold notification + if let start = sitStart { + let elapsed = max(0, Int(Date().timeIntervalSince(start))) + sitElapsedSec = elapsed + // Fire once per crossing of the trigger (and once per + // additional trigger period after that). + let triggerSec = sitTriggerMin * 60 + if triggerSec > 0 && elapsed >= triggerSec { + let lastFired = defaults.double(forKey: K.sitLastNotified) + let nowTs = Date().timeIntervalSince1970 + // Only fire if we crossed *this* boundary (i.e. last + // fired more than triggerSec ago, or never). + if nowTs - lastFired >= Double(triggerSec) { + defaults.set(nowTs, forKey: K.sitLastNotified) + WorkerNotificationCenter.shared.notify( + title: "该起来动一下了", + body: "你已连续坐了 \(sitTriggerMin) 分钟,起身喝口水吧。" + ) + WorkerDebugLog.write("sit threshold notification fired (\(elapsed)s)") + } + } + } else { + sitElapsedSec = 0 + } + + // 3. Weekend countdown — recompute every tick (cheap). + recomputeWeekend() + + // 4. Detect day rollover for pomodoro / water counters. + rolloverIfNeeded() + } + + // MARK: - Pomodoro + + func pomodoroStart() { + if pomodoroPhase == .paused { + pomodoroPhase = pausedPhase + pomodoroRemaining = pausedRemaining + return + } + pomodoroPhase = .focus + pomodoroRemaining = pomodoroFocusMin * 60 + } + + func pomodoroPause() { + guard pomodoroPhase == .focus || pomodoroPhase == .rest else { return } + pausedPhase = pomodoroPhase + pausedRemaining = pomodoroRemaining + pomodoroPhase = .paused + } + + func pomodoroReset() { + pomodoroPhase = .idle + pomodoroRemaining = pomodoroFocusMin * 60 + } + + func pomodoroSetFocus(_ min: Int) { + let v = max(1, min) + pomodoroFocusMin = v + defaults.set(v, forKey: K.pomodoroFocusMin) + if pomodoroPhase == .idle { + pomodoroRemaining = v * 60 + } + } + + func pomodoroSetBreak(_ min: Int) { + let v = max(1, min) + pomodoroBreakMin = v + defaults.set(v, forKey: K.pomodoroBreakMin) + } + + private func pomodoroPhaseEnded() { + switch pomodoroPhase { + case .focus: + // Increment today's tally. + pomodoroTodayCount += 1 + persistPomodoroToday() + WorkerNotificationCenter.shared.notify( + title: "番茄完成 🍅", + body: "干得漂亮!开始 \(pomodoroBreakMin) 分钟休息。" + ) + pomodoroPhase = .rest + pomodoroRemaining = pomodoroBreakMin * 60 + case .rest: + WorkerNotificationCenter.shared.notify( + title: "休息结束", + body: "回到工作 — 再来一个 \(pomodoroFocusMin) 分钟番茄?" + ) + pomodoroPhase = .idle + pomodoroRemaining = pomodoroFocusMin * 60 + default: + break + } + } + + private func persistPomodoroToday() { + var dict = (defaults.dictionary(forKey: K.pomodoroHistory) as? [String: Int]) ?? [:] + dict[Self.dateKey(Date(), calendar: calendar)] = pomodoroTodayCount + defaults.set(dict, forKey: K.pomodoroHistory) + } + + // MARK: - Sit + + func sitStartNow() { + sitStart = Date() + sitElapsedSec = 0 + defaults.set(Date().timeIntervalSince1970, forKey: K.sitStart) + defaults.set(0.0, forKey: K.sitLastNotified) + } + + func sitStop() { + sitStart = nil + sitElapsedSec = 0 + defaults.set(0.0, forKey: K.sitStart) + defaults.set(0.0, forKey: K.sitLastNotified) + } + + func sitSetTrigger(_ min: Int) { + let v = max(5, min) + sitTriggerMin = v + defaults.set(v, forKey: K.sitTriggerMin) + // Reset dedupe window so the new threshold gets a fresh check. + defaults.set(0.0, forKey: K.sitLastNotified) + } + + // MARK: - Water + + func waterAddCup() { + waterCupsToday += 1 + persistWaterToday() + } + + func waterRemoveCup() { + guard waterCupsToday > 0 else { return } + waterCupsToday -= 1 + persistWaterToday() + } + + func waterSetGoal(_ goal: Int) { + let v = max(1, goal) + waterGoal = v + defaults.set(v, forKey: K.waterGoal) + } + + private func persistWaterToday() { + var dict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:] + dict[Self.dateKey(Date(), calendar: calendar)] = waterCupsToday + defaults.set(dict, forKey: K.waterHistory) + } + + // MARK: - Clockout + + func clockoutSet(hour: Int, minute: Int) { + let h = max(0, min(23, hour)) + let m = max(0, min(59, minute)) + clockoutHour = h + clockoutMinute = m + defaults.set(String(format: "%02d:%02d", h, m), forKey: K.clockoutHHmm) + } + + /// Seconds remaining until today's clockout. If it's already past, + /// returns the seconds until tomorrow's clockout (rolls over). + var clockoutRemainingSec: Int { + let now = Date() + var comps = calendar.dateComponents([.year, .month, .day], from: now) + comps.hour = clockoutHour + comps.minute = clockoutMinute + comps.second = 0 + var target = calendar.date(from: comps) ?? now + if target <= now { + target = calendar.date(byAdding: .day, value: 1, to: target) ?? target + } + return max(0, Int(target.timeIntervalSince(now))) + } + + /// 0.0 = full workday left, 1.0 = at/past clockout time. Used for + /// the red bar fill. Anchor: 9 hours before clockout = full bar. + var clockoutProgress: Double { + let remaining = Double(clockoutRemainingSec) + let workdaySec: Double = 9 * 3600 + let elapsed = workdaySec - remaining + return max(0.0, min(1.0, elapsed / workdaySec)) + } + + // MARK: - Weekend + + private func recomputeWeekend() { + // Next Saturday 00:00 local. If today is already Saturday/Sunday, + // we still target the *next* Saturday (so users on weekends see + // a fresh ~7-day countdown — that mirrors the spec's "周末 = 距 + // 离下个周六" intent). + let now = Date() + let weekday = calendar.component(.weekday, from: now) // Sun=1 ... Sat=7 + // Days until next Saturday. If today is Saturday before midnight, + // we already passed the boundary today, so the next one is +7. + var daysUntilSat = (7 - weekday + 7) % 7 + if daysUntilSat == 0 { daysUntilSat = 7 } + + var comps = calendar.dateComponents([.year, .month, .day], from: now) + comps.hour = 0 + comps.minute = 0 + comps.second = 0 + guard let midnightToday = calendar.date(from: comps), + let target = calendar.date(byAdding: .day, value: daysUntilSat, to: midnightToday) else { + return + } + let total = max(0, Int(target.timeIntervalSince(now))) + weekendDays = total / 86400 + let rem1 = total % 86400 + weekendHours = rem1 / 3600 + let rem2 = rem1 % 3600 + weekendMinutes = rem2 / 60 + } + + // MARK: - Day rollover + + private var lastSeenDay: String = "" + + private func rolloverIfNeeded() { + let today = Self.dateKey(Date(), calendar: calendar) + if lastSeenDay.isEmpty { + lastSeenDay = today + return + } + if today != lastSeenDay { + WorkerDebugLog.write("day rollover \(lastSeenDay) → \(today)") + lastSeenDay = today + // Reload day-scoped counters. + let pomDict = (defaults.dictionary(forKey: K.pomodoroHistory) as? [String: Int]) ?? [:] + pomodoroTodayCount = pomDict[today] ?? 0 + let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:] + waterCupsToday = waterDict[today] ?? 0 + } + } + + // MARK: - Load + recompute + + private func loadPersisted() { + // Pomodoro + let storedFocus = defaults.integer(forKey: K.pomodoroFocusMin) + pomodoroFocusMin = storedFocus > 0 ? storedFocus : 25 + let storedBreak = defaults.integer(forKey: K.pomodoroBreakMin) + pomodoroBreakMin = storedBreak > 0 ? storedBreak : 5 + pomodoroRemaining = pomodoroFocusMin * 60 + + let pomDict = (defaults.dictionary(forKey: K.pomodoroHistory) as? [String: Int]) ?? [:] + let today = Self.dateKey(Date(), calendar: calendar) + pomodoroTodayCount = pomDict[today] ?? 0 + lastSeenDay = today + + // Sit + let storedTrig = defaults.integer(forKey: K.sitTriggerMin) + sitTriggerMin = storedTrig > 0 ? storedTrig : 45 + let sitTs = defaults.double(forKey: K.sitStart) + if sitTs > 0 { + sitStart = Date(timeIntervalSince1970: sitTs) + } else { + sitStart = nil + } + + // Water + let storedGoal = defaults.integer(forKey: K.waterGoal) + waterGoal = storedGoal > 0 ? storedGoal : 8 + let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:] + waterCupsToday = waterDict[today] ?? 0 + + // Clockout + let hhmm = defaults.string(forKey: K.clockoutHHmm) ?? "18:00" + let parts = hhmm.split(separator: ":").map { Int($0) ?? 0 } + if parts.count == 2 { + clockoutHour = parts[0] + clockoutMinute = parts[1] + } else { + clockoutHour = 18 + clockoutMinute = 0 + } + } + + private func recomputeAll() { + recomputeWeekend() + if let start = sitStart { + sitElapsedSec = max(0, Int(Date().timeIntervalSince(start))) + } + } + + // MARK: - Helpers + + private static func dateKey(_ date: Date, calendar: Calendar) -> String { + let f = DateFormatter() + f.calendar = calendar + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = calendar.timeZone + f.dateFormat = "yyyy-MM-dd" + return f.string(from: date) + } +} diff --git a/Sources/ui/ClockoutView.swift b/Sources/ui/ClockoutView.swift new file mode 100644 index 0000000..3ca1d0a --- /dev/null +++ b/Sources/ui/ClockoutView.swift @@ -0,0 +1,246 @@ +// +// ClockoutView.swift +// 摸鱼侠 plugin v0.1 +// +// 下班 — 距离下班 X时Y分. Red bar fills as the day progresses. +// + +import SwiftUI + +struct ClockoutView: View { + @ObservedObject var store: WorkerStore + @State private var editing: Bool = false + @State private var editHour: Int = 18 + @State private var editMinute: Int = 0 + + var body: some View { + VStack(spacing: 18) { + statusBadge + + countdownDisplay + + progressBar + + controls + + Divider() + .background(WorkerTheme.overlay08) + .padding(.horizontal, 24) + + timeRow + + tipText + + Spacer(minLength: 0) + } + .padding(.top, 8) + } + + // MARK: - Status + + private var statusBadge: some View { + let isOff = store.clockoutRemainingSec >= 86400 - 60 // basically never + let almost = store.clockoutProgress >= 0.95 + let dotColor: Color = almost ? WorkerTheme.alertRed : WorkerTheme.lime + return HStack(spacing: 8) { + Circle() + .fill(dotColor) + .frame(width: 8, height: 8) + .shadow(color: dotColor.opacity(0.7), radius: 4) + Text(isOff ? "下班时间未设置" : (almost ? "马上就能下班!" : "今日工作中")) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundColor(WorkerTheme.fg85) + } + .padding(.horizontal, 12) + .frame(height: 26) + .background(Capsule().fill(WorkerTheme.overlay06)) + } + + // MARK: - Countdown + + private var countdownDisplay: some View { + let total = store.clockoutRemainingSec + let h = total / 3600 + let m = (total % 3600) / 60 + let s = total % 60 + return VStack(spacing: 6) { + Text("距离下班") + .font(.system(size: 12)) + .foregroundColor(WorkerTheme.fg55) + HStack(alignment: .firstTextBaseline, spacing: 6) { + bigPart(value: h, suffix: "时") + bigPart(value: m, suffix: "分") + bigPart(value: s, suffix: "秒", small: true) + } + Text(targetLabel) + .font(.system(size: 11)) + .foregroundColor(WorkerTheme.fg45) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(WorkerTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(WorkerTheme.overlay08, lineWidth: 0.5) + ) + ) + .padding(.horizontal, 16) + } + + private func bigPart(value: Int, suffix: String, small: Bool = false) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text("\(value)") + .font(.system(size: small ? 28 : 44, weight: .semibold, design: .rounded)) + .foregroundColor(WorkerTheme.fgPrimary) + .monospacedDigit() + Text(suffix) + .font(.system(size: small ? 12 : 14, weight: .medium)) + .foregroundColor(WorkerTheme.fg55) + } + } + + private var targetLabel: String { + String(format: "目标 %02d:%02d", store.clockoutHour, store.clockoutMinute) + } + + // MARK: - Progress bar + + private var progressBar: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(WorkerTheme.overlay06) + .frame(height: 10) + RoundedRectangle(cornerRadius: 6) + .fill(LinearGradient( + colors: [WorkerTheme.tomato, WorkerTheme.alertRed], + startPoint: .leading, endPoint: .trailing + )) + .frame( + width: max(0, geo.size.width * CGFloat(store.clockoutProgress)), + height: 10 + ) + .animation(.linear(duration: 0.3), value: store.clockoutProgress) + } + } + .frame(height: 10) + .padding(.horizontal, 16) + } + + // MARK: - Controls + + private var controls: some View { + HStack(spacing: 10) { + Button(action: { + editHour = store.clockoutHour + editMinute = store.clockoutMinute + editing.toggle() + }) { + HStack(spacing: 6) { + Image(systemName: editing ? "checkmark" : "clock") + .font(.system(size: 11, weight: .semibold)) + Text(editing ? "完成" : "调整下班时间") + .font(.system(size: 12.5, weight: .semibold)) + } + .foregroundColor(Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255)) + .padding(.horizontal, 16) + .frame(height: 32) + .background(Capsule().fill(WorkerTheme.lime)) + } + .buttonStyle(.plain) + } + } + + // MARK: - Time row (only shown when editing) + + @ViewBuilder + private var timeRow: some View { + if editing { + HStack(spacing: 10) { + stepperPill( + title: "时", + value: editHour, + onMinus: { + editHour = max(0, editHour - 1) + store.clockoutSet(hour: editHour, minute: editMinute) + }, + onPlus: { + editHour = min(23, editHour + 1) + store.clockoutSet(hour: editHour, minute: editMinute) + } + ) + stepperPill( + title: "分", + value: editMinute, + onMinus: { + editMinute = max(0, editMinute - 5) + store.clockoutSet(hour: editHour, minute: editMinute) + }, + onPlus: { + editMinute = min(55, editMinute + 5) + store.clockoutSet(hour: editHour, minute: editMinute) + } + ) + } + .padding(.horizontal, 16) + } else { + EmptyView() + } + } + + private func stepperPill(title: String, value: Int, onMinus: @escaping () -> Void, onPlus: @escaping () -> Void) -> some View { + HStack(spacing: 0) { + Text(title) + .font(.system(size: 11)) + .foregroundColor(WorkerTheme.fg55) + .padding(.leading, 10) + + Spacer() + + Button(action: onMinus) { + Image(systemName: "minus") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(WorkerTheme.fg70) + .frame(width: 22, height: 22) + .background(Circle().fill(WorkerTheme.overlay06)) + } + .buttonStyle(.plain) + + Text(String(format: "%02d", value)) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(WorkerTheme.fgPrimary) + .monospacedDigit() + .frame(minWidth: 36) + + Button(action: onPlus) { + Image(systemName: "plus") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(WorkerTheme.fg70) + .frame(width: 22, height: 22) + .background(Circle().fill(WorkerTheme.overlay06)) + } + .buttonStyle(.plain) + .padding(.trailing, 8) + } + .frame(height: 32) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(WorkerTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(WorkerTheme.overlay08, lineWidth: 0.5) + ) + ) + } + + private var tipText: some View { + Text("下班时间到时不会响铃 —\n本插件只负责让你看着时间倒计时偷着乐。") + .font(.system(size: 11)) + .foregroundColor(WorkerTheme.fg45) + .padding(.horizontal, 20) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } +} diff --git a/Sources/ui/ExpandedView.swift b/Sources/ui/ExpandedView.swift new file mode 100644 index 0000000..3792c3b --- /dev/null +++ b/Sources/ui/ExpandedView.swift @@ -0,0 +1,197 @@ +// +// ExpandedView.swift +// 摸鱼侠 plugin v0.1 +// +// Top-level panel container — 380×620 (40pt notch + 580pt content). +// Renders title bar + 5-tab pill strip + active tab body + footer. +// + +import SwiftUI + +struct ExpandedView: View { + @ObservedObject var store: WorkerStore = .shared + + enum Tab: String, CaseIterable, Identifiable { + case pomodoro = "番茄" + case sit = "久坐" + case water = "喝水" + case clockout = "下班" + case weekend = "周末" + var id: String { rawValue } + + var emoji: String { + switch self { + case .pomodoro: return "🍅" + case .sit: return "🪑" + case .water: return "💧" + case .clockout: return "🏃" + case .weekend: return "🎉" + } + } + } + + @State private var tab: Tab = .pomodoro + + var body: some View { + VStack(spacing: 0) { + // Notch reservation (40pt) — same pattern as 看盘侠 / Music + // Player. Host's floating back-chevron lives here. + Color.clear.frame(height: 40) + topBar + tabStrip + body_ + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + footer + } + .frame(width: 380, height: 620) + .background( + ZStack { + WorkerTheme.panelBg + RadialGradient( + colors: [Color.white.opacity(0.04), Color.clear], + center: .top, + startRadius: 4, endRadius: 220 + ) + } + ) + .clipShape( + UnevenRoundedRectangle( + cornerRadii: .init(topLeading: 0, bottomLeading: 28, bottomTrailing: 28, topTrailing: 0) + ) + ) + .onAppear { + store.start() + } + } + + // MARK: - Top bar + + private var topBar: some View { + HStack(spacing: 10) { + HStack(spacing: 8) { + Circle() + .fill(WorkerTheme.lime) + .frame(width: 7, height: 7) + .shadow(color: WorkerTheme.lime.opacity(0.6), radius: 4) + Text("摸鱼侠") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(WorkerTheme.fgPrimary) + } + Spacer() + // Notification status dot — green = authorized, dim = falling + // back to in-panel pulses. + Circle() + .fill(store.notificationsAuthorized ? WorkerTheme.lime.opacity(0.7) : WorkerTheme.fg35) + .frame(width: 6, height: 6) + .help(store.notificationsAuthorized ? "通知已开启" : "通知未开启 · 仅面板提示") + } + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 8) + } + + // MARK: - Tabs + + private var tabStrip: some View { + // 5 tabs at 380pt panel — give them equal share with tight pad. + HStack(spacing: 6) { + ForEach(Tab.allCases) { t in + tabPill(tab: t, selected: tab == t) { tab = t } + } + } + .padding(.horizontal, 12) + .padding(.bottom, 10) + } + + private func tabPill(tab t: Tab, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 4) { + Text(t.emoji).font(.system(size: 11)) + Text(t.rawValue) + .font(.system(size: 11.5, weight: .semibold)) + } + .foregroundColor(selected ? Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255) : WorkerTheme.fg70) + .padding(.horizontal, 10) + .frame(height: 28) + .frame(maxWidth: .infinity) + .background( + Capsule() + .fill(selected ? WorkerTheme.lime : WorkerTheme.overlay04) + .overlay( + Capsule() + .stroke(selected ? Color.clear : WorkerTheme.overlay08, lineWidth: 0.5) + ) + ) + } + .buttonStyle(.plain) + } + + // MARK: - Body + + @ViewBuilder + private var body_: some View { + switch tab { + case .pomodoro: PomodoroView(store: store) + case .sit: SitView(store: store) + case .water: WaterView(store: store) + case .clockout: ClockoutView(store: store) + case .weekend: WeekendView(store: store) + } + } + + // MARK: - Footer + + private var footer: some View { + HStack { + HStack(spacing: 6) { + LiveDot() + Text(footerLeftText) + } + Spacer() + Text("v0.1 · 本地运行") + } + .font(.system(size: 11)) + .foregroundColor(WorkerTheme.fg40) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Rectangle() + .fill(Color.clear) + .overlay( + Rectangle() + .fill(WorkerTheme.overlay04) + .frame(height: 0.5), + alignment: .top + ) + ) + } + + private var footerLeftText: String { + switch tab { + case .pomodoro: return "今日 \(store.pomodoroTodayCount) 个番茄" + case .sit: return store.sitStart == nil ? "未开始计时" : "久坐监控中" + case .water: return "目标 \(store.waterGoal) 杯" + case .clockout: return String(format: "%02d:%02d 下班", store.clockoutHour, store.clockoutMinute) + case .weekend: return "下个周六" + } + } +} + +// MARK: - Live dot + +private struct LiveDot: View { + @State private var pulse = false + var body: some View { + Circle() + .fill(WorkerTheme.lime) + .frame(width: 6, height: 6) + .shadow(color: WorkerTheme.lime.opacity(0.7), radius: 4) + .scaleEffect(pulse ? 0.8 : 1.0) + .opacity(pulse ? 0.4 : 1.0) + .onAppear { + withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) { + pulse = true + } + } + } +} diff --git a/Sources/ui/PomodoroView.swift b/Sources/ui/PomodoroView.swift new file mode 100644 index 0000000..4bb05e3 --- /dev/null +++ b/Sources/ui/PomodoroView.swift @@ -0,0 +1,282 @@ +// +// PomodoroView.swift +// 摸鱼侠 plugin v0.1 +// +// Big timer + start/pause/reset + today's count + focus/break adjusters. +// + +import SwiftUI + +struct PomodoroView: View { + @ObservedObject var store: WorkerStore + + var body: some View { + VStack(spacing: 18) { + phaseBadge + + timerRing + + controls + + Divider() + .background(WorkerTheme.overlay08) + .padding(.horizontal, 24) + + statsRow + + settingsRow + + Spacer(minLength: 0) + } + .padding(.top, 8) + } + + // MARK: - Phase badge + + private var phaseBadge: some View { + HStack(spacing: 8) { + Circle() + .fill(phaseColor) + .frame(width: 8, height: 8) + .shadow(color: phaseColor.opacity(0.7), radius: 4) + Text(store.pomodoroPhase.label) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundColor(WorkerTheme.fg85) + } + .padding(.horizontal, 12) + .frame(height: 26) + .background(Capsule().fill(WorkerTheme.overlay06)) + } + + private var phaseColor: Color { + switch store.pomodoroPhase { + case .focus: return WorkerTheme.tomato + case .rest: return WorkerTheme.lime + case .paused: return WorkerTheme.fg55 + case .idle: return WorkerTheme.fg40 + } + } + + // MARK: - Big circular ring + + private var timerRing: some View { + ZStack { + // Track + Circle() + .stroke(WorkerTheme.overlay08, lineWidth: 8) + + // Progress + Circle() + .trim(from: 0, to: progressFraction) + .stroke( + phaseColor, + style: StrokeStyle(lineWidth: 8, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 0.3), value: store.pomodoroRemaining) + + VStack(spacing: 4) { + Text(WorkerFormat.mmss(store.pomodoroRemaining)) + .font(.system(size: 44, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundColor(WorkerTheme.fgPrimary) + Text(subText) + .font(.system(size: 11)) + .foregroundColor(WorkerTheme.fg55) + } + } + .frame(width: 200, height: 200) + .padding(.vertical, 4) + } + + private var progressFraction: CGFloat { + let total: Int + switch store.pomodoroPhase { + case .focus: total = store.pomodoroFocusMin * 60 + case .rest: total = store.pomodoroBreakMin * 60 + case .paused, .idle: total = store.pomodoroFocusMin * 60 + } + guard total > 0 else { return 0 } + let remaining = max(0, store.pomodoroRemaining) + return CGFloat(total - remaining) / CGFloat(total) + } + + private var subText: String { + switch store.pomodoroPhase { + case .focus: return "专注 \(store.pomodoroFocusMin) 分钟" + case .rest: return "休息 \(store.pomodoroBreakMin) 分钟" + case .paused: return "点击继续" + case .idle: return "准备开始" + } + } + + // MARK: - Controls + + private var controls: some View { + HStack(spacing: 10) { + // Primary start/pause button + Button(action: primaryAction) { + HStack(spacing: 6) { + Image(systemName: primaryIcon) + .font(.system(size: 11, weight: .semibold)) + Text(primaryLabel) + .font(.system(size: 12.5, weight: .semibold)) + } + .foregroundColor(Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255)) + .padding(.horizontal, 18) + .frame(height: 32) + .background(Capsule().fill(WorkerTheme.lime)) + } + .buttonStyle(.plain) + + // Reset + Button(action: { store.pomodoroReset() }) { + HStack(spacing: 6) { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 11, weight: .semibold)) + Text("重置") + .font(.system(size: 12.5, weight: .semibold)) + } + .foregroundColor(WorkerTheme.fg85) + .padding(.horizontal, 16) + .frame(height: 32) + .background( + Capsule() + .fill(WorkerTheme.overlay06) + .overlay(Capsule().stroke(WorkerTheme.overlay12, lineWidth: 0.5)) + ) + } + .buttonStyle(.plain) + } + } + + private var primaryIcon: String { + switch store.pomodoroPhase { + case .focus, .rest: return "pause.fill" + default: return "play.fill" + } + } + + private var primaryLabel: String { + switch store.pomodoroPhase { + case .focus, .rest: return "暂停" + case .paused: return "继续" + case .idle: return "开始" + } + } + + private func primaryAction() { + switch store.pomodoroPhase { + case .focus, .rest: + store.pomodoroPause() + case .paused, .idle: + store.pomodoroStart() + } + } + + // MARK: - Stats + + private var statsRow: some View { + HStack(spacing: 10) { + statCard( + title: "今日番茄", + value: "\(store.pomodoroTodayCount)", + accent: WorkerTheme.tomato + ) + statCard( + title: "专注时长", + value: "\(store.pomodoroTodayCount * store.pomodoroFocusMin)分", + accent: WorkerTheme.lime + ) + } + .padding(.horizontal, 16) + } + + private func statCard(title: String, value: String, accent: Color) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 10.5)) + .foregroundColor(WorkerTheme.fg55) + Text(value) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundColor(accent) + .monospacedDigit() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(WorkerTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(WorkerTheme.overlay08, lineWidth: 0.5) + ) + ) + } + + // MARK: - Settings + + private var settingsRow: some View { + HStack(spacing: 10) { + stepperPill( + title: "专注", + value: store.pomodoroFocusMin, + onMinus: { store.pomodoroSetFocus(store.pomodoroFocusMin - 5) }, + onPlus: { store.pomodoroSetFocus(store.pomodoroFocusMin + 5) } + ) + stepperPill( + title: "休息", + value: store.pomodoroBreakMin, + onMinus: { store.pomodoroSetBreak(store.pomodoroBreakMin - 1) }, + onPlus: { store.pomodoroSetBreak(store.pomodoroBreakMin + 1) } + ) + } + .padding(.horizontal, 16) + } + + private func stepperPill(title: String, value: Int, onMinus: @escaping () -> Void, onPlus: @escaping () -> Void) -> some View { + HStack(spacing: 0) { + Text(title) + .font(.system(size: 11)) + .foregroundColor(WorkerTheme.fg55) + .padding(.leading, 10) + .padding(.trailing, 6) + + Button(action: onMinus) { + Image(systemName: "minus") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(WorkerTheme.fg70) + .frame(width: 22, height: 22) + .background(Circle().fill(WorkerTheme.overlay06)) + } + .buttonStyle(.plain) + + Text("\(value)分") + .font(.system(size: 12.5, weight: .semibold)) + .foregroundColor(WorkerTheme.fgPrimary) + .monospacedDigit() + .frame(minWidth: 40) + + Button(action: onPlus) { + Image(systemName: "plus") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(WorkerTheme.fg70) + .frame(width: 22, height: 22) + .background(Circle().fill(WorkerTheme.overlay06)) + } + .buttonStyle(.plain) + .padding(.trailing, 6) + } + .frame(maxWidth: .infinity) + .frame(height: 30) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(WorkerTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(WorkerTheme.overlay08, lineWidth: 0.5) + ) + ) + } +} diff --git a/Sources/ui/SitView.swift b/Sources/ui/SitView.swift new file mode 100644 index 0000000..f454596 --- /dev/null +++ b/Sources/ui/SitView.swift @@ -0,0 +1,247 @@ +// +// SitView.swift +// 摸鱼侠 plugin v0.1 +// +// 久坐 — countup since "sat down" timestamp. Bouncing ring/dot when +// threshold crossed. Fires a notification once every triggerMin. +// + +import SwiftUI + +struct SitView: View { + @ObservedObject var store: WorkerStore + @State private var bounce = false + + var body: some View { + VStack(spacing: 18) { + statusBadge + + timerDisplay + + controls + + Divider() + .background(WorkerTheme.overlay08) + .padding(.horizontal, 24) + + triggerRow + + tipText + + Spacer(minLength: 0) + } + .padding(.top, 8) + .onChange(of: store.sitElapsedSec) { _, newVal in + // When we cross the trigger boundary, kick the bounce + // animation to draw the eye to the panel. + let trig = store.sitTriggerMin * 60 + if trig > 0 && newVal == trig { + withAnimation(.spring(response: 0.35, dampingFraction: 0.4)) { + bounce = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) { + bounce = false + } + } + } + } + } + + // MARK: - Status + + private var statusBadge: some View { + HStack(spacing: 8) { + Circle() + .fill(badgeColor) + .frame(width: 8, height: 8) + .shadow(color: badgeColor.opacity(0.7), radius: 4) + .scaleEffect(overThreshold ? (bounce ? 1.4 : 1.15) : 1.0) + .opacity(overThreshold ? 0.95 : 1.0) + Text(badgeText) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundColor(WorkerTheme.fg85) + } + .padding(.horizontal, 12) + .frame(height: 26) + .background(Capsule().fill(WorkerTheme.overlay06)) + } + + private var badgeText: String { + if store.sitStart == nil { return "未开始" } + if overThreshold { return "需要起身活动!" } + return "久坐监控中" + } + + private var badgeColor: Color { + if store.sitStart == nil { return WorkerTheme.fg40 } + return overThreshold ? WorkerTheme.alertRed : WorkerTheme.sky + } + + private var overThreshold: Bool { + guard store.sitStart != nil else { return false } + return store.sitElapsedSec >= store.sitTriggerMin * 60 + } + + // MARK: - Timer + + private var timerDisplay: some View { + VStack(spacing: 6) { + Text(elapsedLine) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(WorkerTheme.fg55) + Text(bigDigits) + .font(.system(size: 64, weight: .semibold, design: .rounded)) + .foregroundColor(overThreshold ? WorkerTheme.alertRed : WorkerTheme.fgPrimary) + .monospacedDigit() + .scaleEffect(bounce ? 1.06 : 1.0) + Text(progressHint) + .font(.system(size: 11)) + .foregroundColor(WorkerTheme.fg45) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(WorkerTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(overThreshold ? WorkerTheme.alertRed.opacity(0.5) : WorkerTheme.overlay08, lineWidth: 0.5) + ) + ) + .padding(.horizontal, 16) + } + + private var elapsedLine: String { + if store.sitStart == nil { return "已坐 0 分钟" } + let mins = store.sitElapsedSec / 60 + return "已坐 \(mins) 分钟" + } + + private var bigDigits: String { + let elapsed = store.sitElapsedSec + let h = elapsed / 3600 + let m = (elapsed % 3600) / 60 + let s = elapsed % 60 + if h > 0 { + return String(format: "%d:%02d:%02d", h, m, s) + } + return String(format: "%02d:%02d", m, s) + } + + private var progressHint: String { + if store.sitStart == nil { return "点下方按钮开始计时" } + let trig = store.sitTriggerMin * 60 + let remain = max(0, trig - store.sitElapsedSec) + if remain == 0 { + return "已超过 \(store.sitTriggerMin) 分钟阈值" + } + let m = remain / 60 + return "距下次提醒 \(m) 分" + } + + // MARK: - Controls + + private var controls: some View { + HStack(spacing: 10) { + Button(action: { + if store.sitStart == nil { + store.sitStartNow() + } else { + store.sitStop() + } + }) { + HStack(spacing: 6) { + Image(systemName: store.sitStart == nil ? "play.fill" : "stop.fill") + .font(.system(size: 11, weight: .semibold)) + Text(store.sitStart == nil ? "开始计时" : "停止") + .font(.system(size: 12.5, weight: .semibold)) + } + .foregroundColor(Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255)) + .padding(.horizontal, 18) + .frame(height: 32) + .background(Capsule().fill(WorkerTheme.lime)) + } + .buttonStyle(.plain) + + Button(action: { + // Restart = stop + immediate start. + store.sitStop() + store.sitStartNow() + }) { + HStack(spacing: 6) { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 11, weight: .semibold)) + Text("重新计时") + .font(.system(size: 12.5, weight: .semibold)) + } + .foregroundColor(WorkerTheme.fg85) + .padding(.horizontal, 14) + .frame(height: 32) + .background( + Capsule() + .fill(WorkerTheme.overlay06) + .overlay(Capsule().stroke(WorkerTheme.overlay12, lineWidth: 0.5)) + ) + } + .buttonStyle(.plain) + } + } + + // MARK: - Trigger row + + private var triggerRow: some View { + HStack(spacing: 0) { + Text("提醒间隔") + .font(.system(size: 11)) + .foregroundColor(WorkerTheme.fg55) + .padding(.leading, 12) + + Spacer() + + Button(action: { store.sitSetTrigger(store.sitTriggerMin - 5) }) { + Image(systemName: "minus") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(WorkerTheme.fg70) + .frame(width: 24, height: 24) + .background(Circle().fill(WorkerTheme.overlay06)) + } + .buttonStyle(.plain) + + Text("\(store.sitTriggerMin)分") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(WorkerTheme.fgPrimary) + .monospacedDigit() + .frame(minWidth: 50) + + Button(action: { store.sitSetTrigger(store.sitTriggerMin + 5) }) { + Image(systemName: "plus") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(WorkerTheme.fg70) + .frame(width: 24, height: 24) + .background(Circle().fill(WorkerTheme.overlay06)) + } + .buttonStyle(.plain) + .padding(.trailing, 8) + } + .frame(height: 36) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(WorkerTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(WorkerTheme.overlay08, lineWidth: 0.5) + ) + ) + .padding(.horizontal, 16) + } + + private var tipText: some View { + Text("每隔 \(store.sitTriggerMin) 分钟提醒一次,记得起来动一动。") + .font(.system(size: 11)) + .foregroundColor(WorkerTheme.fg45) + .padding(.horizontal, 20) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } +} diff --git a/Sources/ui/Theme.swift b/Sources/ui/Theme.swift new file mode 100644 index 0000000..9c3f280 --- /dev/null +++ b/Sources/ui/Theme.swift @@ -0,0 +1,71 @@ +// +// Theme.swift +// 摸鱼侠 plugin v0.1 +// +// Single source of truth for the design's colour tokens. Mirrors +// 看盘侠's palette so the two plugins feel like siblings. +// + +import SwiftUI + +enum WorkerTheme { + // Lime accent — primary CTA / running-state highlight. + static let lime = Color(red: 0xD4/255, green: 0xFF/255, blue: 0x3A/255) + + // Warm orange — pomodoro running glow. + static let tomato = Color(red: 0xFF/255, green: 0x7A/255, blue: 0x4D/255) + + // Sky blue — sit timer active. + static let sky = Color(red: 0x6E/255, green: 0xC1/255, blue: 0xFF/255) + + // Water blue — water tab. + static let water = Color(red: 0x52/255, green: 0xC4/255, blue: 0xFF/255) + + // Clockout red — bar fill as it approaches the end. + static let alertRed = Color(red: 0xFF/255, green: 0x5E/255, blue: 0x5E/255) + + // Weekend purple — the "freedom is coming" tab. + static let weekendPurple = Color(red: 0xC9/255, green: 0x8B/255, blue: 0xFF/255) + + // Panel background — pure black with a hint of warmth. + static let panelBg = Color(red: 0x05/255, green: 0x05/255, blue: 0x05/255) + + // Subtle white overlays used everywhere. + static let overlay04 = Color.white.opacity(0.04) + static let overlay06 = Color.white.opacity(0.06) + static let overlay08 = Color.white.opacity(0.08) + static let overlay12 = Color.white.opacity(0.12) + static let overlay18 = Color.white.opacity(0.18) + + // Foreground tints. + static let fgPrimary = Color(red: 0xF4/255, green: 0xF4/255, blue: 0xF5/255) + static let fg85 = Color.white.opacity(0.85) + static let fg70 = Color.white.opacity(0.7) + static let fg55 = Color.white.opacity(0.55) + static let fg45 = Color.white.opacity(0.45) + static let fg40 = Color.white.opacity(0.4) + static let fg35 = Color.white.opacity(0.35) +} + +// MARK: - Time formatting helpers + +enum WorkerFormat { + /// 25:00 / 04:59 — m:ss style for the pomodoro / sit countdown. + static func mmss(_ seconds: Int) -> String { + let s = max(0, seconds) + return String(format: "%02d:%02d", s / 60, s % 60) + } + + /// 1时23分 — for clockout / weekend. + static func chineseShort(hours: Int, minutes: Int) -> String { + if hours <= 0 { return "\(minutes)分" } + return "\(hours)时\(minutes)分" + } + + /// 2天3时5分 — for weekend countdown. + static func dhm(days: Int, hours: Int, minutes: Int) -> String { + if days > 0 { return "\(days)天\(hours)时\(minutes)分" } + if hours > 0 { return "\(hours)时\(minutes)分" } + return "\(minutes)分" + } +} diff --git a/Sources/ui/WaterView.swift b/Sources/ui/WaterView.swift new file mode 100644 index 0000000..2c28417 --- /dev/null +++ b/Sources/ui/WaterView.swift @@ -0,0 +1,256 @@ +// +// WaterView.swift +// 摸鱼侠 plugin v0.1 +// +// 喝水 — 8 cup target with progress dots, tap big cup to log a sip. +// + +import SwiftUI + +struct WaterView: View { + @ObservedObject var store: WorkerStore + + var body: some View { + VStack(spacing: 18) { + statusBadge + + cupHero + + controls + + Divider() + .background(WorkerTheme.overlay08) + .padding(.horizontal, 24) + + goalRow + + tipText + + Spacer(minLength: 0) + } + .padding(.top, 8) + } + + // MARK: - Status + + private var statusBadge: some View { + HStack(spacing: 8) { + Circle() + .fill(WorkerTheme.water) + .frame(width: 8, height: 8) + .shadow(color: WorkerTheme.water.opacity(0.7), radius: 4) + Text(badgeText) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundColor(WorkerTheme.fg85) + } + .padding(.horizontal, 12) + .frame(height: 26) + .background(Capsule().fill(WorkerTheme.overlay06)) + } + + private var badgeText: String { + if store.waterCupsToday >= store.waterGoal { + return "今日目标已达成 ✨" + } + return "今日 \(store.waterCupsToday) / \(store.waterGoal) 杯" + } + + // MARK: - Cup hero (tappable) + + private var cupHero: some View { + VStack(spacing: 12) { + Button(action: { store.waterAddCup() }) { + ZStack { + // Water-fill cup illustration. + cupShape + .frame(width: 130, height: 160) + } + } + .buttonStyle(.plain) + .help("点击杯子记录一杯水") + + // Progress dots + HStack(spacing: 6) { + ForEach(0..= store.waterGoal + ? "今天的水喝够了,给自己鼓个掌 👏" + : "保持每小时一杯,工作更高效。") + .font(.system(size: 11)) + .foregroundColor(WorkerTheme.fg45) + .padding(.horizontal, 20) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } +} + +// MARK: - Cup outline shape + +private struct CupOutline: Shape { + func path(in rect: CGRect) -> Path { + // Slight trapezoid: narrower at the bottom. Straight rim with + // a 4pt rounded base. + var p = Path() + let topInset: CGFloat = 0 + let bottomInset: CGFloat = rect.width * 0.08 + let radius: CGFloat = 8 + + // Start top-left. + p.move(to: CGPoint(x: rect.minX + topInset, y: rect.minY)) + // Top edge. + p.addLine(to: CGPoint(x: rect.maxX - topInset, y: rect.minY)) + // Right side (sloping inward). + p.addLine(to: CGPoint(x: rect.maxX - bottomInset, y: rect.maxY - radius)) + // Bottom-right curve. + p.addQuadCurve( + to: CGPoint(x: rect.maxX - bottomInset - radius, y: rect.maxY), + control: CGPoint(x: rect.maxX - bottomInset, y: rect.maxY) + ) + // Bottom edge. + p.addLine(to: CGPoint(x: rect.minX + bottomInset + radius, y: rect.maxY)) + // Bottom-left curve. + p.addQuadCurve( + to: CGPoint(x: rect.minX + bottomInset, y: rect.maxY - radius), + control: CGPoint(x: rect.minX + bottomInset, y: rect.maxY) + ) + // Left side back to top. + p.addLine(to: CGPoint(x: rect.minX + topInset, y: rect.minY)) + p.closeSubpath() + return p + } +} diff --git a/Sources/ui/WeekendView.swift b/Sources/ui/WeekendView.swift new file mode 100644 index 0000000..983760a --- /dev/null +++ b/Sources/ui/WeekendView.swift @@ -0,0 +1,175 @@ +// +// WeekendView.swift +// 摸鱼侠 plugin v0.1 +// +// 周末 — countdown to next Saturday 00:00. +// + +import SwiftUI + +struct WeekendView: View { + @ObservedObject var store: WorkerStore + + var body: some View { + VStack(spacing: 18) { + statusBadge + + heroCountdown + + partsRow + + Divider() + .background(WorkerTheme.overlay08) + .padding(.horizontal, 24) + + quoteCard + + Spacer(minLength: 0) + } + .padding(.top, 8) + } + + // MARK: - Status + + private var statusBadge: some View { + HStack(spacing: 8) { + Circle() + .fill(WorkerTheme.weekendPurple) + .frame(width: 8, height: 8) + .shadow(color: WorkerTheme.weekendPurple.opacity(0.7), radius: 4) + Text(badgeText) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundColor(WorkerTheme.fg85) + } + .padding(.horizontal, 12) + .frame(height: 26) + .background(Capsule().fill(WorkerTheme.overlay06)) + } + + private var badgeText: String { + if store.weekendDays == 0 && store.weekendHours < 12 { + return "周末就在眼前 ✨" + } + return "距离周末" + } + + // MARK: - Hero countdown + + private var heroCountdown: some View { + VStack(spacing: 4) { + Text("还有") + .font(.system(size: 12)) + .foregroundColor(WorkerTheme.fg55) + Text(WorkerFormat.dhm( + days: store.weekendDays, + hours: store.weekendHours, + minutes: store.weekendMinutes + )) + .font(.system(size: 36, weight: .semibold, design: .rounded)) + .foregroundColor(WorkerTheme.weekendPurple) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.7) + .padding(.horizontal, 16) + Text("下个周六 00:00") + .font(.system(size: 11)) + .foregroundColor(WorkerTheme.fg45) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 22) + .background( + RoundedRectangle(cornerRadius: 14) + .fill( + LinearGradient( + colors: [ + WorkerTheme.weekendPurple.opacity(0.18), + WorkerTheme.weekendPurple.opacity(0.04) + ], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(WorkerTheme.weekendPurple.opacity(0.35), lineWidth: 0.5) + ) + ) + .padding(.horizontal, 16) + } + + // MARK: - Parts row + + private var partsRow: some View { + HStack(spacing: 10) { + partCard(label: "天", value: store.weekendDays) + partCard(label: "时", value: store.weekendHours) + partCard(label: "分", value: store.weekendMinutes) + } + .padding(.horizontal, 16) + } + + private func partCard(label: String, value: Int) -> some View { + VStack(spacing: 4) { + Text("\(value)") + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .foregroundColor(WorkerTheme.fgPrimary) + .monospacedDigit() + Text(label) + .font(.system(size: 10.5)) + .foregroundColor(WorkerTheme.fg55) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(WorkerTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(WorkerTheme.overlay08, lineWidth: 0.5) + ) + ) + } + + // MARK: - Quote card + + private var quoteCard: some View { + let q = quoteForToday() + return VStack(alignment: .leading, spacing: 6) { + Text("今日打工语录") + .font(.system(size: 10.5)) + .foregroundColor(WorkerTheme.fg55) + Text(q) + .font(.system(size: 12.5)) + .foregroundColor(WorkerTheme.fg85) + .lineSpacing(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(WorkerTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(WorkerTheme.overlay08, lineWidth: 0.5) + ) + ) + .padding(.horizontal, 16) + } + + private func quoteForToday() -> String { + // Stable per-day rotation. Day-of-year as the index keeps the + // quote consistent for the whole day. + let quotes = [ + "周末是充电的理由,工作日是放电的代价。", + "上班是为了更好地下班。", + "再坚持一下,咖啡就在转角等你。", + "生活不止眼前的KPI,还有诗和周末的咖啡馆。", + "每一秒倒计时都是给未来周末的铺垫。", + "工作再忙,水也要喝完八杯。", + "下班的钟声永远比开会的提示音动听。" + ] + let cal = Calendar(identifier: .gregorian) + let day = cal.ordinality(of: .day, in: .year, for: Date()) ?? 0 + return quotes[day % quotes.count] + } +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ef7f322 --- /dev/null +++ b/build.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Build the 摸鱼侠 plugin as a .bundle for Mio Island. +# +# Usage: +# ./build.sh # produce build/worker.bundle + build/worker.zip +# ./build.sh install # also copy bundle to ~/.config/codeisland/plugins/ + +set -e +set -o pipefail + +PLUGIN_NAME="worker" +MODULE_NAME="WorkerPlugin" +BUNDLE_NAME="${PLUGIN_NAME}.bundle" +BUILD_DIR="build" + +SOURCES=$(find Sources -name "*.swift" -type f) +SOURCE_COUNT=$(echo "$SOURCES" | wc -l | tr -d ' ') + +echo "Building ${PLUGIN_NAME} plugin (${SOURCE_COUNT} swift files)..." + +rm -rf "${BUILD_DIR}" +mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS" + +# arm64-only is fine for v0.1 — Mio Island host requires macOS 15+ +# which means Apple Silicon dominant. +swiftc \ + -emit-library \ + -module-name "${MODULE_NAME}" \ + -target arm64-apple-macos15.0 \ + -sdk "$(xcrun --show-sdk-path)" \ + -O \ + -o "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS/${MODULE_NAME}" \ + ${SOURCES} + +cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/" + +if [ -d "Resources" ] && [ "$(ls -A Resources 2>/dev/null)" ]; then + mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources" + cp -R Resources/* "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/" +fi + +# Ad-hoc sign the whole bundle. +codesign --force --deep --sign - "${BUILD_DIR}/${BUNDLE_NAME}" + +echo "Built ${BUILD_DIR}/${BUNDLE_NAME}" + +# zip for marketplace upload. +cd "${BUILD_DIR}" +rm -f "${PLUGIN_NAME}.zip" +zip -rq "${PLUGIN_NAME}.zip" "${BUNDLE_NAME}" +cd .. +echo "Created ${BUILD_DIR}/${PLUGIN_NAME}.zip" + +if [ "${1:-}" = "install" ]; then + PLUGIN_DIR="${HOME}/.config/codeisland/plugins" + mkdir -p "${PLUGIN_DIR}" + rm -rf "${PLUGIN_DIR}/${BUNDLE_NAME}" + cp -R "${BUILD_DIR}/${BUNDLE_NAME}" "${PLUGIN_DIR}/" + echo "Installed to ${PLUGIN_DIR}/${BUNDLE_NAME}" + echo " Restart Mio Island (Cmd+Q + reopen) to load the new build." +else + echo "" + echo "Install locally:" + echo " ./build.sh install" +fi