125 lines
5.5 KiB
Swift
125 lines
5.5 KiB
Swift
// Copyright (C) 2026 ysicing
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Localization
|
|
import SwiftUI
|
|
import WidgetKit
|
|
|
|
private struct RestEntry: TimelineEntry {
|
|
let date: Date
|
|
let enabled: Bool
|
|
let isResting: Bool
|
|
let running: Bool
|
|
let remaining: TimeInterval
|
|
let deadline: Date
|
|
let duration: TimeInterval
|
|
}
|
|
|
|
private struct RestProvider: TimelineProvider {
|
|
func placeholder(in context: Context) -> RestEntry {
|
|
RestEntry(date: .now, enabled: true, isResting: false, running: true, remaining: 20 * 60,
|
|
deadline: .now.addingTimeInterval(20 * 60), duration: 20 * 60)
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (RestEntry) -> Void) {
|
|
completion(context.isPreview ? placeholder(in: context) : read())
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<RestEntry>) -> Void) {
|
|
let entry = read()
|
|
guard entry.enabled, entry.running, entry.deadline > entry.date else {
|
|
completion(Timeline(entries: [entry], policy: .after(entry.date.addingTimeInterval(3600))))
|
|
return
|
|
}
|
|
|
|
// 倒计时文字可由系统更新;圆环需要在阶段内提供新的 TimelineEntry。
|
|
var entries = [entry]
|
|
var date = entry.date
|
|
while date < entry.deadline {
|
|
date = min(date.addingTimeInterval(60), entry.deadline)
|
|
entries.append(RestEntry(date: date, enabled: entry.enabled, isResting: entry.isResting,
|
|
running: entry.running, remaining: entry.remaining,
|
|
deadline: entry.deadline, duration: entry.duration))
|
|
}
|
|
completion(Timeline(entries: entries, policy: .after(entry.deadline)))
|
|
}
|
|
|
|
private func read() -> RestEntry {
|
|
let defaults = UserDefaults(suiteName: "group.work.12306.xstats")
|
|
if let language = defaults?.string(forKey: "rest.language").flatMap(AppLanguage.init(rawValue:)) {
|
|
L10n.configure(language)
|
|
}
|
|
let now = Date()
|
|
return RestEntry(date: now, enabled: defaults?.bool(forKey: "rest.enabled") ?? false,
|
|
isResting: ["rest", "longRest"].contains(defaults?.string(forKey: "rest.phase") ?? ""),
|
|
running: defaults?.bool(forKey: "rest.running") ?? false,
|
|
remaining: defaults?.double(forKey: "rest.remaining") ?? 0,
|
|
deadline: Date(timeIntervalSince1970: defaults?.double(forKey: "rest.deadline") ?? 0),
|
|
duration: TimeInterval(defaults?.double(forKey: "rest.duration") ?? 0))
|
|
}
|
|
}
|
|
|
|
private struct RestWidgetView: View {
|
|
@Environment(\.widgetFamily) private var family
|
|
let entry: RestEntry
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
Circle().stroke(Color.cyan.opacity(0.17), lineWidth: 8)
|
|
Circle().trim(from: 0, to: progress)
|
|
.stroke(.cyan, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
Image(systemName: entry.isResting ? "eye" : "timer")
|
|
.font(.system(size: family == .systemSmall ? 28 : 36, weight: .ultraLight))
|
|
}
|
|
.frame(width: family == .systemSmall ? 76 : 96, height: family == .systemSmall ? 76 : 96)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(tr(entry.enabled ? (entry.running ? (entry.isResting ? "休息中" : "专注中") : "已暂停")
|
|
: "护眼休息未开启"))
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
if entry.enabled {
|
|
if entry.running {
|
|
Text(entry.deadline, style: .timer)
|
|
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
|
.monospacedDigit()
|
|
} else {
|
|
Text(String(format: "%02d:%02d", max(0, Int(entry.remaining)) / 60,
|
|
max(0, Int(entry.remaining)) % 60))
|
|
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.containerBackground(.background, for: .widget)
|
|
.environment(\.locale, L10n.locale(for: L10n.language))
|
|
.environment(\.layoutDirection, L10n.language.isRightToLeft ? .rightToLeft : .leftToRight)
|
|
}
|
|
|
|
private var progress: Double {
|
|
guard entry.enabled, entry.duration > 0 else { return 0 }
|
|
let remaining = entry.running ? max(0, entry.deadline.timeIntervalSince(entry.date)) : entry.remaining
|
|
return min(1, max(0, 1 - remaining / entry.duration))
|
|
}
|
|
}
|
|
|
|
struct RestWidget: Widget {
|
|
init() {
|
|
let defaults = UserDefaults(suiteName: "group.work.12306.xstats")
|
|
let language = defaults?.string(forKey: "rest.language").flatMap(AppLanguage.init(rawValue:)) ?? .system
|
|
L10n.configure(language)
|
|
}
|
|
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: "rest", provider: RestProvider()) { entry in
|
|
RestWidgetView(entry: entry)
|
|
}
|
|
.configurationDisplayName(tr("番茄钟"))
|
|
.description(tr("查看下一次休息或当前休息倒计时。"))
|
|
.supportedFamilies([.systemSmall, .systemMedium])
|
|
}
|
|
}
|