feat: add support for user-selected export directories; implement DocumentFile integration for CSV exports
This commit is contained in:
@@ -96,6 +96,8 @@ dependencies {
|
||||
|
||||
// Reorderable LazyColumn for drag-and-drop
|
||||
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
|
||||
|
||||
@@ -29,6 +29,9 @@ import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.List
|
||||
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.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
@@ -105,6 +108,7 @@ fun ListsScreen(
|
||||
var listToDelete by remember { mutableStateOf<CollabList?>(null) }
|
||||
var listToEdit by remember { mutableStateOf<CollabList?>(null) }
|
||||
var listToExport by remember { mutableStateOf<CollabList?>(null) }
|
||||
var exportTargetUri by remember { mutableStateOf<Uri?>(null) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
@@ -345,22 +349,38 @@ fun ListsScreen(
|
||||
}
|
||||
// Export dialog (top-level) separate from create/edit/delete dialogs
|
||||
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(
|
||||
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 ->
|
||||
if (format == "CSV") {
|
||||
coroutineScope.launch {
|
||||
val result = viewModel.exportListToCsv(list.id, list.name)
|
||||
val result = viewModel.exportListToCsv(list.id, list.name, exportTargetUri)
|
||||
result.onSuccess { path ->
|
||||
Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show()
|
||||
}.onFailure {
|
||||
Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
listToExport = null
|
||||
exportTargetUri = null
|
||||
}
|
||||
} else {
|
||||
listToExport = null
|
||||
exportTargetUri = null
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -572,6 +592,8 @@ private fun SortMenu(prefs: PreferencesManager) {
|
||||
@Composable
|
||||
private fun ExportListDialog(
|
||||
list: CollabList,
|
||||
selectedDirectoryLabel: String,
|
||||
onSelectDirectory: () -> Unit,
|
||||
onDismiss: () -> 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 = {
|
||||
|
||||
+23
-1
@@ -19,6 +19,8 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
@@ -234,7 +236,9 @@ class ListsViewModel(
|
||||
suspend fun exportListToCsv(
|
||||
listId: String,
|
||||
listName: String,
|
||||
): Result<String> = try {
|
||||
targetTreeUri: Uri? = null,
|
||||
): Result<String> {
|
||||
return try {
|
||||
// Load fields (ordered) and items with values
|
||||
val fields: List<Field> = database.fieldDao().getFieldsForList(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 ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
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 file = File(dir, fileName)
|
||||
file.writeText(csv)
|
||||
Logger.i("Tables", "📤 Exported table '$listName' to CSV: ${file.absolutePath}")
|
||||
Result.success(file.absolutePath)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e("Tables", "❌ Export failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun escapeCsv(value: String): String {
|
||||
if (value.isEmpty()) return ""
|
||||
|
||||
Reference in New Issue
Block a user