68fed087-9049-446f-bfc5-3f8.../Sources/ui/WaterView.swift
徐翔宇 4b2ac2fc59 feat(water): pomodoro auto-+1 + hourly workday reminder
Two cross-tab integrations from the 2026-05-20 product superpower
review:

1. waterAutoFromPomodoro (default true) — each pomodoro focus phase
   end auto-logs 1 cup. Lazy users get streak data without taps,
   pomodoro break is the natural "go drink water" cue.

2. waterHourlyReminder (default true) — Mon–Fri 9..18 hourly nag if
   cups < goal. Deduped by last-fired-hour so plugin restart in the
   same hour won't double-fire.

UI: WaterView now wraps in ScrollView (toggles push it past 580pt),
adds two togglePill rows below the goalRow. Custom slim-track
indicator since SwiftUI's Toggle styling clashes with the plugin's
dark capsule aesthetic.

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

319 lines
11 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.

//
// WaterView.swift
// plugin v0.1
//
// 8 cup target with progress dots, tap big cup to log a sip.
//
import SwiftUI
struct WaterView: View {
@ObservedObject var store: WorkerStore
var body: some View {
ScrollView {
VStack(spacing: 14) {
statusBadge
cupHero
controls
Divider()
.background(WorkerTheme.overlay08)
.padding(.horizontal, 24)
goalRow
toggleRows
tipText
Spacer(minLength: 0)
}
.padding(.top, 8)
.padding(.bottom, 8)
}
}
// MARK: - Toggle rows (auto-from-pomodoro + hourly reminder)
private var toggleRows: some View {
VStack(spacing: 8) {
togglePill(
icon: "timer",
label: "番茄完成 +1 杯",
isOn: store.waterAutoFromPomodoro,
tint: WorkerTheme.tomato,
action: { store.waterSetAutoFromPomodoro(!store.waterAutoFromPomodoro) }
)
togglePill(
icon: "bell.fill",
label: "整点提醒918 点)",
isOn: store.waterHourlyReminder,
tint: WorkerTheme.water,
action: { store.waterSetHourlyReminder(!store.waterHourlyReminder) }
)
}
.padding(.horizontal, 16)
}
private func togglePill(icon: String, label: String, isOn: Bool, tint: Color, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(isOn ? tint : WorkerTheme.fg55)
.frame(width: 18)
Text(label)
.font(.system(size: 11.5, weight: .medium))
.foregroundColor(isOn ? WorkerTheme.fgPrimary : WorkerTheme.fg70)
Spacer()
// Slim track-style toggle indicator.
ZStack(alignment: isOn ? .trailing : .leading) {
Capsule()
.fill(isOn ? tint.opacity(0.35) : WorkerTheme.overlay08)
.frame(width: 28, height: 16)
Circle()
.fill(isOn ? tint : WorkerTheme.fg55)
.frame(width: 12, height: 12)
.padding(2)
}
}
.padding(.horizontal, 12)
.frame(height: 32)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(WorkerTheme.overlay04)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isOn ? tint.opacity(0.35) : WorkerTheme.overlay08, lineWidth: 0.5)
)
)
}
.buttonStyle(.plain)
}
// MARK: - Status
private var statusBadge: some View {
HStack(spacing: 8) {
Circle()
.fill(WorkerTheme.water)
.frame(width: 8, height: 8)
.shadow(color: WorkerTheme.water.opacity(0.7), radius: 4)
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.waterCupsToday >= store.waterGoal {
return "今日目标已达成 ✨"
}
return "今日 \(store.waterCupsToday) / \(store.waterGoal)"
}
// MARK: - Cup hero (tappable)
private var cupHero: some View {
VStack(spacing: 12) {
Button(action: { store.waterAddCup() }) {
ZStack {
// Water-fill cup illustration.
cupShape
.frame(width: 130, height: 160)
}
}
.buttonStyle(.plain)
.help("点击杯子记录一杯水")
// Progress dots
HStack(spacing: 6) {
ForEach(0..<store.waterGoal, id: \.self) { i in
Circle()
.fill(i < store.waterCupsToday ? WorkerTheme.water : WorkerTheme.overlay08)
.frame(width: 9, height: 9)
.overlay(
Circle()
.stroke(WorkerTheme.overlay12, lineWidth: 0.5)
)
}
}
}
}
private var cupShape: some View {
ZStack(alignment: .bottom) {
// Outline (cup body slight trapezoid)
CupOutline()
.stroke(WorkerTheme.fg55, lineWidth: 2.5)
// Water fill (clipped to the cup)
CupOutline()
.fill(LinearGradient(
colors: [WorkerTheme.water.opacity(0.85), WorkerTheme.water.opacity(0.4)],
startPoint: .top, endPoint: .bottom
))
.mask(
GeometryReader { geo in
let frac = min(1.0, Double(store.waterCupsToday) / Double(max(1, store.waterGoal)))
Rectangle()
.frame(width: geo.size.width, height: geo.size.height * CGFloat(frac))
.frame(maxHeight: .infinity, alignment: .bottom)
}
)
.animation(.easeInOut(duration: 0.4), value: store.waterCupsToday)
// Big number on top of the cup
VStack {
Spacer()
Text("\(store.waterCupsToday)")
.font(.system(size: 40, weight: .semibold, design: .rounded))
.foregroundColor(WorkerTheme.fgPrimary)
.monospacedDigit()
Text("")
.font(.system(size: 11))
.foregroundColor(WorkerTheme.fg55)
.padding(.bottom, 18)
}
}
}
// MARK: - Controls
private var controls: some View {
HStack(spacing: 10) {
Button(action: { store.waterAddCup() }) {
HStack(spacing: 6) {
Image(systemName: "plus")
.font(.system(size: 11, weight: .semibold))
Text("加一杯")
.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: { store.waterRemoveCup() }) {
HStack(spacing: 6) {
Image(systemName: "minus")
.font(.system(size: 11, weight: .semibold))
Text("撤销")
.font(.system(size: 12.5, weight: .semibold))
}
.foregroundColor(WorkerTheme.fg85)
.padding(.horizontal, 16)
.frame(height: 32)
.background(
Capsule()
.fill(WorkerTheme.overlay06)
.overlay(Capsule().stroke(WorkerTheme.overlay12, lineWidth: 0.5))
)
}
.buttonStyle(.plain)
}
}
// MARK: - Goal row
private var goalRow: some View {
HStack(spacing: 0) {
Text("每日目标")
.font(.system(size: 11))
.foregroundColor(WorkerTheme.fg55)
.padding(.leading, 12)
Spacer()
Button(action: { store.waterSetGoal(store.waterGoal - 1) }) {
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.waterGoal)")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(WorkerTheme.fgPrimary)
.monospacedDigit()
.frame(minWidth: 50)
Button(action: { store.waterSetGoal(store.waterGoal + 1) }) {
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.waterCupsToday >= store.waterGoal
? "今天的水喝够了,给自己鼓个掌 👏"
: "保持每小时一杯,工作更高效。")
.font(.system(size: 11))
.foregroundColor(WorkerTheme.fg45)
.padding(.horizontal, 20)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
// MARK: - Cup outline shape
private struct CupOutline: Shape {
func path(in rect: CGRect) -> Path {
// Slight trapezoid: narrower at the bottom. Straight rim with
// a 4pt rounded base.
var p = Path()
let topInset: CGFloat = 0
let bottomInset: CGFloat = rect.width * 0.08
let radius: CGFloat = 8
// Start top-left.
p.move(to: CGPoint(x: rect.minX + topInset, y: rect.minY))
// Top edge.
p.addLine(to: CGPoint(x: rect.maxX - topInset, y: rect.minY))
// Right side (sloping inward).
p.addLine(to: CGPoint(x: rect.maxX - bottomInset, y: rect.maxY - radius))
// Bottom-right curve.
p.addQuadCurve(
to: CGPoint(x: rect.maxX - bottomInset - radius, y: rect.maxY),
control: CGPoint(x: rect.maxX - bottomInset, y: rect.maxY)
)
// Bottom edge.
p.addLine(to: CGPoint(x: rect.minX + bottomInset + radius, y: rect.maxY))
// Bottom-left curve.
p.addQuadCurve(
to: CGPoint(x: rect.minX + bottomInset, y: rect.maxY - radius),
control: CGPoint(x: rect.minX + bottomInset, y: rect.maxY)
)
// Left side back to top.
p.addLine(to: CGPoint(x: rect.minX + topInset, y: rect.minY))
p.closeSubpath()
return p
}
}