mirror of
https://github.com/carey314/mio-plugin-worker.git
synced 2026-08-10 07:04:32 +00:00
Saturday/Sunday previously kept showing the workday countdown even
though no one's working — UX surfaced friction where there was
nothing to count. Now ClockoutView switches to a purple
weekend-hero card with "🌴 今天是周末 · 去做你想做的事" copy.
Implementation:
- WorkerStore.isWeekend computed (Sun=1, Sat=7)
- ClockoutView.body branches: weekendHero + weekendTipText for
weekend, original countdown+progress+controls for weekday
- Clockout celebration logic still runs in the background — if
someone does work Saturday and crosses their configured clockout
hour, the next-weekday view will see the dedupe key updated;
no spurious extra fire
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
352 lines
13 KiB
Swift
352 lines
13 KiB
Swift
//
|
|
// 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 {
|
|
ZStack(alignment: .top) {
|
|
VStack(spacing: 18) {
|
|
if store.isWeekend {
|
|
weekendHero
|
|
weekendTipText
|
|
Spacer(minLength: 0)
|
|
} else {
|
|
statusBadge
|
|
countdownDisplay
|
|
progressBar
|
|
controls
|
|
Divider()
|
|
.background(WorkerTheme.overlay08)
|
|
.padding(.horizontal, 24)
|
|
timeRow
|
|
tipText
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
|
|
// Celebration banner — overlays the panel for ~5s after the
|
|
// user crosses today's clockout boundary. Subtle gold→lime
|
|
// shimmer so it pops without being kindergarten-style confetti.
|
|
if store.clockoutCelebrationActive {
|
|
celebrationBanner
|
|
.transition(
|
|
.asymmetric(
|
|
insertion: .move(edge: .top).combined(with: .opacity),
|
|
removal: .opacity
|
|
)
|
|
)
|
|
}
|
|
}
|
|
.animation(.spring(response: 0.35, dampingFraction: 0.78),
|
|
value: store.clockoutCelebrationActive)
|
|
}
|
|
|
|
private var celebrationBanner: some View {
|
|
VStack(spacing: 4) {
|
|
Text("🎉 今日打卡下班 🎉")
|
|
.font(.system(size: 16, weight: .bold))
|
|
.foregroundColor(.black)
|
|
Text("辛苦了,到点了,关 IDE 下班!")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(.black.opacity(0.7))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [WorkerTheme.lime, WorkerTheme.tomato.opacity(0.95)],
|
|
startPoint: .leading, endPoint: .trailing
|
|
)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
.shadow(color: WorkerTheme.lime.opacity(0.45), radius: 16, x: 0, y: 4)
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 4)
|
|
}
|
|
|
|
// 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)
|
|
)
|
|
)
|
|
}
|
|
|
|
// MARK: - Weekend mode
|
|
|
|
/// Weekend-mode hero — replaces the countdown card with a purple
|
|
/// "today's a weekend" panel. The clockout countdown still works
|
|
/// in the background (anyone working Saturday's edge case), but
|
|
/// the default UI no longer rubs in the fact that there's
|
|
/// theoretically a clockout time on a day no one's working.
|
|
private var weekendHero: some View {
|
|
VStack(spacing: 8) {
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(WorkerTheme.weekendPurple)
|
|
.frame(width: 8, height: 8)
|
|
.shadow(color: WorkerTheme.weekendPurple.opacity(0.7), radius: 4)
|
|
Text("今天不上班")
|
|
.font(.system(size: 12.5, weight: .semibold))
|
|
.foregroundColor(WorkerTheme.fg85)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.frame(height: 26)
|
|
.background(Capsule().fill(WorkerTheme.overlay06))
|
|
|
|
Text("🌴")
|
|
.font(.system(size: 64))
|
|
.padding(.top, 8)
|
|
|
|
Text("今天是周末")
|
|
.font(.system(size: 22, weight: .bold))
|
|
.foregroundColor(WorkerTheme.fgPrimary)
|
|
|
|
Text("去做你想做的事")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(WorkerTheme.fg55)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 28)
|
|
.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)
|
|
.padding(.top, 12)
|
|
}
|
|
|
|
private var weekendTipText: some View {
|
|
Text("如果你周末也上班,记得在 周末 tab 取消休息时调整设置。\n(暂时还没做这个 toggle — 反正下周一会自动恢复倒计时。)")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(WorkerTheme.fg45)
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 8)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private var tipText: some View {
|
|
Text("到点会有通知 + 一段 Glass 音效庆祝下班\n每日只触发一次,重启插件不会重复响铃。")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(WorkerTheme.fg45)
|
|
.padding(.horizontal, 20)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|