[Android] 1.9.2
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m29s

This commit is contained in:
2025-10-12 20:41:39 -06:00
parent 405fb06d5d
commit 30d2b3938e
23 changed files with 620 additions and 1721 deletions

View File

@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -513,7 +513,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -602,7 +602,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -632,7 +632,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;

View File

@@ -0,0 +1,39 @@
import SwiftUI
struct AsyncImageView: View {
let imagePath: String
let targetSize: CGSize
@State private var image: UIImage?
var body: some View {
ZStack {
Rectangle()
.fill(Color(.systemGray6))
if let image = image {
Image(uiImage: image)
.resizable()
.scaledToFill()
.transition(.opacity.animation(.easeInOut(duration: 0.3)))
} else {
Image(systemName: "photo")
.font(.system(size: 24))
.foregroundColor(Color(.systemGray3))
}
}
.frame(width: targetSize.width, height: targetSize.height)
.clipped()
.cornerRadius(8)
.task(id: imagePath) {
if self.image != nil {
self.image = nil
}
self.image = await ImageManager.shared.loadThumbnail(
fromPath: imagePath,
targetSize: targetSize
)
}
}
}

View File

@@ -9,6 +9,7 @@ class SyncService: ObservableObject {
@Published var syncError: String?
@Published var isConnected = false
@Published var isTesting = false
@Published var isOfflineMode = false
private let userDefaults = UserDefaults.standard
private var syncTask: Task<Void, Never>?
@@ -19,8 +20,9 @@ class SyncService: ObservableObject {
static let serverURL = "sync_server_url"
static let authToken = "sync_auth_token"
static let lastSyncTime = "last_sync_time"
static let isConnected = "sync_is_connected"
static let isConnected = "is_connected"
static let autoSyncEnabled = "auto_sync_enabled"
static let offlineMode = "offline_mode"
}
var serverURL: String {
@@ -46,12 +48,9 @@ class SyncService: ObservableObject {
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
self.lastSyncTime = lastSync
}
self.isConnected = userDefaults.bool(forKey: Keys.isConnected)
// Perform image naming migration on initialization
Task {
await performImageNamingMigration()
}
isConnected = userDefaults.bool(forKey: Keys.isConnected)
isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true
isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode)
}
func downloadData() async throws -> ClimbDataBackup {
@@ -211,6 +210,11 @@ class SyncService: ObservableObject {
}
func syncWithServer(dataManager: ClimbingDataManager) async throws {
if isOfflineMode {
print("Sync skipped: Offline mode is enabled.")
return
}
guard isConfigured else {
throw SyncError.notConfigured
}
@@ -1025,105 +1029,7 @@ class SyncService: ObservableObject {
syncTask?.cancel()
}
// MARK: - Image Naming Migration
private func performImageNamingMigration() async {
let migrationKey = "image_naming_migration_completed_v2"
guard !userDefaults.bool(forKey: migrationKey) else {
print("Image naming migration already completed")
return
}
print("Starting image naming migration...")
var updateCount = 0
let imageManager = ImageManager.shared
// Get all problems from UserDefaults
if let problemsData = userDefaults.data(forKey: "problems"),
var problems = try? JSONDecoder().decode([Problem].self, from: problemsData)
{
for problemIndex in 0..<problems.count {
let problem = problems[problemIndex]
guard !problem.imagePaths.isEmpty else { continue }
var updatedImagePaths: [String] = []
var hasChanges = false
for (imageIndex, imagePath) in problem.imagePaths.enumerated() {
let currentFilename = URL(fileURLWithPath: imagePath).lastPathComponent
let consistentFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: imageIndex)
if currentFilename != consistentFilename {
let oldPath = imageManager.imagesDirectory.appendingPathComponent(
currentFilename
).path
let newPath = imageManager.imagesDirectory.appendingPathComponent(
consistentFilename
).path
if FileManager.default.fileExists(atPath: oldPath) {
do {
try FileManager.default.moveItem(atPath: oldPath, toPath: newPath)
updatedImagePaths.append(consistentFilename)
hasChanges = true
updateCount += 1
print("Migrated image: \(currentFilename) -> \(consistentFilename)")
} catch {
print("Failed to migrate image \(currentFilename): \(error)")
updatedImagePaths.append(imagePath)
}
} else {
updatedImagePaths.append(imagePath)
}
} else {
updatedImagePaths.append(imagePath)
}
}
if hasChanges {
// Decode problem to dictionary, update imagePaths, re-encode
if let problemData = try? JSONEncoder().encode(problem),
var problemDict = try? JSONSerialization.jsonObject(with: problemData)
as? [String: Any]
{
problemDict["imagePaths"] = updatedImagePaths
problemDict["updatedAt"] = ISO8601DateFormatter().string(from: Date())
if let updatedData = try? JSONSerialization.data(
withJSONObject: problemDict),
let updatedProblem = try? JSONDecoder().decode(
Problem.self, from: updatedData)
{
problems[problemIndex] = updatedProblem
}
}
}
}
if updateCount > 0 {
if let updatedData = try? JSONEncoder().encode(problems) {
userDefaults.set(updatedData, forKey: "problems")
print("Updated \(updateCount) image paths in UserDefaults")
}
}
}
userDefaults.set(true, forKey: migrationKey)
print("Image naming migration completed, updated \(updateCount) images")
// Notify ClimbingDataManager to reload data if images were updated
if updateCount > 0 {
DispatchQueue.main.async {
NotificationCenter.default.post(
name: NSNotification.Name("ImageMigrationCompleted"),
object: nil,
userInfo: ["updateCount": updateCount]
)
}
}
}
// MARK: - Merging
// MARK: - Safe Merge Functions
private func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym]

