68fed087-9049-446f-bfc5-3f8.../Sources/ui/SitView.swift
徐翔宇 959580c55e 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>
2026-04-27 20:21:12 +08:00

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