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) <noreply@anthropic.com>
This commit is contained in:
徐翔宇 2026-04-27 20:21:12 +08:00
commit 959580c55e
17 changed files with 2295 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
build/
.DS_Store
*.swiftmodule
*.dSYM
.build/

34
Info.plist Normal file
View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<key>CFBundleExecutable</key>
<string>WorkerPlugin</string>
<key>CFBundleIdentifier</key>
<string>com.mioisland.plugin.worker</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>摸鱼侠</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSPrincipalClass</key>
<string>WorkerPlugin.WorkerPlugin</string>
<!--
Panel size: 380×620.
- 380 wide matches 看盘侠 — consistent feel across plugins.
- 620 tall = 40pt notch reservation + 580pt of card content
(top bar + 5-tab pill strip + active tab body + footer).
-->
<key>MioPluginPreferredWidth</key>
<integer>380</integer>
<key>MioPluginPreferredHeight</key>
<integer>620</integer>
</dict>
</plist>

21
LICENSE Normal file
View File

@ -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.

79
README.md Normal file
View File

@ -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).

18
Sources/MioPlugin.swift Normal file
View File

@ -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?
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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))
}
}
}
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,246 @@
//
// ClockoutView.swift
// plugin v0.1
//
// XY. 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)
}
}

View File

@ -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
}
}
}
}

View File

@ -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)
)
)
}
}

247
Sources/ui/SitView.swift Normal file
View File

@ -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)
}
}

71
Sources/ui/Theme.swift Normal file
View File

@ -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)
}
/// 123 for clockout / weekend.
static func chineseShort(hours: Int, minutes: Int) -> String {
if hours <= 0 { return "\(minutes)" }
return "\(hours)\(minutes)"
}
/// 235 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)"
}
}

256
Sources/ui/WaterView.swift Normal file
View File

@ -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, id: \.self) { i in
Circle()
.fill(i < store.waterCupsToday ? WorkerTheme.water : WorkerTheme.overlay08)
.frame(width: 9, height: 9)
.overlay(
Circle()
.stroke(WorkerTheme.overlay12, lineWidth: 0.5)
)
}
}
}
}
private var cupShape: some View {
ZStack(alignment: .bottom) {
// Outline (cup body slight trapezoid)
CupOutline()
.stroke(WorkerTheme.fg55, lineWidth: 2.5)
// Water fill (clipped to the cup)
CupOutline()
.fill(LinearGradient(
colors: [WorkerTheme.water.opacity(0.85), WorkerTheme.water.opacity(0.4)],
startPoint: .top, endPoint: .bottom
))
.mask(
GeometryReader { geo in
let frac = min(1.0, Double(store.waterCupsToday) / Double(max(1, store.waterGoal)))
Rectangle()
.frame(width: geo.size.width, height: geo.size.height * CGFloat(frac))
.frame(maxHeight: .infinity, alignment: .bottom)
}
)
.animation(.easeInOut(duration: 0.4), value: store.waterCupsToday)
// Big number on top of the cup
VStack {
Spacer()
Text("\(store.waterCupsToday)")
.font(.system(size: 40, weight: .semibold, design: .rounded))
.foregroundColor(WorkerTheme.fgPrimary)
.monospacedDigit()
Text("")
.font(.system(size: 11))
.foregroundColor(WorkerTheme.fg55)
.padding(.bottom, 18)
}
}
}
// MARK: - Controls
private var controls: some View {
HStack(spacing: 10) {
Button(action: { store.waterAddCup() }) {
HStack(spacing: 6) {
Image(systemName: "plus")
.font(.system(size: 11, weight: .semibold))
Text("加一杯")
.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: { store.waterRemoveCup() }) {
HStack(spacing: 6) {
Image(systemName: "minus")
.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)
}
}
// MARK: - Goal row
private var goalRow: some View {
HStack(spacing: 0) {
Text("每日目标")
.font(.system(size: 11))
.foregroundColor(WorkerTheme.fg55)
.padding(.leading, 12)
Spacer()
Button(action: { store.waterSetGoal(store.waterGoal - 1) }) {
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.waterGoal)")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(WorkerTheme.fgPrimary)
.monospacedDigit()
.frame(minWidth: 50)
Button(action: { store.waterSetGoal(store.waterGoal + 1) }) {
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.waterCupsToday >= 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
}
}

View File

@ -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]
}
}

65
build.sh Executable file
View File

@ -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