feat(rest): 增加工作番茄模式并保留工作时段
This commit is contained in:
1 parent
9d650f93c7
commit
9df9d439af
17 files changed
+312
-15
No files matched your search
+1
-1
@@ -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 轮进入长休。 ابدأ العمل يدويًا. تتناوب فترات التركيز والراحة تلقائيًا، وتأتي استراحة طويلة بعد أربع فترات تركيز.
|
||||
起身活动一下 انهض وتحرك قليلًا
|
||||
走动几步,看看远处,让身体和眼睛都休息 امشِ قليلًا وانظر إلى البعيد لإراحة جسمك وعينيك
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -1490,3 +1490,10 @@ Sub2API 账号平台与所选来源不匹配 Sub2API アカウントのプラッ
|
||||
VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测 VPNが直接接続を許可せず、ローカルプロキシがTCP接続に先に応答するため、実際の遅延を測定できません。VPNを切って再試行してください
|
||||
国内节点只能测 TCP 建连,经代理测不准,所以始终直连测 中国国内のノードはTCP接続時間のみ測定でき、プロキシ経由では不正確になるため常に直接接続で測定します
|
||||
检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路 {}を検出しました。直接接続ではこれを迂回して回線を測定し、{}経由ではプロキシ経路を測定します
|
||||
工作番茄 仕事
|
||||
开始工作 仕事を始める
|
||||
结束工作 仕事を終える
|
||||
待开始 開始待ち
|
||||
手动开始工作,专注与休息自动交替;每 4 轮进入长休。 手動で仕事を始めると、集中と休憩が自動で切り替わり、4 回の集中後に長い休憩に入ります。
|
||||
起身活动一下 立って体を動かしましょう
|
||||
走动几步,看看远处,让身体和眼睛都休息 少し歩いて遠くを見て、体と目を休めましょう
|
||||
|
@@ -1490,3 +1490,10 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 계정 플랫폼이 선택
|
||||
VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测 VPN을 우회해 직접 연결할 수 없고 로컬 프록시가 TCP 연결에 먼저 응답하므로 실제 지연 시간을 측정할 수 없습니다. VPN을 끄고 다시 시도하세요
|
||||
国内节点只能测 TCP 建连,经代理测不准,所以始终直连测 중국 노드는 TCP 연결 시간만 측정할 수 있으며 프록시를 거치면 부정확하므로 항상 직접 연결로 측정합니다
|
||||
检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路 {}이(가) 감지되었습니다. 직접 연결은 이를 우회해 로컬 회선을 측정하고, {} 경유는 프록시 경로를 측정합니다
|
||||
工作番茄 업무
|
||||
开始工作 업무 시작
|
||||
结束工作 업무 종료
|
||||
待开始 시작 대기
|
||||
手动开始工作,专注与休息自动交替;每 4 轮进入长休。 업무를 직접 시작하면 집중과 휴식이 자동으로 번갈아 진행되며, 집중 4회 후 긴 휴식이 시작됩니다.
|
||||
起身活动一下 일어나서 움직이세요
|
||||
走动几步,看看远处,让身体和眼睛都休息 잠시 걷고 먼 곳을 바라보며 몸과 눈을 쉬게 하세요
|
||||
|
@@ -1491,3 +1491,10 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 帳號平台與所選來源
|
||||
VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测 VPN 不允許繞過它直連,本機代理會提前回應 TCP 連線,無法測出真實延遲。請關閉 VPN 後再測
|
||||
国内节点只能测 TCP 建连,经代理测不准,所以始终直连测 國內節點只能測量 TCP 連線時間,經代理會失準,因此一律直連測量
|
||||
检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路 偵測到 {}。直連會繞過它測量本機寬頻;經 {} 則測量代理線路
|
||||
工作番茄 工作番茄
|
||||
开始工作 開始工作
|
||||
结束工作 結束工作
|
||||
待开始 等待開始
|
||||
手动开始工作,专注与休息自动交替;每 4 轮进入长休。 手動開始工作,專注與休息自動交替;每 4 輪進入長休。
|
||||
起身活动一下 起身活動一下
|
||||
走动几步,看看远处,让身体和眼睛都休息 走動幾步,看看遠方,讓身體和眼睛都休息
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user