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 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}"
|
||||
|
||||
Reference in New Issue
Block a user