1.0.0 for iOS is ready to ship
This commit is contained in:
@@ -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?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user