diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ccb7b66..7f4349a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 36 - versionCode = 33 - versionName = "1.7.4" + versionCode = 35 + versionName = "1.8.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt index e5006c8..ecd8b4a 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt @@ -113,7 +113,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep updateConfiguredState() // Clear connection status when configuration changes _isConnected.value = false - sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply() + sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) } } val isConfigured: Boolean @@ -757,19 +757,16 @@ class SyncService(private val context: Context, private val repository: ClimbRep activeSessionIds.contains(it.sessionId) && !allDeletedAttemptIds.contains(it.id) } + // Merge deletion lists + val localDeletions = repository.getDeletedItems() + val allDeletions = (localDeletions + serverBackup.deletedItems).distinctBy { it.id } + Log.d(TAG, "Merging data...") - val mergedGyms = mergeGyms(localGyms, serverBackup.gyms, serverBackup.deletedItems) + val mergedGyms = mergeGyms(localGyms, serverBackup.gyms, allDeletions) val mergedProblems = - mergeProblems( - localProblems, - serverBackup.problems, - imagePathMapping, - serverBackup.deletedItems - ) - val mergedSessions = - mergeSessions(localSessions, serverBackup.sessions, serverBackup.deletedItems) - val mergedAttempts = - mergeAttempts(localAttempts, serverBackup.attempts, serverBackup.deletedItems) + mergeProblems(localProblems, serverBackup.problems, imagePathMapping, allDeletions) + val mergedSessions = mergeSessions(localSessions, serverBackup.sessions, allDeletions) + val mergedAttempts = mergeAttempts(localAttempts, serverBackup.attempts, allDeletions) // Clear and repopulate with merged data repository.resetAllData() @@ -823,11 +820,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - // Merge deletion lists - val localDeletions = repository.getDeletedItems() - val allDeletions = (localDeletions + serverBackup.deletedItems).distinctBy { it.id } - - // Clear and update local deletions with merged list + // Update local deletions with merged list repository.clearDeletedItems() allDeletions.forEach { deletion -> try { diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt index 78e34e3..08ee0a3 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt @@ -1,5 +1,9 @@ package com.atridad.openclimb.ui.components +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Environment import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* @@ -8,7 +12,9 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -17,164 +23,262 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import coil.compose.AsyncImage import com.atridad.openclimb.utils.ImageUtils +import java.io.File +import java.text.SimpleDateFormat +import java.util.* @Composable fun ImagePicker( - imageUris: List, - onImagesChanged: (List) -> Unit, - modifier: Modifier = Modifier, - maxImages: Int = 5 + imageUris: List, + onImagesChanged: (List) -> Unit, + modifier: Modifier = Modifier, + maxImages: Int = 5 ) { val context = LocalContext.current var tempImageUris by remember { mutableStateOf(imageUris) } - + var showImageSourceDialog by remember { mutableStateOf(false) } + var cameraImageUri by remember { mutableStateOf(null) } + // Image picker launcher - val imagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetMultipleContents() - ) { uris -> - if (uris.isNotEmpty()) { - val currentCount = tempImageUris.size - val remainingSlots = maxImages - currentCount - val urisToProcess = uris.take(remainingSlots) - - // Process images - val newImagePaths = mutableListOf() - urisToProcess.forEach { uri -> - val imagePath = ImageUtils.saveImageFromUri(context, uri) - if (imagePath != null) { - newImagePaths.add(imagePath) + val imagePickerLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris -> + if (uris.isNotEmpty()) { + val currentCount = tempImageUris.size + val remainingSlots = maxImages - currentCount + val urisToProcess = uris.take(remainingSlots) + + // Process images + val newImagePaths = mutableListOf() + urisToProcess.forEach { uri -> + val imagePath = ImageUtils.saveImageFromUri(context, uri) + if (imagePath != null) { + newImagePaths.add(imagePath) + } + } + + if (newImagePaths.isNotEmpty()) { + val updatedUris = tempImageUris + newImagePaths + tempImageUris = updatedUris + onImagesChanged(updatedUris) + } } } - - if (newImagePaths.isNotEmpty()) { - val updatedUris = tempImageUris + newImagePaths - tempImageUris = updatedUris - onImagesChanged(updatedUris) + + // Camera launcher + val cameraLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.TakePicture()) { + success -> + if (success) { + cameraImageUri?.let { uri -> + val imagePath = ImageUtils.saveImageFromUri(context, uri) + if (imagePath != null) { + val updatedUris = tempImageUris + imagePath + tempImageUris = updatedUris + onImagesChanged(updatedUris) + } + } + } } - } - } - + + // Camera permission launcher + val cameraPermissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + // Create image file for camera + val imageFile = createImageFile(context) + val uri = + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + imageFile + ) + cameraImageUri = uri + cameraLauncher.launch(uri) + } + } + Column(modifier = modifier) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Photos (${tempImageUris.size}/$maxImages)", - style = MaterialTheme.typography.titleMedium + text = "Photos (${tempImageUris.size}/$maxImages)", + style = MaterialTheme.typography.titleMedium ) - + if (tempImageUris.size < maxImages) { - TextButton( - onClick = { - imagePickerLauncher.launch("image/*") - } - ) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) + TextButton(onClick = { showImageSourceDialog = true }) { + Icon( + Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) Spacer(modifier = Modifier.width(4.dp)) Text("Add Photos") } } } - + if (tempImageUris.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) - - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(tempImageUris) { imagePath -> ImageItem( - imagePath = imagePath, - onRemove = { - val updatedUris = tempImageUris.filter { it != imagePath } - tempImageUris = updatedUris - onImagesChanged(updatedUris) - - // Delete the image file - ImageUtils.deleteImage(context, imagePath) - } + imagePath = imagePath, + onRemove = { + val updatedUris = tempImageUris.filter { it != imagePath } + tempImageUris = updatedUris + onImagesChanged(updatedUris) + + // Delete the image file + ImageUtils.deleteImage(context, imagePath) + } ) } } } else { Spacer(modifier = Modifier.height(8.dp)) Card( - modifier = Modifier - .fillMaxWidth() - .height(100.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) + modifier = Modifier.fillMaxWidth().height(100.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.3f + ) + ) ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon( - Icons.Default.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Add photos of this problem", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Add photos of this problem", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } + + // Image Source Selection Dialog + if (showImageSourceDialog) { + AlertDialog( + onDismissRequest = { showImageSourceDialog = false }, + title = { Text("Add Photo") }, + text = { Text("Choose how you'd like to add a photo") }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { + showImageSourceDialog = false + imagePickerLauncher.launch("image/*") + } + ) { + Icon( + Icons.Default.PhotoLibrary, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Gallery") + } + + TextButton( + onClick = { + showImageSourceDialog = false + when (ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) + ) { + PackageManager.PERMISSION_GRANTED -> { + // Create image file for camera + val imageFile = createImageFile(context) + val uri = + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + imageFile + ) + cameraImageUri = uri + cameraLauncher.launch(uri) + } + else -> { + cameraPermissionLauncher.launch( + Manifest.permission.CAMERA + ) + } + } + } + ) { + Icon( + Icons.Default.CameraAlt, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Camera") + } + } + }, + dismissButton = { + TextButton(onClick = { showImageSourceDialog = false }) { Text("Cancel") } + } + ) + } } } +private fun createImageFile(context: android.content.Context): File { + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val imageFileName = "JPEG_${timeStamp}_" + val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) + return File.createTempFile(imageFileName, ".jpg", storageDir) +} + @Composable -private fun ImageItem( - imagePath: String, - onRemove: () -> Unit, - modifier: Modifier = Modifier -) { +private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifier = Modifier) { val context = LocalContext.current val imageFile = ImageUtils.getImageFile(context, imagePath) - - Box( - modifier = modifier.size(80.dp) - ) { + + Box(modifier = modifier.size(80.dp)) { AsyncImage( - model = imageFile, - contentDescription = "Problem photo", - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Crop + model = imageFile, + contentDescription = "Problem photo", + modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop ) - - IconButton( - onClick = onRemove, - modifier = Modifier - .align(Alignment.TopEnd) - .size(24.dp) - ) { + + IconButton(onClick = onRemove, modifier = Modifier.align(Alignment.TopEnd).size(24.dp)) { Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) ) { Icon( - Icons.Default.Close, - contentDescription = "Remove photo", - modifier = Modifier - .fillMaxSize() - .padding(2.dp), - tint = MaterialTheme.colorScheme.onErrorContainer + Icons.Default.Close, + contentDescription = "Remove photo", + modifier = Modifier.fillMaxSize().padding(2.dp), + tint = MaterialTheme.colorScheme.onErrorContainer ) } } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 84d3273..b1cea28 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -11,8 +11,8 @@ androidxTestRunner = "1.7.0" androidxTestRules = "1.7.0" lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.11.0" -composeBom = "2025.09.01" -room = "2.8.1" +composeBom = "2025.10.00" +room = "2.8.2" navigation = "2.9.5" viewmodel = "2.9.4" kotlinxSerialization = "1.9.0" diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index 30fa4de..a547356 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -485,7 +485,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.5; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -508,7 +508,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -528,7 +528,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.5; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -592,7 +592,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -603,7 +603,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.5; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -622,7 +622,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -633,7 +633,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.5; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 1879d69..5c5c521 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb/Components/CameraImagePicker.swift b/ios/OpenClimb/Components/CameraImagePicker.swift new file mode 100644 index 0000000..93008f7 --- /dev/null +++ b/ios/OpenClimb/Components/CameraImagePicker.swift @@ -0,0 +1,54 @@ +import SwiftUI +import UIKit + +struct CameraImagePicker: UIViewControllerRepresentable { + @Binding var isPresented: Bool + let onImageCaptured: (UIImage) -> Void + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.sourceType = .camera + picker.cameraCaptureMode = .photo + picker.cameraDevice = .rear + picker.allowsEditing = false + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { + // Nothing here actually... Q_Q + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: CameraImagePicker + + init(_ parent: CameraImagePicker) { + self.parent = parent + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + if let image = info[.originalImage] as? UIImage { + parent.onImageCaptured(image) + } + parent.isPresented = false + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.isPresented = false + } + } +} + +// Extension to check camera availability +extension CameraImagePicker { + static var isCameraAvailable: Bool { + UIImagePickerController.isSourceTypeAvailable(.camera) + } +} diff --git a/ios/OpenClimb/Components/PhotoOptionSheet.swift b/ios/OpenClimb/Components/PhotoOptionSheet.swift new file mode 100644 index 0000000..d0bb26e --- /dev/null +++ b/ios/OpenClimb/Components/PhotoOptionSheet.swift @@ -0,0 +1,83 @@ +import PhotosUI +import SwiftUI + +struct PhotoOptionSheet: View { + @Binding var selectedPhotos: [PhotosPickerItem] + @Binding var imageData: [Data] + let maxImages: Int + let onCameraSelected: () -> Void + let onPhotoLibrarySelected: () -> Void + let onDismiss: () -> Void + + var body: some View { + NavigationView { + VStack(spacing: 20) { + Text("Add Photo") + .font(.title2) + .fontWeight(.semibold) + .padding(.top) + + Text("Choose how you'd like to add a photo") + .font(.subheadline) + .foregroundColor(.secondary) + + VStack(spacing: 16) { + Button(action: { + onPhotoLibrarySelected() + onDismiss() + }) { + HStack { + Image(systemName: "photo.on.rectangle") + .font(.title2) + .foregroundColor(.blue) + Text("Photo Library") + .font(.headline) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(.regularMaterial) + .cornerRadius(12) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: { + onCameraSelected() + onDismiss() + }) { + HStack { + Image(systemName: "camera.fill") + .font(.title2) + .foregroundColor(.blue) + Text("Camera") + .font(.headline) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(.regularMaterial) + .cornerRadius(12) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.horizontal) + + Spacer() + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { + onDismiss() + } + } + } + } + .presentationDetents([.height(300)]) + .interactiveDismissDisabled(false) + } +} diff --git a/ios/OpenClimb/Info.plist b/ios/OpenClimb/Info.plist index e65576e..ce92529 100644 --- a/ios/OpenClimb/Info.plist +++ b/ios/OpenClimb/Info.plist @@ -8,5 +8,7 @@ NSPhotoLibraryUsageDescription This app needs access to your photo library to add photos to climbing problems. + NSCameraUsageDescription + This app needs access to your camera to take photos of climbing problems. diff --git a/ios/OpenClimb/Models/ActivityAttributes.swift b/ios/OpenClimb/Models/ActivityAttributes.swift index afabbaa..f33612f 100644 --- a/ios/OpenClimb/Models/ActivityAttributes.swift +++ b/ios/OpenClimb/Models/ActivityAttributes.swift @@ -17,3 +17,4 @@ extension SessionActivityAttributes { SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date()) } } + \ No newline at end of file diff --git a/ios/OpenClimb/Services/SyncService.swift b/ios/OpenClimb/Services/SyncService.swift index facff7b..27b45eb 100644 --- a/ios/OpenClimb/Services/SyncService.swift +++ b/ios/OpenClimb/Services/SyncService.swift @@ -401,31 +401,35 @@ class SyncService: ObservableObject { let imagePathMapping = try await syncImagesFromServer( backup: serverBackup, dataManager: dataManager) - // Merge data additively - never remove existing local data + // Merge deletion lists first to prevent resurrection of deleted items + let localDeletions = dataManager.getDeletedItems() + let allDeletions = localDeletions + serverBackup.deletedItems + let uniqueDeletions = Array(Set(allDeletions)) + print("Merging gyms...") let mergedGyms = mergeGyms( local: dataManager.gyms, server: serverBackup.gyms, - deletedItems: serverBackup.deletedItems) + deletedItems: uniqueDeletions) print("Merging problems...") let mergedProblems = try mergeProblems( local: dataManager.problems, server: serverBackup.problems, imagePathMapping: imagePathMapping, - deletedItems: serverBackup.deletedItems) + deletedItems: uniqueDeletions) print("Merging sessions...") let mergedSessions = try mergeSessions( local: dataManager.sessions, server: serverBackup.sessions, - deletedItems: serverBackup.deletedItems) + deletedItems: uniqueDeletions) print("Merging attempts...") let mergedAttempts = try mergeAttempts( local: dataManager.attempts, server: serverBackup.attempts, - deletedItems: serverBackup.deletedItems) + deletedItems: uniqueDeletions) // Update data manager with merged data dataManager.gyms = mergedGyms @@ -440,11 +444,6 @@ class SyncService: ObservableObject { dataManager.saveAttempts() dataManager.saveActiveSession() - // Merge deletion lists - let localDeletions = dataManager.getDeletedItems() - let allDeletions = localDeletions + serverBackup.deletedItems - let uniqueDeletions = Array(Set(allDeletions)) - // Update local deletions with merged list dataManager.clearDeletedItems() if let data = try? JSONEncoder().encode(uniqueDeletions) { diff --git a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift index 36d7a0b..e44e39a 100644 --- a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift @@ -23,6 +23,22 @@ struct AddAttemptView: View { @State private var selectedPhotos: [PhotosPickerItem] = [] @State private var imageData: [Data] = [] + enum SheetType: Identifiable { + case photoOptions + case camera + + var id: Int { + switch self { + case .photoOptions: return 0 + case .camera: return 1 + } + } + } + + @State private var activeSheet: SheetType? + @State private var showPhotoPicker = false + @State private var isPhotoPickerActionPending = false + private var activeProblems: [Problem] { dataManager.activeProblems(forGym: gym.id) } @@ -78,6 +94,56 @@ struct AddAttemptView: View { .onChange(of: selectedDifficultySystem) { resetGradeIfNeeded() } + .onChange(of: selectedPhotos) { + Task { + await loadSelectedPhotos() + } + } + + .photosPicker( + isPresented: $showPhotoPicker, + selection: $selectedPhotos, + maxSelectionCount: 5 - imageData.count, + matching: .images + ) + .sheet( + item: $activeSheet, + onDismiss: { + if isPhotoPickerActionPending { + showPhotoPicker = true + isPhotoPickerActionPending = false + } + } + ) { sheetType in + switch sheetType { + case .photoOptions: + PhotoOptionSheet( + selectedPhotos: $selectedPhotos, + imageData: $imageData, + maxImages: 5, + onCameraSelected: { + activeSheet = .camera + }, + onPhotoLibrarySelected: { + isPhotoPickerActionPending = true + }, + onDismiss: { + activeSheet = nil + } + ) + case .camera: + CameraImagePicker( + isPresented: Binding( + get: { activeSheet == .camera }, + set: { if !$0 { activeSheet = nil } } + ) + ) { capturedImage in + if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) { + imageData.append(jpegData) + } + } + } + } } @ViewBuilder @@ -216,11 +282,9 @@ struct AddAttemptView: View { } Section("Photos (Optional)") { - PhotosPicker( - selection: $selectedPhotos, - maxSelectionCount: 5, - matching: .images - ) { + Button(action: { + activeSheet = .photoOptions + }) { HStack { Image(systemName: "camera.fill") .foregroundColor(.blue) @@ -240,11 +304,7 @@ struct AddAttemptView: View { } .padding(.vertical, 4) } - .onChange(of: selectedPhotos) { _, _ in - Task { - await loadSelectedPhotos() - } - } + .disabled(imageData.count >= 5) if !imageData.isEmpty { ScrollView(.horizontal, showsIndicators: false) { @@ -378,6 +438,21 @@ struct AddAttemptView: View { } } + private func loadSelectedPhotos() async { + var newImageData: [Data] = [] + + for item in selectedPhotos { + if let data = try? await item.loadTransferable(type: Data.self) { + newImageData.append(data) + } + } + + await MainActor.run { + imageData.append(contentsOf: newImageData) + selectedPhotos.removeAll() + } + } + private func saveAttempt() { if showingCreateProblem { let difficulty = DifficultyGrade( @@ -436,19 +511,6 @@ struct AddAttemptView: View { dismiss() } - private func loadSelectedPhotos() async { - var newImageData: [Data] = [] - - for item in selectedPhotos { - if let data = try? await item.loadTransferable(type: Data.self) { - newImageData.append(data) - } - } - - await MainActor.run { - imageData = newImageData - } - } } struct ProblemSelectionRow: View { @@ -696,6 +758,22 @@ struct EditAttemptView: View { @State private var selectedPhotos: [PhotosPickerItem] = [] @State private var imageData: [Data] = [] + enum SheetType: Identifiable { + case photoOptions + case camera + + var id: Int { + switch self { + case .photoOptions: return 0 + case .camera: return 1 + } + } + } + + @State private var activeSheet: SheetType? + @State private var showPhotoPicker = false + @State private var isPhotoPickerActionPending = false + private var availableProblems: [Problem] { guard let session = dataManager.session(withId: attempt.sessionId) else { return [] @@ -772,6 +850,56 @@ struct EditAttemptView: View { .onChange(of: selectedDifficultySystem) { resetGradeIfNeeded() } + .onChange(of: selectedPhotos) { + Task { + await loadSelectedPhotos() + } + } + + .photosPicker( + isPresented: $showPhotoPicker, + selection: $selectedPhotos, + maxSelectionCount: 5 - imageData.count, + matching: .images + ) + .sheet( + item: $activeSheet, + onDismiss: { + if isPhotoPickerActionPending { + showPhotoPicker = true + isPhotoPickerActionPending = false + } + } + ) { sheetType in + switch sheetType { + case .photoOptions: + PhotoOptionSheet( + selectedPhotos: $selectedPhotos, + imageData: $imageData, + maxImages: 5, + onCameraSelected: { + activeSheet = .camera + }, + onPhotoLibrarySelected: { + isPhotoPickerActionPending = true + }, + onDismiss: { + activeSheet = nil + } + ) + case .camera: + CameraImagePicker( + isPresented: Binding( + get: { activeSheet == .camera }, + set: { if !$0 { activeSheet = nil } } + ) + ) { capturedImage in + if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) { + imageData.append(jpegData) + } + } + } + } } @ViewBuilder @@ -910,11 +1038,9 @@ struct EditAttemptView: View { } Section("Photos (Optional)") { - PhotosPicker( - selection: $selectedPhotos, - maxSelectionCount: 5, - matching: .images - ) { + Button(action: { + activeSheet = .photoOptions + }) { HStack { Image(systemName: "camera.fill") .foregroundColor(.blue) @@ -934,11 +1060,7 @@ struct EditAttemptView: View { } .padding(.vertical, 4) } - .onChange(of: selectedPhotos) { _, _ in - Task { - await loadSelectedPhotos() - } - } + .disabled(imageData.count >= 5) if !imageData.isEmpty { ScrollView(.horizontal, showsIndicators: false) { @@ -1074,6 +1196,21 @@ struct EditAttemptView: View { } } + private func loadSelectedPhotos() async { + var newImageData: [Data] = [] + + for item in selectedPhotos { + if let data = try? await item.loadTransferable(type: Data.self) { + newImageData.append(data) + } + } + + await MainActor.run { + imageData.append(contentsOf: newImageData) + selectedPhotos.removeAll() + } + } + private func updateAttempt() { if showingCreateProblem { guard let gym = gym else { return } @@ -1131,19 +1268,6 @@ struct EditAttemptView: View { dismiss() } - private func loadSelectedPhotos() async { - var newImageData: [Data] = [] - - for item in selectedPhotos { - if let data = try? await item.loadTransferable(type: Data.self) { - newImageData.append(data) - } - } - - await MainActor.run { - imageData = newImageData - } - } } #Preview { diff --git a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift index c075edc..b9cedd7 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift @@ -22,6 +22,21 @@ struct AddEditProblemView: View { @State private var selectedPhotos: [PhotosPickerItem] = [] @State private var imageData: [Data] = [] @State private var isEditing = false + enum SheetType: Identifiable { + case photoOptions + case camera + + var id: Int { + switch self { + case .photoOptions: return 0 + case .camera: return 1 + } + } + } + + @State private var activeSheet: SheetType? + @State private var showPhotoPicker = false + @State private var isPhotoPickerActionPending = false private var existingProblem: Problem? { guard let problemId = problemId else { return nil } @@ -87,6 +102,12 @@ struct AddEditProblemView: View { loadExistingProblem() setupInitialGym() } + .onChange(of: dataManager.gyms) { + // Ensure a gym is selected when gyms are loaded or changed + if selectedGym == nil && !dataManager.gyms.isEmpty { + selectedGym = dataManager.gyms.first + } + } .onChange(of: selectedGym) { updateAvailableOptions() } @@ -96,11 +117,56 @@ struct AddEditProblemView: View { .onChange(of: selectedDifficultySystem) { resetGradeIfNeeded() } + .sheet( + item: $activeSheet, + onDismiss: { + if isPhotoPickerActionPending { + showPhotoPicker = true + isPhotoPickerActionPending = false + } + } + ) { sheetType in + switch sheetType { + case .photoOptions: + PhotoOptionSheet( + selectedPhotos: $selectedPhotos, + imageData: $imageData, + maxImages: 5, + onCameraSelected: { + activeSheet = .camera + }, + onPhotoLibrarySelected: { + isPhotoPickerActionPending = true + }, + onDismiss: { + activeSheet = nil + } + ) + case .camera: + CameraImagePicker( + isPresented: Binding( + get: { activeSheet == .camera }, + set: { if !$0 { activeSheet = nil } } + ) + ) { capturedImage in + if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) { + imageData.append(jpegData) + } + } + } + } + .photosPicker( + isPresented: $showPhotoPicker, + selection: $selectedPhotos, + maxSelectionCount: 5 - imageData.count, + matching: .images + ) .onChange(of: selectedPhotos) { Task { await loadSelectedPhotos() } } + } @ViewBuilder @@ -302,11 +368,9 @@ struct AddEditProblemView: View { @ViewBuilder private func PhotosSection() -> some View { Section("Photos (Optional)") { - PhotosPicker( - selection: $selectedPhotos, - maxSelectionCount: 5, - matching: .images - ) { + Button(action: { + activeSheet = .photoOptions + }) { HStack { Image(systemName: "camera.fill") .foregroundColor(.blue) @@ -326,6 +390,7 @@ struct AddEditProblemView: View { } .padding(.vertical, 4) } + .disabled(imageData.count >= 5) if !imageData.isEmpty { ScrollView(.horizontal, showsIndicators: false) { @@ -398,9 +463,14 @@ struct AddEditProblemView: View { } private func setupInitialGym() { - if let gymId = gymId, selectedGym == nil { + if let gymId = gymId { selectedGym = dataManager.gym(withId: gymId) } + + // Always ensure a gym is selected if available and none is currently selected + if selectedGym == nil && !dataManager.gyms.isEmpty { + selectedGym = dataManager.gyms.first + } } private func loadExistingProblem() { @@ -466,18 +536,14 @@ struct AddEditProblemView: View { private func loadSelectedPhotos() async { for item in selectedPhotos { if let data = try? await item.loadTransferable(type: Data.self) { - // Use ImageManager to save image - if let relativePath = ImageManager.shared.saveImageData(data) { - imagePaths.append(relativePath) - imageData.append(data) - } + imageData.append(data) } } selectedPhotos.removeAll() } private func saveProblem() { - guard let gym = selectedGym else { return } + guard let gym = selectedGym, canSave else { return } let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) @@ -490,6 +556,14 @@ struct AddEditProblemView: View { let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade) + // Save new image data and combine with existing paths + var allImagePaths = imagePaths + for data in imageData { + if let relativePath = ImageManager.shared.saveImageData(data) { + allImagePaths.append(relativePath) + } + } + if isEditing, let problem = existingProblem { let updatedProblem = problem.updated( name: trimmedName.isEmpty ? nil : trimmedName, @@ -499,7 +573,7 @@ struct AddEditProblemView: View { tags: trimmedTags, location: trimmedLocation.isEmpty ? nil : trimmedLocation, - imagePaths: imagePaths, + imagePaths: allImagePaths, isActive: isActive, dateSet: dateSet, notes: trimmedNotes.isEmpty ? nil : trimmedNotes @@ -515,7 +589,7 @@ struct AddEditProblemView: View { tags: trimmedTags, location: trimmedLocation.isEmpty ? nil : trimmedLocation, - imagePaths: imagePaths, + imagePaths: allImagePaths, dateSet: dateSet, notes: trimmedNotes.isEmpty ? nil : trimmedNotes ) diff --git a/sync/main.go b/sync/main.go index 4fe0ca3..9d46eb0 100644 --- a/sync/main.go +++ b/sync/main.go @@ -13,6 +13,8 @@ import ( "time" ) +const VERSION = "1.1.0" + func min(a, b int) int { if a < b { return a @@ -223,8 +225,9 @@ func (s *SyncServer) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{ - "status": "healthy", - "time": time.Now().UTC().Format(time.RFC3339), + "status": "healthy", + "version": VERSION, + "time": time.Now().UTC().Format(time.RFC3339), }) } @@ -355,7 +358,7 @@ func main() { http.HandleFunc("/images/upload", server.handleImageUpload) http.HandleFunc("/images/download", server.handleImageDownload) - fmt.Printf("OpenClimb sync server starting on port %s\n", port) + fmt.Printf("OpenClimb sync server v%s starting on port %s\n", VERSION, port) fmt.Printf("Data file: %s\n", dataFile) fmt.Printf("Images directory: %s\n", imagesDir) fmt.Printf("Health check available at /health\n") diff --git a/sync/version.md b/sync/version.md deleted file mode 100644 index 9084fa2..0000000 --- a/sync/version.md +++ /dev/null @@ -1 +0,0 @@ -1.1.0