[iOS & Android] iOS 1.3.0 & Android 1.8.0
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m13s
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m13s
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<String>,
|
||||
onImagesChanged: (List<String>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
maxImages: Int = 5
|
||||
imageUris: List<String>,
|
||||
onImagesChanged: (List<String>) -> 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<Uri?>(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<String>()
|
||||
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<String>()
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user