diff --git a/CollabTableAndroid/app/build.gradle b/CollabTableAndroid/app/build.gradle index a998794..f7edae1 100644 --- a/CollabTableAndroid/app/build.gradle +++ b/CollabTableAndroid/app/build.gradle @@ -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 diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt index 06c799a..e4d37a5 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt @@ -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(null) } var listToEdit by remember { mutableStateOf(null) } var listToExport by remember { mutableStateOf(null) } + var exportTargetUri by remember { mutableStateOf(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 = { diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt index c461f72..26e3b47 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt @@ -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 = try { + targetTreeUri: Uri? = null, + ): Result { + return try { // Load fields (ordered) and items with values val fields: List = database.fieldDao().getFieldsForList(listId).first() val items: List = 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 ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) val fileName = "${safeBase}_${ts}.csv" - 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) + 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 {