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
|
// 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 = {
|
||||||
|
|||||||
+31
-9
@@ -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,14 +267,32 @@ 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"
|
||||||
val dir = context.getExternalFilesDir(null) ?: context.filesDir
|
if (targetTreeUri != null) {
|
||||||
val file = File(dir, fileName)
|
val docTree = DocumentFile.fromTreeUri(context, targetTreeUri)
|
||||||
file.writeText(csv)
|
if (docTree == null || !docTree.isDirectory || !docTree.canWrite()) {
|
||||||
Logger.i("Tables", "📤 Exported table '$listName' to CSV: ${file.absolutePath}")
|
return Result.failure(IllegalStateException("Selected folder not writable"))
|
||||||
Result.success(file.absolutePath)
|
}
|
||||||
} catch (e: Throwable) {
|
docTree.findFile(fileName)?.delete()
|
||||||
Logger.e("Tables", "❌ Export failed: ${e.message}")
|
val created = docTree.createFile("text/csv", fileName)
|
||||||
Result.failure(e)
|
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 {
|
private fun escapeCsv(value: String): String {
|
||||||
|
|||||||
Reference in New Issue
Block a user