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
}
}
}
}
}

View File

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

View File

@@ -1,9 +1,3 @@
//
// AddEditProblemView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import PhotosUI
import SwiftUI
@@ -459,19 +453,10 @@ struct AddEditProblemView: View {
private func loadSelectedPhotos() async {
for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) {
// Save to app's documents directory
let documentsPath = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
).first!
let imageName = "photo_\(UUID().uuidString).jpg"
let imagePath = documentsPath.appendingPathComponent(imageName)
do {
try data.write(to: imagePath)
imagePaths.append(imagePath.path)
// Use ImageManager to save image
if let relativePath = ImageManager.shared.saveImageData(data) {
imagePaths.append(relativePath)
imageData.append(data)
} catch {
print("Failed to save image: \(error)")
}
}
}

View File

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

View File

@@ -1,10 +1,3 @@
//
// AnalyticsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct AnalyticsView: View {
@@ -538,8 +531,6 @@ struct ProgressDataPoint {
let difficultySystem: DifficultySystem
}
// MARK: - Helper Functions
#Preview {
AnalyticsView()
.environmentObject(ClimbingDataManager.preview)

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?")
}
}
}

View File

@@ -1,9 +1,3 @@
//
// GymsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
@@ -37,14 +31,47 @@ struct GymsView: View {
struct GymsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var gymToDelete: Gym?
@State private var gymToEdit: Gym?
var body: some View {
List(dataManager.gyms, id: \.id) { gym in
NavigationLink(destination: GymDetailView(gymId: gym.id)) {
GymRow(gym: gym)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
gymToDelete = gym
} label: {
Label("Delete", systemImage: "trash")
}
Button {
gymToEdit = gym
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
.alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) {
Button("Cancel", role: .cancel) {
gymToDelete = nil
}
Button("Delete", role: .destructive) {
if let gym = gymToDelete {
dataManager.deleteGym(gym)
gymToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this gym? This will also delete all associated problems and sessions."
)
}
.sheet(item: $gymToEdit) { gym in
AddEditGymView(gymId: gym.id)
}
.listStyle(.plain)
}
}
@@ -124,7 +151,7 @@ struct GymRow: View {
.lineLimit(2)
}
}
.padding(.vertical, 4)
.padding(.vertical, 8)
}
}

View File

@@ -1,9 +1,3 @@
//
// ProblemsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
@@ -45,9 +39,13 @@ struct ProblemsView: View {
NavigationView {
VStack(spacing: 0) {
if !dataManager.problems.isEmpty {
FilterSection()
.padding()
.background(.regularMaterial)
FilterSection(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
filteredProblems: filteredProblems
)
.padding()
.background(.regularMaterial)
}
if filteredProblems.isEmpty {
@@ -79,8 +77,9 @@ struct ProblemsView: View {
struct FilterSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var selectedClimbType: ClimbType?
@State private var selectedGym: Gym?
@Binding var selectedClimbType: ClimbType?
@Binding var selectedGym: Gym?
let filteredProblems: [Problem]
var body: some View {
VStack(spacing: 12) {
@@ -154,19 +153,6 @@ struct FilterSection: View {
}
}
private var filteredProblems: [Problem] {
var filtered = dataManager.problems
if let climbType = selectedClimbType {
filtered = filtered.filter { $0.climbType == climbType }
}
if let gym = selectedGym {
filtered = filtered.filter { $0.gymId == gym.id }
}
return filtered
}
}
struct FilterChip: View {
@@ -195,14 +181,47 @@ struct FilterChip: View {
struct ProblemsList: View {
let problems: [Problem]
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var problemToDelete: Problem?
@State private var problemToEdit: Problem?
var body: some View {
List(problems) { problem in
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
ProblemRow(problem: problem)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
problemToDelete = problem
} label: {
Label("Delete", systemImage: "trash")
}
Button {
problemToEdit = problem
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
.alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) {
Button("Cancel", role: .cancel) {
problemToDelete = nil
}
Button("Delete", role: .destructive) {
if let problem = problemToDelete {
dataManager.deleteProblem(problem)
problemToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this problem? This will also delete all associated attempts."
)
}
.sheet(item: $problemToEdit) { problem in
AddEditProblemView(problemId: problem.id)
}
.listStyle(.plain)
}
}
@@ -269,19 +288,10 @@ struct ProblemRow: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
AsyncImage(url: URL(fileURLWithPath: imagePath)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
}
.frame(width: 60, height: 60)
.clipped()
.cornerRadius(8)
ProblemImageView(imagePath: imagePath)
}
}
.padding(.horizontal, 4)
}
}
@@ -292,7 +302,7 @@ struct ProblemRow: View {
.fontWeight(.medium)
}
}
.padding(.vertical, 4)
.padding(.vertical, 8)
}
}
@@ -356,6 +366,70 @@ struct EmptyProblemsView: View {
}
}
struct ProblemImageView: 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: 60, height: 60)
.clipped()
.cornerRadius(8)
} else if hasFailed {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.2))
.frame(width: 60, height: 60)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.title3)
}
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(width: 60, height: 60)
.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
}
}
}
}
}
#Preview {
ProblemsView()
.environmentObject(ClimbingDataManager.preview)