View File

@@ -1,10 +1,12 @@
import Foundation
import ImageIO
import SwiftUI
import UIKit
class ImageManager {
static let shared = ImageManager()
private let thumbnailCache = NSCache<NSString, UIImage>()
private let fileManager = FileManager.default
private let appSupportDirectoryName = "OpenClimb"
private let imagesDirectoryName = "Images"
@@ -479,6 +481,51 @@ class ImageManager {
return nil
}
func loadThumbnail(fromPath path: String, targetSize: CGSize) async -> UIImage? {
let cacheKey = "\(path)-\(targetSize.width)x\(targetSize.height)" as NSString
if let cachedImage = thumbnailCache.object(forKey: cacheKey) {
return cachedImage
}
guard let imageData = loadImageData(fromPath: path) else {
return nil
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: max(targetSize.width, targetSize.height)
* UIScreen.main.scale,
]
guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil) else {
return UIImage(data: imageData)
}
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any]
let orientation = properties?[kCGImagePropertyOrientation] as? UInt32 ?? 1
if let cgImage = CGImageSourceCreateThumbnailAtIndex(
imageSource, 0, options as CFDictionary)
{
let imageOrientation = UIImage.Orientation(rawValue: Int(orientation - 1)) ?? .up
let thumbnail = UIImage(
cgImage: cgImage, scale: UIScreen.main.scale, orientation: imageOrientation)
thumbnailCache.setObject(thumbnail, forKey: cacheKey)
return thumbnail
} else {
if let fallbackImage = UIImage(data: imageData) {
thumbnailCache.setObject(fallbackImage, forKey: cacheKey)
return fallbackImage
}
}
return nil
}
func imageExists(atPath path: String) -> Bool {
let primaryPath = getFullPath(from: path)
let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path))
@@ -854,72 +901,4 @@ class ImageManager {
}
}
func migrateImageNamesToDeterministic(dataManager: ClimbingDataManager) {
print("Starting migration of image names to deterministic format...")
var migrationCount = 0
var updatedProblems: [Problem] = []
for problem in dataManager.problems {
guard !problem.imagePaths.isEmpty else { continue }
var newImagePaths: [String] = []
var problemNeedsUpdate = false
for (index, imagePath) in problem.imagePaths.enumerated() {
let currentFilename = URL(fileURLWithPath: imagePath).lastPathComponent
if ImageNamingUtils.isValidImageFilename(currentFilename) {
newImagePaths.append(imagePath)
continue
}
let deterministicName = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
let oldPath = imagesDirectory.appendingPathComponent(currentFilename)
let newPath = imagesDirectory.appendingPathComponent(deterministicName)
if fileManager.fileExists(atPath: oldPath.path) {
do {
try fileManager.moveItem(at: oldPath, to: newPath)
let oldBackupPath = backupDirectory.appendingPathComponent(currentFilename)
let newBackupPath = backupDirectory.appendingPathComponent(
deterministicName)
if fileManager.fileExists(atPath: oldBackupPath.path) {
try? fileManager.moveItem(at: oldBackupPath, to: newBackupPath)
}
newImagePaths.append(deterministicName)
problemNeedsUpdate = true
migrationCount += 1
print("Migrated: \(currentFilename)\(deterministicName)")
} catch {
print("Failed to migrate \(currentFilename): \(error)")
newImagePaths.append(imagePath)
}
} else {
print("Warning: Image file not found: \(currentFilename)")
newImagePaths.append(imagePath)
}
}
if problemNeedsUpdate {
let updatedProblem = problem.updated(imagePaths: newImagePaths)
updatedProblems.append(updatedProblem)
}
}
for updatedProblem in updatedProblems {
dataManager.updateProblem(updatedProblem)
}
print(
"Migration completed: \(migrationCount) images renamed, \(updatedProblems.count) problems updated"
)
}
}

