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

View File

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

View File

@@ -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(

View File

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

View File

@@ -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(

View File

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

View File

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