Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ad8723b8fe
|
|||
|
6a39d23f28
|
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,8 +23,13 @@ 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(
|
||||||
@@ -29,9 +40,12 @@ fun ImagePicker(
|
|||||||
) {
|
) {
|
||||||
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 =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.GetMultipleContents()
|
contract = ActivityResultContracts.GetMultipleContents()
|
||||||
) { uris ->
|
) { uris ->
|
||||||
if (uris.isNotEmpty()) {
|
if (uris.isNotEmpty()) {
|
||||||
@@ -56,6 +70,41 @@ fun ImagePicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
Column(modifier = modifier) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -68,12 +117,12 @@ fun ImagePicker(
|
|||||||
)
|
)
|
||||||
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
@@ -83,9 +132,7 @@ fun ImagePicker(
|
|||||||
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,
|
||||||
@@ -103,20 +150,17 @@ fun ImagePicker(
|
|||||||
} 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,
|
||||||
@@ -132,48 +176,108 @@ fun ImagePicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
|
||||||
contentScale = ContentScale.Crop
|
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 =
|
||||||
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
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()
|
|
||||||
.padding(2.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = 18;
|
||||||
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 = 18;
|
||||||
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 = 18;
|
||||||
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 = 18;
|
||||||
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;
|
||||||
|
|||||||
Binary file not shown.
54
ios/OpenClimb/Components/CameraImagePicker.swift
Normal file
54
ios/OpenClimb/Components/CameraImagePicker.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
83
ios/OpenClimb/Components/PhotoOptionSheet.swift
Normal file
83
ios/OpenClimb/Components/PhotoOptionSheet.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ extension SessionActivityAttributes {
|
|||||||
SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date())
|
SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
if let relativePath = ImageManager.shared.saveImageData(data) {
|
|
||||||
imagePaths.append(relativePath)
|
|
||||||
imageData.append(data)
|
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,20 @@ 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
|
||||||
|
|
||||||
|
// Only save NEW images (those beyond the existing imagePaths count)
|
||||||
|
let newImagesStartIndex = imagePaths.count
|
||||||
|
if imageData.count > newImagesStartIndex {
|
||||||
|
for i in newImagesStartIndex..<imageData.count {
|
||||||
|
let data = imageData[i]
|
||||||
|
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 +579,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 +595,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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -224,6 +226,7 @@ func (s *SyncServer) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
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",
|
||||||
|
"version": VERSION,
|
||||||
"time": time.Now().UTC().Format(time.RFC3339),
|
"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")
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
1.1.0
|
|
||||||
Reference in New Issue
Block a user