View File

@@ -37,7 +37,7 @@ struct OrientationAwareImage: View {
}
private func loadImageWithCorrectOrientation() {
Task {
Task.detached(priority: .userInitiated) {
let correctedImage = await loadAndCorrectImage()
await MainActor.run {
self.uiImage = correctedImage
@@ -48,17 +48,10 @@ struct OrientationAwareImage: View {
}
private func loadAndCorrectImage() async -> UIImage? {
// Load image data from ImageManager
guard
let data = await MainActor.run(body: {
ImageManager.shared.loadImageData(fromPath: imagePath)
})
else { return nil }
guard let data = ImageManager.shared.loadImageData(fromPath: imagePath) else { return nil }
// Create UIImage from data
guard let originalImage = UIImage(data: data) else { return nil }
// Apply orientation correction
return correctImageOrientation(originalImage)
}

View File

@@ -9,8 +9,26 @@ struct ProblemsView: View {
@State private var showingSearch = false
@FocusState private var isSearchFocused: Bool
private var filteredProblems: [Problem] {
var filtered = dataManager.problems
@State private var cachedFilteredProblems: [Problem] = []
private func updateFilteredProblems() {
Task(priority: .userInitiated) {
let result = await computeFilteredProblems()
// Switch back to the main thread to update the UI
await MainActor.run {
cachedFilteredProblems = result
}
}
}
private func computeFilteredProblems() async -> [Problem] {
// Capture dependencies for safe background processing
let problems = dataManager.problems
let searchText = self.searchText
let selectedClimbType = self.selectedClimbType
let selectedGym = self.selectedGym
var filtered = problems
// Apply search filter
if !searchText.isEmpty {
@@ -93,19 +111,19 @@ struct ProblemsView: View {
FilterSection(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
filteredProblems: filteredProblems
filteredProblems: cachedFilteredProblems
)
.padding()
.background(.regularMaterial)
}
if filteredProblems.isEmpty {
if cachedFilteredProblems.isEmpty {
EmptyProblemsView(
isEmpty: dataManager.problems.isEmpty,
isFiltered: !dataManager.problems.isEmpty
)
} else {
ProblemsList(problems: filteredProblems)
ProblemsList(problems: cachedFilteredProblems)
}
}
}
@@ -158,6 +176,21 @@ struct ProblemsView: View {
AddEditProblemView()
}
}
.onAppear {
updateFilteredProblems()
}
.onChange(of: dataManager.problems) {
updateFilteredProblems()
}
.onChange(of: searchText) {
updateFilteredProblems()
}
.onChange(of: selectedClimbType) {
updateFilteredProblems()
}
.onChange(of: selectedGym) {
updateFilteredProblems()
}
}
}
@@ -269,6 +302,7 @@ struct ProblemsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var problemToDelete: Problem?
@State private var problemToEdit: Problem?
@State private var animationKey = 0
var body: some View {
List(problems, id: \.id) { problem in
@@ -309,8 +343,11 @@ struct ProblemsList: View {
}
.animation(
.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1),
value: problems.map { "\($0.id):\($0.isActive)" }.joined()
value: animationKey
)
.onChange(of: problems) {
animationKey += 1
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollIndicators(.hidden)
@@ -344,6 +381,12 @@ struct ProblemRow: View {
dataManager.gym(withId: problem.gymId)
}
private var isCompleted: Bool {
dataManager.attempts.contains { attempt in
attempt.problemId == problem.id && attempt.result.isSuccessful
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
@@ -361,10 +404,24 @@ struct ProblemRow: View {
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(problem.difficulty.grade)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
HStack(spacing: 8) {
if !problem.imagePaths.isEmpty {
Image(systemName: "photo")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.blue)
}
if isCompleted {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.green)
}
Text(problem.difficulty.grade)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
}
Text(problem.climbType.displayName)
.font(.caption)
@@ -396,17 +453,6 @@ struct ProblemRow: View {
}
}
if !problem.imagePaths.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 8) {
ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
ProblemImageView(imagePath: imagePath)
}
}
.padding(.horizontal, 4)
}
}
if !problem.isActive {
Text("Reset / No Longer Set")
.font(.caption)
@@ -478,17 +524,6 @@ struct EmptyProblemsView: View {
}
}
struct ProblemImageView: View {
let imagePath: String
var body: some View {
OrientationAwareImage.fill(imagePath: imagePath)
.frame(width: 60, height: 60)
.clipped()
.cornerRadius(8)
}
}
#Preview {
ProblemsView()
.environmentObject(ClimbingDataManager.preview)

View File

@@ -80,8 +80,7 @@ struct DataManagementSection: View {
@Binding var activeSheet: SheetType?
@State private var showingResetAlert = false
@State private var isExporting = false
@State private var isMigrating = false
@State private var showingMigrationAlert = false
@State private var isDeletingImages = false
@State private var showingDeleteImagesAlert = false
@@ -121,27 +120,6 @@ struct DataManagementSection: View {
}
.foregroundColor(.primary)
// Migrate Image Names
Button(action: {
showingMigrationAlert = true
}) {
HStack {
if isMigrating {
ProgressView()
.scaleEffect(0.8)
Text("Migrating Images...")
.foregroundColor(.secondary)
} else {
Image(systemName: "photo.badge.arrow.down")
.foregroundColor(.orange)
Text("Fix Image Names")
}
Spacer()
}
}
.disabled(isMigrating)
.foregroundColor(.primary)
// Delete All Images
Button(action: {
showingDeleteImagesAlert = true
@@ -186,16 +164,7 @@ struct DataManagementSection: View {
"Are you sure you want to reset all data? This will permanently delete:\n\n• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data\n\nThis action cannot be undone. Consider exporting your data first."
)
}
.alert("Fix Image Names", isPresented: $showingMigrationAlert) {
Button("Cancel", role: .cancel) {}
Button("Fix Names") {
migrateImageNames()
}
} message: {
Text(
"This will rename all existing image files to use a consistent naming system across devices.\n\nThis improves sync reliability between iOS and Android. Your images will not be lost, only renamed.\n\nThis is safe to run multiple times."
)
}
.alert("Delete All Images", isPresented: $showingDeleteImagesAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
@@ -219,17 +188,6 @@ struct DataManagementSection: View {
}
}
private func migrateImageNames() {
isMigrating = true
Task {
await MainActor.run {
ImageManager.shared.migrateImageNamesToDeterministic(dataManager: dataManager)
isMigrating = false
dataManager.successMessage = "Image names fixed successfully!"
}
}
}
private func deleteAllImages() {
isDeletingImages = true
Task {

23
ios/README.md Normal file
View File

@@ -0,0 +1,23 @@
# OpenClimb for iOS
The native iOS, watchOS, and widget client for OpenClimb, built with Swift and SwiftUI.
## Project Structure
This is a standard Xcode project. The main app code is in the `OpenClimb/` directory.
- `Models/`: Swift `Codable` models (`Problem`, `Gym`, `ClimbSession`) that match the Android app.
- `ViewModels/`: App state and logic. `ClimbingDataManager` is the core here, handling data with SwiftData.
- `Views/`: All the SwiftUI views.
- `AddEdit/`: Views for adding/editing gyms, problems, etc.
- `Detail/`: Detail views for items.
- `Services/`: Handles HealthKit and sync server communication.
- `Utils/`: Helper functions and utilities.
## Other Targets
- `OpenClimbWatch/`: The watchOS app for tracking sessions.
- `ClimbingActivityWidget/`: A home screen widget.
- `SessionStatusLive/`: A Live Activity for the lock screen.
The app is built to be offline-first. All data is stored locally on your device and works without an internet connection.