mirror of
https://github.com/carey314/mio-plugin-worker.git
synced 2026-08-10 07:04:32 +00:00
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>
248 lines
8.2 KiB
Swift
248 lines
8.2 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|