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,7 +333,8 @@ fun ListDetailScreen(
.horizontalScroll(horizontalScrollState), .horizontalScroll(horizontalScrollState),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
fields.forEach { field -> stableFields.forEach { field ->
key(field.id) {
FieldHeader( FieldHeader(
field = field, field = field,
width = fieldWidths[field.id] ?: 150.dp, width = fieldWidths[field.id] ?: 150.dp,
@@ -341,9 +346,10 @@ fun ListDetailScreen(
) )
} }
} }
}
// 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,8 +606,9 @@ 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 value = valuesByFieldId[field.id]
val fieldWidth = fieldWidths[field.id] ?: 150.dp val fieldWidth = fieldWidths[field.id] ?: 150.dp
Box( Box(
@@ -1002,6 +1016,7 @@ fun ItemRow(
} }
} }
} }
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -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,8 +107,17 @@ class ListDetailViewModel(
updatedAt = timestamp updatedAt = timestamp
) )
} }
} else {
emptyList()
}
// Insert atomically to avoid intermediate inconsistent states
database.withTransaction {
database.fieldDao().insertField(newField)
if (newValues.isNotEmpty()) {
database.itemValueDao().insertValues(newValues) database.itemValueDao().insertValues(newValues)
} }
}
performSync() performSync()
} }
@@ -143,8 +155,9 @@ class ListDetailViewModel(
createdAt = timestamp, createdAt = timestamp,
updatedAt = timestamp updatedAt = timestamp
) )
// Insert item and its values atomically
database.withTransaction {
database.itemDao().insertItem(newItem) database.itemDao().insertItem(newItem)
// Create empty values for each field // Create empty values for each field
val values = _fields.value.map { field -> val values = _fields.value.map { field ->
ItemValue( ItemValue(
@@ -155,7 +168,10 @@ class ListDetailViewModel(
updatedAt = timestamp updatedAt = timestamp
) )
} }
if (values.isNotEmpty()) {
database.itemValueDao().insertValues(values) database.itemValueDao().insertValues(values)
}
}
performSync() performSync()
} }
} }
@@ -169,8 +185,9 @@ class ListDetailViewModel(
createdAt = timestamp, createdAt = timestamp,
updatedAt = timestamp updatedAt = timestamp
) )
// Insert item and provided values atomically
database.withTransaction {
database.itemDao().insertItem(newItem) database.itemDao().insertItem(newItem)
// Create values for each field with the provided values // Create values for each field with the provided values
val values = _fields.value.map { field -> val values = _fields.value.map { field ->
ItemValue( ItemValue(
@@ -181,7 +198,10 @@ class ListDetailViewModel(
updatedAt = timestamp 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.