// Merged Swift Files // MARK: - utils/OfflineTi...
创建于:2025年3月12日
创建于:2025年3月12日
// Merged Swift Files
// MARK: - utils/OfflineTimer/Live/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)") } } return activity }
func updateActivity(elapsedSeconds: Int64, isPaused: Bool = false) {
print("更新 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 { print("正在更新 LiveActivity ID: \(activity.id)") await activity.update(content) print("LiveActivity 更新完成") } else { print("无法更新 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 { await activity.update(using: contentState) } } }
}
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/Live/OfflineTimerAttributes.swift
import ActivityKit
import SwiftUI
struct OfflineTimerAttributes: ActivityAttributes {
textpublic struct ContentState: Codable, Hashable { // 动态属性,可以在活动创建后更新 var elapsedTime: String // 已经过的时间,格式化为字符串 var elapsedSeconds: Int64 // 已经过的秒数 var timerType: String // 计时器类型(如健身、学习等) var timerState: String // 计时器状态(running, paused, stopped) } // 静态属性,只在创建活动时使用 var timerName: String // 计时器名称 var emoji: String // 计时器类型对应的表情符号
}
// MARK: - utils/OfflineTimer/Live/OfflineTimerIntent.swift
import AppIntents
import SwiftUI
// 添加 @available 注解确保在正确的 iOS 版本上使用
@available(iOS 16.1, *)
struct OfflineTimerIntent: AppIntent, LiveActivityIntent {
// 添加静态类型标识符
static var typeDisplayRepresentation: TypeDisplayRepresentation {
return TypeDisplayRepresentation(name: "离线计时器操作")
}
textstatic var title: LocalizedStringResource = "离线计时器操作" static var description: IntentDescription = IntentDescription("控制离线计时器") @Parameter(title: "操作") var action: String init() { self.action = "pause" } init(action: String) { self.action = action } // 确保操作在后台运行,不打开应用 static var openAppWhenRun: Bool { false } func perform() async throws -> some IntentResult { // 添加日志,帮助调试 print("AppIntent执行: \(action)") // 直接调用相应方法,不使用通知 await MainActor.run { print("直接执行操作: \(action)") switch action { case "pause": print("执行暂停操作") OfflineTimerHelper.pause(suspend: false) // 立即更新 LiveActivity if #available(iOS 16.1, *) { if let status = OfflineTimerStoreHelper.getStatus() { LiveActivityManager.shared.updateActivity( elapsedSeconds: status.accumulatedSec, isPaused: true ) } } case "resume": print("执行继续操作") OfflineTimerHelper.resume() // 立即更新 LiveActivity if #available(iOS 16.1, *) { if let status = OfflineTimerStoreHelper.getStatus() { LiveActivityManager.shared.updateActivity( elapsedSeconds: status.accumulatedSec, isPaused: false ) } } case "stop": print("执行停止操作") OfflineTimerHelper.stop() // LiveActivity 会在 stop 方法中结束 default: print("未知操作: \(action)") } // 发送通知以刷新 UI NotificationCenter.default.post(name: .offlineTimerStatusChange, object: OfflineTimerStoreHelper.getStatus()) } return .result() }
}
// MARK: - utils/OfflineTimer/Live/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) } } } private 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: 20, style: .continuous) .foregroundStyle(Color.black) VStack(spacing: 0) { HStack { Text("Offline Timer") .font(.headline) .foregroundColor(.cyan) Spacer() } .padding(.horizontal) .padding(.top, 8) HStack(alignment: .center) { // 健身图标和类型 VStack(alignment: .leading, spacing: 4) { Text(context.attributes.emoji) .font(.title2) Text(context.attributes.timerName) .font(.caption) .foregroundColor(.white) } Spacer() // 大号时间显示 - 已经过时间 Text(context.state.elapsedTime) .font(.system(size: 60, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundColor(.white) Spacer() // 控制按钮 VStack(spacing: 10) { if #available(iOS 17.0, *) { // 使用AppIntents框架处理按钮交互 let isPaused = context.state.timerState == "paused" let action = isPaused ? "resume" : "pause" Button(intent: OfflineTimerIntent(action: action)) { Circle() .fill(Color.cyan) .frame(width: 40, height: 40) .overlay( Image(systemName: isPaused ? "play.fill" : "pause.fill") .font(.title3) .foregroundColor(.white) ) } Button(intent: OfflineTimerIntent(action: "stop")) { Circle() .fill(Color.red) .frame(width: 40, height: 40) .overlay( Image(systemName: "stop.fill") .font(.title3) .foregroundColor(.white) ) } } } } .padding(.horizontal) .padding(.vertical, 8) } } }
}
@available(iOS 16.1, *)
private struct LeadingView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { VStack(alignment: .leading, spacing: 4) { Text(context.attributes.timerName) .font(.system(size: 16, weight: .medium)) .foregroundColor(.primary) Text(context.attributes.emoji) .font(.title2) } .padding(.leading, 8) }
}
@available(iOS 16.1, *)
private struct CenterView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { VStack(spacing: 4) { Text("Offline Timer") .font(.caption) .foregroundColor(.cyan) .padding(.top, 4) Text(context.state.elapsedTime) .font(.system(size: 60, weight: .bold, design: .rounded)) .monospacedDigit() .contentTransition(.numericText()) .foregroundColor(.white) } }
}
@available(iOS 16.1, *)
private struct TrailingView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { if #available(iOS 17.0, *) { // 使用AppIntents框架处理按钮交互 let isPaused = context.state.timerState == "paused" let action = isPaused ? "resume" : "pause" Button(intent: OfflineTimerIntent(action: action)) { Circle() .fill(Color.cyan) .frame(width: 40, height: 40) .overlay( Image(systemName: isPaused ? "play.fill" : "pause.fill") .font(.title3) .foregroundColor(.white) ) } .padding(.trailing, 8) } }
}
@available(iOS 16.1, *)
private struct BottomView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { if #available(iOS 17.0, *) { // 使用AppIntents框架处理按钮交互 Button(intent: OfflineTimerIntent(action: "stop")) { Circle() .fill(Color.red) .frame(width: 40, height: 40) .overlay( Image(systemName: "stop.fill") .font(.title3) .foregroundColor(.white) ) } .padding(.bottom, 8) } }
}
@available(iOS 16.1, *)
private struct CompactLeadingView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { Image(systemName: "timer") .font(.system(size: 20)) .foregroundColor(.cyan) .frame(width: 30, height: 30) }
}
@available(iOS 16.1, *)
private struct CompactTrailingView: View {
let context: ActivityViewContext<OfflineTimerAttributes>
textvar body: some View { Text(formatTimeShort(context.state.elapsedSeconds)) .font(.system(size: 18, weight: .semibold)) .monospacedDigit() .foregroundColor(.primary) .lineLimit(1) .minimumScaleFactor(0.8) .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 { ZStack { Capsule() .fill(Color.black) .frame(height: 30) HStack(spacing: 4) { Text("TIMER") .font(.system(size: 10, weight: .bold)) .foregroundColor(.cyan) Text(formatMinimalTime(context.state.elapsedSeconds)) .font(.system(size: 12, weight: .bold)) .monospacedDigit() .foregroundColor(.white) } .padding(.horizontal, 10) } } private func formatMinimalTime(_ seconds: Int64) -> String { let minutes = seconds / 60 let remainingSeconds = seconds % 60 return "\(minutes):\(String(format: "%02d", remainingSeconds))" }
}
// 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 }) 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 }) 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 }) 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 ) } } @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?) { if let newStatus = changing(getStatus()) { saveStatus(newStatus) } } // 保存数据到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() DispatchQueue.main.async { NotificationCenter.default.post(name: .offlineTimerStatusChange, object: data) } } 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: "") }
}
//
// OfflineTimerCard.swift
// TimeMiner
//
// Created by 张磊 on 2024/4/26.
//
import SwiftUI
import Combine
struct OfflineTimerCard: View {
text@StateObject private var dataHelper = OfflineTimerDataHelper() @EnvironmentObject var mainHelper: MainDataHelper var body: some View { VStack(spacing: 0) { HStack(spacing: 0) { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(dataHelper.minsUnRedeem.description) .numberFont(size: 32, weight: .w_700) .foregroundColor(.color_text) Text(NSLocalizedString("min", comment: "")) .defaultFont(size: 18, weight: .w_600) .foregroundColor(.color_text) Text(NSLocalizedString("to_redeem", comment: "")) .defaultFont(size: 18, weight: .w_600) .foregroundColor(.color_text_subtlest) } Spacer() HStack (spacing: 8, content: { Text(dataHelper.emoji) .defaultFont(size: 20, weight: .w_600) .foregroundColor(.color_text_subtlest) Text(dataHelper.title) .defaultFont(size: 18, weight: .w_500) .foregroundColor(.color_text) }) } HStack { VStack(alignment: .leading, spacing: 4, content: { Text(String(format: NSLocalizedString("redeemed_mins", comment: ""), dataHelper.redeemedMins)) .defaultFont(size: 15, weight: .w_500) .foregroundColor(.color_text_subtlest) Text(String(format: NSLocalizedString("offline_timer_total_mins", comment: ""), dataHelper.totalOfflineMins)) .defaultFont(size: 18, enWeight: .w_700, otherW: .w_600) .foregroundColor(.color_text) }).padding(.top, 24) Spacer() } Color.clear.frame(height: 12) if (dataHelper.isPaused) { HStack(spacing: 12) { ResumeTimer(durSecForNow: dataHelper.pausedTimerDurSecForNow) .onTapGesture { OfflineTimerHelper.resume() HapticManager.shared.impactOccurred(.light) } StopTimer().onTapGesture { dataHelper.showStopDoubleCheck = true HapticManager.shared.impactOccurred(.medium) } } } else { if (dataHelper.mins2Redeem > 0) { HStack(spacing: 12) { StartOffline(primary: false) .onTapGesture { mainHelper.showChooseTimerType { type in onChosenTimerType(type: type) } HapticManager.shared.impactOccurred(.light) } AddMinButton(mins: dataHelper.mins2Redeem) .gesture(SimultaneousGesture(TapGesture().onEnded({ dataHelper.redeem() }), TapGesture(count: 2).onEnded({ // TODO: }))) } } else { StartOffline(primary: true) .onTapGesture { mainHelper.showChooseTimerType { type in onChosenTimerType(type: type) HapticManager.shared.impactOccurred(.light) } } } } } .padding(.all, 12) .background(RoundedRectangle(cornerRadius: 20).fill(Color.color_background_accent_gray_subtle) .shadow(color: .black.opacity(0.06), radius: 20, x: 0, y: 4)) .onAppear { dataHelper.refresh() } .alert(isPresented: $dataHelper.showStopDoubleCheck) { offlineStopDoubleCheckAlert() } }
}
struct StartOffline: View {
text// true -- 蓝色 // false -- 白色 let primary: Bool var body: some View { HStack(spacing: 10) { let iconTxtColor: Color = primary ? .color_text_white_solid : .color_border_selected SVGView(svgName: "timer", strokeColor: iconTxtColor, fillColor: .clear) .frame(width: 20, height: 20) Text(NSLocalizedString("start_offline", comment: "")) .defaultFont(size: 16, enWeight: .w_700, otherW: .w_600) .foregroundColor(iconTxtColor) }.padding(.vertical, 9) .frame(maxWidth: .infinity) .frame(minHeight: 42) .background(RoundedRectangle(cornerRadius: 12).fill(primary ? Color.color_background_brand_solid : Color.color_background_neutral)) }
}
struct ResumeTimer: View {
textlet durSecForNow: Int64 var body: some View { HStack(spacing: 10) { Image("ic_play").frame(width: 20, height: 20) Text(String(format: NSLocalizedString("continue_offline", comment: ""), durSecForNow.sec2ColonStr)) .defaultFont(size: 16, enWeight: .w_700, otherW: .w_600) .foregroundColor(.color_text_white_solid) } .frame(height: 42) .frame(maxWidth: .infinity) .background(RoundedRectangle(cornerRadius: 12).fill(Color.color_background_brand_solid)) }
}
struct StopTimer: View {
textvar body: some View { HStack(spacing: 10) { Image("ic_stop").frame(width: 20, height: 20) Text(NSLocalizedString("stop", comment: "")) .defaultFont(size: 16, enWeight: .w_700, otherW: .w_600) .foregroundColor(.color_text_white_solid) } .frame(height: 42) .padding(.horizontal, 17) .background(RoundedRectangle(cornerRadius: 12).fill(Color.color_accent_red_100_solid)) }
}
class OfflineTimerDataHelper: ObservableObject {
text@Published var minsUnRedeem: Int = 0 @Published var redeemedMins = 0 @Published var totalOfflineMins = 0 @Published var mins2Redeem = 0 @Published var isPaused = false @Published var emoji: String = "⏲️" @Published var title: String = NSLocalizedString("offline_timer", comment: "") @Published var pausedTimerDurSecForNow: Int64 = 0 @Published var showStopDoubleCheck = false @ObservedObject private var redeemConfig = RedeemConfig.shared private var costOfflineMins = 0 private var cancellables = Set<AnyCancellable>() init() { NotificationCenter.default.publisher(for: .offlineTimerTotalMinsChange).sink(receiveValue: { out in if let mins = out.object as? Int { self.updateTotal(mins) } }).store(in: &cancellables) NotificationCenter.default.publisher(for: .offlineTimerStatusChange).sink(receiveValue: { out in if let status = out.object as? OfflineTimerStatus { self.updateStatus(status) } }).store(in: &cancellables) NotificationCenter.default.publisher(for: .offlineTimerRedeemInfoChange).sink( receiveValue: { out in if let info = out.object as? OfflineTimerRedeemInfo { self.updateRedeemInfo(info) } }).store(in: &cancellables) redeemConfig.changePublisher(type: .offline_timer).sink(receiveValue: { _ in DispatchQueue.main.async { // 防止数据变化还没生效 self.updateMins2Redeem() } }).store(in: &cancellables) NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification).sink(receiveValue: { _ in self.refresh() }).store(in: &cancellables) } var costMinsForThisRedeem: Int { return mins2Redeem * redeemConfig.difficulty.offlineTimerMinsFor1Min } private func updateMins2Redeem() { let mins: Int = minsUnRedeem / redeemConfig.difficulty.offlineTimerMinsFor1Min if (mins > 0) { mins2Redeem = min(redeemConfig.limit.mins, mins) } else { mins2Redeem = 0 } } func redeem() { if (mins2Redeem > 0) { let mins = mins2Redeem mins2Redeem = 0 let oldInfo = OfflineTimerStoreHelper.loadRedeemInfo() let costOfflineMins = redeemConfig.difficulty.offlineTimerMinsFor1Min * mins let info = OfflineTimerRedeemInfo(costTimerMins: (oldInfo?.costTimerMins ?? 0) + costOfflineMins, redeemedMins: (oldInfo?.redeemedMins ?? 0) + mins) OfflineTimerStoreHelper.saveRedeemInfo(info) StoreHelper.producedMinsAdd(mins) Tracking.trackRedeem(costType: .offlineTimer, redeemLimit: redeemConfig.limit, costStepCntOrMins: costOfflineMins, gotMins: mins) FileLog.log(msg: "[OFFLINE_TIMER >>>] got: [\(mins) / \(info.redeemedMins)], timer mins: [ \(info.costTimerMins) / \(totalOfflineMins)] difficulty: [\(redeemConfig.difficulty.offlineTimerMinsFor1Min)]") } } func refresh() { updateTotal(OfflineTimerStoreHelper.getTotalTime()) updateRedeemInfo(OfflineTimerStoreHelper.loadRedeemInfo()) updateStatus(OfflineTimerStoreHelper.getStatus()) } func updateRedeemInfo(_ info: OfflineTimerRedeemInfo?) { redeemedMins = info?.redeemedMins ?? 0 costOfflineMins = info?.costTimerMins ?? 0 updateUnRedeemMins() } func updateStatus(_ status: OfflineTimerStatus?) { isPaused = status?.state == .paused pausedTimerDurSecForNow = isPaused ? status?.accumulatedSec ?? 0 : 0 emoji = (isPaused ? status?.type.emoji : nil) ?? "⏲️" title = (isPaused ? status?.type.title : nil) ?? NSLocalizedString("offline_timer", comment: "") } func updateTotal(_ mins: Int) { totalOfflineMins = mins updateUnRedeemMins() } private func updateUnRedeemMins() { minsUnRedeem = max(0, totalOfflineMins - costOfflineMins) updateMins2Redeem() }
}
#Preview {
OfflineTimerCard()
}
现在的问题是 点击灵动岛上(或者动态锁屏)的button 无法触发相关行为(暂停 继续等 )
先不要写代码先思考
请一步一步的给我详细的思考链和具体步骤的解析
分析哪里出错了 应该如何修改
下面给出一个比较详细的「排查思路」和「可能的修改方向」,帮助你一步一步地定位为什么点击灵动岛/锁屏按钮没有触发暂停、继续、停止等操作。请先耐心看完整个思路,再去对照项目中的实际情况来进行调整。
Button(intent:) 并不工作在你的代码里,你使用了类似下面的写法(在 LockScreenView、TrailingView、BottomView 等多个地方都出现了):
swiftif #available(iOS 17.0, *) { Button(intent: OfflineTimerIntent(action: "stop")) { Circle() .fill(Color.red) .frame(width: 40, height: 40) .overlay( Image(systemName: "stop.fill") .font(.title3) .foregroundColor(.white) ) } }
但是你给这个代码块套了一个 if #available(iOS 17.0, *) { ... } else { ... } 的判断。也就是说,如果系统不是 iOS 17.0 或以上,那么这一段 Button(intent:) 的 UI 和交互代码 根本不会被执行——它在 iOS 16.1/16.2 上会被直接跳过。这是导致点击灵动岛按钮毫无反应的最常见原因。
换句话说:
Button(intent:) 是 iOS 17 新增的 “AppIntents in widgets” 交互方式。@available(iOS 16.1, *),但在 iOS 16.1/16.2 上,这种交互写法不会走 Button(intent:) 的逻辑。这一步问题的根源:在 iOS 17 之前,如果你想要灵动岛或锁屏上点按钮能触发 App 内逻辑,通常只能通过打开 App 深链接,或者用远程推送方式(比较复杂)。iOS 17 才正式支持 Button(intent:) 这种无须跳转、在锁屏/岛上直接运行 AppIntent 的做法。
如果你仅仅是在 iOS 17 上测试,且设备系统确实在 17+,那么请先确认「是否真的已经把 Deployment Target/模拟器/真机」都跑在 iOS 17,且 Info.plist、Xcode 的 Target 都已经正确启用了 “AppIntents in Widget Extension” 等设置。如果这些都没问题,但依然无效,就要看第 4、5 点。
但从你的代码注释看,你在 iOS 16.1、16.2 也想用按钮直接暂停、继续……那么就需要知道,iOS 16.1/16.2 并不原生支持 Button(intent:),且不支持在锁屏上点击后直接运行 Intent。官方当时给的思路有两种:
用 Link(destination:) 或 WidgetURL:点击后会跳转回你的 App,App 启动后在 scene(_:, openURLContexts:) 或 onOpenURL 中根据自定义的 scheme 去执行暂停或继续的逻辑。
.compactLeading / .compactTrailing / .minimal / .expanded 区域里放一个小按钮,然后用 Link(destination: URL("myApp://offlineTimer/pause")) { ... },当用户点了以后,手机会唤起你的 App,执行相应操作。用「基于推送通知的 Live Activity 更新」:当你监听到用户点击时,系统会发一个带有 push token 的事件,你可以从服务器下发一个更新 Activity 的推送。但这种做法也相当麻烦,需要真·服务器下发 push,App 本地无法直接捕获“点击后执行本地逻辑”。
结论:如果你想兼容 iOS 16.1/16.2 并实现直接在灵动岛/锁屏上点按钮就让本地代码跑起来,这在系统层面并不支持。苹果直到 iOS 17 才支持 Button(intent:)(运行 AppIntent 而无需跳转)。
因此,如果你的目标用户大多数在 iOS 17,就可以继续使用你现有写法,但要把 #available(iOS 17.0, *) 部分的 else 分支中至少给一个“跳转App”作为兜底。如果你的目标是兼容 16.1/16.2,想要“点按钮就生效”,那就只能做「跳转回 App」这一套逻辑——无法在锁屏直接执行本地暂停/继续。
最简单的自测方法:
if #available(iOS 17.0, *) { ... } 里面的代码去掉或者确保它一定会执行。OfflineTimerIntent.perform() 里的逻辑。
如果你在 iOS 17 下也不行,那就继续往下看第 4、5 条,排查是否是 Info.plist 配置或 AppIntent 解析流程有问题。
要让 Live Activity 里的交互按钮正常运转,除了要在 Xcode 中勾选 “Enable Live Activities” 之类的 Capability,还需检查 Info.plist/Targets 的配置(尤其是 Widget Extension 的 Info.plist)。常见的要点有:
NSExtension > NSExtensionAttributes > Supports Live Activities = YES?AppIntentsSupported = YES 等类似布尔值。(Xcode 15 在添加 App Intent 时会自动帮你配好一些内容,但如果是老项目手动添加,可能漏掉。)Button(intent:) 还可能需要 com.apple.developer.appintents.declarations / com.apple.developer.widgetkit-intents 等类似的 entitlement。可以检查一下 Xcode -> Signing & Capabilities 里有没有自动生成。没有这些,点击按钮也可能无法触发任何动作。
当 Button(intent:) 被触发时,系统会在后台调用 OfflineTimerIntent.perform()。所以需要确认:
OfflineTimerIntent 上有没有 static var openAppWhenRun: Bool { false } 这样的配置(你代码里是这样写的,表示不会唤起主 App)?perform() 里有没有写 print("AppIntent执行: \(action)") 等 log 信息,去确认在点击按钮以后是否有这行输出?另外,你在 OfflineTimerIntent.perform() 里写了:
swift@available(iOS 16.1, *) struct OfflineTimerIntent: AppIntent, LiveActivityIntent { ... static var openAppWhenRun: Bool { false } func perform() async throws -> some IntentResult { // ... OfflineTimerHelper.pause(...) // ... return .result() } }
如果系统从来没有进入到这个断点,说明要么是系统没有调用这个 Intent,要么是版本判断压根没走到这里。
一个小提示:你也可以在 Xcode 的 Console 确认有无输出 "AppIntent执行: ...";如果没有任何输出,可以把断点下在 perform()` 里看有没有被执行过。如果完全没执行,那就一定是因为系统没有调用这个 Intent,很可能是前面提到的 iOS 17 代码分支没被走到,或者是 Info.plist 等配置问题导致点击无法触发。
去掉或者修改如下检查,让 Button(intent:) 在 iOS 17 之前也能至少出现在界面上(当然依然不会执行成功,但可以提示):
swiftif #available(iOS 17.0, *) { Button(intent: OfflineTimerIntent(action: action)) { // ... } }
或者给 iOS 16.x 添加一个 fallback 的 Link(...),写法类似:
swiftif #available(iOS 17.0, *) { Button(intent: OfflineTimerIntent(action: action)) { // iOS 17 上真正执行 pause/resume/stop } } else { Link(destination: URL(string: "timeminer://offlineTimer/\(action)")!) { // 跳转回App,让App里 handleOpenURL 时去执行 OfflineTimerHelper.pause/resume/stop } }
此外,确保 Info.plist / entitlements 配置正确,让 iOS 17 的按钮点击能够成功调用你的 OfflineTimerIntent.
抱歉,系统层面不支持。iOS 16 的 Live Activity 上的点击只允许两种模式:
所以正确的做法是:在 iOS 16.1/16.2 下,如果用户想暂停/继续,就必须跳回 App,在 App 前台执行你的逻辑。想避免跳回 App 又能直接执行逻辑,只能上苹果文档中的 “Push Token Based Live Activity Update”,需要你的服务器给 Activity 下发 push,然后 Activity 在后台接收 push 后进行状态更新;但这种做法也没法直接调用你 App 里的本地代码(只能改 Activity UI),对 “暂停计时器” 这种需要本地存储/操作的逻辑来说并不合适。
所以,如果你是为了兼容老系统,请换用 Link(destination: ...) 的方式点击跳 App,然后在 scene(_:openURLContexts:) 或 onOpenURL 里去解析自定义 URL,运行本地暂停/继续逻辑。
Button(intent:) 这种写法,所以如果你在 16.1、16.2 上测试,点击自然不会回调到 OfflineTimerIntent.perform()。Button(intent:) 并且直接在后台执行你的逻辑。你写了 if #available(iOS 17.0, *) {...},导致在 17 以下版本上什么也没有。Link(destination: URL("...")) { ... } 代替,让它跳转回 App。Button(intent:) 真能触发。当你把这些都捋清楚后,就能明确为什么点击灵动岛按钮没反应,以及怎么去修正。希望这份思路能帮你一步一步地找到问题所在。祝调试顺利!