feat: add table export functionality; implement CSV export and UI dialog for selecting format

This commit is contained in:
2025-11-14 12:27:58 +01:00
parent 71f208b624
commit 6cdec69faf
4 changed files with 146 additions and 0 deletions
+3
View File
@@ -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/<package>/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.
@@ -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<CollabList?>(null) }
var listToEdit by remember { mutableStateOf<CollabList?>(null) }
var listToExport by remember { mutableStateOf<CollabList?>(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,
@@ -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<String> = 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()
// 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
}
}
@@ -83,4 +83,12 @@
<string name="ft_image">Image</string>
<string name="ft_file">File</string>
<!-- Additional existing types could be added similarly -->
<!-- Export feature -->
<string name="export">Export</string>
<string name="export_list">Export Table</string>
<string name="export_format">Format</string>
<string name="format_csv">CSV</string>
<string name="exporting">Exporting…</string>
<string name="export_success">Exported to: %1$s</string>
<string name="export_error">Export failed</string>
</resources>