View File

@@ -1,9 +1,3 @@
//
// SessionsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import Combine
import SwiftUI
@@ -127,6 +121,7 @@ struct ActiveSessionBanner: View {
struct SessionsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var sessionToDelete: ClimbSession?
private var completedSessions: [ClimbSession] {
dataManager.sessions
@@ -139,8 +134,29 @@ struct SessionsList: View {
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
SessionRow(session: session)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
sessionToDelete = session
} label: {
Label("Delete", systemImage: "trash")
}
}
}
.alert("Delete Session", isPresented: .constant(sessionToDelete != nil)) {
Button("Cancel", role: .cancel) {
sessionToDelete = nil
}
Button("Delete", role: .destructive) {
if let session = sessionToDelete {
dataManager.deleteSession(session)
sessionToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this session? This will also delete all attempts associated with this session."
)
}
.listStyle(.plain)
}
}
@@ -179,7 +195,7 @@ struct SessionRow: View {
.lineLimit(2)
}
}
.padding(.vertical, 4)
.padding(.vertical, 8)
}
private func formatDate(_ date: Date) -> String {

View File

@@ -1,9 +1,3 @@
//
// SettingsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
import UniformTypeIdentifiers
@@ -23,6 +17,8 @@ struct SettingsView: View {
activeSheet: $activeSheet
)
ImageStorageSection()
AppInfoSection()
}
.navigationTitle("Settings")
@@ -130,6 +126,96 @@ struct DataManagementSection: View {
}
}
struct ImageStorageSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingStorageInfo = false
@State private var storageInfo = ""
@State private var showingRecoveryAlert = false
@State private var showingEmergencyAlert = false
var body: some View {
Section("Image Storage") {
// Storage Status
Button(action: {
storageInfo = dataManager.getImageRecoveryStatus()
showingStorageInfo = true
}) {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
Text("Check Storage Health")
Spacer()
}
}
.foregroundColor(.primary)
// Manual Maintenance
Button(action: {
dataManager.manualImageMaintenance()
}) {
HStack {
Image(systemName: "wrench.and.screwdriver")
.foregroundColor(.orange)
Text("Run Maintenance")
Spacer()
}
}
.foregroundColor(.primary)
// Force Recovery
Button(action: {
showingRecoveryAlert = true
}) {
HStack {
Image(systemName: "arrow.clockwise")
.foregroundColor(.orange)
Text("Force Image Recovery")
Spacer()
}
}
.foregroundColor(.primary)
// Emergency Restore
Button(action: {
showingEmergencyAlert = true
}) {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.red)
Text("Emergency Restore")
Spacer()
}
}
.foregroundColor(.red)
}
.alert("Storage Information", isPresented: $showingStorageInfo) {
Button("OK") {}
} message: {
Text(storageInfo)
}
.alert("Force Image Recovery", isPresented: $showingRecoveryAlert) {
Button("Cancel", role: .cancel) {}
Button("Force Recovery", role: .destructive) {
dataManager.forceImageRecovery()
}
} message: {
Text(
"This will attempt to recover missing images from backups and legacy locations. Use this if images are missing after app updates or debug sessions."
)
}
.alert("Emergency Restore", isPresented: $showingEmergencyAlert) {
Button("Cancel", role: .cancel) {}
Button("Emergency Restore", role: .destructive) {
dataManager.emergencyImageRestore()
}
} message: {
Text(
"This will restore all images from the backup directory, potentially overwriting current images. Only use this if normal recovery fails."
)
}
}
}
struct AppInfoSection: View {
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"