1.03 for iOS and 1.5.0 for Android

This commit is contained in:
2025-09-27 02:13:51 -06:00
parent b6dded50d9
commit 346f1a438e
16 changed files with 1708 additions and 1271 deletions

View File

@@ -105,9 +105,26 @@ struct ProgressChartSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var selectedSystem: DifficultySystem = .vScale
@State private var showAllTime: Bool = true
@State private var cachedGradeCountData: [GradeCount] = []
@State private var lastCalculationDate: Date = Date.distantPast
@State private var lastDataHash: Int = 0
private var gradeCountData: [GradeCount] {
calculateGradeCounts()
let currentHash =
dataManager.problems.count + dataManager.attempts.count + (showAllTime ? 1 : 0)
let now = Date()
// Recalculate only if data changed or cache is older than 30 seconds
if currentHash != lastDataHash || now.timeIntervalSince(lastCalculationDate) > 30 {
let newData = calculateGradeCounts()
DispatchQueue.main.async {
self.cachedGradeCountData = newData
self.lastCalculationDate = now
self.lastDataHash = currentHash
}
}
return cachedGradeCountData.isEmpty ? calculateGradeCounts() : cachedGradeCountData
}
private var usedSystems: [DifficultySystem] {

View File

@@ -15,6 +15,18 @@ struct SessionDetailView: View {
dataManager.session(withId: sessionId)
}
private func startTimer() {
// Update every 5 seconds instead of 1 second for better performance
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
currentTime = Date()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private var gym: Gym? {
guard let session = session else { return nil }
return dataManager.gym(withId: session.gymId)
@@ -35,7 +47,7 @@ struct SessionDetailView: View {
calculateSessionStats()
}
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@State private var timer: Timer?
var body: some View {
ScrollView {
@@ -57,8 +69,11 @@ struct SessionDetailView: View {
}
.padding()
}
.onReceive(timer) { _ in
currentTime = Date()
.onAppear {
startTimer()
}
.onDisappear {
stopTimer()
}
.navigationTitle("Session Details")
.navigationBarTitleDisplayMode(.inline)
@@ -153,46 +168,14 @@ struct SessionDetailView: View {
let uniqueProblems = Set(attempts.map { $0.problemId })
let completedProblems = Set(successfulAttempts.map { $0.problemId })
let attemptedProblems = uniqueProblems.compactMap { dataManager.problem(withId: $0) }
let boulderProblems = attemptedProblems.filter { $0.climbType == .boulder }
let ropeProblems = attemptedProblems.filter { $0.climbType == .rope }
let boulderRange = gradeRange(for: boulderProblems)
let ropeRange = gradeRange(for: ropeProblems)
return SessionStats(
totalAttempts: attempts.count,
successfulAttempts: successfulAttempts.count,
uniqueProblemsAttempted: uniqueProblems.count,
uniqueProblemsCompleted: completedProblems.count,
boulderRange: boulderRange,
ropeRange: ropeRange
uniqueProblemsCompleted: completedProblems.count
)
}
private func gradeRange(for problems: [Problem]) -> String? {
guard !problems.isEmpty else { return nil }
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: ", ")
}
}
struct SessionHeaderCard: View {
@@ -300,19 +283,6 @@ struct SessionStatsCard: View {
StatItem(label: "Successful", value: "\(stats.successfulAttempts)")
StatItem(label: "Completed", value: "\(stats.uniqueProblemsCompleted)")
}
// Grade ranges
VStack(alignment: .leading, spacing: 8) {
if let boulderRange = stats.boulderRange, let ropeRange = stats.ropeRange {
HStack {
StatItem(label: "Boulder Range", value: boulderRange)
StatItem(label: "Rope Range", value: ropeRange)
}
} else if let singleRange = stats.boulderRange ?? stats.ropeRange {
StatItem(label: "Grade Range", value: singleRange)
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
}
.padding()
@@ -504,8 +474,6 @@ struct SessionStats {
let successfulAttempts: Int
let uniqueProblemsAttempted: Int
let uniqueProblemsCompleted: Int
let boulderRange: String?
let ropeRange: String?
}
#Preview {

View File

@@ -1,4 +1,3 @@
import SwiftUI
struct ProblemsView: View {
@@ -286,7 +285,7 @@ struct ProblemRow: View {
if !problem.imagePaths.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
LazyHStack(spacing: 8) {
ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
ProblemImageView(imagePath: imagePath)
}
@@ -372,6 +371,13 @@ struct ProblemImageView: View {
@State private var isLoading = true
@State private var hasFailed = false
private static var imageCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB
return cache
}()
var body: some View {
Group {
if let uiImage = uiImage {
@@ -412,10 +418,22 @@ struct ProblemImageView: View {
return
}
let cacheKey = NSString(string: imagePath)
// Check cache first
if let cachedImage = Self.imageCache.object(forKey: cacheKey) {
self.uiImage = cachedImage
self.isLoading = false
return
}
DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
// Cache the image
Self.imageCache.setObject(image, forKey: cacheKey)
DispatchQueue.main.async {
self.uiImage = image
self.isLoading = false

View File

@@ -114,7 +114,7 @@ struct ActiveSessionBanner: View {
@State private var currentTime = Date()
@State private var navigateToDetail = false
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@State private var timer: Timer?
var body: some View {
HStack {
@@ -162,8 +162,11 @@ struct ActiveSessionBanner: View {
.fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1)
)
.onReceive(timer) { _ in
currentTime = Date()
.onAppear {
startTimer()
}
.onDisappear {
stopTimer()
}
.background(
NavigationLink(
@@ -190,6 +193,17 @@ struct ActiveSessionBanner: View {
return String(format: "%ds", seconds)
}
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
currentTime = Date()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
}
struct SessionRow: View {

View File

@@ -164,60 +164,70 @@ struct ExportDataView: View {
let data: Data
@Environment(\.dismiss) private var dismiss
@State private var tempFileURL: URL?
@State private var isCreatingFile = true
var body: some View {
NavigationView {
VStack(spacing: 20) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 60))
.foregroundColor(.blue)
VStack(spacing: 30) {
if isCreatingFile {
// Loading state - more prominent
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
.tint(.blue)
Text("Export Data")
.font(.title)
.fontWeight(.bold)
Text("Preparing Your Export")
.font(.title2)
.fontWeight(.semibold)
Text(
"Your climbing data has been prepared for export. Use the share button below to save or send your data."
)
.multilineTextAlignment(.center)
.padding(.horizontal)
if let fileURL = tempFileURL {
ShareLink(
item: fileURL,
preview: SharePreview(
"OpenClimb Data Export",
image: Image("MountainsIcon"))
) {
Label("Share Data", systemImage: "square.and.arrow.up")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.blue)
)
Text("Creating ZIP file with your climbing data and images...")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.padding(.horizontal)
.buttonStyle(.plain)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Button(action: {}) {
Label("Preparing Export...", systemImage: "hourglass")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.gray)
)
}
.disabled(true)
.padding(.horizontal)
}
// Ready state
VStack(spacing: 20) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundColor(.green)
Spacer()
Text("Export Ready!")
.font(.title)
.fontWeight(.bold)
Text(
"Your climbing data has been prepared for export. Use the share button below to save or send your data."
)
.multilineTextAlignment(.center)
.padding(.horizontal)
if let fileURL = tempFileURL {
ShareLink(
item: fileURL,
preview: SharePreview(
"OpenClimb Data Export",
image: Image("MountainsIcon"))
) {
Label("Share Data", systemImage: "square.and.arrow.up")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.blue)
)
}
.padding(.horizontal)
.buttonStyle(.plain)
}
}
Spacer()
}
}
.padding()
.navigationTitle("Export")
@@ -259,6 +269,9 @@ struct ExportDataView: View {
).first
else {
print("Could not access Documents directory")
DispatchQueue.main.async {
self.isCreatingFile = false
}
return
}
let fileURL = documentsURL.appendingPathComponent(filename)
@@ -268,9 +281,13 @@ struct ExportDataView: View {
DispatchQueue.main.async {
self.tempFileURL = fileURL
self.isCreatingFile = false
}
} catch {
print("Failed to create export file: \(error)")
DispatchQueue.main.async {
self.isCreatingFile = false
}
}
}
}