Compare commits

...

1 Commits

Author SHA1 Message Date
6a39d23f28 [iOS & Android] iOS 1.3.0 & Android 1.8.0
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m13s
2025-10-09 21:00:12 -06:00
15 changed files with 643 additions and 207 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 33 versionCode = 35
versionName = "1.7.4" versionName = "1.8.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -113,7 +113,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
updateConfiguredState() updateConfiguredState()
// Clear connection status when configuration changes // Clear connection status when configuration changes
_isConnected.value = false _isConnected.value = false
sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply() sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
} }
val isConfigured: Boolean 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) 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...") Log.d(TAG, "Merging data...")
val mergedGyms = mergeGyms(localGyms, serverBackup.gyms, serverBackup.deletedItems) val mergedGyms = mergeGyms(localGyms, serverBackup.gyms, allDeletions)
val mergedProblems = val mergedProblems =
mergeProblems( mergeProblems(localProblems, serverBackup.problems, imagePathMapping, allDeletions)
localProblems, val mergedSessions = mergeSessions(localSessions, serverBackup.sessions, allDeletions)
serverBackup.problems, val mergedAttempts = mergeAttempts(localAttempts, serverBackup.attempts, allDeletions)
imagePathMapping,
serverBackup.deletedItems
)
val mergedSessions =
mergeSessions(localSessions, serverBackup.sessions, serverBackup.deletedItems)
val mergedAttempts =
mergeAttempts(localAttempts, serverBackup.attempts, serverBackup.deletedItems)
// Clear and repopulate with merged data // Clear and repopulate with merged data
repository.resetAllData() repository.resetAllData()
@@ -823,11 +820,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
// Merge deletion lists // Update local deletions with merged list
val localDeletions = repository.getDeletedItems()
val allDeletions = (localDeletions + serverBackup.deletedItems).distinctBy { it.id }
// Clear and update local deletions with merged list
repository.clearDeletedItems() repository.clearDeletedItems()
allDeletions.forEach { deletion -> allDeletions.forEach { deletion ->
try { try {

View File

@@ -1,5 +1,9 @@
package com.atridad.openclimb.ui.components 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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -8,7 +12,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.Close
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils import com.atridad.openclimb.utils.ImageUtils
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
@Composable @Composable
fun ImagePicker( fun ImagePicker(
imageUris: List<String>, imageUris: List<String>,
onImagesChanged: (List<String>) -> Unit, onImagesChanged: (List<String>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
maxImages: Int = 5 maxImages: Int = 5
) { ) {
val context = LocalContext.current val context = LocalContext.current
var tempImageUris by remember { mutableStateOf(imageUris) } var tempImageUris by remember { mutableStateOf(imageUris) }
var showImageSourceDialog by remember { mutableStateOf(false) }
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
// Image picker launcher // Image picker launcher
val imagePickerLauncher = rememberLauncherForActivityResult( val imagePickerLauncher =
contract = ActivityResultContracts.GetMultipleContents() rememberLauncherForActivityResult(
) { uris -> contract = ActivityResultContracts.GetMultipleContents()
if (uris.isNotEmpty()) { ) { uris ->
val currentCount = tempImageUris.size if (uris.isNotEmpty()) {
val remainingSlots = maxImages - currentCount val currentCount = tempImageUris.size
val urisToProcess = uris.take(remainingSlots) val remainingSlots = maxImages - currentCount
val urisToProcess = uris.take(remainingSlots)
// Process images
val newImagePaths = mutableListOf<String>() // Process images
urisToProcess.forEach { uri -> val newImagePaths = mutableListOf<String>()
val imagePath = ImageUtils.saveImageFromUri(context, uri) urisToProcess.forEach { uri ->
if (imagePath != null) { val imagePath = ImageUtils.saveImageFromUri(context, uri)
newImagePaths.add(imagePath) if (imagePath != null) {
newImagePaths.add(imagePath)
}
}
if (newImagePaths.isNotEmpty()) {
val updatedUris = tempImageUris + newImagePaths
tempImageUris = updatedUris
onImagesChanged(updatedUris)
}
} }
} }
if (newImagePaths.isNotEmpty()) { // Camera launcher
val updatedUris = tempImageUris + newImagePaths val cameraLauncher =
tempImageUris = updatedUris rememberLauncherForActivityResult(contract = ActivityResultContracts.TakePicture()) {
onImagesChanged(updatedUris) 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) { Column(modifier = modifier) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "Photos (${tempImageUris.size}/$maxImages)", text = "Photos (${tempImageUris.size}/$maxImages)",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
if (tempImageUris.size < maxImages) { if (tempImageUris.size < maxImages) {
TextButton( TextButton(onClick = { showImageSourceDialog = true }) {
onClick = { Icon(
imagePickerLauncher.launch("image/*") Icons.Default.Add,
} contentDescription = null,
) { modifier = Modifier.size(16.dp)
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text("Add Photos") Text("Add Photos")
} }
} }
} }
if (tempImageUris.isNotEmpty()) { if (tempImageUris.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
LazyRow( LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tempImageUris) { imagePath -> items(tempImageUris) { imagePath ->
ImageItem( ImageItem(
imagePath = imagePath, imagePath = imagePath,
onRemove = { onRemove = {
val updatedUris = tempImageUris.filter { it != imagePath } val updatedUris = tempImageUris.filter { it != imagePath }
tempImageUris = updatedUris tempImageUris = updatedUris
onImagesChanged(updatedUris) onImagesChanged(updatedUris)
// Delete the image file // Delete the image file
ImageUtils.deleteImage(context, imagePath) ImageUtils.deleteImage(context, imagePath)
} }
) )
} }
} }
} else { } else {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Card( Card(
modifier = Modifier modifier = Modifier.fillMaxWidth().height(100.dp),
.fillMaxWidth() colors =
.height(100.dp), CardDefaults.cardColors(
colors = CardDefaults.cardColors( containerColor =
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) MaterialTheme.colorScheme.surfaceVariant.copy(
) alpha = 0.3f
)
)
) { ) {
Box( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
modifier = Modifier.fillMaxSize(), Column(horizontalAlignment = Alignment.CenterHorizontally) {
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon( Icon(
Icons.Default.Add, Icons.Default.Add,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Add photos of this problem", text = "Add photos of this problem",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant 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 @Composable
private fun ImageItem( private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifier = Modifier) {
imagePath: String,
onRemove: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current val context = LocalContext.current
val imageFile = ImageUtils.getImageFile(context, imagePath) val imageFile = ImageUtils.getImageFile(context, imagePath)
Box( Box(modifier = modifier.size(80.dp)) {
modifier = modifier.size(80.dp)
) {
AsyncImage( AsyncImage(
model = imageFile, model = imageFile,
contentDescription = "Problem photo", contentDescription = "Problem photo",
modifier = Modifier modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
.fillMaxSize() contentScale = ContentScale.Crop
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
) )
IconButton( IconButton(onClick = onRemove, modifier = Modifier.align(Alignment.TopEnd).size(24.dp)) {
onClick = onRemove,
modifier = Modifier
.align(Alignment.TopEnd)
.size(24.dp)
) {
Card( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors( colors =
containerColor = MaterialTheme.colorScheme.errorContainer CardDefaults.cardColors(
) containerColor = MaterialTheme.colorScheme.errorContainer
)
) { ) {
Icon( Icon(
Icons.Default.Close, Icons.Default.Close,
contentDescription = "Remove photo", contentDescription = "Remove photo",
modifier = Modifier modifier = Modifier.fillMaxSize().padding(2.dp),
.fillMaxSize() tint = MaterialTheme.colorScheme.onErrorContainer
.padding(2.dp),
tint = MaterialTheme.colorScheme.onErrorContainer
) )
} }
} }

View File

@@ -11,8 +11,8 @@ androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.4" lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0" activityCompose = "1.11.0"
composeBom = "2025.09.01" composeBom = "2025.10.00"
room = "2.8.1" room = "2.8.2"
navigation = "2.9.5" navigation = "2.9.5"
viewmodel = "2.9.4" viewmodel = "2.9.4"
kotlinxSerialization = "1.9.0" kotlinxSerialization = "1.9.0"

View File

@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -485,7 +485,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.5; MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -508,7 +508,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -528,7 +528,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.5; MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -592,7 +592,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -603,7 +603,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.2.5; MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -622,7 +622,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -633,7 +633,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.2.5; MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -8,5 +8,7 @@
<true/> <true/>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library to add photos to climbing problems.</string> <string>This app needs access to your photo library to add photos to climbing problems.</string>
<key>NSCameraUsageDescription</key>
<string>This app needs access to your camera to take photos of climbing problems.</string>
</dict> </dict>
</plist> </plist>

View File

@@ -17,3 +17,4 @@ extension SessionActivityAttributes {
SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date()) SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date())
} }
} }

View File

@@ -401,31 +401,35 @@ class SyncService: ObservableObject {
let imagePathMapping = try await syncImagesFromServer( let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager) 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...") print("Merging gyms...")
let mergedGyms = mergeGyms( let mergedGyms = mergeGyms(
local: dataManager.gyms, local: dataManager.gyms,
server: serverBackup.gyms, server: serverBackup.gyms,
deletedItems: serverBackup.deletedItems) deletedItems: uniqueDeletions)
print("Merging problems...") print("Merging problems...")
let mergedProblems = try mergeProblems( let mergedProblems = try mergeProblems(
local: dataManager.problems, local: dataManager.problems,
server: serverBackup.problems, server: serverBackup.problems,
imagePathMapping: imagePathMapping, imagePathMapping: imagePathMapping,
deletedItems: serverBackup.deletedItems) deletedItems: uniqueDeletions)
print("Merging sessions...") print("Merging sessions...")
let mergedSessions = try mergeSessions( let mergedSessions = try mergeSessions(
local: dataManager.sessions, local: dataManager.sessions,
server: serverBackup.sessions, server: serverBackup.sessions,
deletedItems: serverBackup.deletedItems) deletedItems: uniqueDeletions)
print("Merging attempts...") print("Merging attempts...")
let mergedAttempts = try mergeAttempts( let mergedAttempts = try mergeAttempts(
local: dataManager.attempts, local: dataManager.attempts,
server: serverBackup.attempts, server: serverBackup.attempts,
deletedItems: serverBackup.deletedItems) deletedItems: uniqueDeletions)
// Update data manager with merged data // Update data manager with merged data
dataManager.gyms = mergedGyms dataManager.gyms = mergedGyms
@@ -440,11 +444,6 @@ class SyncService: ObservableObject {
dataManager.saveAttempts() dataManager.saveAttempts()
dataManager.saveActiveSession() 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 // Update local deletions with merged list
dataManager.clearDeletedItems() dataManager.clearDeletedItems()
if let data = try? JSONEncoder().encode(uniqueDeletions) { if let data = try? JSONEncoder().encode(uniqueDeletions) {

View File

@@ -23,6 +23,22 @@ struct AddAttemptView: View {
@State private var selectedPhotos: [PhotosPickerItem] = [] @State private var selectedPhotos: [PhotosPickerItem] = []
@State private var imageData: [Data] = [] @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] { private var activeProblems: [Problem] {
dataManager.activeProblems(forGym: gym.id) dataManager.activeProblems(forGym: gym.id)
} }
@@ -78,6 +94,56 @@ struct AddAttemptView: View {
.onChange(of: selectedDifficultySystem) { .onChange(of: selectedDifficultySystem) {
resetGradeIfNeeded() 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 @ViewBuilder
@@ -216,11 +282,9 @@ struct AddAttemptView: View {
} }
Section("Photos (Optional)") { Section("Photos (Optional)") {
PhotosPicker( Button(action: {
selection: $selectedPhotos, activeSheet = .photoOptions
maxSelectionCount: 5, }) {
matching: .images
) {
HStack { HStack {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.foregroundColor(.blue) .foregroundColor(.blue)
@@ -240,11 +304,7 @@ struct AddAttemptView: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
.onChange(of: selectedPhotos) { _, _ in .disabled(imageData.count >= 5)
Task {
await loadSelectedPhotos()
}
}
if !imageData.isEmpty { if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { 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() { private func saveAttempt() {
if showingCreateProblem { if showingCreateProblem {
let difficulty = DifficultyGrade( let difficulty = DifficultyGrade(
@@ -436,19 +511,6 @@ struct AddAttemptView: View {
dismiss() 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 { struct ProblemSelectionRow: View {
@@ -696,6 +758,22 @@ struct EditAttemptView: View {
@State private var selectedPhotos: [PhotosPickerItem] = [] @State private var selectedPhotos: [PhotosPickerItem] = []
@State private var imageData: [Data] = [] @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] { private var availableProblems: [Problem] {
guard let session = dataManager.session(withId: attempt.sessionId) else { guard let session = dataManager.session(withId: attempt.sessionId) else {
return [] return []
@@ -772,6 +850,56 @@ struct EditAttemptView: View {
.onChange(of: selectedDifficultySystem) { .onChange(of: selectedDifficultySystem) {
resetGradeIfNeeded() 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 @ViewBuilder
@@ -910,11 +1038,9 @@ struct EditAttemptView: View {
} }
Section("Photos (Optional)") { Section("Photos (Optional)") {
PhotosPicker( Button(action: {
selection: $selectedPhotos, activeSheet = .photoOptions
maxSelectionCount: 5, }) {
matching: .images
) {
HStack { HStack {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.foregroundColor(.blue) .foregroundColor(.blue)
@@ -934,11 +1060,7 @@ struct EditAttemptView: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
.onChange(of: selectedPhotos) { _, _ in .disabled(imageData.count >= 5)
Task {
await loadSelectedPhotos()
}
}
if !imageData.isEmpty { if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { 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() { private func updateAttempt() {
if showingCreateProblem { if showingCreateProblem {
guard let gym = gym else { return } guard let gym = gym else { return }
@@ -1131,19 +1268,6 @@ struct EditAttemptView: View {
dismiss() 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 { #Preview {

View File

@@ -22,6 +22,21 @@ struct AddEditProblemView: View {
@State private var selectedPhotos: [PhotosPickerItem] = [] @State private var selectedPhotos: [PhotosPickerItem] = []
@State private var imageData: [Data] = [] @State private var imageData: [Data] = []
@State private var isEditing = false @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? { private var existingProblem: Problem? {
guard let problemId = problemId else { return nil } guard let problemId = problemId else { return nil }
@@ -87,6 +102,12 @@ struct AddEditProblemView: View {
loadExistingProblem() loadExistingProblem()
setupInitialGym() 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) { .onChange(of: selectedGym) {
updateAvailableOptions() updateAvailableOptions()
} }
@@ -96,11 +117,56 @@ struct AddEditProblemView: View {
.onChange(of: selectedDifficultySystem) { .onChange(of: selectedDifficultySystem) {
resetGradeIfNeeded() 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) { .onChange(of: selectedPhotos) {
Task { Task {
await loadSelectedPhotos() await loadSelectedPhotos()
} }
} }
} }
@ViewBuilder @ViewBuilder
@@ -302,11 +368,9 @@ struct AddEditProblemView: View {
@ViewBuilder @ViewBuilder
private func PhotosSection() -> some View { private func PhotosSection() -> some View {
Section("Photos (Optional)") { Section("Photos (Optional)") {
PhotosPicker( Button(action: {
selection: $selectedPhotos, activeSheet = .photoOptions
maxSelectionCount: 5, }) {
matching: .images
) {
HStack { HStack {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.foregroundColor(.blue) .foregroundColor(.blue)
@@ -326,6 +390,7 @@ struct AddEditProblemView: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
.disabled(imageData.count >= 5)
if !imageData.isEmpty { if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
@@ -398,9 +463,14 @@ struct AddEditProblemView: View {
} }
private func setupInitialGym() { private func setupInitialGym() {
if let gymId = gymId, selectedGym == nil { if let gymId = gymId {
selectedGym = dataManager.gym(withId: 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() { private func loadExistingProblem() {
@@ -466,18 +536,14 @@ struct AddEditProblemView: View {
private func loadSelectedPhotos() async { private func loadSelectedPhotos() async {
for item in selectedPhotos { for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) { if let data = try? await item.loadTransferable(type: Data.self) {
// Use ImageManager to save image imageData.append(data)
if let relativePath = ImageManager.shared.saveImageData(data) {
imagePaths.append(relativePath)
imageData.append(data)
}
} }
} }
selectedPhotos.removeAll() selectedPhotos.removeAll()
} }
private func saveProblem() { private func saveProblem() {
guard let gym = selectedGym else { return } guard let gym = selectedGym, canSave else { return }
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -490,6 +556,14 @@ struct AddEditProblemView: View {
let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade) 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 { if isEditing, let problem = existingProblem {
let updatedProblem = problem.updated( let updatedProblem = problem.updated(
name: trimmedName.isEmpty ? nil : trimmedName, name: trimmedName.isEmpty ? nil : trimmedName,
@@ -499,7 +573,7 @@ struct AddEditProblemView: View {
tags: trimmedTags, tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation, location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: imagePaths, imagePaths: allImagePaths,
isActive: isActive, isActive: isActive,
dateSet: dateSet, dateSet: dateSet,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes notes: trimmedNotes.isEmpty ? nil : trimmedNotes
@@ -515,7 +589,7 @@ struct AddEditProblemView: View {
tags: trimmedTags, tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation, location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: imagePaths, imagePaths: allImagePaths,
dateSet: dateSet, dateSet: dateSet,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes notes: trimmedNotes.isEmpty ? nil : trimmedNotes
) )

View File

@@ -13,6 +13,8 @@ import (
"time" "time"
) )
const VERSION = "1.1.0"
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a
@@ -223,8 +225,9 @@ func (s *SyncServer) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{ json.NewEncoder(w).Encode(map[string]string{
"status": "healthy", "status": "healthy",
"time": time.Now().UTC().Format(time.RFC3339), "version": VERSION,
"time": time.Now().UTC().Format(time.RFC3339),
}) })
} }
@@ -355,7 +358,7 @@ func main() {
http.HandleFunc("/images/upload", server.handleImageUpload) http.HandleFunc("/images/upload", server.handleImageUpload)
http.HandleFunc("/images/download", server.handleImageDownload) 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("Data file: %s\n", dataFile)
fmt.Printf("Images directory: %s\n", imagesDir) fmt.Printf("Images directory: %s\n", imagesDir)
fmt.Printf("Health check available at /health\n") fmt.Printf("Health check available at /health\n")

View File

@@ -1 +0,0 @@
1.1.0