feat: add table export functionality; implement CSV export and UI dialog for selecting format
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user