feat: Refactor DAO insert methods to use Upsert and add transaction support for field reordering

This commit is contained in:
2025-10-25 15:40:58 +02:00
parent 95b839c791
commit 8d2e110cfd
7 changed files with 130 additions and 84 deletions
@@ -12,10 +12,10 @@ interface FieldDao {
@Query("SELECT * FROM fields WHERE id = :fieldId") @Query("SELECT * FROM fields WHERE id = :fieldId")
suspend fun getFieldById(fieldId: String): Field? suspend fun getFieldById(fieldId: String): Field?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Upsert
suspend fun insertField(field: Field) suspend fun insertField(field: Field)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Upsert
suspend fun insertFields(fields: List<Field>) suspend fun insertFields(fields: List<Field>)
@Update @Update
@@ -30,4 +30,17 @@ interface FieldDao {
// Get all fields updated since timestamp, including deleted ones for sync // Get all fields updated since timestamp, including deleted ones for sync
@Query("SELECT * FROM fields WHERE updatedAt >= :since") @Query("SELECT * FROM fields WHERE updatedAt >= :since")
suspend fun getFieldsUpdatedSince(since: Long): List<Field> suspend fun getFieldsUpdatedSince(since: Long): List<Field>
// Reorder fields in a single transaction for clarity and consistency
@Transaction
suspend fun reorderFieldsInTransaction(reorderedFields: List<Field>, timestamp: Long) {
reorderedFields.forEachIndexed { index, field ->
updateField(
field.copy(
order = index,
updatedAt = timestamp
)
)
}
}
} }
@@ -17,10 +17,10 @@ interface ItemDao {
@Query("SELECT * FROM items WHERE id = :itemId") @Query("SELECT * FROM items WHERE id = :itemId")
suspend fun getItemById(itemId: String): Item? suspend fun getItemById(itemId: String): Item?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Upsert
suspend fun insertItem(item: Item) suspend fun insertItem(item: Item)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Upsert
suspend fun insertItems(items: List<Item>) suspend fun insertItems(items: List<Item>)
@Update @Update
@@ -12,10 +12,10 @@ interface ItemValueDao {
@Query("SELECT * FROM item_values WHERE id = :valueId") @Query("SELECT * FROM item_values WHERE id = :valueId")
suspend fun getValueById(valueId: String): ItemValue? suspend fun getValueById(valueId: String): ItemValue?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Upsert
suspend fun insertValue(value: ItemValue) suspend fun insertValue(value: ItemValue)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Upsert
suspend fun insertValues(values: List<ItemValue>) suspend fun insertValues(values: List<ItemValue>)
@Update @Update
@@ -17,10 +17,10 @@ interface ListDao {
@Query("SELECT * FROM lists WHERE id = :listId") @Query("SELECT * FROM lists WHERE id = :listId")
suspend fun getListById(listId: String): CollabList? suspend fun getListById(listId: String): CollabList?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Upsert
suspend fun insertList(list: CollabList) suspend fun insertList(list: CollabList)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Upsert
suspend fun insertLists(lists: List<CollabList>) suspend fun insertLists(lists: List<CollabList>)
@Update @Update
@@ -66,6 +66,10 @@ fun ListDetailScreen(
val fields by viewModel.fields.collectAsState() val fields by viewModel.fields.collectAsState()
val items by viewModel.items.collectAsState() val items by viewModel.items.collectAsState()
// Use derivedStateOf to create stable references
val stableFields by remember { derivedStateOf { fields } }
val stableItems by remember { derivedStateOf { items } }
var showManageColumnsDialog by remember { mutableStateOf(false) } var showManageColumnsDialog by remember { mutableStateOf(false) }
var showAddItemDialog by remember { mutableStateOf(false) } var showAddItemDialog by remember { mutableStateOf(false) }
var itemToEdit by remember { mutableStateOf<ItemWithValues?>(null) } var itemToEdit by remember { mutableStateOf<ItemWithValues?>(null) }
@@ -88,8 +92,8 @@ fun ListDetailScreen(
val fieldWidths = remember { mutableStateMapOf<String, Dp>() } val fieldWidths = remember { mutableStateMapOf<String, Dp>() }
// Initialize field widths // Initialize field widths
LaunchedEffect(fields) { LaunchedEffect(stableFields) {
fields.forEach { field -> stableFields.forEach { field ->
if (!fieldWidths.containsKey(field.id)) { if (!fieldWidths.containsKey(field.id)) {
fieldWidths[field.id] = 150.dp fieldWidths[field.id] = 150.dp
} }
@@ -97,8 +101,8 @@ fun ListDetailScreen(
} }
// Apply filtering, sorting, and grouping // Apply filtering, sorting, and grouping
val processedItems = remember(items, filterField, filterValue, sortField, sortAscending, groupByField) { val processedItems = remember(stableItems, stableFields, filterField, filterValue, sortField, sortAscending, groupByField) {
var result = items var result = stableItems
// Apply filter // Apply filter
if (filterField != null && filterValue.isNotBlank()) { if (filterField != null && filterValue.isNotBlank()) {
@@ -174,7 +178,7 @@ fun ListDetailScreen(
} }
} }
) { padding -> ) { padding ->
if (fields.isEmpty()) { if (stableFields.isEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -329,21 +333,23 @@ fun ListDetailScreen(
.horizontalScroll(horizontalScrollState), .horizontalScroll(horizontalScrollState),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
fields.forEach { field -> stableFields.forEach { field ->
FieldHeader( key(field.id) {
field = field, FieldHeader(
width = fieldWidths[field.id] ?: 150.dp, field = field,
onWidthChange = { delta -> width = fieldWidths[field.id] ?: 150.dp,
val currentWidth = fieldWidths[field.id] ?: 150.dp onWidthChange = { delta ->
val newWidth = (currentWidth.value + delta).coerceIn(100f, 400f) val currentWidth = fieldWidths[field.id] ?: 150.dp
fieldWidths[field.id] = newWidth.dp val newWidth = (currentWidth.value + delta).coerceIn(100f, 400f)
} fieldWidths[field.id] = newWidth.dp
) }
)
}
} }
} }
// Items list with synchronized scrolling // Items list with synchronized scrolling
if (items.isEmpty()) { if (stableItems.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -378,7 +384,7 @@ fun ListDetailScreen(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
groupedItems.forEach { (groupName, groupItems) -> groupedItems.entries.forEach { (groupName, groupItems) ->
// Show group header if grouping is enabled // Show group header if grouping is enabled
if (groupByField != null) { if (groupByField != null) {
item(key = "group_$groupName") { item(key = "group_$groupName") {
@@ -397,9 +403,12 @@ fun ListDetailScreen(
} }
// Show items in the group // Show items in the group
items(groupItems, key = { it.item.id }) { itemWithValues -> items(
items = groupItems,
key = { it.item.id }
) { itemWithValues ->
ItemRow( ItemRow(
fields = fields, fields = stableFields,
fieldWidths = fieldWidths, fieldWidths = fieldWidths,
itemWithValues = itemWithValues, itemWithValues = itemWithValues,
scrollState = horizontalScrollState, scrollState = horizontalScrollState,
@@ -415,7 +424,7 @@ fun ListDetailScreen(
if (showManageColumnsDialog) { if (showManageColumnsDialog) {
ManageColumnsDialog( ManageColumnsDialog(
fields = fields, fields = stableFields,
onDismiss = { showManageColumnsDialog = false }, onDismiss = { showManageColumnsDialog = false },
onAddField = { name, fieldType, fieldOptions -> onAddField = { name, fieldType, fieldOptions ->
viewModel.addField(name, fieldType, fieldOptions) viewModel.addField(name, fieldType, fieldOptions)
@@ -434,7 +443,7 @@ fun ListDetailScreen(
if (showAddItemDialog) { if (showAddItemDialog) {
AddItemDialog( AddItemDialog(
fields = fields, fields = stableFields,
onDismiss = { showAddItemDialog = false }, onDismiss = { showAddItemDialog = false },
onAdd = { fieldValues -> onAdd = { fieldValues ->
viewModel.addItemWithValues(fieldValues) viewModel.addItemWithValues(fieldValues)
@@ -446,7 +455,7 @@ fun ListDetailScreen(
// Sort Dialog // Sort Dialog
if (showSortDialog) { if (showSortDialog) {
SortDialog( SortDialog(
fields = fields, fields = stableFields,
currentSortField = sortField, currentSortField = sortField,
currentSortAscending = sortAscending, currentSortAscending = sortAscending,
onDismiss = { showSortDialog = false }, onDismiss = { showSortDialog = false },
@@ -461,7 +470,7 @@ fun ListDetailScreen(
// Group Dialog // Group Dialog
if (showGroupDialog) { if (showGroupDialog) {
GroupDialog( GroupDialog(
fields = fields, fields = stableFields,
currentGroupByField = groupByField, currentGroupByField = groupByField,
onDismiss = { showGroupDialog = false }, onDismiss = { showGroupDialog = false },
onApply = { newGroupByField -> onApply = { newGroupByField ->
@@ -474,7 +483,7 @@ fun ListDetailScreen(
// Filter Dialog // Filter Dialog
if (showFilterDialog) { if (showFilterDialog) {
FilterDialog( FilterDialog(
fields = fields, fields = stableFields,
currentFilterField = filterField, currentFilterField = filterField,
currentFilterValue = filterValue, currentFilterValue = filterValue,
onDismiss = { showFilterDialog = false }, onDismiss = { showFilterDialog = false },
@@ -502,7 +511,7 @@ fun ListDetailScreen(
itemToEdit?.let { itemWithValues -> itemToEdit?.let { itemWithValues ->
EditItemDialog( EditItemDialog(
fields = fields, fields = stableFields,
itemWithValues = itemWithValues, itemWithValues = itemWithValues,
onDismiss = { itemToEdit = null }, onDismiss = { itemToEdit = null },
onUpdate = { fieldValues -> onUpdate = { fieldValues ->
@@ -586,6 +595,10 @@ fun ItemRow(
scrollState: androidx.compose.foundation.ScrollState, scrollState: androidx.compose.foundation.ScrollState,
onClick: () -> Unit onClick: () -> Unit
) { ) {
// Map values by fieldId to ensure correct value-column alignment regardless of original order
val valuesByFieldId = remember(itemWithValues.values) {
itemWithValues.values.associateBy { it.fieldId }
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -593,19 +606,20 @@ fun ItemRow(
.horizontalScroll(scrollState), .horizontalScroll(scrollState),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
fields.forEachIndexed { _, field -> fields.forEach { field ->
val value = itemWithValues.values.find { it.fieldId == field.id } key(field.id) {
val fieldWidth = fieldWidths[field.id] ?: 150.dp val value = valuesByFieldId[field.id]
val fieldWidth = fieldWidths[field.id] ?: 150.dp
Box(
modifier = Modifier Box(
.width(fieldWidth) modifier = Modifier
.border( .width(fieldWidth)
width = 1.dp, .border(
color = MaterialTheme.colorScheme.outline width = 1.dp,
) color = MaterialTheme.colorScheme.outline
.padding(8.dp) )
) { .padding(8.dp)
) {
when (field.getType()) { when (field.getType()) {
com.collabtable.app.data.model.FieldType.TEXT -> { com.collabtable.app.data.model.FieldType.TEXT -> {
Text( Text(
@@ -1001,6 +1015,7 @@ fun ItemRow(
} }
} }
} }
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -2596,6 +2611,10 @@ fun EditItemDialog(
) { ) {
val fieldValues = remember { mutableStateMapOf<String, String>() } val fieldValues = remember { mutableStateMapOf<String, String>() }
var showDeleteConfirmation by remember { mutableStateOf(false) } var showDeleteConfirmation by remember { mutableStateOf(false) }
// Map values by field to avoid linear lookups and ensure consistent mapping when fields reorder
val valuesByFieldId = remember(itemWithValues.values) {
itemWithValues.values.associateBy { it.fieldId }
}
// Initialize field values from existing item // Initialize field values from existing item
LaunchedEffect(itemWithValues) { LaunchedEffect(itemWithValues) {
@@ -2647,7 +2666,7 @@ fun EditItemDialog(
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(fields, key = { it.id }) { field -> items(fields, key = { it.id }) { field ->
val itemValue = itemWithValues.values.find { it.fieldId == field.id } val itemValue = valuesByFieldId[field.id]
if (itemValue != null) { if (itemValue != null) {
FieldInput( FieldInput(
field = field, field = field,
@@ -3,12 +3,14 @@ package com.collabtable.app.ui.screens
import android.content.Context import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.room.withTransaction
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.* import com.collabtable.app.data.model.*
import com.collabtable.app.data.repository.SyncRepository import com.collabtable.app.data.repository.SyncRepository
import kotlinx.coroutines.flow.MutableStateFlow 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.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
@@ -35,7 +37,9 @@ class ListDetailViewModel(
private fun loadListData() { private fun loadListData() {
viewModelScope.launch { viewModelScope.launch {
database.listDao().getListWithFields(listId).collect { listWithFields -> database.listDao().getListWithFields(listId)
.debounce(75)
.collect { listWithFields ->
_list.value = listWithFields?.list _list.value = listWithFields?.list
_fields.value = listWithFields?.fields ?: emptyList() _fields.value = listWithFields?.fields ?: emptyList()
} }
@@ -90,12 +94,11 @@ class ListDetailViewModel(
createdAt = timestamp, createdAt = timestamp,
updatedAt = timestamp updatedAt = timestamp
) )
database.fieldDao().insertField(newField)
// Create empty ItemValue entries for this new field for all existing items // Create empty ItemValue entries for this new field for all existing items
val existingItems = _items.value val existingItems = _items.value
if (existingItems.isNotEmpty()) { val newValues = if (existingItems.isNotEmpty()) {
val newValues = existingItems.map { itemWithValues -> existingItems.map { itemWithValues ->
ItemValue( ItemValue(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
itemId = itemWithValues.item.id, itemId = itemWithValues.item.id,
@@ -104,7 +107,16 @@ class ListDetailViewModel(
updatedAt = timestamp updatedAt = timestamp
) )
} }
database.itemValueDao().insertValues(newValues) } else {
emptyList()
}
// Insert atomically to avoid intermediate inconsistent states
database.withTransaction {
database.fieldDao().insertField(newField)
if (newValues.isNotEmpty()) {
database.itemValueDao().insertValues(newValues)
}
} }
performSync() performSync()
@@ -143,19 +155,23 @@ class ListDetailViewModel(
createdAt = timestamp, createdAt = timestamp,
updatedAt = timestamp updatedAt = timestamp
) )
database.itemDao().insertItem(newItem) // Insert item and its values atomically
database.withTransaction {
// Create empty values for each field database.itemDao().insertItem(newItem)
val values = _fields.value.map { field -> // Create empty values for each field
ItemValue( val values = _fields.value.map { field ->
id = UUID.randomUUID().toString(), ItemValue(
itemId = newItem.id, id = UUID.randomUUID().toString(),
fieldId = field.id, itemId = newItem.id,
value = "", fieldId = field.id,
updatedAt = timestamp value = "",
) updatedAt = timestamp
)
}
if (values.isNotEmpty()) {
database.itemValueDao().insertValues(values)
}
} }
database.itemValueDao().insertValues(values)
performSync() performSync()
} }
} }
@@ -169,19 +185,23 @@ class ListDetailViewModel(
createdAt = timestamp, createdAt = timestamp,
updatedAt = timestamp updatedAt = timestamp
) )
database.itemDao().insertItem(newItem) // Insert item and provided values atomically
database.withTransaction {
// Create values for each field with the provided values database.itemDao().insertItem(newItem)
val values = _fields.value.map { field -> // Create values for each field with the provided values
ItemValue( val values = _fields.value.map { field ->
id = UUID.randomUUID().toString(), ItemValue(
itemId = newItem.id, id = UUID.randomUUID().toString(),
fieldId = field.id, itemId = newItem.id,
value = fieldValues[field.id] ?: "", fieldId = field.id,
updatedAt = timestamp value = fieldValues[field.id] ?: "",
) updatedAt = timestamp
)
}
if (values.isNotEmpty()) {
database.itemValueDao().insertValues(values)
}
} }
database.itemValueDao().insertValues(values)
performSync() performSync()
} }
} }
@@ -211,14 +231,8 @@ class ListDetailViewModel(
fun reorderFields(reorderedFields: List<Field>) { fun reorderFields(reorderedFields: List<Field>) {
viewModelScope.launch { viewModelScope.launch {
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
reorderedFields.forEachIndexed { index, field -> // Use DAO-level transaction for clarity
database.fieldDao().updateField( database.fieldDao().reorderFieldsInTransaction(reorderedFields, timestamp)
field.copy(
order = index,
updatedAt = timestamp
)
)
}
performSync() performSync()
} }
} }
Binary file not shown.