1.0.0 for iOS is ready to ship
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
//
|
||||
// AddEditGymView.swift
|
||||
// OpenClimb
|
||||
//
|
||||
// Created by OpenClimb on 2025-01-17.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
//
|
||||
// AddEditSessionView.swift
|
||||
// OpenClimb
|
||||
//
|
||||
// Created by OpenClimb on 2025-01-17.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
//
|
||||
// GymDetailView.swift
|
||||
// OpenClimb
|
||||
//
|
||||
// Created by OpenClimb on 2025-01-17.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user