1.03 for iOS and 1.5.0 for Android
This commit is contained in:
@@ -7,6 +7,10 @@ import UniformTypeIdentifiers
|
||||
import WidgetKit
|
||||
#endif
|
||||
|
||||
#if canImport(ActivityKit)
|
||||
import ActivityKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
class ClimbingDataManager: ObservableObject {
|
||||
|
||||
@@ -23,6 +27,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb")
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private var liveActivityObserver: NSObjectProtocol?
|
||||
|
||||
private enum Keys {
|
||||
static let gyms = "openclimb_gyms"
|
||||
@@ -57,6 +62,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
_ = ImageManager.shared
|
||||
loadAllData()
|
||||
migrateImagePaths()
|
||||
setupLiveActivityNotifications()
|
||||
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
@@ -67,6 +73,12 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let observer = liveActivityObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAllData() {
|
||||
loadGyms()
|
||||
loadProblems()
|
||||
@@ -463,6 +475,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
let exportData = ClimbDataExport(
|
||||
exportedAt: dateFormatter.string(from: Date()),
|
||||
version: "1.0",
|
||||
gyms: gyms.map { AndroidGym(from: $0) },
|
||||
problems: problems.map { AndroidProblem(from: $0) },
|
||||
sessions: sessions.map { AndroidClimbSession(from: $0) },
|
||||
@@ -471,13 +484,21 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
// Collect referenced image paths
|
||||
let referencedImagePaths = collectReferencedImagePaths()
|
||||
print("🎯 Starting export with \(referencedImagePaths.count) images")
|
||||
|
||||
return try ZipUtils.createExportZip(
|
||||
let zipData = try ZipUtils.createExportZip(
|
||||
exportData: exportData,
|
||||
referencedImagePaths: referencedImagePaths
|
||||
)
|
||||
|
||||
print("✅ Export completed successfully")
|
||||
successMessage = "Export completed with \(referencedImagePaths.count) images"
|
||||
clearMessageAfterDelay()
|
||||
return zipData
|
||||
} catch {
|
||||
setError("Export failed: \(error.localizedDescription)")
|
||||
let errorMessage = "Export failed: \(error.localizedDescription)"
|
||||
print("❌ \(errorMessage)")
|
||||
setError(errorMessage)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -565,16 +586,18 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
struct ClimbDataExport: Codable {
|
||||
let exportedAt: String
|
||||
let version: String
|
||||
let gyms: [AndroidGym]
|
||||
let problems: [AndroidProblem]
|
||||
let sessions: [AndroidClimbSession]
|
||||
let attempts: [AndroidAttempt]
|
||||
|
||||
init(
|
||||
exportedAt: String, gyms: [AndroidGym], problems: [AndroidProblem],
|
||||
exportedAt: String, version: String = "1.0", gyms: [AndroidGym], problems: [AndroidProblem],
|
||||
sessions: [AndroidClimbSession], attempts: [AndroidAttempt]
|
||||
) {
|
||||
self.exportedAt = exportedAt
|
||||
self.version = version
|
||||
self.gyms = gyms
|
||||
self.problems = problems
|
||||
self.sessions = sessions
|
||||
@@ -588,6 +611,7 @@ struct AndroidGym: Codable {
|
||||
let location: String?
|
||||
let supportedClimbTypes: [ClimbType]
|
||||
let difficultySystems: [DifficultySystem]
|
||||
let customDifficultyGrades: [String]
|
||||
let notes: String?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
@@ -598,6 +622,7 @@ struct AndroidGym: Codable {
|
||||
self.location = gym.location
|
||||
self.supportedClimbTypes = gym.supportedClimbTypes
|
||||
self.difficultySystems = gym.difficultySystems
|
||||
self.customDifficultyGrades = gym.customDifficultyGrades
|
||||
self.notes = gym.notes
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
@@ -607,13 +632,15 @@ struct AndroidGym: Codable {
|
||||
|
||||
init(
|
||||
id: String, name: String, location: String?, supportedClimbTypes: [ClimbType],
|
||||
difficultySystems: [DifficultySystem], notes: String?, createdAt: String, updatedAt: String
|
||||
difficultySystems: [DifficultySystem], customDifficultyGrades: [String] = [],
|
||||
notes: String?, createdAt: String, updatedAt: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.location = location
|
||||
self.supportedClimbTypes = supportedClimbTypes
|
||||
self.difficultySystems = difficultySystems
|
||||
self.customDifficultyGrades = customDifficultyGrades
|
||||
self.notes = notes
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
@@ -633,7 +660,7 @@ struct AndroidGym: Codable {
|
||||
location: location,
|
||||
supportedClimbTypes: supportedClimbTypes,
|
||||
difficultySystems: difficultySystems,
|
||||
customDifficultyGrades: [],
|
||||
customDifficultyGrades: customDifficultyGrades,
|
||||
notes: notes,
|
||||
createdAt: createdDate,
|
||||
updatedAt: updatedDate
|
||||
@@ -648,7 +675,13 @@ struct AndroidProblem: Codable {
|
||||
let description: String?
|
||||
let climbType: ClimbType
|
||||
let difficulty: DifficultyGrade
|
||||
let setter: String?
|
||||
let tags: [String]
|
||||
let location: String?
|
||||
let imagePaths: [String]?
|
||||
let isActive: Bool
|
||||
let dateSet: String?
|
||||
let notes: String?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
@@ -659,16 +692,26 @@ struct AndroidProblem: Codable {
|
||||
self.description = problem.description
|
||||
self.climbType = problem.climbType
|
||||
self.difficulty = problem.difficulty
|
||||
self.setter = problem.setter
|
||||
self.tags = problem.tags
|
||||
self.location = problem.location
|
||||
self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths
|
||||
self.isActive = problem.isActive
|
||||
self.notes = problem.notes
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
self.dateSet = problem.dateSet != nil ? formatter.string(from: problem.dateSet!) : nil
|
||||
self.createdAt = formatter.string(from: problem.createdAt)
|
||||
self.updatedAt = formatter.string(from: problem.updatedAt)
|
||||
}
|
||||
|
||||
init(
|
||||
id: String, gymId: String, name: String?, description: String?, climbType: ClimbType,
|
||||
difficulty: DifficultyGrade, imagePaths: [String]?, createdAt: String, updatedAt: String
|
||||
difficulty: DifficultyGrade, setter: String? = nil, tags: [String] = [],
|
||||
location: String? = nil,
|
||||
imagePaths: [String]? = nil, isActive: Bool = true, dateSet: String? = nil,
|
||||
notes: String? = nil,
|
||||
createdAt: String, updatedAt: String
|
||||
) {
|
||||
self.id = id
|
||||
self.gymId = gymId
|
||||
@@ -676,7 +719,13 @@ struct AndroidProblem: Codable {
|
||||
self.description = description
|
||||
self.climbType = climbType
|
||||
self.difficulty = difficulty
|
||||
self.setter = setter
|
||||
self.tags = tags
|
||||
self.location = location
|
||||
self.imagePaths = imagePaths
|
||||
self.isActive = isActive
|
||||
self.dateSet = dateSet
|
||||
self.notes = notes
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
@@ -697,13 +746,13 @@ struct AndroidProblem: Codable {
|
||||
description: description,
|
||||
climbType: climbType,
|
||||
difficulty: difficulty,
|
||||
setter: nil,
|
||||
tags: [],
|
||||
location: nil,
|
||||
setter: setter,
|
||||
tags: tags,
|
||||
location: location,
|
||||
imagePaths: imagePaths ?? [],
|
||||
isActive: true,
|
||||
dateSet: nil,
|
||||
notes: nil,
|
||||
isActive: isActive,
|
||||
dateSet: dateSet != nil ? formatter.date(from: dateSet!) : nil,
|
||||
notes: notes,
|
||||
createdAt: createdDate,
|
||||
updatedAt: updatedDate
|
||||
)
|
||||
@@ -717,7 +766,13 @@ struct AndroidProblem: Codable {
|
||||
description: self.description,
|
||||
climbType: self.climbType,
|
||||
difficulty: self.difficulty,
|
||||
setter: self.setter,
|
||||
tags: self.tags,
|
||||
location: self.location,
|
||||
imagePaths: newImagePaths.isEmpty ? nil : newImagePaths,
|
||||
isActive: self.isActive,
|
||||
dateSet: self.dateSet,
|
||||
notes: self.notes,
|
||||
createdAt: self.createdAt,
|
||||
updatedAt: self.updatedAt
|
||||
)
|
||||
@@ -730,8 +785,9 @@ struct AndroidClimbSession: Codable {
|
||||
let date: String
|
||||
let startTime: String?
|
||||
let endTime: String?
|
||||
let duration: Int?
|
||||
let duration: Int64?
|
||||
let status: SessionStatus
|
||||
let notes: String?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
@@ -743,15 +799,17 @@ struct AndroidClimbSession: Codable {
|
||||
self.date = formatter.string(from: session.date)
|
||||
self.startTime = session.startTime != nil ? formatter.string(from: session.startTime!) : nil
|
||||
self.endTime = session.endTime != nil ? formatter.string(from: session.endTime!) : nil
|
||||
self.duration = session.duration
|
||||
self.duration = session.duration != nil ? Int64(session.duration!) : nil
|
||||
self.status = session.status
|
||||
self.notes = session.notes
|
||||
self.createdAt = formatter.string(from: session.createdAt)
|
||||
self.updatedAt = formatter.string(from: session.updatedAt)
|
||||
}
|
||||
|
||||
init(
|
||||
id: String, gymId: String, date: String, startTime: String?, endTime: String?,
|
||||
duration: Int?, status: SessionStatus, createdAt: String, updatedAt: String
|
||||
duration: Int64?, status: SessionStatus, notes: String? = nil, createdAt: String,
|
||||
updatedAt: String
|
||||
) {
|
||||
self.id = id
|
||||
self.gymId = gymId
|
||||
@@ -760,6 +818,7 @@ struct AndroidClimbSession: Codable {
|
||||
self.endTime = endTime
|
||||
self.duration = duration
|
||||
self.status = status
|
||||
self.notes = notes
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
@@ -783,9 +842,9 @@ struct AndroidClimbSession: Codable {
|
||||
date: sessionDate,
|
||||
startTime: sessionStartTime,
|
||||
endTime: sessionEndTime,
|
||||
duration: duration,
|
||||
duration: duration != nil ? Int(duration!) : nil,
|
||||
status: status,
|
||||
notes: nil,
|
||||
notes: notes,
|
||||
createdAt: createdDate,
|
||||
updatedAt: updatedDate
|
||||
)
|
||||
@@ -799,8 +858,8 @@ struct AndroidAttempt: Codable {
|
||||
let result: AttemptResult
|
||||
let highestHold: String?
|
||||
let notes: String?
|
||||
let duration: Int?
|
||||
let restTime: Int?
|
||||
let duration: Int64?
|
||||
let restTime: Int64?
|
||||
let timestamp: String
|
||||
let createdAt: String
|
||||
|
||||
@@ -811,8 +870,8 @@ struct AndroidAttempt: Codable {
|
||||
self.result = attempt.result
|
||||
self.highestHold = attempt.highestHold
|
||||
self.notes = attempt.notes
|
||||
self.duration = attempt.duration
|
||||
self.restTime = attempt.restTime
|
||||
self.duration = attempt.duration != nil ? Int64(attempt.duration!) : nil
|
||||
self.restTime = attempt.restTime != nil ? Int64(attempt.restTime!) : nil
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
self.timestamp = formatter.string(from: attempt.timestamp)
|
||||
@@ -821,7 +880,7 @@ struct AndroidAttempt: Codable {
|
||||
|
||||
init(
|
||||
id: String, sessionId: String, problemId: String, result: AttemptResult,
|
||||
highestHold: String?, notes: String?, duration: Int?, restTime: Int?,
|
||||
highestHold: String?, notes: String?, duration: Int64?, restTime: Int64?,
|
||||
timestamp: String, createdAt: String
|
||||
) {
|
||||
self.id = id
|
||||
@@ -853,8 +912,8 @@ struct AndroidAttempt: Codable {
|
||||
result: result,
|
||||
highestHold: highestHold,
|
||||
notes: notes,
|
||||
duration: duration,
|
||||
restTime: restTime,
|
||||
duration: duration != nil ? Int(duration!) : nil,
|
||||
restTime: restTime != nil ? Int(restTime!) : nil,
|
||||
timestamp: attemptTimestamp,
|
||||
createdAt: createdDate
|
||||
)
|
||||
@@ -864,9 +923,33 @@ struct AndroidAttempt: Codable {
|
||||
extension ClimbingDataManager {
|
||||
private func collectReferencedImagePaths() -> Set<String> {
|
||||
var imagePaths = Set<String>()
|
||||
print("🖼️ Starting image path collection...")
|
||||
print("📊 Total problems: \(problems.count)")
|
||||
|
||||
for problem in problems {
|
||||
imagePaths.formUnion(problem.imagePaths)
|
||||
if !problem.imagePaths.isEmpty {
|
||||
print(
|
||||
"📸 Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
|
||||
)
|
||||
for imagePath in problem.imagePaths {
|
||||
print(" - Relative path: \(imagePath)")
|
||||
let fullPath = ImageManager.shared.getFullPath(from: imagePath)
|
||||
print(" - Full path: \(fullPath)")
|
||||
|
||||
// Check if file exists
|
||||
if FileManager.default.fileExists(atPath: fullPath) {
|
||||
print(" ✅ File exists")
|
||||
imagePaths.insert(fullPath)
|
||||
} else {
|
||||
print(" ❌ File does NOT exist")
|
||||
// Still add it to let ZipUtils handle the error logging
|
||||
imagePaths.insert(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("🖼️ Collected \(imagePaths.count) total image paths for export")
|
||||
return imagePaths
|
||||
}
|
||||
|
||||
@@ -1046,23 +1129,111 @@ extension ClimbingDataManager {
|
||||
}
|
||||
|
||||
private func checkAndRestartLiveActivity() async {
|
||||
guard let activeSession = activeSession else { return }
|
||||
guard let activeSession = activeSession else {
|
||||
// No active session, make sure all Live Activities are cleaned up
|
||||
await LiveActivityManager.shared.endLiveActivity()
|
||||
return
|
||||
}
|
||||
|
||||
// Only restart if session is actually active
|
||||
guard activeSession.status == .active else {
|
||||
print(
|
||||
"⚠️ Session exists but is not active (status: \(activeSession.status)), ending Live Activity"
|
||||
)
|
||||
await LiveActivityManager.shared.endLiveActivity()
|
||||
return
|
||||
}
|
||||
|
||||
if let gym = gym(withId: activeSession.gymId) {
|
||||
print("🔍 Checking Live Activity for active session at \(gym.name)")
|
||||
|
||||
// First cleanup any dismissed activities
|
||||
await LiveActivityManager.shared.cleanupDismissedActivities()
|
||||
|
||||
// Then attempt to restart if needed
|
||||
await LiveActivityManager.shared.restartLiveActivityIfNeeded(
|
||||
activeSession: activeSession,
|
||||
gymName: gym.name
|
||||
)
|
||||
|
||||
// Update with current session data
|
||||
await updateLiveActivityData()
|
||||
}
|
||||
}
|
||||
|
||||
/// Call this when app becomes active to check for Live Activity restart
|
||||
func onAppBecomeActive() {
|
||||
print("📱 App became active - checking Live Activity status")
|
||||
Task {
|
||||
await checkAndRestartLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
/// Call this when app enters background to update Live Activity
|
||||
func onAppEnterBackground() {
|
||||
print("📱 App entering background - updating Live Activity if needed")
|
||||
Task {
|
||||
await updateLiveActivityData()
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup notifications for Live Activity events
|
||||
private func setupLiveActivityNotifications() {
|
||||
liveActivityObserver = NotificationCenter.default.addObserver(
|
||||
forName: .liveActivityDismissed,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
print("🔔 Received Live Activity dismissed notification - attempting restart")
|
||||
Task { @MainActor in
|
||||
await self?.handleLiveActivityDismissed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Live Activity being dismissed by user
|
||||
private func handleLiveActivityDismissed() async {
|
||||
guard let activeSession = activeSession,
|
||||
activeSession.status == .active,
|
||||
let gym = gym(withId: activeSession.gymId)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
print("🔄 Attempting to restart dismissed Live Activity for \(gym.name)")
|
||||
|
||||
// Wait a bit before restarting to avoid frequency limits
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
|
||||
|
||||
await LiveActivityManager.shared.startLiveActivity(
|
||||
for: activeSession,
|
||||
gymName: gym.name
|
||||
)
|
||||
|
||||
// Update with current data
|
||||
await updateLiveActivityData()
|
||||
}
|
||||
|
||||
/// Update Live Activity with current session statistics
|
||||
private func updateLiveActivityData() async {
|
||||
guard let activeSession = activeSession,
|
||||
activeSession.status == .active
|
||||
else { return }
|
||||
|
||||
let elapsed = Date().timeIntervalSince(activeSession.startTime ?? activeSession.date)
|
||||
let sessionAttempts = attempts.filter { $0.sessionId == activeSession.id }
|
||||
let totalAttempts = sessionAttempts.count
|
||||
let completedProblems = Set(
|
||||
sessionAttempts.filter { $0.result.isSuccessful }.map { $0.problemId }
|
||||
).count
|
||||
|
||||
await LiveActivityManager.shared.updateLiveActivity(
|
||||
elapsed: elapsed,
|
||||
totalAttempts: totalAttempts,
|
||||
completedProblems: completedProblems
|
||||
)
|
||||
}
|
||||
|
||||
/// Update Live Activity with current session data
|
||||
private func updateLiveActivityForActiveSession() {
|
||||
guard let activeSession = activeSession,
|
||||
|
||||
Reference in New Issue
Block a user