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 2. Fill in values for each field
3. Values are saved automatically as you type 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) ### Performance Notes (Large Tables)
For very large tables the app applies several optimizations: 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. - 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.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable 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.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.IosShare
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
@@ -47,6 +49,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.data.preferences.PreferencesManager
import com.collabtable.app.ui.components.ConnectionStatusAction import com.collabtable.app.ui.components.ConnectionStatusAction
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorder import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.rememberReorderableLazyListState
@@ -100,6 +104,8 @@ fun ListsScreen(
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
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) }
val coroutineScope = rememberCoroutineScope()
Scaffold( Scaffold(
topBar = { topBar = {
@@ -274,6 +280,7 @@ fun ListsScreen(
onListClick = { onNavigateToList(list.id) }, onListClick = { onNavigateToList(list.id) },
onEditClick = { listToEdit = list }, onEditClick = { listToEdit = list },
onDeleteClick = { listToDelete = list }, onDeleteClick = { listToDelete = list },
onExportClick = { listToExport = list },
dragHandle = { dragHandle = {
Icon( Icon(
imageVector = Icons.Default.DragHandle, 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 @Composable
@@ -344,6 +373,7 @@ fun ListItem(
onListClick: () -> Unit, onListClick: () -> Unit,
onEditClick: () -> Unit, onEditClick: () -> Unit,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onExportClick: () -> Unit,
dragHandle: (@Composable () -> Unit)? = null, dragHandle: (@Composable () -> Unit)? = null,
) { ) {
// Expand the touch target to include the vertical spacing between rows without visually changing layout. // 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, tint = MaterialTheme.colorScheme.primary,
) )
} }
IconButton(onClick = onExportClick) {
Icon(
Icons.Default.IosShare,
contentDescription = stringResource(R.string.export),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = onDeleteClick) { IconButton(onClick = onDeleteClick) {
Icon( Icon(
Icons.Default.Delete, Icons.Default.Delete,
@@ -442,6 +479,7 @@ fun CreateListDialog(
} }
}, },
) )
} }
@Composable @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) // Format updatedAt relative to now: "Updated 5 sec/min/hour(s) ago" (days included when applicable)
private fun formatUpdatedAgo( private fun formatUpdatedAgo(
updatedAt: Long, updatedAt: Long,
@@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList 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.data.repository.SyncRepository
import com.collabtable.app.notifications.NotificationHelper import com.collabtable.app.notifications.NotificationHelper
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
@@ -14,8 +16,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.UUID import java.util.UUID
import java.util.Locale
class ListsViewModel( class ListsViewModel(
private val database: CollabTableDatabase, private val database: CollabTableDatabase,
@@ -218,4 +225,58 @@ class ListsViewModel(
NotificationHelper.showListRemoved(context, list.id, list.name) 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_image">Image</string>
<string name="ft_file">File</string> <string name="ft_file">File</string>
<!-- Additional existing types could be added similarly --> <!-- 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> </resources>