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 android.os.Environment
import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.model.* 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.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -163,6 +165,110 @@ class ClimbRepository(
throw Exception("Failed to import data: ${e.message}") 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 @kotlinx.serialization.Serializable
@@ -19,7 +19,9 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.ui.components.ImagePicker
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import kotlinx.coroutines.flow.first
import java.time.LocalDateTime import java.time.LocalDateTime
// Data class for attempt input // Data class for attempt input
@@ -224,6 +226,27 @@ fun AddEditProblemScreen(
var tags by remember { mutableStateOf("") } var tags by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") } var notes by remember { mutableStateOf("") }
var isActive by remember { mutableStateOf(true) } 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) { LaunchedEffect(gymId, gyms) {
if (gymId != null && selectedGym == null) { if (gymId != null && selectedGym == null) {
@@ -265,6 +288,7 @@ fun AddEditProblemScreen(
setter = setter.ifBlank { null }, setter = setter.ifBlank { null },
tags = tags.split(",").map { it.trim() }.filter { it.isNotBlank() }, tags = tags.split(",").map { it.trim() }.filter { it.isNotBlank() },
location = location.ifBlank { null }, location = location.ifBlank { null },
imagePaths = imagePaths,
notes = notes.ifBlank { null } 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 { item {
Card( Card(
@@ -26,6 +26,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.window.Dialog 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.ui.viewmodel.ClimbViewModel
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -398,7 +400,10 @@ fun ProblemDetailScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToEdit: (String) -> Unit onNavigateToEdit: (String) -> Unit
) { ) {
val context = LocalContext.current
var showDeleteDialog by remember { mutableStateOf(false) } 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 attempts by viewModel.getAttemptsByProblem(problemId).collectAsState(initial = emptyList())
val sessions by viewModel.sessions.collectAsState() val sessions by viewModel.sessions.collectAsState()
val gyms by viewModel.gyms.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 -> problem?.setter?.let { setter ->
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
@@ -682,7 +702,7 @@ fun ProblemDetailScreen(
TextButton( TextButton(
onClick = { onClick = {
problem?.let { p -> problem?.let { p ->
viewModel.deleteProblem(p) viewModel.deleteProblem(p, context)
onNavigateBack() onNavigateBack()
} }
showDeleteDialog = false 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) @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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.Problem 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 import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -24,6 +26,9 @@ fun ProblemsScreen(
) { ) {
val problems by viewModel.problems.collectAsState() val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.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( Column(
modifier = Modifier modifier = Modifier
@@ -51,13 +56,27 @@ fun ProblemsScreen(
ProblemCard( ProblemCard(
problem = problem, problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", 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)) Spacer(modifier = Modifier.height(8.dp))
} }
} }
} }
} }
// Fullscreen Image Viewer
if (showImageViewer && selectedImagePaths.isNotEmpty()) {
FullscreenImageViewer(
imagePaths = selectedImagePaths,
initialIndex = selectedImageIndex,
onDismiss = { showImageViewer = false }
)
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -65,7 +84,8 @@ fun ProblemsScreen(
fun ProblemCard( fun ProblemCard(
problem: Problem, problem: Problem,
gymName: String, gymName: String,
onClick: () -> Unit onClick: () -> Unit,
onImageClick: ((List<String>, Int) -> Unit)? = null
) { ) {
Card( Card(
onClick = onClick, 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) { if (!problem.isActive) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
@@ -30,14 +30,25 @@ fun SettingsScreen(
} }
val appVersion = packageInfo.versionName val appVersion = packageInfo.versionName
// File picker launcher for import // File picker launcher for import - accepts both ZIP and JSON files
val importLauncher = rememberLauncherForActivityResult( val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.GetContent()
) { uri -> ) { uri ->
uri?.let { uri?.let {
try { try {
val inputStream = context.contentResolver.openInputStream(uri) 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 -> inputStream?.use { input ->
tempFile.outputStream().use { output -> tempFile.outputStream().use { output ->
input.copyTo(output) input.copyTo(output)
@@ -50,20 +61,25 @@ fun SettingsScreen(
} }
} }
// File picker launcher for export - save location // File picker launcher for export - save location (ZIP format with images)
val exportLauncher = rememberLauncherForActivityResult( 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") contract = ActivityResultContracts.CreateDocument("application/json")
) { uri -> ) { uri ->
uri?.let { uri?.let {
try { try {
val defaultFileName = "openclimb_export_${
java.time.LocalDateTime.now()
.toString()
.replace(":", "-")
.replace(".", "-")
}.json"
// Use the selected URI for export
viewModel.exportDataToUri(context, uri) viewModel.exportDataToUri(context, uri)
} catch (e: Exception) { } catch (e: Exception) {
viewModel.setError("Failed to save file: ${e.message}") viewModel.setError("Failed to save file: ${e.message}")
@@ -112,8 +128,46 @@ fun SettingsScreen(
) )
) { ) {
ListItem( ListItem(
headlineContent = { Text("Export Data") }, headlineContent = { Text("Export Data with Images") },
supportingContent = { Text("Export all your climbing data to JSON") }, 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) }, leadingContent = { Icon(Icons.Default.Share, contentDescription = null) },
trailingContent = { trailingContent = {
TextButton( TextButton(
@@ -124,7 +178,7 @@ fun SettingsScreen(
.replace(":", "-") .replace(":", "-")
.replace(".", "-") .replace(".", "-")
}.json" }.json"
exportLauncher.launch(defaultFileName) exportJsonLauncher.launch(defaultFileName)
}, },
enabled = !uiState.isLoading enabled = !uiState.isLoading
) { ) {
@@ -134,7 +188,7 @@ fun SettingsScreen(
strokeWidth = 2.dp strokeWidth = 2.dp
) )
} else { } else {
Text("Export") Text("Export JSON")
} }
} }
} }
@@ -151,12 +205,12 @@ fun SettingsScreen(
) { ) {
ListItem( ListItem(
headlineContent = { Text("Import Data") }, 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) }, leadingContent = { Icon(Icons.Default.Add, contentDescription = null) },
trailingContent = { trailingContent = {
TextButton( TextButton(
onClick = { onClick = {
importLauncher.launch("application/json") importLauncher.launch("*/*")
}, },
enabled = !uiState.isLoading enabled = !uiState.isLoading
) { ) {
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.data.repository.ClimbRepository import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.service.SessionTrackingService import com.atridad.openclimb.service.SessionTrackingService
import com.atridad.openclimb.utils.ImageUtils
import com.atridad.openclimb.utils.SessionShareUtils import com.atridad.openclimb.utils.SessionShareUtils
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -90,12 +91,26 @@ class ClimbViewModel(
} }
} }
fun deleteProblem(problem: Problem) { fun deleteProblem(problem: Problem, context: Context) {
viewModelScope.launch { viewModelScope.launch {
// Delete associated images
problem.imagePaths.forEach { imagePath ->
ImageUtils.deleteImage(context, imagePath)
}
repository.deleteProblem(problem) 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 { fun getProblemById(id: String): Flow<Problem?> = flow {
emit(repository.getProblemById(id)) 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) { fun importData(file: File) {
viewModelScope.launch { viewModelScope.launch {
try { try {
_uiState.value = _uiState.value.copy(isLoading = true) _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( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
message = "Data imported successfully from ${file.name}" message = "Data imported successfully from ${file.name}"