feat(rest): 增加工作番茄模式并保留工作时段

This commit is contained in:
ysicing committed 2026-09-26 15:32:29 +08:00
1 parent 9d650f93c7
commit 9df9d439af
17 files changed
+312 -15

No files matched your search

+1 -1
View File
@@ -57,7 +57,7 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布
用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group;
小组件不读取 AI 登录凭据,也不自行请求额度或 IP 服务。日历节假日与黄历由主应用预计算,Widget 只读共享摘要;主应用未生成数据时,小组件显示空状态。
网络测速在检测到 VPN 或代理时提供“直连 / 经代理”选择,默认直连并绑定物理网卡;经代理的延迟使用目标 HTTP 往返而非本机代理提前应答的 TCP 握手。国内三网节点只支持 TCP 建连计时,因此始终尝试直连;无法取得直连路径时显示原因而不提供虚假的延迟。测速线路随设置备份保存,切换线路会清除上一路线的结果。
番茄钟与护眼休息为默认关闭的本机功能:通用设置只显示模块开关,时长、目标与声音由独立设置弹层中的系统下拉菜单调整;启用后手动开始,可暂停、重置当前阶段或跳过;切换阶段时保留各自剩余时间;有未完成进度时用“继续”恢复,点“开始”从完整时长启动,点“重置”恢复完整时长但保持暂停;跳过或自动进入下一阶段也从完整时长开始;单次模式在一轮专注与休息后暂停,循环模式连续运行,每完成四轮专注进入长休。每日完成数只保存在本机偏好中,不加入设置备份。`RestSession` 用 `mach_continuous_time` 的绝对截止点计算阶段,唤醒后重算而非累加定时器 tick;多屏幕布、独立菜单栏计时入口与 Mini HUD 由主应用创建;HUD 可拖动并保存位置,支持数字、圆环和沙漏样式,Esc 或长按可以跳过,休息时可再专注五分钟。白噪音、粉红噪音和雨声由 `AVAudioSourceNode` 实时合成,仅在休息阶段播放。休息 Widget 从 App Group 读取阶段和预计截止时间,实际刷新受 WidgetKit 管理,不能作为秒级提醒来源。
番茄钟与护眼休息为默认关闭的本机功能:通用设置只显示模块开关,时长、目标与声音由独立设置弹层中的系统下拉菜单调整;启用后手动开始,可暂停、重置当前阶段或跳过;切换阶段时保留各自剩余时间;有未完成进度时用“继续”恢复,点“开始”从完整时长启动,点“重置”恢复完整时长但保持暂停;跳过或自动进入下一阶段也从完整时长开始;单次模式在一轮专注与休息后暂停,循环模式连续运行,每完成四轮专注进入长休。“工作番茄”需手动点“开始工作”,随后自动交替专注与休息,每四轮进入长休;暂停或切换运行方式不结束工作时段;切换运行方式会暂停当前阶段并保留剩余时间,修改时长会暂停并回到完整专注阶段,但保留已完成轮数;点“结束工作”才结束并回到待开始状态。每日完成数只保存在本机偏好中,不加入设置备份。`RestSession` 用 `mach_continuous_time` 的绝对截止点计算阶段,唤醒后重算而非累加定时器 tick;多屏幕布、独立菜单栏计时入口与 Mini HUD 由主应用创建;HUD 可拖动并保存位置,支持数字、圆环和沙漏样式,Esc 或长按可以跳过,休息时可再专注五分钟。白噪音、粉红噪音和雨声由 `AVAudioSourceNode` 实时合成,仅在休息阶段播放。休息 Widget 从 App Group 读取阶段和预计截止时间,实际刷新受 WidgetKit 管理,不能作为秒级提醒来源。
主应用与扩展的 Bundle ID 分别是 `work.12306.xstats.app` 和 `work.12306.xstats.app.widget`;
带 App Group 能力的 Developer ID provisioning profile 要分别安装,并通过
`XSTATS_APP_PROFILE`、`XSTATS_WIDGET_PROFILE` 指定 profile 名称后再签名构建。
@@ -1490,3 +1490,10 @@ Sub2API 账号平台与所选来源不匹配 منصة حساب Sub2API لا تط
VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测 لا تسمح شبكة VPN بالاتصال المباشر، ويستجيب الوكيل المحلي لاتصال TCP قبل الهدف، لذا لا يمكن قياس زمن الاستجابة الحقيقي. أوقف VPN ثم أعد المحاولة
国内节点只能测 TCP 建连,经代理测不准,所以始终直连测 لا تدعم عُقد الصين إلا قياس اتصال TCP، ويكون القياس غير دقيق عبر الوكيل، لذا تُقاس دائمًا باتصال مباشر
检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路 تم اكتشاف {}. يتجاوزه الاتصال المباشر لقياس اتصالك؛ وعبر {} يُقاس مسار الوكيل
工作番茄 العمل
开始工作 بدء العمل
结束工作 إنهاء العمل
待开始 جاهز للبدء
手动开始工作,专注与休息自动交替;每 4 轮进入长休。 ابدأ العمل يدويًا. تتناوب فترات التركيز والراحة تلقائيًا، وتأتي استراحة طويلة بعد أربع فترات تركيز.
起身活动一下 انهض وتحرك قليلًا
走动几步,看看远处,让身体和眼睛都休息 امشِ قليلًا وانظر إلى البعيد لإراحة جسمك وعينيك
1 # Copyright (C) 2026 ysicing
1490 VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测
1491 国内节点只能测 TCP 建连,经代理测不准,所以始终直连测
1492 检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路
1493 工作番茄
1494 开始工作
1495 结束工作
1496 待开始
1497 手动开始工作,专注与休息自动交替;每 4 轮进入长休。
1498 起身活动一下
1499 走动几步,看看远处,让身体和眼睛都休息
@@ -1490,3 +1490,10 @@ Sub2API 账号平台与所选来源不匹配 Die Plattform des Sub2API-Kontos st
VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测 Das VPN lässt keine direkte Verbindung zu. Der lokale Proxy beantwortet TCP-Verbindungen vorzeitig, daher ist keine echte Latenzmessung möglich. VPN ausschalten und erneut messen
国内节点只能测 TCP 建连,经代理测不准,所以始终直连测 China-Knoten erlauben nur TCP-Verbindungszeiten; über einen Proxy wären sie ungenau und werden daher immer direkt gemessen
检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路 {} erkannt. Direkt umgeht es für einen Test Ihres Anschlusses; über {} wird die Proxy-Strecke gemessen
工作番茄 Arbeit
开始工作 Arbeit beginnen
结束工作 Arbeit beenden
待开始 Bereit zum Start
手动开始工作,专注与休息自动交替;每 4 轮进入长休。 Arbeit manuell beginnen. Fokus und Pausen wechseln automatisch; nach vier Fokusphasen folgt eine lange Pause.
起身活动一下 Aufstehen und bewegen
走动几步,看看远处,让身体和眼睛都休息 Gehen Sie ein paar Schritte und blicken Sie in die Ferne, um Körper und Augen zu entspannen
1 # Copyright (C) 2026 ysicing
1490 VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测
1491 国内节点只能测 TCP 建连,经代理测不准,所以始终直连测
1492 检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路
1493 工作番茄
1494 开始工作
1495 结束工作
1496 待开始
1497 手动开始工作,专注与休息自动交替;每 4 轮进入长休。
1498 起身活动一下
1499 走动几步,看看远处,让身体和眼睛都休息
@@ -1490,3 +1490,10 @@ Sub2API 账号平台与所选来源不匹配 La plataforma de la cuenta de Sub2A
VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测 La VPN impide la conexión directa y el proxy local responde antes al enlace TCP, por lo que no se puede medir la latencia real. Desactiva la VPN y vuelve a intentarlo
国内节点只能测 TCP 建连,经代理测不准,所以始终直连测 Los nodos de China solo permiten medir la conexión TCP; con un proxy sería impreciso, así que siempre se miden directamente
检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路 Se detectó {}. Directo lo evita y mide tu conexión; por {} mide la ruta del proxy
工作番茄 Trabajo
开始工作 Empezar a trabajar
结束工作 Terminar el trabajo
待开始 Listo para empezar
手动开始工作,专注与休息自动交替;每 4 轮进入长休。 Inicia el trabajo manualmente. La concentración y los descansos se alternan; tras cuatro sesiones, llega un descanso largo.
起身活动一下 Levántate y muévete
走动几步,看看远处,让身体和眼睛都休息 Camina un poco y mira a lo lejos para descansar el cuerpo y la vista
1 # Copyright (C) 2026 ysicing
1490 VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测
1491 国内节点只能测 TCP 建连,经代理测不准,所以始终直连测
1492 检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路
1493 工作番茄
1494 开始工作
1495 结束工作
1496 待开始
1497 手动开始工作,专注与休息自动交替;每 4 轮进入长休。
1498 起身活动一下
1499 走动几步,看看远处,让身体和眼睛都休息
@@ -1490,3 +1490,10 @@ Sub2API 账号平台与所选来源不匹配 La plateforme du compte Sub2API ne
VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测 Le VPN interdit la connexion directe et le proxy local répond à la connexion TCP avant la cible : la latence réelle ne peut pas être mesurée. Désactivez le VPN puis réessayez
国内节点只能测 TCP 建连,经代理测不准,所以始终直连测 Les nœuds chinois ne permettent que de mesurer la connexion TCP ; via un proxy, la mesure serait fausse, donc elle se fait toujours en direct
检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路 {} détecté. Le mode direct le contourne pour mesurer votre connexion ; via {}, il mesure la route du proxy
工作番茄 Travail
开始工作 Commencer à travailler
结束工作 Terminer le travail
待开始 Prêt à commencer
手动开始工作,专注与休息自动交替;每 4 轮进入长休。 Démarrez le travail manuellement. Concentration et pauses alternent ; une longue pause suit quatre sessions.
起身活动一下 Levez-vous et bougez
走动几步,看看远处,让身体和眼睛都休息 Marchez un peu et regardez au loin pour reposer votre corps et vos yeux
1 # Copyright (C) 2026 ysicing
1490 VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测
1491 国内节点只能测 TCP 建连,经代理测不准,所以始终直连测
1492 检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路
1493 工作番茄
1494 开始工作
1495 结束工作
1496 待开始
1497 手动开始工作,专注与休息自动交替;每 4 轮进入长休。
1498 起身活动一下
1499 走动几步,看看远处,让身体和眼睛都休息
@@ -1490,3 +1490,10 @@ Sub2API 账号平台与所选来源不匹配 Sub2API アカウントのプラッ
VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测 VPNが直接接続を許可せず、ローカルプロキシがTCP接続に先に応答するため、実際の遅延を測定できません。VPNを切って再試行してください
国内节点只能测 TCP 建连,经代理测不准,所以始终直连测 中国国内のノードはTCP接続時間のみ測定でき、プロキシ経由では不正確になるため常に直接接続で測定します
检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路 {}を検出しました。直接接続ではこれを迂回して回線を測定し、{}経由ではプロキシ経路を測定します
工作番茄 仕事
开始工作 仕事を始める
结束工作 仕事を終える
待开始 開始待ち
手动开始工作,专注与休息自动交替;每 4 轮进入长休。 手動で仕事を始めると、集中と休憩が自動で切り替わり、4 回の集中後に長い休憩に入ります。
起身活动一下 立って体を動かしましょう
走动几步,看看远处,让身体和眼睛都休息 少し歩いて遠くを見て、体と目を休めましょう
1 # Copyright (C) 2026 ysicing
1490 VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测
1491 国内节点只能测 TCP 建连,经代理测不准,所以始终直连测
1492 检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路
1493 工作番茄
1494 开始工作
1495 结束工作
1496 待开始
1497 手动开始工作,专注与休息自动交替;每 4 轮进入长休。
1498 起身活动一下
1499 走动几步,看看远处,让身体和眼睛都休息
@@ -1490,3 +1490,10 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 계정 플랫폼이 선택
VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测 VPN을 우회해 직접 연결할 수 없고 로컬 프록시가 TCP 연결에 먼저 응답하므로 실제 지연 시간을 측정할 수 없습니다. VPN을 끄고 다시 시도하세요
国内节点只能测 TCP 建连,经代理测不准,所以始终直连测 중국 노드는 TCP 연결 시간만 측정할 수 있으며 프록시를 거치면 부정확하므로 항상 직접 연결로 측정합니다
检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路 {}이(가) 감지되었습니다. 직접 연결은 이를 우회해 로컬 회선을 측정하고, {} 경유는 프록시 경로를 측정합니다
工作番茄 업무
开始工作 업무 시작
结束工作 업무 종료
待开始 시작 대기
手动开始工作,专注与休息自动交替;每 4 轮进入长休。 업무를 직접 시작하면 집중과 휴식이 자동으로 번갈아 진행되며, 집중 4회 후 긴 휴식이 시작됩니다.
起身活动一下 일어나서 움직이세요
走动几步,看看远处,让身体和眼睛都休息 잠시 걷고 먼 곳을 바라보며 몸과 눈을 쉬게 하세요
1 # Copyright (C) 2026 ysicing
1490 VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测
1491 国内节点只能测 TCP 建连,经代理测不准,所以始终直连测
1492 检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路
1493 工作番茄
1494 开始工作
1495 结束工作
1496 待开始
1497 手动开始工作,专注与休息自动交替;每 4 轮进入长休。
1498 起身活动一下
1499 走动几步,看看远处,让身体和眼睛都休息
@@ -1491,3 +1491,10 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 帳號平台與所選來源
VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测 VPN 不允許繞過它直連,本機代理會提前回應 TCP 連線,無法測出真實延遲。請關閉 VPN 後再測
国内节点只能测 TCP 建连,经代理测不准,所以始终直连测 國內節點只能測量 TCP 連線時間,經代理會失準,因此一律直連測量
检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路 偵測到 {}。直連會繞過它測量本機寬頻;經 {} 則測量代理線路
工作番茄 工作番茄
开始工作 開始工作
结束工作 結束工作
待开始 等待開始
手动开始工作,专注与休息自动交替;每 4 轮进入长休。 手動開始工作,專注與休息自動交替;每 4 輪進入長休。
起身活动一下 起身活動一下
走动几步,看看远处,让身体和眼睛都休息 走動幾步,看看遠方,讓身體和眼睛都休息
1 # Copyright (c) 2026 GiantAccel, LLC
1491 VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测
1492 国内节点只能测 TCP 建连,经代理测不准,所以始终直连测
1493 检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路
1494 工作番茄
1495 开始工作
1496 结束工作
1497 待开始
1498 手动开始工作,专注与休息自动交替;每 4 轮进入长休。
1499 起身活动一下
1500 走动几步,看看远处,让身体和眼睛都休息
@@ -39,6 +39,13 @@ extension Translations {
长休 Long break
单次 Single
循环 Cycle
工作番茄 Workday
开始工作 Start work
结束工作 End work
待开始 Ready to start
手动开始工作,专注与休息自动交替;每 4 轮进入长休。 Start your workday manually. Focus and breaks alternate, with a long break after four focus sessions.
起身活动一下 Stand up and move
走动几步,看看远处,让身体和眼睛都休息 Walk a little and look into the distance to rest your body and eyes
计时中 Running
已暂停 Paused
暂停 Pause
@@ -107,6 +107,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
observeWorkspace()
observeRestSettings()
observeRestMode()
observeRestSound()
rest.sync()
restMenuBar.sync()
@@ -575,6 +576,18 @@ public final class AppController: NSObject, NSApplicationDelegate {
}
}
private func observeRestMode() {
withObservationTracking {
_ = model.settings.restMode
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.rest.modeDidChange()
self.observeRestMode()
}
}
}
private func observeRestSound() {
withObservationTracking {
_ = model.settings.restSound
@@ -40,7 +40,8 @@ struct RestSession {
let restNanoseconds: UInt64
let longRestNanoseconds: UInt64
init(now: UInt64, workMinutes: Int, restMinutes: Int, longBreakMinutes: Int = 15) {
init(now: UInt64, workMinutes: Int, restMinutes: Int, longBreakMinutes: Int = 15,
completedFocus: Int = 0) {
workNanoseconds = UInt64(max(1, workMinutes)) * 60_000_000_000
restNanoseconds = UInt64(max(1, restMinutes)) * 60_000_000_000
longRestNanoseconds = UInt64(max(1, longBreakMinutes)) * 60_000_000_000
@@ -49,6 +50,7 @@ struct RestSession {
remainingByPhase = [.work: workNanoseconds, .rest: restNanoseconds, .longRest: longRestNanoseconds]
activeDurationByPhase = remainingByPhase
deadline = now + workNanoseconds
self.completedFocus = completedFocus
}
func remaining(at now: UInt64) -> TimeInterval {
@@ -172,6 +174,7 @@ struct RestSession {
final class RestController {
private(set) var phase: RestPhase = .work
private(set) var isRunning = false
private(set) var isWorkdayActive = false
private(set) var canContinue = false
private(set) var secondsRemaining: TimeInterval = 0
private(set) var phaseDuration: TimeInterval = 0
@@ -245,6 +248,10 @@ final class RestController {
func startPause() {
guard settings.restEnabled else { return }
if settings.restMode == .workday && !isWorkdayActive {
startWorkday()
return
}
settleExpiredSession()
guard var session else { return }
let now = clock()
@@ -257,6 +264,42 @@ final class RestController {
refresh()
}
/// 工作时段必须由用户开始;开始时从完整的第一轮专注计时。
private func startWorkday() {
let now = clock()
var session = RestSession(now: now, workMinutes: settings.restWorkMinutes,
restMinutes: settings.restBreakMinutes,
longBreakMinutes: settings.restLongBreakMinutes)
session.startOrContinue(at: now)
self.session = session
isWorkdayActive = true
suspendedAt = nil
lastFocusCredited = false
refresh()
}
func endWorkday() {
guard settings.restMode == .workday, isWorkdayActive else { return }
settleExpiredSession()
isWorkdayActive = false
suspendedAt = nil
lastFocusCredited = false
session = RestSession(now: clock(), workMinutes: settings.restWorkMinutes,
restMinutes: settings.restBreakMinutes,
longBreakMinutes: settings.restLongBreakMinutes)
refresh()
}
/// 改运行方式只暂停当前阶段并保留剩余时间;工作时段只能显式结束。
func modeDidChange() {
settleExpiredSession()
if var session {
session.pause(at: clock())
self.session = session
}
refresh()
}
func selectPhase(_ selected: RestPhase) {
settleExpiredSession()
guard var session else { return }
@@ -275,15 +318,18 @@ final class RestController {
func restart() {
settleExpiredSession()
let completedFocus = isWorkdayActive ? session?.completedFocus ?? 0 : 0
suspendedAt = nil
lastFocusCredited = false
session = RestSession(now: clock(), workMinutes: settings.restWorkMinutes,
restMinutes: settings.restBreakMinutes,
longBreakMinutes: settings.restLongBreakMinutes)
longBreakMinutes: settings.restLongBreakMinutes,
completedFocus: completedFocus)
sync()
}
func skip() {
guard settings.restMode != .workday || isWorkdayActive else { return }
settleExpiredSession()
guard var session else { return }
session.skip(at: clock())
@@ -312,7 +358,9 @@ final class RestController {
guard var session else { return }
refreshDay()
let now = clock()
let completedAt = session.advance(to: now, cycle: settings.restCycleEnabled)
let continuesAutomatically = settings.restMode == .cycle
|| (settings.restMode == .workday && isWorkdayActive)
let completedAt = session.advance(to: now, cycle: continuesAutomatically)
let nowDate = date()
let today = Calendar.current.startOfDay(for: nowDate)
let credited = completedAt.filter { completion in
@@ -392,6 +440,7 @@ final class RestController {
func stop() {
suspend()
session = nil
isWorkdayActive = false
suspendedAt = nil
lastFocusCredited = false
lastSharedDeadline = nil
@@ -20,12 +20,19 @@ struct RestPage: View {
ForEach(RestPhase.allCases) { phase in
Button(phase.title) { rest.selectPhase(phase) }
.buttonStyle(DSButtonStyle(kind: rest.phase == phase ? .primary : .secondary))
.disabled(settings.restMode == .workday && !rest.isWorkdayActive)
}
Spacer()
RestOptionsButton()
SegmentedControl(selection: $settings.restCycleEnabled,
options: [(false, tr("单次")), (true, tr("循环"))])
.frame(width: 150)
SegmentedControl(selection: $settings.restMode,
options: RestRunMode.allCases.map { ($0, $0.title) })
.frame(width: 240)
}
if settings.restMode == .workday {
Text(tr("手动开始工作,专注与休息自动交替;每 4 轮进入长休。"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
VStack(spacing: DS.Space.s4) {
@@ -44,7 +51,8 @@ struct RestPage: View {
.font(.system(size: 48, weight: .light, design: .rounded))
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
Text(rest.isRunning ? tr("计时中") : tr("已暂停"))
Text(rest.isRunning ? tr("计时中")
: settings.restMode == .workday && !rest.isWorkdayActive ? tr("待开始") : tr("已暂停"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
@@ -52,12 +60,18 @@ struct RestPage: View {
.frame(width: 214, height: 214)
HStack(spacing: DS.Space.s2) {
Button(rest.isRunning ? tr("暂停") : rest.canContinue ? tr("继续") : tr("开始")) { rest.startPause() }
Button(primaryTitle(rest: rest, mode: settings.restMode)) { rest.startPause() }
.buttonStyle(DSButtonStyle(kind: .primary))
Button(tr("重置")) { rest.resetCurrentPhase() }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(settings.restMode == .workday && !rest.isWorkdayActive)
Button(tr("跳过")) { rest.skip() }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(settings.restMode == .workday && !rest.isWorkdayActive)
if settings.restMode == .workday && rest.isWorkdayActive {
Button(tr("结束工作")) { rest.endWorkday() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
}
.frame(maxWidth: .infinity)
@@ -90,6 +104,12 @@ struct RestPage: View {
let whole = max(0, Int(seconds.rounded(.up)))
return String(format: "%02d:%02d", whole / 60, whole % 60)
}
private func primaryTitle(rest: RestController, mode: RestRunMode) -> String {
if rest.isRunning { return tr("暂停") }
if mode == .workday { return rest.isWorkdayActive ? tr("继续") : tr("开始工作") }
return rest.canContinue ? tr("继续") : tr("开始")
}
}
extension RestPhase {
@@ -4,6 +4,21 @@
import Localization
import SwiftUI
/// 专注与休息的运行方式;工作番茄由用户明确开始和结束一个工作时段。
public enum RestRunMode: String, CaseIterable, Identifiable, Sendable {
case single, cycle, workday
public var id: String { rawValue }
var title: String {
switch self {
case .single: tr("单次")
case .cycle: tr("循环")
case .workday: tr("工作番茄")
}
}
}
public enum RestHUDStyle: String, CaseIterable, Identifiable, Sendable {
case countdown, ring, hourglass
@@ -94,10 +94,12 @@ private struct RestOverlayView: View {
.foregroundStyle(accent)
}
.frame(width: 250, height: 250)
Text(tr("让眼睛休息一下"))
Text(tr(rest.settings.restMode == .workday ? "起身活动一下" : "让眼睛休息一下"))
.font(.system(size: 34, weight: .medium, design: .rounded))
.tracking(-1)
Text(tr("看看远处,轻轻眨眼,放松肩颈"))
Text(tr(rest.settings.restMode == .workday
? "走动几步,看看远处,让身体和眼睛都休息"
: "看看远处,轻轻眨眼,放松肩颈"))
.font(.system(size: 16))
.foregroundStyle(.secondary)
HStack(spacing: 14) {
@@ -172,11 +174,19 @@ private struct RestHUDView: View {
Button { rest.startPause() } label: {
Image(systemName: rest.isRunning ? "pause.fill" : "play.fill")
}
.help(tr(rest.isRunning ? "暂停" : rest.canContinue ? "继续" : "开始"))
.help(tr(rest.isRunning ? "暂停"
: rest.settings.restMode == .workday && !rest.isWorkdayActive ? "开始工作"
: rest.canContinue || rest.isWorkdayActive ? "继续" : "开始"))
Button { rest.resetCurrentPhase() } label: { Image(systemName: "arrow.counterclockwise") }
.help(tr("重置"))
.disabled(rest.settings.restMode == .workday && !rest.isWorkdayActive)
Button { rest.skip() } label: { Image(systemName: "forward.end.fill") }
.help(tr("跳过"))
.disabled(rest.settings.restMode == .workday && !rest.isWorkdayActive)
if rest.settings.restMode == .workday && rest.isWorkdayActive {
Button { rest.endWorkday() } label: { Image(systemName: "stop.fill") }
.help(tr("结束工作"))
}
}
.buttonStyle(.plain)
}
@@ -464,8 +464,16 @@ public final class AppSettings {
public var restDailyGoal: Int {
didSet { defaults.set(restDailyGoal, forKey: Keys.restDailyGoal) }
}
/// 兼容旧版布尔偏好与备份;新界面以 restMode 为准。
public var restCycleEnabled: Bool {
didSet { defaults.set(restCycleEnabled, forKey: Keys.restCycleEnabled) }
get { restMode == .cycle }
set { restMode = newValue ? .cycle : .single }
}
public var restMode: RestRunMode {
didSet {
defaults.set(restMode.rawValue, forKey: Keys.restMode)
defaults.set(restCycleEnabled, forKey: Keys.restCycleEnabled)
}
}
public var restSound: RestSound {
didSet { defaults.set(restSound.rawValue, forKey: Keys.restSound) }
@@ -664,7 +672,8 @@ public final class AppSettings {
? defaults.integer(forKey: Keys.restLongBreakMinutes) : 15
restDailyGoal = Self.restDailyGoalOptions.contains(defaults.integer(forKey: Keys.restDailyGoal))
? defaults.integer(forKey: Keys.restDailyGoal) : 8
restCycleEnabled = defaults.bool(forKey: Keys.restCycleEnabled)
restMode = defaults.string(forKey: Keys.restMode).flatMap(RestRunMode.init(rawValue:))
?? (defaults.bool(forKey: Keys.restCycleEnabled) ? .cycle : .single)
restSound = defaults.string(forKey: Keys.restSound).flatMap(RestSound.init(rawValue:)) ?? .off
restHUDStyle = defaults.string(forKey: Keys.restHUDStyle).flatMap(RestHUDStyle.init(rawValue:)) ?? .countdown
calendarEnabled = defaults.bool(forKey: Keys.calendarEnabled)
@@ -780,6 +789,7 @@ public final class AppSettings {
static let restLongBreakMinutes = "restLongBreakMinutes"
static let restDailyGoal = "restDailyGoal"
static let restCycleEnabled = "restCycleEnabled"
static let restMode = "restMode"
static let restSound = "restSound"
static let restHUDStyle = "restHUDStyle"
static let calendarEnabled = "calendarEnabled"
@@ -23,6 +23,7 @@ public struct SettingsDocument: Codable, Equatable, Sendable {
public var restLongBreakMinutes: Int?
public var restDailyGoal: Int?
public var restCycleEnabled: Bool?
public var restMode: String?
public var restSound: String?
public var restHUDStyle: String?
public var calendarFeatures: [String]?
@@ -70,6 +71,7 @@ extension AppSettings {
doc.restLongBreakMinutes = restLongBreakMinutes
doc.restDailyGoal = restDailyGoal
doc.restCycleEnabled = restCycleEnabled
doc.restMode = restMode.rawValue
doc.restSound = restSound.rawValue
doc.restHUDStyle = restHUDStyle.rawValue
doc.calendarFeatures = calendarFeatures.map(\.rawValue).sorted()
@@ -121,7 +123,11 @@ extension AppSettings {
assign(\.restBreakMinutes, option(doc.restBreakMinutes, in: Self.restBreakOptions))
assign(\.restLongBreakMinutes, option(doc.restLongBreakMinutes, in: Self.restLongBreakOptions))
assign(\.restDailyGoal, option(doc.restDailyGoal, in: Self.restDailyGoalOptions))
assign(\.restCycleEnabled, doc.restCycleEnabled)
if let mode = doc.restMode.flatMap(RestRunMode.init(rawValue:)) {
assign(\.restMode, mode)
} else {
assign(\.restCycleEnabled, doc.restCycleEnabled)
}
assign(\.restSound, doc.restSound.flatMap(RestSound.init(rawValue:)))
assign(\.restHUDStyle, doc.restHUDStyle.flatMap(RestHUDStyle.init(rawValue:)))
assign(\.calendarFeatures, doc.calendarFeatures.map(CalendarFeature.restored(from:)))
@@ -190,6 +190,106 @@ import Testing
@MainActor
@Suite struct RestSettingsTests {
@Test func workdayStartsExplicitlyAndEndsWithoutLosingTodaysCount() {
let suite = "RestWorkdayTests.\(UUID())"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let settings = AppSettings(defaults: defaults)
settings.restEnabled = true
settings.restMode = .workday
var now: UInt64 = 0
let rest = RestController(settings: settings, localDefaults: defaults,
sharedDefaults: defaults, clock: { now })
rest.sync()
#expect(!rest.isWorkdayActive && !rest.isRunning && rest.phase == .work)
rest.startPause()
#expect(rest.isWorkdayActive && rest.isRunning)
now = 25 * 60_000_000_000
rest.sync()
#expect(rest.phase == .rest && rest.isRunning)
#expect(rest.completedToday == 1)
rest.endWorkday()
#expect(!rest.isWorkdayActive && !rest.isRunning && rest.phase == .work)
#expect(rest.secondsRemaining == 25 * 60)
#expect(rest.completedToday == 1)
rest.stop()
}
@Test func workdayCyclesIntoLongBreakAfterFourFocusPeriods() {
let suite = "RestWorkdayLongBreakTests.\(UUID())"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let settings = AppSettings(defaults: defaults)
settings.restEnabled = true
settings.restMode = .workday
var now: UInt64 = 0
let rest = RestController(settings: settings, localDefaults: defaults,
sharedDefaults: defaults, clock: { now })
rest.sync()
rest.startPause()
now = 115 * 60_000_000_000
rest.sync()
#expect(rest.phase == .longRest && rest.isRunning)
#expect(rest.completedToday == 4)
rest.stop()
}
@Test func switchingModesKeepsActiveWorkdayAndLongBreakCadence() {
let suite = "RestWorkdayModeChangeTests.\(UUID())"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let settings = AppSettings(defaults: defaults)
settings.restEnabled = true
settings.restMode = .workday
var now: UInt64 = 0
let rest = RestController(settings: settings, localDefaults: defaults,
sharedDefaults: defaults, clock: { now })
rest.sync()
rest.startPause()
now = 95 * 60_000_000_000
rest.sync()
#expect(rest.completedToday == 3 && rest.phase == .work)
settings.restMode = .single
rest.modeDidChange()
#expect(rest.isWorkdayActive && !rest.isRunning)
#expect(rest.secondsRemaining == 20 * 60 && rest.canContinue)
settings.restMode = .workday
rest.modeDidChange()
#expect(rest.isWorkdayActive && rest.secondsRemaining == 20 * 60)
rest.startPause()
now = 115 * 60_000_000_000
rest.sync()
#expect(rest.phase == .longRest && rest.completedToday == 4)
rest.stop()
}
@Test func changingDurationDoesNotEndWorkdayOrLoseRoundCount() {
let suite = "RestWorkdayDurationTests.\(UUID())"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let settings = AppSettings(defaults: defaults)
settings.restEnabled = true
settings.restMode = .workday
var now: UInt64 = 0
let rest = RestController(settings: settings, localDefaults: defaults,
sharedDefaults: defaults, clock: { now })
rest.sync()
rest.startPause()
now = 90 * 60_000_000_000
rest.sync()
#expect(rest.completedToday == 3 && rest.phase == .work)
settings.restBreakMinutes = 10
rest.restart()
#expect(rest.isWorkdayActive && !rest.isRunning)
rest.startPause()
now = 115 * 60_000_000_000
rest.sync()
#expect(rest.phase == .longRest && rest.completedToday == 4)
rest.stop()
}
@Test func failedBreakSoundRetriesAfterCooldown() {
enum StartupFailure: Error { case unavailable }
var now: UInt64 = 0
@@ -483,4 +583,22 @@ import Testing
#expect(target.restSound == .rain)
#expect(target.restHUDStyle == .hourglass)
}
@Test func workdayModeMigratesLegacyCycleAndRoundTripsBackup() {
let oldSuite = "RestLegacyModeTests.\(UUID())"
let oldDefaults = UserDefaults(suiteName: oldSuite)!
defer { oldDefaults.removePersistentDomain(forName: oldSuite) }
oldDefaults.set(true, forKey: "restCycleEnabled")
let source = AppSettings(defaults: oldDefaults)
#expect(source.restMode == .cycle)
source.restMode = .workday
let targetSuite = "RestModeBackupTests.\(UUID())"
let targetDefaults = UserDefaults(suiteName: targetSuite)!
defer { targetDefaults.removePersistentDomain(forName: targetSuite) }
let target = AppSettings(defaults: targetDefaults)
target.apply(source.exportDocument())
#expect(target.restMode == .workday)
#expect(AppSettings(defaults: targetDefaults).restMode == .workday)
}
}