mirror of
https://github.com/carey314/mio-plugin-worker.git
synced 2026-08-09 23:04:31 +00:00
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:
commit
959580c55e
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
build/
|
||||
.DS_Store
|
||||
*.swiftmodule
|
||||
*.dSYM
|
||||
.build/
|
||||
34
Info.plist
Normal file
34
Info.plist
Normal 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
21
LICENSE
Normal 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
79
README.md
Normal 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
18
Sources/MioPlugin.swift
Normal 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?
|
||||
}
|
||||
38
Sources/WorkerPlugin.swift
Normal file
38
Sources/WorkerPlugin.swift
Normal 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
|
||||
}
|
||||
}
|
||||
77
Sources/engine/NotificationCenter.swift
Normal file
77
Sources/engine/NotificationCenter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
44
Sources/engine/WorkerDebugLog.swift
Normal file
44
Sources/engine/WorkerDebugLog.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
440
Sources/engine/WorkerStore.swift
Normal file
440
Sources/engine/WorkerStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
246
Sources/ui/ClockoutView.swift
Normal file
246
Sources/ui/ClockoutView.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
197
Sources/ui/ExpandedView.swift
Normal file
197
Sources/ui/ExpandedView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
282
Sources/ui/PomodoroView.swift
Normal file
282
Sources/ui/PomodoroView.swift
Normal 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
247
Sources/ui/SitView.swift
Normal 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
71
Sources/ui/Theme.swift
Normal 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)
|
||||
}
|
||||
|
||||
/// 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)分"
|
||||
}
|
||||
}
|
||||
256
Sources/ui/WaterView.swift
Normal file
256
Sources/ui/WaterView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
175
Sources/ui/WeekendView.swift
Normal file
175
Sources/ui/WeekendView.swift
Normal 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
65
build.sh
Executable 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
|
||||
Loading…
Reference in New Issue
Block a user