diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index 530d3a3..cf0d7f6 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -390,7 +390,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -410,7 +410,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -430,7 +430,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -450,7 +450,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 7292e37..e829cb1 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/WorkspaceSettings.xcsettings b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..723a561 --- /dev/null +++ b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,16 @@ + + + + + BuildLocationStyle + UseAppPreferences + CompilationCachingSetting + Default + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/ios/OpenClimb/ViewModels/LiveActivityManager.swift b/ios/OpenClimb/ViewModels/LiveActivityManager.swift index efa406e..2598e42 100644 --- a/ios/OpenClimb/ViewModels/LiveActivityManager.swift +++ b/ios/OpenClimb/ViewModels/LiveActivityManager.swift @@ -87,16 +87,28 @@ final class LiveActivityManager { /// 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 + // First end the tracked activity if it exists + if let currentActivity { + print("🔴 Ending tracked Live Activity: \(currentActivity.id)") + await currentActivity.end(nil, dismissalPolicy: .immediate) + self.currentActivity = nil + print("✅ Tracked Live Activity ended successfully") } - print("🔴 Ending Live Activity: \(currentActivity.id)") + // Force end ALL active activities of our type to ensure cleanup + print("🔍 Checking for any remaining active activities...") + let activities = Activity.activities - await currentActivity.end(nil, dismissalPolicy: .immediate) - self.currentActivity = nil - print("✅ Live Activity ended successfully") + if activities.isEmpty { + print("â„šī¸ No additional activities found") + } else { + print("🔴 Found \(activities.count) additional active activities, ending them...") + for activity in activities { + print("🔴 Force ending activity: \(activity.id)") + await activity.end(nil, dismissalPolicy: .immediate) + } + print("✅ All Live Activities ended successfully") + } } /// Check if Live Activities are available and authorized diff --git a/ios/OpenClimb/Views/SessionsView.swift b/ios/OpenClimb/Views/SessionsView.swift index 3e7f600..8fa486e 100644 --- a/ios/OpenClimb/Views/SessionsView.swift +++ b/ios/OpenClimb/Views/SessionsView.swift @@ -1,4 +1,3 @@ - import Combine import SwiftUI @@ -8,19 +7,7 @@ struct SessionsView: View { var body: some View { NavigationView { - VStack(spacing: 0) { - // Active session banner - if let activeSession = dataManager.activeSession, - let gym = dataManager.gym(withId: activeSession.gymId) - { - VStack(spacing: 8) { - ActiveSessionBanner(session: activeSession, gym: gym) - .padding(.horizontal) - } - .padding(.top, 8) - } - - // Sessions list + Group { if dataManager.sessions.isEmpty && dataManager.activeSession == nil { EmptySessionsView() } else { @@ -28,6 +15,7 @@ struct SessionsView: View { } } .navigationTitle("Sessions") + .navigationBarTitleDisplayMode(.automatic) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { if dataManager.gyms.isEmpty { @@ -47,75 +35,7 @@ struct SessionsView: View { AddEditSessionView() } } - } -} - -struct ActiveSessionBanner: View { - let session: ClimbSession - let gym: Gym - @EnvironmentObject var dataManager: ClimbingDataManager - @State private var currentTime = Date() - - private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - - var body: some View { - NavigationLink(destination: SessionDetailView(sessionId: session.id)) { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: "play.fill") - .foregroundColor(.green) - .font(.caption) - Text("Active Session") - .font(.headline) - .fontWeight(.bold) - } - - Text(gym.name) - .font(.subheadline) - .foregroundColor(.secondary) - - if let startTime = session.startTime { - Text(formatDuration(from: startTime, to: currentTime)) - .font(.caption) - .foregroundColor(.secondary) - } - } - - Spacer() - - Button("End") { - dataManager.endSession(session.id) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .fill(.green.opacity(0.1)) - .stroke(.green.opacity(0.3), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .onReceive(timer) { _ in - currentTime = Date() - } - } - - private func formatDuration(from start: Date, to end: Date) -> String { - let interval = end.timeIntervalSince(start) - let hours = Int(interval) / 3600 - let minutes = Int(interval) % 3600 / 60 - let seconds = Int(interval) % 60 - - if hours > 0 { - return String(format: "%dh %dm %ds", hours, minutes, seconds) - } else if minutes > 0 { - return String(format: "%dm %ds", minutes, seconds) - } else { - return String(format: "%ds", seconds) - } + .navigationViewStyle(.stack) } } @@ -130,18 +50,45 @@ struct SessionsList: View { } var body: some View { - List(completedSessions) { session in - NavigationLink(destination: SessionDetailView(sessionId: session.id)) { - SessionRow(session: session) + List { + // Active session banner section + if let activeSession = dataManager.activeSession, + let gym = dataManager.gym(withId: activeSession.gymId) + { + Section { + ActiveSessionBanner(session: activeSession, gym: gym) + .padding(.horizontal, 16) + .listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - sessionToDelete = session - } label: { - Label("Delete", systemImage: "trash") + + // Completed sessions section + if !completedSessions.isEmpty { + Section { + ForEach(completedSessions) { session in + NavigationLink(destination: SessionDetailView(sessionId: session.id)) { + SessionRow(session: session) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + sessionToDelete = session + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } header: { + if dataManager.activeSession != nil { + Text("Previous Sessions") + .font(.headline) + .fontWeight(.semibold) + } } } } + .listStyle(.insetGrouped) .alert("Delete Session", isPresented: .constant(sessionToDelete != nil)) { Button("Cancel", role: .cancel) { sessionToDelete = nil @@ -160,6 +107,91 @@ struct SessionsList: View { } } +struct ActiveSessionBanner: View { + let session: ClimbSession + let gym: Gym + @EnvironmentObject var dataManager: ClimbingDataManager + @State private var currentTime = Date() + @State private var navigateToDetail = false + + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "play.fill") + .foregroundColor(.green) + .font(.caption) + Text("Active Session") + .font(.headline) + .fontWeight(.bold) + } + + Text(gym.name) + .font(.subheadline) + .foregroundColor(.secondary) + + if let startTime = session.startTime { + Text(formatDuration(from: startTime, to: currentTime)) + .font(.caption) + .foregroundColor(.secondary) + } + } + .contentShape(Rectangle()) + .onTapGesture { + navigateToDetail = true + } + + Spacer() + + Button(action: { + dataManager.endSession(session.id) + }) { + Image(systemName: "stop.fill") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .frame(width: 32, height: 32) + .background(Color.red) + .clipShape(Circle()) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.green.opacity(0.1)) + .stroke(.green.opacity(0.3), lineWidth: 1) + ) + .onReceive(timer) { _ in + currentTime = Date() + } + .background( + NavigationLink( + destination: SessionDetailView(sessionId: session.id), + isActive: $navigateToDetail + ) { + EmptyView() + } + .hidden() + ) + } + + private func formatDuration(from start: Date, to end: Date) -> String { + let interval = end.timeIntervalSince(start) + let hours = Int(interval) / 3600 + let minutes = Int(interval) % 3600 / 60 + let seconds = Int(interval) % 60 + + if hours > 0 { + return String(format: "%dh %dm %ds", hours, minutes, seconds) + } else if minutes > 0 { + return String(format: "%dm %ds", minutes, seconds) + } else { + return String(format: "%ds", seconds) + } + } +} + struct SessionRow: View { let session: ClimbSession @EnvironmentObject var dataManager: ClimbingDataManager