// Merged Swift Files // MARK: - utils/OfflineTi...

创建于:2025年3月12日

使用 O1 回答 Chat01

问题

// 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 {

text
static 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)")

text
if #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) } } }

}

text
func 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 {

text
public 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: "离线计时器操作")
}

text
static 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)
}

text
DynamicIslandExpandedRegion(.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>

text
var 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>

text
var 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>

text
var 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>

text
var 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>

text
var 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>

text
var 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>

text
var 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>

text
var 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 {

text
private 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 // 当前状态

text
let 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 {

text
static 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 {

text
case 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 {

text
let 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 {

text
var 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 无法触发相关行为(暂停 继续等 )
先不要写代码先思考
请一步一步的给我详细的思考链和具体步骤的解析

分析哪里出错了 应该如何修改

思考

Analyzing behavior

I’m reviewing the Swift code for the Offline Timer to ensure button clicks on dynamic islands or lock screen widgets trigger the correct actions. This involves using iOS 17's new interaction model with the Button(intent:) function, defaulting to "pause."

Assessing potential issues

I'm thinking through whether the user needs to configure the "providesAppNotificationIntent" or update Info.plist to support AppIntent. Also, it's worth noting that iOS 16.1 or 16.2 may lack the iOS 17 dynamic island support.

Assessing code compatibility

I'm noting that the code uses iOS 17 syntax but is compiled for iOS 16.1 or 16.2, which might explain why buttons aren't responding correctly.

Updating for iOS 16

OK, let me see. iOS 17 introduces the "Button(intent: MyIntent)" syntax. For iOS 16.1 or 16.2, "WidgetURL" or "Link" should be used instead.

Implementing iOS 16 solutions

Switching from the unavailable "Button(intent:)'' approach in iOS 16.1 or 16.2 to "Link" or "WidgetURL" ensures the dynamic island triggers the correct actions.

Finding solutions

I’m thinking of an alternative approach for iOS 16.1 or 16.2, using "Link" or "WidgetURL" to enable interactions with the dynamic island button, as the iOS 17.0 syntax isn't available.

Creating a fallback

I’m wrapping up the structured approach, suggesting 'Link' or 'WidgetURL' for iOS 16, and 'custom action with pushType: .token' or 'Activity updates from push notifications' as fallback options.

Reasoned about Live Activity interactions for 15 seconds

回答

下面给出一个比较详细的「排查思路」和「可能的修改方向」,帮助你一步一步地定位为什么点击灵动岛/锁屏按钮没有触发暂停、继续、停止等操作。请先耐心看完整个思路,再去对照项目中的实际情况来进行调整。


1. 确认在 iOS 17 之前,Button(intent:) 并不工作

在你的代码里,你使用了类似下面的写法(在 LockScreenViewTrailingViewBottomView 等多个地方都出现了):

swift
if #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:) 的逻辑。
  • 结果就是:UI 界面上可能勉强还看得到按钮样子,但实际上不具备点击能力或点击后没有任何响应(因为没有任何 fallback 逻辑)。

这一步问题的根源:在 iOS 17 之前,如果你想要灵动岛或锁屏上点按钮能触发 App 内逻辑,通常只能通过打开 App 深链接,或者用远程推送方式(比较复杂)。iOS 17 才正式支持 Button(intent:) 这种无须跳转、在锁屏/岛上直接运行 AppIntent 的做法。


2. 如果目标是兼容 iOS 16.1 / 16.2 的 Live Activity 交互,该怎么做?

如果你仅仅是在 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。官方当时给的思路有两种:

  1. Link(destination:)WidgetURL:点击后会跳转回你的 App,App 启动后在 scene(_:, openURLContexts:)onOpenURL 中根据自定义的 scheme 去执行暂停或继续的逻辑。

    • 这种方式在 iOS 16.1~16.2 的 Live Activity 动态岛或锁屏组件中,是最常见也最简单的做法:只能跳到 App 前台,再去做暂停/继续。
    • 你可以在 .compactLeading / .compactTrailing / .minimal / .expanded 区域里放一个小按钮,然后用 Link(destination: URL("myApp://offlineTimer/pause")) { ... },当用户点了以后,手机会唤起你的 App,执行相应操作。
  2. 用「基于推送通知的 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」这一套逻辑——无法在锁屏直接执行本地暂停/继续


3. 如何判断是否真的是因为「iOS 17 代码未生效」导致按钮没反应?

最简单的自测方法:

  1. 你先把模拟器或真机系统切到 iOS 17(例如 iOS 17.0+),把 if #available(iOS 17.0, *) { ... } 里面的代码去掉或者确保它一定会执行。
  2. 运行看看是否按钮能点击、能调用 OfflineTimerIntent.perform() 里的逻辑。
    • 如果在 iOS 17 下,仍然点击按钮无反应,说明还有其他问题。
    • 如果在 iOS 17 下可以正确工作,那么在 iOS 16.x 不行,就说明这个功能确实是 iOS 17 新特性——是预期行为。

如果你在 iOS 17 下也不行,那就继续往下看第 4、5 条,排查是否是 Info.plist 配置或 AppIntent 解析流程有问题。


4. 检查 Info.plist 是否已配置了 Live Activity 和 AppIntent 相应的权限

要让 Live Activity 里的交互按钮正常运转,除了要在 Xcode 中勾选 “Enable Live Activities” 之类的 Capability,还需检查 Info.plist/Targets 的配置(尤其是 Widget Extension 的 Info.plist)。常见的要点有:

  • 是否在你的 Widget Extension 的 Info.plist 中开启 NSExtension > NSExtensionAttributes > Supports Live Activities = YES
  • 是否把 “AppIntents” 也放在主 App 的 Info.plist 或 extension 的 Info.plist 中?有时需要添加 AppIntentsSupported = YES 等类似布尔值。(Xcode 15 在添加 App Intent 时会自动帮你配好一些内容,但如果是老项目手动添加,可能漏掉。)
  • iOS 17 中 Button(intent:) 还可能需要 com.apple.developer.appintents.declarations / com.apple.developer.widgetkit-intents 等类似的 entitlement。可以检查一下 Xcode -> Signing & Capabilities 里有没有自动生成。

没有这些,点击按钮也可能无法触发任何动作。


5. 确认你的 AppIntent 本身写法无误、并且在前后台都可运行

Button(intent:) 被触发时,系统会在后台调用 OfflineTimerIntent.perform()。所以需要确认:

  • OfflineTimerIntent 上有没有 static var openAppWhenRun: Bool { false } 这样的配置(你代码里是这样写的,表示不会唤起主 App)?
  • 这个 Intent 是否正确声明在主 App 里或者某个 Intents Extension 里?并且在 Xcode 里被正确地索引到?
  • 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 等配置问题导致点击无法触发。


6. 总结:具体如何修改,才能「点击灵动岛按钮」真正执行本地逻辑?

6.1 如果你只需要在 iOS 17 上可用

  • 去掉或者修改如下检查,让 Button(intent:) 在 iOS 17 之前也能至少出现在界面上(当然依然不会执行成功,但可以提示):

    swift
    if #available(iOS 17.0, *) { Button(intent: OfflineTimerIntent(action: action)) { // ... } }
  • 或者给 iOS 16.x 添加一个 fallback 的 Link(...),写法类似:

    swift
    if #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.

6.2 如果你想在 iOS 16.1/16.2 就让点击按钮立刻执行 “暂停/继续” 而不唤起 App

  • 抱歉,系统层面不支持。iOS 16 的 Live Activity 上的点击只允许两种模式:

    1. 跳转到 App
    2. 走服务器下发的远程推送来更新 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,运行本地暂停/继续逻辑。


7. 最终的思考和结论

  • 问题根因:iOS 16.1/16.2 并不支持 Button(intent:) 这种写法,所以如果你在 16.1、16.2 上测试,点击自然不会回调到 OfflineTimerIntent.perform()
  • iOS 17 上才可以用 Button(intent:) 并且直接在后台执行你的逻辑。你写了 if #available(iOS 17.0, *) {...},导致在 17 以下版本上什么也没有。
  • 修改思路
    1. 如果要兼容老系统,建议在 16.x 系统用 Link(destination: URL("...")) { ... } 代替,让它跳转回 App。
    2. 确认 Info.plist / entitlement 配置无误,保证 iOS 17 下的 Button(intent:) 真能触发。
    3. 测试时一定先确认设备/模拟器系统版本Xcode 里可执行分支是否匹配。

当你把这些都捋清楚后,就能明确为什么点击灵动岛按钮没反应,以及怎么去修正。希望这份思路能帮你一步一步地找到问题所在。祝调试顺利!

分享这个问答