Proper 1.0 release for iOS. Pending App Store submission.

This commit is contained in:
2025-09-15 21:01:02 -06:00
parent d95c45abbb
commit afd954785a
24 changed files with 1848 additions and 2 deletions

View File

@@ -1,9 +1,9 @@
import SwiftUI
struct ContentView: View {
@StateObject private var dataManager = ClimbingDataManager()
@State private var selectedTab = 0
@Environment(\.scenePhase) private var scenePhase
var body: some View {
TabView(selection: $selectedTab) {
@@ -43,6 +43,11 @@ struct ContentView: View {
.tag(4)
}
.environmentObject(dataManager)
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
dataManager.onAppBecomeActive()
}
}
.overlay(alignment: .top) {
if let message = dataManager.successMessage {
SuccessMessageView(message: message)

View File

@@ -4,5 +4,7 @@
<dict>
<key>UIFileSharingEnabled</key>
<true/>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,19 @@
import ActivityKit
import Foundation
struct SessionActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var elapsed: TimeInterval
var totalAttempts: Int
var completedProblems: Int
}
var gymName: String
var startTime: Date
}
extension SessionActivityAttributes {
static var preview: SessionActivityAttributes {
SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date())
}
}

View File

@@ -3,6 +3,10 @@ import Foundation
import SwiftUI
import UniformTypeIdentifiers
#if canImport(WidgetKit)
import WidgetKit
#endif
@MainActor
class ClimbingDataManager: ObservableObject {
@@ -16,6 +20,7 @@ class ClimbingDataManager: ObservableObject {
@Published var successMessage: String?
private let userDefaults = UserDefaults.standard
private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb")
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
@@ -35,6 +40,9 @@ class ClimbingDataManager: ObservableObject {
Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
await performImageMaintenance()
// Check if we need to restart Live Activity for active session
await checkAndRestartLiveActivity()
}
}
@@ -89,24 +97,34 @@ class ClimbingDataManager: ObservableObject {
private func saveGyms() {
if let data = try? encoder.encode(gyms) {
userDefaults.set(data, forKey: Keys.gyms)
// Share with widget
sharedUserDefaults?.set(data, forKey: Keys.gyms)
}
}
private func saveProblems() {
if let data = try? encoder.encode(problems) {
userDefaults.set(data, forKey: Keys.problems)
// Share with widget
sharedUserDefaults?.set(data, forKey: Keys.problems)
}
}
private func saveSessions() {
if let data = try? encoder.encode(sessions) {
userDefaults.set(data, forKey: Keys.sessions)
// Share with widget
sharedUserDefaults?.set(data, forKey: Keys.sessions)
}
}
private func saveAttempts() {
if let data = try? encoder.encode(attempts) {
userDefaults.set(data, forKey: Keys.attempts)
// Share with widget
sharedUserDefaults?.set(data, forKey: Keys.attempts)
// Update widget timeline
updateWidgetTimeline()
}
}
@@ -216,6 +234,14 @@ class ClimbingDataManager: ObservableObject {
successMessage = "Session started successfully"
clearMessageAfterDelay()
// MARK: - Start Live Activity for new session
if let gym = gym(withId: gymId) {
Task {
await LiveActivityManager.shared.startLiveActivity(
for: newSession, gymName: gym.name)
}
}
}
func endSession(_ sessionId: UUID) {
@@ -234,6 +260,11 @@ class ClimbingDataManager: ObservableObject {
saveSessions()
successMessage = "Session completed successfully"
clearMessageAfterDelay()
// MARK: - End Live Activity after session ends
Task {
await LiveActivityManager.shared.endLiveActivity()
}
}
}
@@ -249,6 +280,9 @@ class ClimbingDataManager: ObservableObject {
saveSessions()
successMessage = "Session updated successfully"
clearMessageAfterDelay()
// Update Live Activity when session updates
updateLiveActivityForActiveSession()
}
}
@@ -290,6 +324,9 @@ class ClimbingDataManager: ObservableObject {
successMessage = "Attempt logged successfully"
clearMessageAfterDelay()
// Update Live Activity when new attempt is added
updateLiveActivityForActiveSession()
}
func updateAttempt(_ attempt: Attempt) {
@@ -298,6 +335,9 @@ class ClimbingDataManager: ObservableObject {
saveAttempts()
successMessage = "Attempt updated successfully"
clearMessageAfterDelay()
// Update Live Activity when attempt is updated
updateLiveActivityForActiveSession()
}
}
@@ -306,6 +346,9 @@ class ClimbingDataManager: ObservableObject {
saveAttempts()
successMessage = "Attempt deleted successfully"
clearMessageAfterDelay()
// Update Live Activity when attempt is deleted
updateLiveActivityForActiveSession()
}
func attempts(forSession sessionId: UUID) -> [Attempt] {
@@ -924,6 +967,100 @@ extension ClimbingDataManager {
"""
}
func testLiveActivity() {
print("🧪 Testing Live Activity functionality...")
// Check Live Activity availability
let status = LiveActivityManager.shared.checkLiveActivityAvailability()
print(status)
// Test with dummy data if we have a gym
guard let testGym = gyms.first else {
print("❌ No gyms available for testing")
return
}
// Create a test session
let testSession = ClimbSession(gymId: testGym.id, notes: "Test session for Live Activity")
Task {
await LiveActivityManager.shared.startLiveActivity(
for: testSession, gymName: testGym.name)
// Wait a bit then update
try? await Task.sleep(nanoseconds: 2_000_000_000)
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 120, totalAttempts: 5, completedProblems: 1)
// Wait then end
try? await Task.sleep(nanoseconds: 5_000_000_000)
await LiveActivityManager.shared.endLiveActivity()
}
}
private func checkAndRestartLiveActivity() async {
guard let activeSession = activeSession else { return }
if let gym = gym(withId: activeSession.gymId) {
await LiveActivityManager.shared.restartLiveActivityIfNeeded(
activeSession: activeSession,
gymName: gym.name
)
}
}
/// Call this when app becomes active to check for Live Activity restart
func onAppBecomeActive() {
Task {
await checkAndRestartLiveActivity()
}
}
/// Update Live Activity with current session data
private func updateLiveActivityForActiveSession() {
guard let activeSession = activeSession,
activeSession.status == .active,
let gym = gym(withId: activeSession.gymId)
else {
return
}
let attemptsForSession = attempts(forSession: activeSession.id)
let totalAttempts = attemptsForSession.count
let completedProblemIds = Set(
attemptsForSession.filter { $0.result.isSuccessful }.map { $0.problemId }
)
let completedProblems = completedProblemIds.count
let elapsedInterval: TimeInterval
if let startTime = activeSession.startTime {
elapsedInterval = Date().timeIntervalSince(startTime)
} else {
elapsedInterval = 0
}
Task {
await LiveActivityManager.shared.updateLiveActivity(
elapsed: elapsedInterval,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
}
}
/// Manually force Live Activity update (useful for debugging)
func forceLiveActivityUpdate() {
updateLiveActivityForActiveSession()
}
/// Update widget timeline when data changes
private func updateWidgetTimeline() {
#if canImport(WidgetKit)
WidgetCenter.shared.reloadTimelines(ofKind: "SessionStatusLive")
#endif
}
private func validateImportData(_ importData: ClimbDataExport) throws {
if importData.gyms.isEmpty {
throw NSError(

View File

@@ -0,0 +1,146 @@
import ActivityKit
import Foundation
@MainActor
final class LiveActivityManager {
static let shared = LiveActivityManager()
private init() {}
private var currentActivity: Activity<SessionActivityAttributes>?
/// Check if there's an active session and restart Live Activity if needed
func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async {
// If we have an active session but no Live Activity, restart it
guard let activeSession = activeSession,
let gymName = gymName,
activeSession.status == .active
else {
return
}
// Check if we already have a running Live Activity
if currentActivity != nil {
print(" Live Activity already running")
return
}
print("🔄 Restarting Live Activity for existing session")
await startLiveActivity(for: activeSession, gymName: gymName)
}
/// Call this when a ClimbSession starts to begin a Live Activity
func startLiveActivity(for session: ClimbSession, gymName: String) async {
print("🔴 Starting Live Activity for gym: \(gymName)")
await endLiveActivity()
let attributes = SessionActivityAttributes(
gymName: gymName, startTime: session.startTime ?? session.date)
let initialContentState = SessionActivityAttributes.ContentState(
elapsed: 0,
totalAttempts: 0,
completedProblems: 0
)
do {
let activity = try Activity<SessionActivityAttributes>.request(
attributes: attributes,
contentState: initialContentState,
pushType: nil
)
self.currentActivity = activity
print("✅ Live Activity started successfully: \(activity.id)")
} catch {
print("❌ Failed to start live activity: \(error)")
print("Error details: \(error.localizedDescription)")
// Check specific error types
if error.localizedDescription.contains("authorization") {
print("Authorization error - check Live Activity permissions in Settings")
} else if error.localizedDescription.contains("content") {
print("Content error - check ActivityAttributes structure")
}
}
}
/// Call this to update the Live Activity with new session progress
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{
guard let currentActivity else {
print("⚠️ No current activity to update")
return
}
print(
"🔄 Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)"
)
let updatedContentState = SessionActivityAttributes.ContentState(
elapsed: elapsed,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
do {
await currentActivity.update(using: updatedContentState, alertConfiguration: nil)
print("✅ Live Activity updated successfully")
} catch {
print("❌ Failed to update live activity: \(error)")
}
}
/// Call this when a ClimbSession ends to end the Live Activity
func endLiveActivity() async {
guard let currentActivity else {
print(" No current activity to end")
return
}
print("🔴 Ending Live Activity: \(currentActivity.id)")
do {
await currentActivity.end(using: nil, dismissalPolicy: .immediate)
self.currentActivity = nil
print("✅ Live Activity ended successfully")
} catch {
print("❌ Failed to end live activity: \(error)")
self.currentActivity = nil
}
}
/// Check if Live Activities are available and authorized
func checkLiveActivityAvailability() -> String {
let authorizationInfo = ActivityAuthorizationInfo()
let status = authorizationInfo.areActivitiesEnabled
let message = """
Live Activity Status:
• Enabled: \(status)
• Authorization: \(authorizationInfo.areActivitiesEnabled ? "Granted" : "Denied/Unknown")
• Current Activity: \(currentActivity?.id.description ?? "None")
"""
print(message)
return message
}
/// Start periodic updates for Live Activity
func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int)
{
guard currentActivity != nil else { return }
Task {
while currentActivity != nil {
let elapsed = Date().timeIntervalSince(session.startTime ?? session.date)
await updateLiveActivity(
elapsed: elapsed,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
// Wait 30 seconds before next update
try? await Task.sleep(nanoseconds: 30_000_000_000)
}
}
}
}

View File

@@ -0,0 +1,280 @@
//
// LiveActivityDebugView.swift
// OpenClimb
//
// Created by Assistant on 2025-09-15.
//
import SwiftUI
struct LiveActivityDebugView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var debugOutput: String = ""
@State private var isTestRunning = false
var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 20) {
// Header
VStack(alignment: .leading, spacing: 8) {
Text("Live Activity Debug")
.font(.title)
.fontWeight(.bold)
Text("Test and debug Live Activities for climbing sessions")
.font(.subheadline)
.foregroundColor(.secondary)
}
// Status Section
GroupBox("Current Status") {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "circle.fill")
.foregroundColor(dataManager.activeSession != nil ? .green : .red)
Text(
"Active Session: \(dataManager.activeSession != nil ? "Yes" : "No")"
)
}
HStack {
Image(systemName: "building.2")
Text("Total Gyms: \(dataManager.gyms.count)")
}
if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
HStack {
Image(systemName: "location")
Text("Current Gym: \(gym.name)")
}
}
}
}
// Test Buttons
GroupBox("Live Activity Tests") {
VStack(spacing: 16) {
Button(action: checkStatus) {
HStack {
Image(systemName: "checkmark.circle")
Text("Check Live Activity Status")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(isTestRunning)
Button(action: testLiveActivity) {
HStack {
Image(systemName: isTestRunning ? "hourglass" : "play.circle")
Text(
isTestRunning
? "Running Test..." : "Run Full Live Activity Test")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(isTestRunning || dataManager.gyms.isEmpty)
Button(action: forceLiveActivityUpdate) {
HStack {
Image(systemName: "arrow.clockwise")
Text("Force Live Activity Update")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(dataManager.activeSession == nil)
if dataManager.gyms.isEmpty {
Text("⚠️ Add at least one gym to test Live Activities")
.font(.caption)
.foregroundColor(.orange)
}
if dataManager.activeSession != nil {
Button(action: endCurrentSession) {
HStack {
Image(systemName: "stop.circle")
Text("End Current Session")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(isTestRunning)
}
}
}
// Debug Output
GroupBox("Debug Output") {
ScrollView {
ScrollViewReader { proxy in
VStack(alignment: .leading, spacing: 4) {
if debugOutput.isEmpty {
Text("No debug output yet. Run a test to see details.")
.foregroundColor(.secondary)
.italic()
} else {
Text(debugOutput)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.id("bottom")
.onChange(of: debugOutput) { _ in
withAnimation {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
}
.frame(maxHeight: 200)
.background(Color(UIColor.systemGray6))
.cornerRadius(8)
}
// Clear button
HStack {
Spacer()
Button("Clear Output") {
debugOutput = ""
}
.buttonStyle(.bordered)
}
Spacer()
}
.padding()
}
.navigationTitle("Live Activity Debug")
.navigationBarTitleDisplayMode(.inline)
}
private func appendDebugOutput(_ message: String) {
let timestamp = DateFormatter.timeFormatter.string(from: Date())
let newLine = "[\(timestamp)] \(message)"
DispatchQueue.main.async {
if debugOutput.isEmpty {
debugOutput = newLine
} else {
debugOutput += "\n" + newLine
}
}
}
private func checkStatus() {
appendDebugOutput("🔍 Checking Live Activity status...")
let status = LiveActivityManager.shared.checkLiveActivityAvailability()
appendDebugOutput("Status: \(status)")
// Check iOS version
if #available(iOS 16.1, *) {
appendDebugOutput("✅ iOS version supports Live Activities")
} else {
appendDebugOutput("❌ iOS version does not support Live Activities (requires 16.1+)")
}
// Check if we're on simulator
#if targetEnvironment(simulator)
appendDebugOutput("⚠️ Running on Simulator - Live Activities have limited functionality")
#else
appendDebugOutput("✅ Running on device - Live Activities should work fully")
#endif
}
private func testLiveActivity() {
guard !dataManager.gyms.isEmpty else {
appendDebugOutput("❌ No gyms available for testing")
return
}
isTestRunning = true
appendDebugOutput("🧪 Starting Live Activity test...")
Task {
defer {
DispatchQueue.main.async {
isTestRunning = false
}
}
// Test with first gym
let testGym = dataManager.gyms[0]
appendDebugOutput("Using gym: \(testGym.name)")
// Create test session
let testSession = ClimbSession(
gymId: testGym.id, notes: "Test session for Live Activity")
appendDebugOutput("Created test session")
// Start Live Activity
await LiveActivityManager.shared.startLiveActivity(
for: testSession, gymName: testGym.name)
appendDebugOutput("Live Activity start request sent")
// Wait and update
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
appendDebugOutput("Updating Live Activity with test data...")
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 180,
totalAttempts: 8,
completedProblems: 2
)
// Another update
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
appendDebugOutput("Second update...")
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 360,
totalAttempts: 15,
completedProblems: 4
)
// End after delay
try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds
appendDebugOutput("Ending Live Activity...")
await LiveActivityManager.shared.endLiveActivity()
appendDebugOutput("🏁 Live Activity test completed!")
}
}
private func endCurrentSession() {
guard let activeSession = dataManager.activeSession else {
appendDebugOutput("❌ No active session to end")
return
}
appendDebugOutput("🛑 Ending current session: \(activeSession.id)")
dataManager.endSession(activeSession.id)
appendDebugOutput("✅ Session ended")
}
private func forceLiveActivityUpdate() {
appendDebugOutput("🔄 Forcing Live Activity update...")
dataManager.forceLiveActivityUpdate()
appendDebugOutput("✅ Live Activity update sent")
}
}
extension DateFormatter {
static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
}
#Preview {
LiveActivityDebugView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -1,4 +1,3 @@
import SwiftUI
import UniformTypeIdentifiers
@@ -17,6 +16,8 @@ struct SettingsView: View {
activeSheet: $activeSheet
)
LiveActivitySection()
ImageStorageSection()
AppInfoSection()
@@ -508,6 +509,44 @@ struct ImportDataView: View {
}
}
struct LiveActivitySection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingDebugView = false
var body: some View {
Section("Live Activities") {
NavigationLink(destination: LiveActivityDebugView()) {
HStack {
Image(systemName: "bell.badge")
.foregroundColor(.purple)
Text("Debug Live Activities")
Spacer()
}
}
.foregroundColor(.primary)
Button(action: {
dataManager.testLiveActivity()
}) {
HStack {
Image(systemName: "play.circle")
.foregroundColor(.blue)
Text("Quick Test")
Spacer()
}
}
.foregroundColor(.primary)
.disabled(dataManager.gyms.isEmpty)
if dataManager.gyms.isEmpty {
Text("Add a gym first to test Live Activities")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
#Preview {
SettingsView()
.environmentObject(ClimbingDataManager.preview)