1.0.0 for iOS is ready to ship

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

View File

@@ -1,9 +1,3 @@
//
// AddAttemptView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
@@ -99,14 +93,20 @@ struct AddAttemptView: View {
}
.padding(.vertical, 8)
} else {
ForEach(activeProblems, id: \.id) { problem in
ProblemSelectionRow(
problem: problem,
isSelected: selectedProblem?.id == problem.id
) {
selectedProblem = problem
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
spacing: 8
) {
ForEach(activeProblems, id: \.id) { problem in
ProblemSelectionCard(
problem: problem,
isSelected: selectedProblem?.id == problem.id
) {
selectedProblem = problem
}
}
}
.padding(.vertical, 8)
Button("Create New Problem") {
showingCreateProblem = true
@@ -391,6 +391,197 @@ struct ProblemSelectionRow: View {
}
}
struct ProblemSelectionCard: View {
let problem: Problem
let isSelected: Bool
let action: () -> Void
@State private var showingExpandedView = false
var body: some View {
VStack(spacing: 8) {
// Image section
ZStack {
if let firstImagePath = problem.imagePaths.first {
ProblemSelectionImageView(imagePath: firstImagePath)
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.2))
.frame(height: 80)
.overlay {
Image(systemName: "mountain.2.fill")
.foregroundColor(.gray)
.font(.title2)
}
}
// Selection indicator
VStack {
HStack {
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.white)
.background(Circle().fill(.blue))
.font(.title3)
}
}
Spacer()
}
.padding(6)
// Multiple images indicator
if problem.imagePaths.count > 1 {
VStack {
Spacer()
HStack {
Spacer()
Text("+\(problem.imagePaths.count - 1)")
.font(.caption2)
.fontWeight(.bold)
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(.black.opacity(0.6))
)
}
}
.padding(6)
}
}
// Problem info
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unnamed")
.font(.caption)
.fontWeight(.medium)
.lineLimit(1)
Text(problem.difficulty.grade)
.font(.caption2)
.fontWeight(.bold)
.foregroundColor(.blue)
if let location = problem.location {
Text(location)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isSelected ? .blue.opacity(0.1) : .gray.opacity(0.05))
.stroke(isSelected ? .blue : .clear, lineWidth: 2)
)
.contentShape(Rectangle())
.onTapGesture {
if isSelected {
showingExpandedView = true
} else {
action()
}
}
.sheet(isPresented: $showingExpandedView) {
ProblemExpandedView(problem: problem)
}
}
}
struct ProblemExpandedView: View {
let problem: Problem
@Environment(\.dismiss) private var dismiss
@State private var selectedImageIndex = 0
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Images
if !problem.imagePaths.isEmpty {
TabView(selection: $selectedImageIndex) {
ForEach(problem.imagePaths.indices, id: \.self) { index in
ProblemSelectionImageFullView(imagePath: problem.imagePaths[index])
.tag(index)
}
}
.frame(height: 250)
.tabViewStyle(.page(indexDisplayMode: .always))
}
// Problem details
VStack(alignment: .leading, spacing: 12) {
Text(problem.name ?? "Unnamed Problem")
.font(.title2)
.fontWeight(.bold)
HStack {
Text(problem.difficulty.grade)
.font(.title3)
.fontWeight(.bold)
.foregroundColor(.blue)
Text(problem.climbType.displayName)
.font(.subheadline)
.foregroundColor(.secondary)
}
if let location = problem.location, !location.isEmpty {
Label(location, systemImage: "location")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let setter = problem.setter, !setter.isEmpty {
Label(setter, systemImage: "person")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let description = problem.description, !description.isEmpty {
Text(description)
.font(.body)
}
if !problem.tags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(problem.tags, id: \.self) { tag in
Text(tag)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1))
)
.foregroundColor(.blue)
}
}
.padding(.horizontal)
}
}
}
.padding(.horizontal)
}
}
.navigationTitle("Problem Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
}
struct EditAttemptView: View {
let attempt: Attempt
@EnvironmentObject var dataManager: ClimbingDataManager
@@ -556,3 +747,131 @@ struct EditAttemptView: View {
)
.environmentObject(ClimbingDataManager.preview)
}
struct ProblemSelectionImageView: 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(height: 80)
.clipped()
.cornerRadius(8)
} else if hasFailed {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.2))
.frame(height: 80)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.title3)
}
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(height: 80)
.overlay {
ProgressView()
.scaleEffect(0.8)
}
}
}
.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 ProblemSelectionImageFullView: 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 {
RoundedRectangle(cornerRadius: 12)
.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 {
RoundedRectangle(cornerRadius: 12)
.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
}
}
}
}
}