// Merged Swift Files // MARK: - utils/OfflineTi...
作成日: 2025年3月15日
作成日: 2025年3月15日
// Merged Swift Files
// MARK: - utils/OfflineTimer/LiveActivity/LiveActivityManager.swift
//
// File.swift
// utils
//
// Created by 潘令川 on 2025/3/11.
//
import ActivityKit
import Foundation
@available(iOS 16.1, *)
class LiveActivityManager {
textstatic let shared = LiveActivityManager() private var currentActivity: Activity<OfflineTimerAttributes>? private init() {} func startActivity(timerName: String, emoji: String, timerType: String) -> Activity<OfflineTimerAttributes>? { // 先结束之前的活动 endActivity() var activity: Activity<OfflineTimerAttributes>? if #available(iOS 16.2, *) { do { let attributes = OfflineTimerAttributes( timerName: timerName, emoji: emoji ) let contentState = OfflineTimerAttributes.ContentState( elapsedTime: "0:00", elapsedSeconds: 0, timerType: timerType, timerState: "running" ) let content = ActivityContent(state: contentState, staleDate: nil) activity = try Activity<OfflineTimerAttributes>.request( attributes: attributes, content: content, pushType: nil ) currentActivity = activity print("开始离线计时器活动: \(activity?.id ?? "unknown")") } catch { print("创建活动失败: \(error.localizedDescription)") } }
// else if #available(iOS 16.1, *) {
// do {
// let attributes = OfflineTimerAttributes(
// timerName: timerName,
// emoji: emoji
// )
//
// let contentState = OfflineTimerAttributes.ContentState(
// elapsedTime: "0:00",
// elapsedSeconds: 0,
// timerType: timerType,
// timerState: "running"
// )
//
// activity = try Activity<OfflineTimerAttributes>.request(
// attributes: attributes,
// contentState: contentState,
// pushType: nil
// )
//
// currentActivity = activity
//
// print("开始离线计时器活动: (activity?.id ?? "unknown")")
// } catch {
// print("创建活动失败: (error.localizedDescription)")
// }
// }
textreturn activity }
func updateActivity(elapsedSeconds: Int64, isPaused: Bool = false) {
// print("更新 LiveActivity: 时间 = (elapsedSeconds), 暂停状态 = (isPaused)")
// FileLog.log(msg: "更新 LiveActivity: 时间 = (elapsedSeconds), 暂停状态 = (isPaused)")
textif #available(iOS 16.2, *) { Task { let contentState = OfflineTimerAttributes.ContentState( elapsedTime: formatSimpleTime(elapsedSeconds), elapsedSeconds: elapsedSeconds, timerType: currentActivity?.content.state.timerType ?? "", timerState: isPaused ? "paused" : "running" ) let content = ActivityContent(state: contentState, staleDate: nil) if let activity = currentActivity { await activity.update(content) } else { print("无法更新 LiveActivity: 当前活动为空") FileLog.log(msg: "无法更新 LiveActivity: 当前活动为空") } } }
// else if #available(iOS 16.1, *) {
// Task {
// let contentState = OfflineTimerAttributes.ContentState(
// elapsedTime: formatSimpleTime(elapsedSeconds),
// elapsedSeconds: elapsedSeconds,
// timerType: currentActivity?.contentState.timerType ?? "",
// timerState: isPaused ? "paused" : "running"
// )
//
// if let activity = currentActivity {
// print("正在更新 LiveActivity ID: (activity.id)")
// FileLog.log(msg: "正在更新 LiveActivity ID: (activity.id), 状态: (isPaused ? "paused" : "running")")
// await activity.update(using: contentState)
// print("LiveActivity 更新完成")
// FileLog.log(msg: "LiveActivity 更新完成")
// } else {
// print("无法更新 LiveActivity: 当前活动为空")
// FileLog.log(msg: "无法更新 LiveActivity: 当前活动为空")
// }
// }
// }
}
textfunc endActivity() { if #available(iOS 16.2, *) { Task { for activity in Activity<OfflineTimerAttributes>.activities { let finalContent = ActivityContent( state: activity.content.state, staleDate: nil ) await activity.end(finalContent, dismissalPolicy: .immediate) } currentActivity = nil } } // else if #available(iOS 16.1, *) { // Task { // for activity in Activity<OfflineTimerAttributes>.activities { // await activity.end(dismissalPolicy: .immediate) // } // currentActivity = nil // } // } }
}
// 扩展 Int64 以格式化时间
extension Int64 {
func format(using units: NSCalendar.Unit) -> String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = units
formatter.unitsStyle = .positional
formatter.zeroFormattingBehavior = .pad
return formatter.string(from: TimeInterval(self)) ?? "00:00:00"
}
}
// 简化的时间格式化函数,只显示分钟和秒,格式为"6:32"
private func formatSimpleTime(_ seconds: Int64) -> String {
let minutes = seconds / 60
let remainingSeconds = seconds % 60
return "(minutes):(String(format: "%02d", remainingSeconds))"
}
// MARK: - utils/OfflineTimer/LiveActivity/OfflineTimerAttributes.swift
import ActivityKit
import SwiftUI
public struct OfflineTimerAttributes: ActivityAttributes {
textpublic struct ContentState: Codable, Hashable { // 动态属性,可以在活动创建后更新 public var elapsedTime: String // 已经过的时间,格式化为字符串 public var elapsedSeconds: Int64 // 已经过的秒数 public var timerType: String // 计时器类型(如健身、学习等) public var timerState: String // 计时器状态(running, paused, stopped) public init(elapsedTime: String, elapsedSeconds: Int64, timerType: String, timerState: String) { self.elapsedTime = elapsedTime self.elapsedSeconds = elapsedSeconds self.timerType = timerType self.timerState = timerState } } // 静态属性,只在创建活动时使用 public var timerName: String // 计时器名称 public var emoji: String // 计时器类型对应的表情符号 public init(timerName: String, emoji: String) { self.timerName = timerName self.emoji = emoji }
}
// MARK: - utils/OfflineTimer/LiveActivity/OfflineTimerIntent.swift
import AppIntents
import SwiftUI
@available(iOS 16.1, *)
public struct PauseTimerIntent: AppIntent, LiveActivityIntent {
public static var title: LocalizedStringResource = "暂停计时器"
public static var description = IntentDescription("暂停离线计时器")
textpublic init() {} public static var openAppWhenRun: Bool { false } public func perform() async throws -> some IntentResult {
// print("灵动岛执行暂停操作")
// FileLog.log(msg: "灵动岛执行暂停操作")
textawait MainActor.run { OfflineTimerHelper.pause(suspend: false) NotificationCenter.default.post(name: .offlineTimerStatusChange, object: OfflineTimerStoreHelper.getStatus()) } return .result() }
}
@available(iOS 16.1, *)
public struct ResumeTimerIntent: AppIntent, LiveActivityIntent {
public static var title: LocalizedStringResource = "继续计时器"
public static var description = IntentDescription("继续离线计时器")
textpublic init() {} public static var openAppWhenRun: Bool { false } public func perform() async throws -> some IntentResult { // print("灵动岛执行继续操作") // FileLog.log(msg: "灵动岛执行继续操作") await MainActor.run { OfflineTimerHelper.resume() NotificationCenter.default.post(name: .offlineTimerStatusChange, object: OfflineTimerStoreHelper.getStatus()) } return .result() }
}
@available(iOS 16.1, *)
public struct StopTimerIntent: AppIntent, LiveActivityIntent {
public static var title: LocalizedStringResource = "停止计时器"
public static var description = IntentDescription("停止离线计时器")
textpublic init() {} public static var openAppWhenRun: Bool { false } public func perform() async throws -> some IntentResult { // print("灵动岛执行停止操作") // FileLog.log(msg: "灵动岛执行停止操作") await MainActor.run { OfflineTimerHelper.stop() NotificationCenter.default.post(name: .offlineTimerStatusChange, object: OfflineTimerStoreHelper.getStatus()) } return .result() }
}
@available(iOS 16.1, *)
public struct OfflineTimerIntents: AppIntentsPackage {
public static var appIntents: [any AppIntent.Type] {
[PauseTimerIntent.self, ResumeTimerIntent.self, StopTimerIntent.self]
}
}
// MARK: - utils/OfflineTimer/LiveActivity/OfflineTimerWidget.swift
import WidgetKit
import SwiftUI
import ActivityKit
import AppIntents
@available(iOS 16.1, *)
struct OfflineTimerWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: OfflineTimerAttributes.self) { context in
// 锁屏界面
LockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
// 展开区域
DynamicIslandExpandedRegion(.leading) {
LeadingView(context: context)
}
textDynamicIslandExpandedRegion(.center) { CenterView(context: context) } DynamicIslandExpandedRegion(.trailing) { TrailingView(context: context) } DynamicIslandExpandedRegion(.bottom) {
// BottomView(context: context)
}
} compactLeading: {
CompactLeadingView(context: context)
} compactTrailing: {
CompactTrailingView(context: context)
} minimal: {
MinimalView(context: context)
}
}
}
textprivate func formatSeconds(_ seconds: Int64) -> String { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] formatter.unitsStyle = .positional formatter.zeroFormattingBehavior = .pad return formatter.string(from: TimeInterval(seconds)) ?? "00:00:00" }
}
@available(iOS 16.1, *)
private struct LockScreenView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { ZStack { RoundedRectangle(cornerRadius: 24, style: .continuous) .foregroundStyle(Color.black) HStack(spacing: 0) { VStack(alignment: .leading,spacing:16){ Text(NSLocalizedString("offline_timer", comment: "")) .font(.system(size: 14, weight: .semibold, design: .rounded)) .foregroundColor(Color(hex: "#67C9FF")) VStack(alignment: .center, spacing: 2) { Text(context.attributes.emoji) .font(.system(size: 56, design: .rounded)) Text(context.attributes.timerName) .font(.system(size: 14, weight: .regular, design: .rounded)) .foregroundColor(Color(hex: "#B1BCCB")) } } .padding(.horizontal, 16) .padding(.leading, 20) Spacer() VStack(alignment: .trailing, spacing:14){ // 大号时间显示 - 已经过时间 VStack(alignment: .trailing, spacing: 2) { // 根据时间长度调整字体大小和格式 if context.state.elapsedSeconds >= 36000 { // 10小时或以上 Text(formatLongTime(context.state.elapsedSeconds)) .font(.system(size: 52, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundColor(.white) } else { Text(context.state.elapsedTime) .font(.system(size: 56, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundColor(.white) } // 添加暂停状态显示 if context.state.timerState == "paused" { Text(NSLocalizedString("Paused", comment: "Paused state")) .font(.system(size: 14, weight: .medium, design: .rounded)) .foregroundColor(.white) } } // 控制按钮 HStack(spacing: 8) { if #available(iOS 17.0, *) { // 使用专用Intent处理按钮交互 let isPaused = context.state.timerState == "paused" if isPaused { Button(intent: ResumeTimerIntent()) { Capsule() .fill(Color(hex: "#67C9FF")) .frame(width: 60, height: 38) .overlay( Image(systemName: "play.fill") .font(.system(size: 20, design: .rounded)) .foregroundColor(Color(hex: "#031C3E")) ) } .buttonStyle(.plain) .contentShape(Rectangle()) } else { Button(intent: PauseTimerIntent()) { Capsule() .fill(Color(hex: "#67C9FF")) .frame(width: 60, height: 38) .overlay( Image(systemName: "pause.fill") .font(.system(size: 20, design: .rounded)) .foregroundColor(Color(hex: "#031C3E")) ) } .buttonStyle(.plain) .contentShape(Rectangle()) } Button(intent: StopTimerIntent()) { Capsule() .fill(Color(hex: "#F65252")) .frame(width: 60, height: 38) .overlay( Image(systemName: "stop.fill") .font(.system(size: 20, design: .rounded)) .foregroundColor(Color(hex: "#031C3E")) ) } .buttonStyle(.plain) .contentShape(Rectangle()) } } } .padding(16) } } } // 格式化长时间显示 (10小时以上) private func formatLongTime(_ seconds: Int64) -> String { let hours = seconds / 3600 let minutes = (seconds % 3600) / 60 return "\(hours)h\(String(format: "%02d", minutes))m" }
}
@available(iOS 16.1, *)
private struct LeadingView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { VStack(alignment: .leading, spacing: 14) { Text(NSLocalizedString("offline_timer", comment: "")) .font(.system(size: 14, weight: .semibold, design: .rounded)) .foregroundColor(Color(hex: "#67C9FF")) .fixedSize(horizontal: true, vertical: false) VStack(alignment: .center, spacing: 2) { Text(context.attributes.emoji) .font(.system(size: 42, design: .rounded)) Text(context.attributes.timerName) .font(.system(size: 14, weight: .regular, design: .rounded)) .foregroundColor(Color(hex: "#B1BCCB")) } } .padding(.leading, 8) }
}
@available(iOS 16.1, *)
private struct CenterView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { VStack(spacing: 2) { // 根据时间长度调整字体大小和格式 if context.state.elapsedSeconds >= 36000 { // 10小时或以上 Text(formatLongTime(context.state.elapsedSeconds)) .font(.system(size: 52, weight: .semibold, design: .rounded)) .monospacedDigit() .contentTransition(.numericText()) .fixedSize(horizontal: true, vertical: false) } else { Text(context.state.elapsedTime) .font(.system(size: 72, weight: .semibold, design: .rounded)) .monospacedDigit() .contentTransition(.numericText()) .fixedSize(horizontal: true, vertical: false) } // 添加暂停状态显示 if context.state.timerState == "paused" { Text(NSLocalizedString("Paused", comment: "Paused state")) .font(.system(size: 16, weight: .medium, design: .rounded)) .foregroundColor(.white) } } } // 格式化长时间显示 (10小时以上) private func formatLongTime(_ seconds: Int64) -> String { let hours = seconds / 3600 let minutes = (seconds % 3600) / 60 return "\(hours)h\(String(format: "%02d", minutes))m" }
}
@available(iOS 16.1, *)
private struct TrailingView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { VStack(spacing:14){ if #available(iOS 17.0, *) { // 使用专用Intent处理按钮交互 let isPaused = context.state.timerState == "paused" if isPaused { Button(intent: ResumeTimerIntent()) { Circle() .fill(Color(hex: "#67C9FF")) .frame(width: 48, height: 48) .overlay( Image(systemName: "play.fill") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 18, height: 18) .foregroundColor(Color(hex: "#031C3E")) ) } .buttonStyle(.plain) .contentShape(Rectangle()) } else { Button(intent: PauseTimerIntent()) { Circle() .fill(Color(hex: "#67C9FF")) .frame(width: 48, height: 48) .overlay( Image(systemName: "pause.fill") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 18, height: 18) .foregroundColor(Color(hex: "#031C3E")) ) } .buttonStyle(.plain) .contentShape(Rectangle()) } Button(intent: StopTimerIntent()) { Circle() .fill(Color(hex: "#F65252")) .frame(width: 48, height: 48) .overlay( Image(systemName: "stop.fill") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 18, height: 18) .foregroundColor(Color(hex: "#031C3E")) ) } .buttonStyle(.plain) .contentShape(Rectangle()) } } .padding(.trailing,8) }
}
//@available(iOS 16.1, *)
//private struct BottomView: View {
// let context: ActivityViewContext<OfflineTimerAttributes>
//
// var body: some View {
// if #available(iOS 17.0, *) {
// Button(intent: StopTimerIntent()) {
// Circle()
// .fill(Color.red)
// .frame(width: 40, height: 40)
// .overlay(
// Image(systemName: "stop.fill")
// .font(.title3)
// .foregroundColor(.white)
// )
// }
// .buttonStyle(.plain)
// .contentShape(Rectangle())
// .padding(.bottom, 8)
// }
// }
//}
@available(iOS 16.1, *)
private struct CompactLeadingView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { Image("offline_timer") .font(.system(size: 22, design: .rounded)) .foregroundColor(Color(hex: "#67C9FF"))
// .foregroundColor(Color.color_chart_tint_accentTurquoise_bar_primary_solid)
}
}
@available(iOS 16.1, *)
private struct CompactTrailingView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { HStack(spacing: 4) { Text(formatTimeShort(context.state.elapsedSeconds)) .font(.system(size: 18, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundColor(.primary) .lineLimit(1) .minimumScaleFactor(0.8) // 添加暂停状态图标 if context.state.timerState == "paused" { Image(systemName: "pause.fill") .font(.system(size: 15, weight: .semibold)) .foregroundColor(.white) } } .padding(.trailing, 4) } private func formatTimeShort(_ seconds: Int64) -> String { let hours = seconds / 3600 let minutes = (seconds % 3600) / 60 if hours > 0 { return "\(hours):\(String(format: "%02d", minutes))" } else { return "\(minutes):\(String(format: "%02d", seconds % 60))" } }
}
@available(iOS 16.1, *)
private struct MinimalView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { VStack(spacing: 2) { Text(NSLocalizedString("TIMER", comment: "Title for timer feature")) .font(.system(size: 20, weight: .semibold, design: .rounded)) .foregroundColor(Color(hex: "#67C9FF")) Text(formatMinimalTime(context.state.elapsedSeconds)) .font(.system(size: 15, weight: .semibold, design: .rounded)) .monospacedDigit() // 添加暂停状态显示 if context.state.timerState == "paused" { Text(NSLocalizedString("Paused", comment: "Paused state")) .font(.system(size: 10, weight: .medium, design: .rounded)) .foregroundColor(Color(hex: "#B1BCCB")) } } } private func formatMinimalTime(_ seconds: Int64) -> String { let hours = Double(seconds) / 3600.0 let minutes = seconds / 60 if hours >= 1.0 { // 高于1小时,显示为小时格式,保留1位小数 return String(format: "%.1f%@", hours, NSLocalizedString("h", comment: "Hour unit")) } else { // 低于1小时,显示为分钟格式 return "\(minutes)\(NSLocalizedString("min", comment: "Minute unit"))" } }
}
// 添加格式化时间的辅助函数
private func formatTimeShort(_ seconds: Int64) -> String {
let hours = seconds / 3600
let minutes = (seconds % 3600) / 60
textif hours > 0 { return "\(hours):\(String(format: "%02d", minutes))" } else { return "\(minutes):\(String(format: "%02d", seconds % 60))" }
}
extension Color {
init(hex: String) {
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
textvar rgb: UInt64 = 0 Scanner(string: hexSanitized).scanHexInt64(&rgb) let red = Double((rgb & 0xFF0000) >> 16) / 255.0 let green = Double((rgb & 0x00FF00) >> 8) / 255.0 let blue = Double(rgb & 0x0000FF) / 255.0 self.init(red: red, green: green, blue: blue) }
}
// MARK: - utils/OfflineTimer/OfflineTimerHelper.swift
//
// OfflineTimerHelper.swift
// TimeMiner
//
// Created by 张磊 on 2024/4/25.
//
import Foundation
import DeviceActivity
import ActivityKit
class OfflineTimerHelper {
textprivate static let deviceCenter = DeviceActivityCenter() private static var liveActivityUpdateTimer: Timer? static let emoji = "⌚️" // > 更新 offline timer // > 停掉 limit/recharge 的 monitor // > shield掉所有app static func start(type: OfflineTimerType) { FileLog.log(msg: "\(emoji) timer start \(type.emoji)") let status = OfflineTimerStatus(type: type, state: .running, startTime: .now, endTime: nil, runingStartTime: .now, accumulatedSec: 0, pauseCount: 0) OfflineTimerStoreHelper.saveStatus(status) stopOtherMonitorsShieldAllAndStartTimer(type: type) // 启动灵动岛 if #available(iOS 16.1, *) { startLiveActivity(type: type) startLiveActivityUpdateTimer() } } static func getOfflineStatusShield() -> ShieldStatus? { if let t = OfflineTimerStoreHelper.getStatus()?.type { return ShieldStatus(type: .offline, offlineType: t) } return nil } private static func stopOtherMonitorsShieldAllAndStartTimer(type: OfflineTimerType) { ShieldHelper.refreshOnMain() } // > 更新 offline timer // > 刷新 shield settings static func pause(suspend: Bool) { FileLog.log(msg: "\(emoji) timer pause") OfflineTimerStoreHelper.updateStatus(changing: { old in if let status = old, let runingStartTime = status.runingStartTime { let totalSec = max(Int64(0), Int64(Date.now.timeIntervalSince(runingStartTime))) + status.accumulatedSec if (suspend) { // 当 suspend = false 时,说明时主程序调用,此时直接存到db里,不需要存到 store 里 if (Date.now.timeIntervalSince(runingStartTime) > 60) { TimeTrackStore.save(track: TimeTrack(start: runingStartTime, end: .now, type: .offlineTimer, offlineType: status.type)).wait() } } return OfflineTimerStatus(type: status.type, state: .paused, startTime: status.startTime, endTime: nil, runingStartTime: nil, accumulatedSec: totalSec, pauseCount: status.pauseCount + 1) } return nil }) // 确保UserDefaults同步 StoreHelper.userDefaults.synchronize() onTimerPauseOrStop(suspend: suspend) // 更新灵动岛状态 if #available(iOS 16.1, *) { updateLiveActivity() stopLiveActivityUpdateTimer() } // 添加通知,确保 UI 更新 NotificationCenter.default.post(name: .offlineTimerStatusChange, object: OfflineTimerStoreHelper.getStatus()) } private static func onTimerPauseOrStop(suspend: Bool) { ShieldHelper.refreshOnMain() } // > 更新 offline timer // > 停掉 limit/recharge 的 monitor // > shield掉所有app static func resume() { if EarlySleepHelp.isRunning() { return } FileLog.log(msg: "\(emoji) timer resume") var type: OfflineTimerType? OfflineTimerStoreHelper.updateStatus(changing: { old in if let status = old { type = status.type return OfflineTimerStatus(type: status.type, state: .running, startTime: status.startTime, endTime: nil, runingStartTime: .now, accumulatedSec: status.accumulatedSec, pauseCount: status.pauseCount) } return nil }) // 确保UserDefaults同步 StoreHelper.userDefaults.synchronize() if let t = type { stopOtherMonitorsShieldAllAndStartTimer(type: t) } // 更新灵动岛状态 if #available(iOS 16.1, *) { updateLiveActivity() startLiveActivityUpdateTimer() } // 添加通知,确保 UI 更新 NotificationCenter.default.post(name: .offlineTimerStatusChange, object: OfflineTimerStoreHelper.getStatus()) } // > 更新 offline timer 状态为 stoped,等待用户下次打开app时存到db // > 启动 limit/recharge monitor // > 刷新 shield settings static func stop() { OfflineTimerStoreHelper.updateStatus(changing: { old in if let o = old { var totalSec = o.accumulatedSec if let runingStartTime = o.runingStartTime { totalSec += max(Int64(0), Int64(Date.now.timeIntervalSince(runingStartTime))) } return OfflineTimerStatus(type: o.type, state: .stoped, startTime: o.startTime, endTime: .now, runingStartTime: nil, accumulatedSec: totalSec, pauseCount: o.pauseCount) } return nil }) // 确保UserDefaults同步 StoreHelper.userDefaults.synchronize() onTimerPauseOrStop(suspend: false) // 结束灵动岛 if #available(iOS 16.1, *) { endLiveActivity() stopLiveActivityUpdateTimer() } // 添加通知,确保 UI 更新 NotificationCenter.default.post(name: .offlineTimerStatusChange, object: OfflineTimerStoreHelper.getStatus()) } static func pauseIfRunningOnNonMain() { if OfflineTimerStoreHelper.isRunning() { pause(suspend: true) } } // MARK: - 灵动岛相关方法 @available(iOS 16.1, *) private static func startLiveActivity(type: OfflineTimerType) { if let status = OfflineTimerStoreHelper.getStatus() { // 启动灵动岛活动 LiveActivityManager.shared.startActivity( timerName: type.title, emoji: type.emoji, timerType: type.rawValue ) } } @available(iOS 16.1, *) private static func updateLiveActivity() { if let status = OfflineTimerStoreHelper.getStatus() { var elapsedSeconds = status.accumulatedSec // 如果计时器正在运行,需要加上当前运行的时间 if status.state == .running, let runingStartTime = status.runingStartTime { elapsedSeconds += Int64(Date.now.timeIntervalSince(runingStartTime)) } // 更新灵动岛活动,传递暂停状态 LiveActivityManager.shared.updateActivity( elapsedSeconds: elapsedSeconds, isPaused: status.state == .paused ) } else { print("OfflineTimerHelper: 无法更新LiveActivity - 状态为空") FileLog.log(msg: "OfflineTimerHelper: 无法更新LiveActivity - 状态为空") } } @available(iOS 16.1, *) private static func endLiveActivity() { // 结束灵动岛活动 LiveActivityManager.shared.endActivity() } // MARK: - 灵动岛更新计时器 @available(iOS 16.1, *) private static func startLiveActivityUpdateTimer() { // 停止已有的计时器 stopLiveActivityUpdateTimer() // 创建新的计时器,每秒更新一次灵动岛 liveActivityUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in updateLiveActivity() } } @available(iOS 16.1, *) private static func stopLiveActivityUpdateTimer() { liveActivityUpdateTimer?.invalidate() liveActivityUpdateTimer = nil } // 添加初始化通知监听的方法 static func setupNotificationObserver() { print("设置离线计时器通知监听器") // 确保在主线程上执行 if !Thread.isMainThread { DispatchQueue.main.async { setupNotificationObserver() } return } // 由于我们现在直接在AppIntent中调用相应方法,不再需要通知监听器 // 但为了保持代码的完整性,我们保留这个方法 print("离线计时器通知监听器设置完成") } // 处理按钮操作的方法 private static func handleAction(_ action: String) { print("开始处理离线计时器操作: \(action)") switch action { case "pause": print("执行暂停操作") pause(suspend: false) FileLog.log(msg: "灵动岛触发暂停操作") case "resume": print("执行继续操作") resume() FileLog.log(msg: "灵动岛触发继续操作") case "stop": print("执行停止操作") stop() FileLog.log(msg: "灵动岛触发停止操作") default: print("未知操作: \(action)") FileLog.log(msg: "灵动岛触发未知操作: \(action)") } print("离线计时器操作处理完成: \(action)") } // 在应用启动时调用setupNotificationObserver static func initialize() { print("初始化离线计时器助手") // 设置通知监听器 setupNotificationObserver() // 其他初始化代码... print("离线计时器助手初始化完成") }
}
// MARK: - utils/OfflineTimer/OfflineTimerRedeemInfo.swift
//
// OfflineTimerRedeemInfo.swift
// TimeMiner
//
// Created by 张磊 on 2024/4/26.
//
import Foundation
// 当天的离线计时总时间 = offlineTimer.db 中endTime在今天的所有数据加起来的和
//
struct OfflineTimerRedeemInfo: Codable {
var costTimerMins: Int // 消耗的 offline timer 分钟
var redeemedMins: Int // 获得了多少分钟
}
// MARK: - utils/OfflineTimer/OfflineTimerStatus.swift
//
// OfflineTimerStatus.swift
// TimeMiner
//
// Created by 张磊 on 2024/4/25.
//
import Foundation
struct OfflineTimerStatus: Codable, CustomStringConvertible {
let type: OfflineTimerType
let state: OfflineTimerState // 当前状态
textlet startTime: Date // 开始时点 let endTime: Date? // 停止时点 let runingStartTime: Date? // 从哪个时点开始的,只有当 type == running 时才有意义 let accumulatedSec: Int64 // 累计的秒数,不包含正在进行的 let pauseCount: Int var description: String { return "\(type)-\(state) (\(startTime.secStr())-\(endTime?.secStr() ?? "?")) running(\(runingStartTime?.secStr() ?? "?")) accumulatedSec \(accumulatedSec) pauseCount \(pauseCount)" } func getOfflineDurSec() -> Int64 { if let status = OfflineTimerStoreHelper.getStatus() { var durSec = status.accumulatedSec if status.state == .running, let runingStartTime = status.runingStartTime { durSec += Int64(Date.now.timeIntervalSince(runingStartTime)) } return durSec } return 0 }
}
enum OfflineTimerState: String, Codable {
case running
case paused
case stoped
}
// MARK: - utils/OfflineTimer/OfflineTimerStoreHelper.swift
//
// OfflineTimerStoreHelper.swift
// TimeMiner
//
// Created by 张磊 on 2024/4/26.
//
import Foundation
import Combine
class OfflineTimerStoreHelper {
textstatic let statusFlow = CurrentValueSubject<OfflineTimerStatus?, Never>(nil) static func loadRedeemInfo() -> OfflineTimerRedeemInfo? { if let savedData = StoreHelper.userDefaults.data(forKey: StoreKeys.offline_timer_redeem_info) { let decoder = JSONDecoder() if let loadedData = try? decoder.decode(OfflineTimerRedeemInfo.self, from: savedData) { return loadedData } } return nil } static func saveRedeemInfo(_ data: OfflineTimerRedeemInfo) { let encoder = JSONEncoder() if let encodedData = try? encoder.encode(data) { StoreHelper.userDefaults.set(encodedData, forKey: StoreKeys.offline_timer_redeem_info) StoreHelper.userDefaults.synchronize() DispatchQueue.main.async { NotificationCenter.default.post(name: .offlineTimerRedeemInfoChange, object: data) } } } static func getTotalTime() -> Int { return StoreHelper.getInt(key: StoreKeys.total_offline_timer_mins) } static func getRemainTime() -> Int { let totalMins = getTotalTime() let info = loadRedeemInfo() return max(0, totalMins - (info?.costTimerMins ?? 0)) } static func setTotalTime(mins: Int) { StoreHelper.saveInt(key: StoreKeys.total_offline_timer_mins, value: mins) DispatchQueue.main.async { NotificationCenter.default.post(name: .offlineTimerTotalMinsChange, object: mins) } } static func totalMinsAdd(mins: Int) { setTotalTime(mins: getTotalTime() + mins) } static func getStatus() -> OfflineTimerStatus? { if let savedData = StoreHelper.userDefaults.data(forKey: StoreKeys.offline_timer_status) { let decoder = JSONDecoder() if let loadedData = try? decoder.decode(OfflineTimerStatus.self, from: savedData) { return loadedData } } return nil } static func isRunning() -> Bool { return getStatus()?.state == .running } static func updateStatus(changing: (OfflineTimerStatus?) -> OfflineTimerStatus?) { FileLog.log(msg: "OfflineTimerStoreHelper: 开始更新状态") let oldStatus = getStatus() FileLog.log(msg: "OfflineTimerStoreHelper: 旧状态 = \(oldStatus?.description ?? "nil")") if let newStatus = changing(oldStatus) { saveStatus(newStatus) FileLog.log(msg: "OfflineTimerStoreHelper: 状态已更新为 = \(newStatus.description)") } else { FileLog.log(msg: "OfflineTimerStoreHelper: 状态更新失败 - 新状态为nil") } } // 保存数据到UserDefaults static func saveStatus(_ data: OfflineTimerStatus) { let encoder = JSONEncoder() if let encodedData = try? encoder.encode(data) { StoreHelper.userDefaults.set(encodedData, forKey: StoreKeys.offline_timer_status) StoreHelper.userDefaults.synchronize() FileLog.log(msg: "OfflineTimerStoreHelper: 状态已保存到UserDefaults") DispatchQueue.main.async { NotificationCenter.default.post(name: .offlineTimerStatusChange, object: data) FileLog.log(msg: "OfflineTimerStoreHelper: 已发送状态变更通知") } } else { FileLog.log(msg: "OfflineTimerStoreHelper: 状态保存失败 - 编码错误") } print("save status \(data.description)") statusFlow.send(data) } static func clearStatus() { StoreHelper.clearKeys(keys: [StoreKeys.offline_timer_status]) statusFlow.send(nil) }
}
// MARK: - utils/OfflineTimer/OfflineTimerType.swift
//
// OfflineTimerInfo.swift
// TimeMiner
//
// Created by 张磊 on 2024/4/25.
//
import Foundation
enum OfflineTimerType: String, Codable {
textcase study // 学习 case work // 工作 case read // 读书 case fitness // 健身 case meditate // 冥想 static var defaultValue: OfflineTimerType { return .meditate } var emoji: String { switch(self) { case .meditate: return "🧘" case .study: return "🎓" case .work: return "🧑💻" case .read: return "📚" case .fitness: return "🏋️" } } var title: String { switch(self) { case .meditate: return NSLocalizedString("mediate", comment: "") case .work: return NSLocalizedString("work", comment: "") case .read: return NSLocalizedString("read", comment: "") case .fitness: return NSLocalizedString("fitness", comment: "") case .study: return NSLocalizedString("study", comment: "") } } var blockTitle: String { return "\(title)\n\(NSLocalizedString("offline_timing", comment: ""))" } var blockSubtitle: String { return NSLocalizedString("offline_timer_subtitle", comment: "") } var primaryBtnText: String { return NSLocalizedString("block_continue_offline", comment: "") } var secondaryBtnText: String { return NSLocalizedString("pause_offline", comment: "") }
}
这是优化的具体思路 : 但是内部部分已经完成
内部计时逻辑:由应用内部负责记录开始、结束、暂停等时间点,并存储至 UserDefaults,即使应用退出也能保证数据不会丢失。这种方案可以确保计时状态的持续性和正确性。
外部显示与同步:利用灵动岛的 Text(timerInterval:) 来展示计时状态,依靠系统级的时间计算来保持显示的实时更新,这样即使应用进程被暂停或者退出,计时器的显示依然能保持正确的时间显示。
AppIntent 交互:使用 AppIntent 来实现内外部状态同步,通过与应用内部的数据交互更新状态(例如暂停、继续、中止),可以使灵动岛上的显示与应用内部计时逻辑保持一致。
我们主要修改的是 利用灵动岛的 Text(timerInterval:) 来展示计时状态,依靠系统级的时间计算来保持显示的实时更新,这样即使应用进程被暂停或者退出,计时器的显示依然能保持正确的时间显示。
但是还有很多细节需要仔细审查 辨别 给我详细的修改思路
先不要写代码先思考
请一步一步的给我详细的思考链和具体步骤的解析
下面给出一个比较系统且循序渐进的思考流程,帮助你从“内部计时逻辑”到“Live Activity 展示”再到“AppIntent 交互”一步步梳理如何将灵动岛的计时显示与应用内部的离线计时逻辑整合起来,同时利用系统级 Text(timerInterval:)
来自动刷新 UI。以下步骤并没有直接给出实现代码,而是着重强调思路、原理与流程。
内部计时逻辑:
外部展示与自动刷新:
ActivityKit
提供了 Text(timerInterval:)
这种基于系统级别的计时控件。它可以根据一个指定的开始时间和结束时间,自动进行实时的倒计时或正计时渲染,而无需我们自己通过定时器去 update()
Live Activity。AppIntent 交互:
AppIntent
,以便让应用内部能够同步更新状态。timerInterval
)能让我们在 UI 层级上不必每秒手动去 update()
, 避免在后台时被系统限制更新频率,从而在锁屏和灵动岛始终显示准确的计时信息。Text(timerInterval: startDate...endDate, countsDown: false)
这种形式可以做正向计时(从 startDate
到 endDate
之间显示经过的时间)。endDate
设得非常久远(例如几小时甚至几天后),或者动态更新 endDate
。timerInterval
乃至 countsDown
的值实现不同的效果。startTime
如果要用到 Text(timerInterval:)
,通常需要把计时开始的时间(以及预计结束的时间)放进你的 ActivityAttributes.ContentState
或者 ActivityAttributes
中。
OfflineTimerAttributes.ContentState
中增加一个字段:var startDate: Date
。startDate = Date()
;如果你有结束时间的概念,也可以加 var endDate: Date?
。elapsedSeconds
在你原先的逻辑里,你通过每秒计算并手动更新 elapsedSeconds
。如果要让系统自动刷新 Live Activity,不需要再手动每秒推送更新内容,而是把实际的 “开始时间” 和 “结束时间” 或者 “暂停时间” 告诉系统,令其自行绘制。
Text(timerInterval:)
的关键点在 Live Activity 的视图里,你可以写类似:
swiftText(timerInterval: state.startDate...Date(), countsDown: false)
state.startDate
到当前时间进行正计时。Date()
在 Live Activity 的 Widget Extension 中可以直接拿到“当前时间”吗?其实系统会自动帮你进行流动性的渲染,你可以只在初次渲染时提供一个 Date.distantFuture
做上限,或者在 ContentState
里也放一个“动态计算的 endDate”。update()
。startDate
调整为 (当前时间 - 已经过去的秒数) 这样就能继续往后累计。ActivityAttributes.ContentState
里塞入合适的 startDate
,就可以让系统层的动态文本继续滚动或静止。Text(timerInterval:)
,需要用 16.1 版本的 DynamicIsland
里手动更新 elapsedTime
。所以在代码里往往需要做 “if #available(iOS 16.2, *)
就用系统级计时;否则自己定期更新 elapsedTime
” 这样一个兼容处理。Text(timerInterval:)
优化,避免多余的 Timer。当用户通过灵动岛或锁屏界面点击“暂停”按钮(触发 PauseTimerIntent
)时:
.paused
,记录当前时间等。startDate...startDate
(或者干脆把 endDate = startDate
),让 Text(timerInterval:)
不再前进;对 iOS 16.1 则手动改 ContentState.elapsedTime
。ContentState
中反映。当用户点击“继续”(ResumeTimerIntent
)时:
.running
,并且把 startDate
调整为 “现在 - 已累积时长”。当用户点击“停止”(StopTimerIntent
)时:
.stopped
,持久化到本地数据库或 UserDefaults。activity.end(dismissalPolicy:...)
)。AppIntent
有机会拉起进程或执行某些操作时,你要确保能够正确地反序列化原先保存在 UserDefaults 中的状态。App Group
里,那么一定要在主 App、Widget Extension、AppIntent Extension 间使用相同的 App Group
,保证数据共享。原先的做法(Timer.scheduledTimer
+ updateActivity()
):
activity.update(...)
。现在的做法(iOS 16.2+,Text(timerInterval:)
):
ContentState
(或直接以 ActivityAttributes
),由系统去做计时显示,不需要手动的 Timer。activity.update(...)
。而不是每秒都更新。保留 fallback:
Text(timerInterval:)
。#available
条件语句配合即可。拓展 OfflineTimerAttributes.ContentState
startDate
,必要时还可以加 endDate
(如果你有预计结束时间的需求)。timerState
(running / paused / stopped)以表示当前状态。body
或 LockScreenView
/DynamicIslandExpandedRegion
的 View
中使用:
等类似写法(仅举例),实现正向计时或暂停不动。swiftif context.state.timerState == "running" { Text(timerInterval: context.state.startDate...Date(), countsDown: false) } else if context.state.timerState == "paused" { Text("已暂停") }
在“启动计时”时
startTime = Date()
并保存到 UserDefaults。Activity<OfflineTimerAttributes>
,给 ContentState
初始化 startDate = Date()
,timerState = "running"
。timerInterval
,iOS 16.1 用手动数值刷新。在“暂停计时”时
accumulatedSec
,并置状态为 paused
。timerState = "paused"
,UI 上就切到暂停状态,也可以把 startDate
改成一个固定值让 timerInterval
不再滚动。elapsedSeconds
或字符串显示。在“继续计时”时
startDate
= Date() - accumulatedSec
,这样 timerInterval
就能从之前的累积时间“继续”往后走。timerState = "running"
+ startDate = 现在 - 累计秒数
。在“停止计时”时
activity.end(...)
结束活跃状态,并设定“收尾时要展示的内容”(如果需要)。处理 AppIntent
PauseTimerIntent
, ResumeTimerIntent
, StopTimerIntent
三个 Intent 中,先做内部状态更新(以 UserDefaults 为主),再做Live Activity 的更新/结束操作。AppIntent
来改变状态时,必须能读/写相同的存储。timerInterval
减少频繁更新、自动保证在后台锁屏时也能实时刷新计时。accumulatedSec
(已累计的总时长) 和 startDate
(当前这段的开始时刻)。每次暂停都更新累计时长,每次继续都设置新的 startDate
。Text(timerInterval:)
;16.2+ 可以直接使用,替换掉原先的每秒定时器。if #available(iOS 16.2, *) {...} else {...}
同时保留老版本逻辑。综上,改造的关键在于把“所有时间计算”放在内部(以免进程退出后丢状态),而将“实时刷新”交给系统级控件(Text(timerInterval:)
)来做。这样,只要在关键的状态切换时更新一两次 ActivityContent
,就可以让系统自动在锁屏或灵动岛上进行流畅的计时动画,而不必手动每秒去更新它。
当你在正式实现时,可以把以上几个步骤融入到你现有的 OfflineTimerAttributes
、OfflineTimerHelper
、LiveActivityManager
等结构中,进行相应字段和逻辑的调整即可。这样既能保留你原有的业务逻辑(包括持久化和统计),也可以充分利用 iOS 16.2+ 提供的新特性来减轻手动刷新负担,并在应用被杀或后台时依旧在灵动岛界面进行准确的时间展示。