Added photos and ZIP exports
This commit is contained in:
Binary file not shown.
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,10 +91,24 @@ class ClimbViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteProblem(problem: Problem) {
|
fun deleteProblem(problem: Problem, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.deleteProblem(problem)
|
// 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 {
|
fun getProblemById(id: String): Flow<Problem?> = flow {
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
// Check if it's a ZIP file or JSON file
|
||||||
|
if (file.name.lowercase().endsWith(".zip")) {
|
||||||
|
repository.importDataFromZip(file)
|
||||||
|
} else {
|
||||||
repository.importDataFromJson(file)
|
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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user