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