68fed087-9049-446f-bfc5-3f8.../Sources/ui/ExpandedView.swift
徐翔宇 dc0d22700c chore: bump v0.3.0 build 3 + holiday data + cross-day water dedupe fix
Three things squashed into one commit since they're all release-prep:

1. Bump CFBundleVersion 2 → 3, CFBundleShortVersionString 0.2.0 →
   0.3.0, WorkerPlugin.version & ExpandedView footer string to match.
   Aligns with the 5-feature v0.3.0 ship.

2. fix(water): lastWaterReminderHr was an Int hour (0-23) so a
   yesterday-fired 17:00 blocked today's 17:00 reminder. Now stores
   "yyyy-MM-dd HH" composite key so dedupe is scoped per-day.

3. Holidays.swift: 2026 entry table re-calibrated per typical
   State Council 调休 pattern. Notable changes:
   - 春节 starts on 除夕 02-16 (not 初一 02-17) per 2024 policy
   - 中秋 + 国庆 merged into 10-day 09-25 ~ 10-04 window (since
     2026 中秋 09-25 is 6 days before 国庆 10-01)
   - 劳动节 renamed from "五一" for clarity
   Disclaimer still in header: verify against 国务院 announcement
   when published, edit + re-release if 调休 differs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:12:40 +08:00

198 lines
6.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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.3.0 · 本地运行")
}
.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
}
}
}
}