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