feat: add support for user-selected export directories; implement DocumentFile integration for CSV exports

This commit is contained in:
2025-11-22 16:17:30 +01:00
parent b66ed6f2e1
commit c2d9549800
3 changed files with 60 additions and 11 deletions
+2
View File
@@ -96,6 +96,8 @@ dependencies {
// Reorderable LazyColumn for drag-and-drop // Reorderable LazyColumn for drag-and-drop
implementation 'org.burnoutcrew.composereorderable:reorderable:0.9.6' implementation 'org.burnoutcrew.composereorderable:reorderable:0.9.6'
// DocumentFile (SAF) support for user-selected export directories
implementation 'androidx.documentfile:documentfile:1.0.1'
} }
// Ktlint configuration // Ktlint configuration
@@ -29,6 +29,9 @@ import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import android.net.Uri
import android.content.Intent
import androidx.documentfile.provider.DocumentFile
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -105,6 +108,7 @@ fun ListsScreen(
var listToDelete by remember { mutableStateOf<CollabList?>(null) } var listToDelete by remember { mutableStateOf<CollabList?>(null) }
var listToEdit by remember { mutableStateOf<CollabList?>(null) } var listToEdit by remember { mutableStateOf<CollabList?>(null) }
var listToExport by remember { mutableStateOf<CollabList?>(null) } var listToExport by remember { mutableStateOf<CollabList?>(null) }
var exportTargetUri by remember { mutableStateOf<Uri?>(null) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
Scaffold( Scaffold(
@@ -345,22 +349,38 @@ fun ListsScreen(
} }
// Export dialog (top-level) separate from create/edit/delete dialogs // Export dialog (top-level) separate from create/edit/delete dialogs
listToExport?.let { list -> listToExport?.let { list ->
val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
// Take persistable permission for potential reuse during this session
try {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, flags)
} catch (_: Exception) { }
exportTargetUri = uri
}
}
ExportListDialog( ExportListDialog(
list = list, list = list,
onDismiss = { listToExport = null }, selectedDirectoryLabel = exportTargetUri?.let { uri ->
DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)"
} ?: "App storage (default)",
onSelectDirectory = { folderPicker.launch(null) },
onDismiss = { listToExport = null; exportTargetUri = null },
onConfirmExport = { format -> onConfirmExport = { format ->
if (format == "CSV") { if (format == "CSV") {
coroutineScope.launch { coroutineScope.launch {
val result = viewModel.exportListToCsv(list.id, list.name) val result = viewModel.exportListToCsv(list.id, list.name, exportTargetUri)
result.onSuccess { path -> result.onSuccess { path ->
Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show() Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show()
}.onFailure { }.onFailure {
Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show() Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show()
} }
listToExport = null listToExport = null
exportTargetUri = null
} }
} else { } else {
listToExport = null listToExport = null
exportTargetUri = null
} }
}, },
) )
@@ -572,6 +592,8 @@ private fun SortMenu(prefs: PreferencesManager) {
@Composable @Composable
private fun ExportListDialog( private fun ExportListDialog(
list: CollabList, list: CollabList,
selectedDirectoryLabel: String,
onSelectDirectory: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirmExport: (String) -> Unit, onConfirmExport: (String) -> Unit,
) { ) {
@@ -594,6 +616,9 @@ private fun ExportListDialog(
) )
} }
} }
// Directory selection (optional, per export only)
Text("Directory: $selectedDirectoryLabel", style = MaterialTheme.typography.bodySmall)
TextButton(onClick = onSelectDirectory) { Text("Select Directory") }
} }
}, },
confirmButton = { confirmButton = {
@@ -19,6 +19,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
@@ -234,7 +236,9 @@ class ListsViewModel(
suspend fun exportListToCsv( suspend fun exportListToCsv(
listId: String, listId: String,
listName: String, listName: String,
): Result<String> = try { targetTreeUri: Uri? = null,
): Result<String> {
return try {
// Load fields (ordered) and items with values // Load fields (ordered) and items with values
val fields: List<Field> = database.fieldDao().getFieldsForList(listId).first() val fields: List<Field> = database.fieldDao().getFieldsForList(listId).first()
val items: List<ItemWithValues> = database.itemDao().getItemsWithValuesForList(listId).first() val items: List<ItemWithValues> = database.itemDao().getItemsWithValuesForList(listId).first()
@@ -263,15 +267,33 @@ class ListsViewModel(
val safeBase = listName.lowercase(Locale.US).replace("[^a-z0-9]+".toRegex(), "_").trim('_').ifBlank { "table" } val safeBase = listName.lowercase(Locale.US).replace("[^a-z0-9]+".toRegex(), "_").trim('_').ifBlank { "table" }
val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileName = "${safeBase}_${ts}.csv" val fileName = "${safeBase}_${ts}.csv"
if (targetTreeUri != null) {
val docTree = DocumentFile.fromTreeUri(context, targetTreeUri)
if (docTree == null || !docTree.isDirectory || !docTree.canWrite()) {
return Result.failure(IllegalStateException("Selected folder not writable"))
}
docTree.findFile(fileName)?.delete()
val created = docTree.createFile("text/csv", fileName)
if (created == null) {
return Result.failure(IllegalStateException("Failed to create file in selected folder"))
}
context.contentResolver.openOutputStream(created.uri)?.use { os ->
os.write(csv.toByteArray())
} ?: return Result.failure(IllegalStateException("Failed to open output stream"))
Logger.i("Tables", "📤 Exported table '$listName' to CSV (SAF): ${created.uri}")
Result.success(created.uri.toString())
} else {
val dir = context.getExternalFilesDir(null) ?: context.filesDir val dir = context.getExternalFilesDir(null) ?: context.filesDir
val file = File(dir, fileName) val file = File(dir, fileName)
file.writeText(csv) file.writeText(csv)
Logger.i("Tables", "📤 Exported table '$listName' to CSV: ${file.absolutePath}") Logger.i("Tables", "📤 Exported table '$listName' to CSV: ${file.absolutePath}")
Result.success(file.absolutePath) Result.success(file.absolutePath)
}
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e("Tables", "❌ Export failed: ${e.message}") Logger.e("Tables", "❌ Export failed: ${e.message}")
Result.failure(e) Result.failure(e)
} }
}
private fun escapeCsv(value: String): String { private fun escapeCsv(value: String): String {
if (value.isEmpty()) return "" if (value.isEmpty()) return ""