diff --git a/.gradle/8.11.1/executionHistory/executionHistory.bin b/.gradle/8.11.1/executionHistory/executionHistory.bin index 4802fa5..915418b 100644 Binary files a/.gradle/8.11.1/executionHistory/executionHistory.bin and b/.gradle/8.11.1/executionHistory/executionHistory.bin differ diff --git a/.gradle/8.11.1/executionHistory/executionHistory.lock b/.gradle/8.11.1/executionHistory/executionHistory.lock index 841fb19..151ef54 100644 Binary files a/.gradle/8.11.1/executionHistory/executionHistory.lock and b/.gradle/8.11.1/executionHistory/executionHistory.lock differ diff --git a/.gradle/8.11.1/fileHashes/fileHashes.bin b/.gradle/8.11.1/fileHashes/fileHashes.bin index 6958c31..1f17bcd 100644 Binary files a/.gradle/8.11.1/fileHashes/fileHashes.bin and b/.gradle/8.11.1/fileHashes/fileHashes.bin differ diff --git a/.gradle/8.11.1/fileHashes/fileHashes.lock b/.gradle/8.11.1/fileHashes/fileHashes.lock index 4fc00b9..02d5b3c 100644 Binary files a/.gradle/8.11.1/fileHashes/fileHashes.lock and b/.gradle/8.11.1/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.11.1/fileHashes/resourceHashesCache.bin b/.gradle/8.11.1/fileHashes/resourceHashesCache.bin index 3dc6789..62595cb 100644 Binary files a/.gradle/8.11.1/fileHashes/resourceHashesCache.bin and b/.gradle/8.11.1/fileHashes/resourceHashesCache.bin differ diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock index f93eaef..af02abd 100644 Binary files a/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt b/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt index f7d4e84..b399086 100644 --- a/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt +++ b/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt @@ -4,6 +4,8 @@ import android.content.Context import android.os.Environment import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.openclimb.data.model.* +import com.atridad.openclimb.utils.ImageUtils +import com.atridad.openclimb.utils.ZipExportImportUtils import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.serialization.encodeToString @@ -163,6 +165,110 @@ class ClimbRepository( throw Exception("Failed to import data: ${e.message}") } } + + // ZIP Export functionality with images + suspend fun exportAllDataToZip(directory: File? = null): File { + val allGyms = gymDao.getAllGyms().first() + val allProblems = problemDao.getAllProblems().first() + val allSessions = sessionDao.getAllSessions().first() + val allAttempts = attemptDao.getAllAttempts().first() + + val exportData = ClimbDataExport( + exportedAt = LocalDateTime.now().toString(), + gyms = allGyms, + problems = allProblems, + sessions = allSessions, + attempts = allAttempts + ) + + // Collect all referenced image paths + val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() + + return ZipExportImportUtils.createExportZip( + context = context, + exportData = exportData, + referencedImagePaths = referencedImagePaths, + directory = directory + ) + } + + suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) { + val gyms = gymDao.getAllGyms().first() + val problems = problemDao.getAllProblems().first() + val sessions = sessionDao.getAllSessions().first() + val attempts = attemptDao.getAllAttempts().first() + + val exportData = ClimbDataExport( + exportedAt = LocalDateTime.now().toString(), + gyms = gyms, + problems = problems, + sessions = sessions, + attempts = attempts + ) + + // Collect all referenced image paths + val referencedImagePaths = problems.flatMap { it.imagePaths }.toSet() + + ZipExportImportUtils.createExportZipToUri( + context = context, + uri = uri, + exportData = exportData, + referencedImagePaths = referencedImagePaths + ) + } + + suspend fun importDataFromZip(file: File) { + try { + val importResult = ZipExportImportUtils.extractImportZip(context, file) + val importData = json.decodeFromString(importResult.jsonContent) + + // Update problem image paths with the new imported paths + val updatedProblems = ZipExportImportUtils.updateProblemImagePaths( + importData.problems, + importResult.importedImagePaths + ) + + // Import gyms (replace if exists due to primary key constraint) + importData.gyms.forEach { gym -> + try { + gymDao.insertGym(gym) + } catch (e: Exception) { + // If insertion fails due to primary key conflict, update instead + gymDao.updateGym(gym) + } + } + + // Import problems with updated image paths + updatedProblems.forEach { problem -> + try { + problemDao.insertProblem(problem) + } catch (e: Exception) { + problemDao.updateProblem(problem) + } + } + + // Import sessions + importData.sessions.forEach { session -> + try { + sessionDao.insertSession(session) + } catch (e: Exception) { + sessionDao.updateSession(session) + } + } + + // Import attempts + importData.attempts.forEach { attempt -> + try { + attemptDao.insertAttempt(attempt) + } catch (e: Exception) { + attemptDao.updateAttempt(attempt) + } + } + + } catch (e: Exception) { + throw Exception("Failed to import data: ${e.message}") + } + } } @kotlinx.serialization.Serializable diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt index 70b16a8..c629703 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt @@ -19,7 +19,9 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.atridad.openclimb.data.model.* +import com.atridad.openclimb.ui.components.ImagePicker import com.atridad.openclimb.ui.viewmodel.ClimbViewModel +import kotlinx.coroutines.flow.first import java.time.LocalDateTime // Data class for attempt input @@ -224,6 +226,27 @@ fun AddEditProblemScreen( var tags by remember { mutableStateOf("") } var notes by remember { mutableStateOf("") } var isActive by remember { mutableStateOf(true) } + var imagePaths by remember { mutableStateOf>(emptyList()) } + + // Load existing problem data for editing + LaunchedEffect(problemId) { + if (problemId != null) { + val existingProblem = viewModel.getProblemById(problemId).first() + existingProblem?.let { p -> + problemName = p.name ?: "" + description = p.description ?: "" + selectedClimbType = p.climbType + selectedDifficultySystem = p.difficulty.system + difficultyGrade = p.difficulty.grade + setter = p.setter ?: "" + location = p.location ?: "" + tags = p.tags.joinToString(", ") + notes = p.notes ?: "" + isActive = p.isActive + imagePaths = p.imagePaths + } + } + } LaunchedEffect(gymId, gyms) { if (gymId != null && selectedGym == null) { @@ -265,6 +288,7 @@ fun AddEditProblemScreen( setter = setter.ifBlank { null }, tags = tags.split(",").map { it.trim() }.filter { it.isNotBlank() }, location = location.ifBlank { null }, + imagePaths = imagePaths, notes = notes.ifBlank { null } ) @@ -482,6 +506,30 @@ fun AddEditProblemScreen( } } + // Images Section + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Photos", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + ImagePicker( + imageUris = imagePaths, + onImagesChanged = { imagePaths = it }, + maxImages = 5 + ) + } + } + } item { Card( diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt index 54d1432..cebb369 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt @@ -26,6 +26,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.window.Dialog +import com.atridad.openclimb.ui.components.FullscreenImageViewer +import com.atridad.openclimb.ui.components.ImageDisplaySection import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.data.model.* import kotlinx.coroutines.flow.first @@ -398,7 +400,10 @@ fun ProblemDetailScreen( onNavigateBack: () -> Unit, onNavigateToEdit: (String) -> Unit ) { + val context = LocalContext.current var showDeleteDialog by remember { mutableStateOf(false) } + var showImageViewer by remember { mutableStateOf(false) } + var selectedImageIndex by remember { mutableStateOf(0) } val attempts by viewModel.getAttemptsByProblem(problemId).collectAsState(initial = emptyList()) val sessions by viewModel.sessions.collectAsState() val gyms by viewModel.gyms.collectAsState() @@ -519,6 +524,21 @@ fun ProblemDetailScreen( ) } + // Display images if any + problem?.let { p -> + if (p.imagePaths.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + ImageDisplaySection( + imagePaths = p.imagePaths, + title = "Photos", + onImageClick = { index -> + selectedImageIndex = index + showImageViewer = true + } + ) + } + } + problem?.setter?.let { setter -> Spacer(modifier = Modifier.height(8.dp)) Text( @@ -682,7 +702,7 @@ fun ProblemDetailScreen( TextButton( onClick = { problem?.let { p -> - viewModel.deleteProblem(p) + viewModel.deleteProblem(p, context) onNavigateBack() } showDeleteDialog = false @@ -698,6 +718,17 @@ fun ProblemDetailScreen( } ) } + + // Fullscreen Image Viewer + problem?.let { p -> + if (showImageViewer && p.imagePaths.isNotEmpty()) { + FullscreenImageViewer( + imagePaths = p.imagePaths, + initialIndex = selectedImageIndex, + onDismiss = { showImageViewer = false } + ) + } + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt index 70355d8..9f2b3a0 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.atridad.openclimb.data.model.Problem +import com.atridad.openclimb.ui.components.FullscreenImageViewer +import com.atridad.openclimb.ui.components.ImageDisplay import com.atridad.openclimb.ui.viewmodel.ClimbViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -24,6 +26,9 @@ fun ProblemsScreen( ) { val problems by viewModel.problems.collectAsState() val gyms by viewModel.gyms.collectAsState() + var showImageViewer by remember { mutableStateOf(false) } + var selectedImagePaths by remember { mutableStateOf>(emptyList()) } + var selectedImageIndex by remember { mutableStateOf(0) } Column( modifier = Modifier @@ -51,13 +56,27 @@ fun ProblemsScreen( ProblemCard( problem = problem, gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", - onClick = { onNavigateToProblemDetail(problem.id) } + onClick = { onNavigateToProblemDetail(problem.id) }, + onImageClick = { imagePaths, index -> + selectedImagePaths = imagePaths + selectedImageIndex = index + showImageViewer = true + } ) Spacer(modifier = Modifier.height(8.dp)) } } } } + + // Fullscreen Image Viewer + if (showImageViewer && selectedImagePaths.isNotEmpty()) { + FullscreenImageViewer( + imagePaths = selectedImagePaths, + initialIndex = selectedImageIndex, + onDismiss = { showImageViewer = false } + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -65,7 +84,8 @@ fun ProblemsScreen( fun ProblemCard( problem: Problem, gymName: String, - onClick: () -> Unit + onClick: () -> Unit, + onImageClick: ((List, Int) -> Unit)? = null ) { Card( onClick = onClick, @@ -133,6 +153,18 @@ fun ProblemCard( } } + // Display images if any + if (problem.imagePaths.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + ImageDisplay( + imagePaths = problem.imagePaths.take(3), // Show max 3 images in list + imageSize = 60, + onImageClick = { index -> + onImageClick?.invoke(problem.imagePaths, index) + } + ) + } + if (!problem.isActive) { Spacer(modifier = Modifier.height(8.dp)) Text( diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt index 6ae55f5..6c71a09 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt @@ -30,14 +30,25 @@ fun SettingsScreen( } val appVersion = packageInfo.versionName - // File picker launcher for import + // File picker launcher for import - accepts both ZIP and JSON files val importLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri -> uri?.let { try { val inputStream = context.contentResolver.openInputStream(uri) - val tempFile = File(context.cacheDir, "temp_import.json") + // Determine file extension from content resolver + val fileName = context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && cursor.moveToFirst()) { + cursor.getString(nameIndex) + } else null + } ?: "import_file" + + val extension = fileName.substringAfterLast(".", "") + val tempFileName = if (extension.isNotEmpty()) "temp_import.$extension" else "temp_import" + val tempFile = File(context.cacheDir, tempFileName) + inputStream?.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) @@ -50,20 +61,25 @@ fun SettingsScreen( } } - // File picker launcher for export - save location - val exportLauncher = rememberLauncherForActivityResult( + // File picker launcher for export - save location (ZIP format with images) + val exportZipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/zip") + ) { uri -> + uri?.let { + try { + viewModel.exportDataToZipUri(context, uri) + } catch (e: Exception) { + viewModel.setError("Failed to save file: ${e.message}") + } + } + } + + // JSON export launcher + val exportJsonLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("application/json") ) { uri -> uri?.let { try { - val defaultFileName = "openclimb_export_${ - java.time.LocalDateTime.now() - .toString() - .replace(":", "-") - .replace(".", "-") - }.json" - - // Use the selected URI for export viewModel.exportDataToUri(context, uri) } catch (e: Exception) { viewModel.setError("Failed to save file: ${e.message}") @@ -112,8 +128,46 @@ fun SettingsScreen( ) ) { ListItem( - headlineContent = { Text("Export Data") }, - supportingContent = { Text("Export all your climbing data to JSON") }, + headlineContent = { Text("Export Data with Images") }, + supportingContent = { Text("Export all your climbing data and images to ZIP file (recommended)") }, + leadingContent = { Icon(Icons.Default.Share, contentDescription = null) }, + trailingContent = { + TextButton( + onClick = { + val defaultFileName = "openclimb_export_${ + java.time.LocalDateTime.now() + .toString() + .replace(":", "-") + .replace(".", "-") + }.zip" + exportZipLauncher.launch(defaultFileName) + }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Export ZIP") + } + } + } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + ) { + ListItem( + headlineContent = { Text("Export Data Only") }, + supportingContent = { Text("Export climbing data to JSON without images") }, leadingContent = { Icon(Icons.Default.Share, contentDescription = null) }, trailingContent = { TextButton( @@ -124,7 +178,7 @@ fun SettingsScreen( .replace(":", "-") .replace(".", "-") }.json" - exportLauncher.launch(defaultFileName) + exportJsonLauncher.launch(defaultFileName) }, enabled = !uiState.isLoading ) { @@ -134,7 +188,7 @@ fun SettingsScreen( strokeWidth = 2.dp ) } else { - Text("Export") + Text("Export JSON") } } } @@ -151,12 +205,12 @@ fun SettingsScreen( ) { ListItem( headlineContent = { Text("Import Data") }, - supportingContent = { Text("Import climbing data from JSON file") }, + supportingContent = { Text("Import climbing data from ZIP or JSON file") }, leadingContent = { Icon(Icons.Default.Add, contentDescription = null) }, trailingContent = { TextButton( onClick = { - importLauncher.launch("application/json") + importLauncher.launch("*/*") }, enabled = !uiState.isLoading ) { diff --git a/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt b/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt index 1cab666..25eaa82 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.repository.ClimbRepository import com.atridad.openclimb.service.SessionTrackingService +import com.atridad.openclimb.utils.ImageUtils import com.atridad.openclimb.utils.SessionShareUtils import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -90,12 +91,26 @@ class ClimbViewModel( } } - fun deleteProblem(problem: Problem) { + fun deleteProblem(problem: Problem, context: Context) { viewModelScope.launch { + // Delete associated images + problem.imagePaths.forEach { imagePath -> + ImageUtils.deleteImage(context, imagePath) + } + repository.deleteProblem(problem) + + // Clean up any remaining orphaned images + cleanupOrphanedImages(context) } } + private suspend fun cleanupOrphanedImages(context: Context) { + val allProblems = repository.getAllProblems().first() + val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() + ImageUtils.cleanupOrphanedImages(context, referencedImagePaths) + } + fun getProblemById(id: String): Flow = flow { emit(repository.getProblemById(id)) } @@ -268,11 +283,55 @@ class ClimbViewModel( } } + // ZIP Export operations with images + fun exportDataToZip(context: Context, directory: File? = null) { + viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy(isLoading = true) + val exportFile = repository.exportAllDataToZip(directory) + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "Data with images exported to: ${exportFile.absolutePath}" + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Export failed: ${e.message}" + ) + } + } + } + + fun exportDataToZipUri(context: Context, uri: android.net.Uri) { + viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy(isLoading = true) + repository.exportAllDataToZipUri(context, uri) + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "Data with images exported successfully" + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Export failed: ${e.message}" + ) + } + } + } + fun importData(file: File) { viewModelScope.launch { try { _uiState.value = _uiState.value.copy(isLoading = true) - repository.importDataFromJson(file) + + // Check if it's a ZIP file or JSON file + if (file.name.lowercase().endsWith(".zip")) { + repository.importDataFromZip(file) + } else { + repository.importDataFromJson(file) + } + _uiState.value = _uiState.value.copy( isLoading = false, message = "Data imported successfully from ${file.name}"