From 6cdec69faf82f65b1bd9ad52bd3a49f0ac06e1e9 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 14 Nov 2025 12:27:58 +0100 Subject: [PATCH] feat: add table export functionality; implement CSV export and UI dialog for selecting format --- CollabTableAndroid/README.md | 3 + .../collabtable/app/ui/screens/ListsScreen.kt | 74 +++++++++++++++++++ .../app/ui/screens/ListsViewModel.kt | 61 +++++++++++++++ .../app/src/main/res/values/strings.xml | 8 ++ 4 files changed, 146 insertions(+) diff --git a/CollabTableAndroid/README.md b/CollabTableAndroid/README.md index bb55c04..21e7bbe 100644 --- a/CollabTableAndroid/README.md +++ b/CollabTableAndroid/README.md @@ -93,6 +93,9 @@ app/src/main/java/com/collabtable/app/ 2. Fill in values for each field 3. Values are saved automatically as you type +### Exporting a Table +In the Tables overview screen each table row includes an Export icon between Edit and Delete. Tap it to open a dialog, choose a format (currently only CSV), then confirm. A CSV file with all visible columns and item values is saved to the app-specific external files directory (`Android/data//files/`). A toast displays the full path. Additional formats (JSON, XLSX, etc.) can be added in the same dialog later. + ### Performance Notes (Large Tables) For very large tables the app applies several optimizations: - Rows are kept in their original creation order rather than sorting by last update timestamp. This prevents the entire list from resorting after each edit and reduces scroll jank. 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 7bea04a..3392303 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 @@ -5,6 +5,7 @@ package com.collabtable.app.ui.screens import android.Manifest import android.content.pm.PackageManager import android.os.Build +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable @@ -27,6 +28,7 @@ import androidx.compose.material.icons.filled.Delete 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.IosShare import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -47,6 +49,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,6 +63,7 @@ import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.ui.components.ConnectionStatusAction import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.detectReorder import org.burnoutcrew.reorderable.rememberReorderableLazyListState @@ -100,6 +104,8 @@ fun ListsScreen( var showCreateDialog by remember { mutableStateOf(false) } var listToDelete by remember { mutableStateOf(null) } var listToEdit by remember { mutableStateOf(null) } + var listToExport by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() Scaffold( topBar = { @@ -274,6 +280,7 @@ fun ListsScreen( onListClick = { onNavigateToList(list.id) }, onEditClick = { listToEdit = list }, onDeleteClick = { listToDelete = list }, + onExportClick = { listToExport = list }, dragHandle = { Icon( imageVector = Icons.Default.DragHandle, @@ -335,6 +342,28 @@ fun ListsScreen( }, ) } + // Export dialog (top-level) separate from create/edit/delete dialogs + listToExport?.let { list -> + ExportListDialog( + list = list, + onDismiss = { listToExport = null }, + onConfirmExport = { format -> + if (format == "CSV") { + coroutineScope.launch { + val result = viewModel.exportListToCsv(list.id, list.name) + 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 + } + } else { + listToExport = null + } + }, + ) + } } @Composable @@ -344,6 +373,7 @@ fun ListItem( onListClick: () -> Unit, onEditClick: () -> Unit, onDeleteClick: () -> Unit, + onExportClick: () -> Unit, dragHandle: (@Composable () -> Unit)? = null, ) { // Expand the touch target to include the vertical spacing between rows without visually changing layout. @@ -398,6 +428,13 @@ fun ListItem( tint = MaterialTheme.colorScheme.primary, ) } + IconButton(onClick = onExportClick) { + Icon( + Icons.Default.IosShare, + contentDescription = stringResource(R.string.export), + tint = MaterialTheme.colorScheme.primary, + ) + } IconButton(onClick = onDeleteClick) { Icon( Icons.Default.Delete, @@ -442,6 +479,7 @@ fun CreateListDialog( } }, ) + } @Composable @@ -530,6 +568,42 @@ private fun SortMenu(prefs: PreferencesManager) { } } +@Composable +private fun ExportListDialog( + list: CollabList, + onDismiss: () -> Unit, + onConfirmExport: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var selectedFormat by remember { mutableStateOf("CSV") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.export_list) + ": " + list.name) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.export_format)) + Box { + TextButton(onClick = { expanded = true }) { + Text(if (selectedFormat == "CSV") stringResource(R.string.format_csv) else selectedFormat) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.format_csv)) }, + onClick = { selectedFormat = "CSV"; expanded = false }, + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirmExport(selectedFormat) }) { Text(stringResource(R.string.export)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} + // Format updatedAt relative to now: "Updated 5 sec/min/hour(s) ago" (days included when applicable) private fun formatUpdatedAgo( updatedAt: Long, 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 097ad41..c461f72 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 @@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.model.CollabList +import com.collabtable.app.data.model.Field +import com.collabtable.app.data.model.ItemWithValues import com.collabtable.app.data.repository.SyncRepository import com.collabtable.app.notifications.NotificationHelper import com.collabtable.app.utils.Logger @@ -14,8 +16,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date import java.util.UUID +import java.util.Locale class ListsViewModel( private val database: CollabTableDatabase, @@ -218,4 +225,58 @@ class ListsViewModel( NotificationHelper.showListRemoved(context, list.id, list.name) } } + + /** + * Export the specified list (its fields and row values) to a CSV file + * stored in the app's external files directory. Returns a Result containing + * the absolute file path on success. + */ + suspend fun exportListToCsv( + listId: String, + listName: String, + ): Result = try { + // Load fields (ordered) and items with values + val fields: List = database.fieldDao().getFieldsForList(listId).first() + val items: List = database.itemDao().getItemsWithValuesForList(listId).first() + + // Build header + val headers = fields.map { escapeCsv(it.name) } + val fieldIdOrder = fields.map { it.id } + + val rows = items.map { iwv -> + val valueMap = iwv.values.associateBy { it.fieldId } + fieldIdOrder.joinToString(",") { fid -> + val raw = valueMap[fid]?.value ?: "" + escapeCsv(raw) + } + } + + val csv = buildString { + append(headers.joinToString(",")) + append('\n') + rows.forEachIndexed { idx, row -> + append(row) + if (idx != rows.lastIndex) append('\n') + } + } + + 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) + } + + private fun escapeCsv(value: String): String { + if (value.isEmpty()) return "" + val needsQuotes = value.any { it == '"' || it == ',' || it == '\n' || it == '\r' } + val escaped = value.replace("\"", "\"\"") + return if (needsQuotes) "\"$escaped\"" else escaped + } } diff --git a/CollabTableAndroid/app/src/main/res/values/strings.xml b/CollabTableAndroid/app/src/main/res/values/strings.xml index 7eafa35..6f21341 100644 --- a/CollabTableAndroid/app/src/main/res/values/strings.xml +++ b/CollabTableAndroid/app/src/main/res/values/strings.xml @@ -83,4 +83,12 @@ Image File + + Export + Export Table + Format + CSV + Exporting… + Exported to: %1$s + Export failed