Added photos and ZIP exports

This commit is contained in:
2025-08-15 14:22:09 -06:00
parent 8ac86f7b5c
commit 0f7e995ae8
12 changed files with 353 additions and 23 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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<ClimbDataExport>(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
@@ -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<List<String>>(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(
@@ -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)
@@ -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<List<String>>(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<String>, 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(
@@ -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
) {
@@ -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<Problem?> = 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}"