1.0.0 for iOS is ready to ship

This commit is contained in:
2025-09-14 23:07:32 -06:00
parent a3e60ce995
commit 127c25f506
33 changed files with 2646 additions and 251 deletions

View File

@@ -1,9 +1,3 @@
//
// GymDetailView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI

View File

@@ -1,9 +1,3 @@
//
// ProblemDetailView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
@@ -296,21 +290,11 @@ struct PhotosSection: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(imagePaths.indices, id: \.self) { index in
AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 12)
.fill(.gray.opacity(0.3))
}
.frame(width: 120, height: 120)
.clipped()
.cornerRadius(12)
.onTapGesture {
selectedImageIndex = index
showingImageViewer = true
}
ProblemDetailImageView(imagePath: imagePaths[index])
.onTapGesture {
selectedImageIndex = index
showingImageViewer = true
}
}
}
.padding(.horizontal, 1)
@@ -444,14 +428,8 @@ struct ImageViewerView: View {
NavigationView {
TabView(selection: $currentIndex) {
ForEach(imagePaths.indices, id: \.self) { index in
AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
ProgressView()
}
.tag(index)
ProblemDetailImageFullView(imagePath: imagePaths[index])
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
@@ -468,6 +446,133 @@ struct ImageViewerView: View {
}
}
struct ProblemDetailImageView: View {
let imagePath: String
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
var body: some View {
Group {
if let uiImage = uiImage {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 120, height: 120)
.clipped()
.cornerRadius(12)
} else if hasFailed {
RoundedRectangle(cornerRadius: 12)
.fill(.gray.opacity(0.2))
.frame(width: 120, height: 120)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.title2)
}
} else {
RoundedRectangle(cornerRadius: 12)
.fill(.gray.opacity(0.3))
.frame(width: 120, height: 120)
.overlay {
ProgressView()
}
}
}
.onAppear {
loadImage()
}
}
private func loadImage() {
guard !imagePath.isEmpty else {
hasFailed = true
isLoading = false
return
}
DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
DispatchQueue.main.async {
self.uiImage = image
self.isLoading = false
}
} else {
DispatchQueue.main.async {
self.hasFailed = true
self.isLoading = false
}
}
}
}
}
struct ProblemDetailImageFullView: View {
let imagePath: String
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
var body: some View {
Group {
if let uiImage = uiImage {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
} else if hasFailed {
Rectangle()
.fill(.gray.opacity(0.2))
.frame(height: 250)
.overlay {
VStack(spacing: 8) {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.largeTitle)
Text("Image not available")
.foregroundColor(.gray)
}
}
} else {
Rectangle()
.fill(.gray.opacity(0.3))
.frame(height: 250)
.overlay {
ProgressView()
}
}
}
.onAppear {
loadImage()
}
}
private func loadImage() {
guard !imagePath.isEmpty else {
hasFailed = true
isLoading = false
return
}
DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
DispatchQueue.main.async {
self.uiImage = image
self.isLoading = false
}
} else {
DispatchQueue.main.async {
self.hasFailed = true
self.isLoading = false
}
}
}
}
}
#Preview {
NavigationView {
ProblemDetailView(problemId: UUID())

View File

@@ -1,10 +1,5 @@
//
// SessionDetailView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import Combine
import SwiftUI
struct SessionDetailView: View {
@@ -14,6 +9,8 @@ struct SessionDetailView: View {
@State private var showingDeleteAlert = false
@State private var showingAddAttempt = false
@State private var editingAttempt: Attempt?
@State private var attemptToDelete: Attempt?
@State private var currentTime = Date()
private var session: ClimbSession? {
dataManager.session(withId: sessionId)
@@ -39,15 +36,20 @@ struct SessionDetailView: View {
calculateSessionStats()
}
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
if let session = session, let gym = gym {
SessionHeaderCard(session: session, gym: gym, stats: sessionStats)
SessionHeaderCard(
session: session, gym: gym, stats: sessionStats, currentTime: currentTime)
SessionStatsCard(stats: sessionStats)
AttemptsSection(attemptsWithProblems: attemptsWithProblems)
AttemptsSection(
attemptsWithProblems: attemptsWithProblems,
attemptToDelete: $attemptToDelete)
} else {
Text("Session not found")
.foregroundColor(.secondary)
@@ -55,6 +57,9 @@ struct SessionDetailView: View {
}
.padding()
}
.onReceive(timer) { _ in
currentTime = Date()
}
.navigationTitle("Session Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -80,6 +85,33 @@ struct SessionDetailView: View {
}
}
}
.alert(
"Delete Attempt",
isPresented: Binding<Bool>(
get: { attemptToDelete != nil },
set: { if !$0 { attemptToDelete = nil } }
)
) {
Button("Cancel", role: .cancel) {
attemptToDelete = nil
}
Button("Delete", role: .destructive) {
if let attempt = attemptToDelete {
dataManager.deleteAttempt(attempt)
attemptToDelete = nil
}
}
} message: {
if let attempt = attemptToDelete,
let problem = dataManager.problem(withId: attempt.problemId)
{
Text(
"Are you sure you want to delete this attempt on \"\(problem.name ?? "Unknown Problem")\"? This action cannot be undone."
)
} else {
Text("Are you sure you want to delete this attempt? This action cannot be undone.")
}
}
.overlay(alignment: .bottomTrailing) {
if session?.status == .active {
Button(action: { showingAddAttempt = true }) {
@@ -140,12 +172,26 @@ struct SessionDetailView: View {
private func gradeRange(for problems: [Problem]) -> String? {
guard !problems.isEmpty else { return nil }
let grades = problems.map { $0.difficulty }.sorted()
if grades.count == 1 {
return grades.first?.grade
} else {
return "\(grades.first?.grade ?? "") - \(grades.last?.grade ?? "")"
let difficulties = problems.map { $0.difficulty }
// Group by difficulty system first
let groupedBySystem = Dictionary(grouping: difficulties) { $0.system }
// For each system, find the range
let ranges = groupedBySystem.compactMap { (system, difficulties) -> String? in
let sortedDifficulties = difficulties.sorted()
guard let min = sortedDifficulties.first, let max = sortedDifficulties.last else {
return nil
}
if min == max {
return min.grade
} else {
return "\(min.grade) - \(max.grade)"
}
}
return ranges.joined(separator: ", ")
}
}
@@ -153,6 +199,7 @@ struct SessionHeaderCard: View {
let session: ClimbSession
let gym: Gym
let stats: SessionStats
let currentTime: Date
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -165,7 +212,13 @@ struct SessionHeaderCard: View {
.font(.title2)
.foregroundColor(.blue)
if let duration = session.duration {
if session.status == .active {
if let startTime = session.startTime {
Text("Duration: \(formatDuration(from: startTime, to: currentTime))")
.font(.subheadline)
.foregroundColor(.secondary)
}
} else if let duration = session.duration {
Text("Duration: \(duration) minutes")
.font(.subheadline)
.foregroundColor(.secondary)
@@ -209,6 +262,21 @@ struct SessionHeaderCard: View {
formatter.dateStyle = .full
return formatter.string(from: 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)
}
}
}
struct SessionStatsCard: View {
@@ -276,6 +344,7 @@ struct StatItem: View {
struct AttemptsSection: View {
let attemptsWithProblems: [(Attempt, Problem)]
@Binding var attemptToDelete: Attempt?
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var editingAttempt: Attempt?
@@ -311,6 +380,30 @@ struct AttemptsSection: View {
ForEach(attemptsWithProblems.indices, id: \.self) { index in
let (attempt, problem) = attemptsWithProblems[index]
AttemptCard(attempt: attempt, problem: problem)
.background(.regularMaterial)
.cornerRadius(12)
.shadow(radius: 2)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
// Add haptic feedback for delete action
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.impactOccurred()
attemptToDelete = attempt
} label: {
Label("Delete", systemImage: "trash")
}
.accessibilityLabel("Delete attempt")
.accessibilityHint("Removes this attempt from the session")
Button {
editingAttempt = attempt
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
.accessibilityLabel("Edit attempt")
.accessibilityHint("Modify the details of this attempt")
}
.onTapGesture {
editingAttempt = attempt
}
@@ -327,8 +420,6 @@ struct AttemptsSection: View {
struct AttemptCard: View {
let attempt: Attempt
let problem: Problem
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingDeleteAlert = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -353,15 +444,6 @@ struct AttemptCard: View {
VStack(alignment: .trailing, spacing: 8) {
AttemptResultBadge(result: attempt.result)
HStack(spacing: 12) {
Button(action: { showingDeleteAlert = true }) {
Image(systemName: "trash")
.font(.caption)
.foregroundColor(.red)
}
.buttonStyle(.plain)
}
}
}
@@ -378,19 +460,6 @@ struct AttemptCard: View {
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.stroke(.quaternary, lineWidth: 1)
)
.alert("Delete Attempt", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
dataManager.deleteAttempt(attempt)
}
} message: {
Text("Are you sure you want to delete this attempt?")
}
}
}