68fed087-9049-446f-bfc5-3f8.../Sources/ui/ExpandedView.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

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