From 0bc3b3b06f5691e882959df46f98c9ba9deeab17 Mon Sep 17 00:00:00 2001 From: gabriel20xx Date: Sat, 8 Nov 2025 20:34:19 +0100 Subject: [PATCH] feat: enhance sync functionality with network backoff handling and improve item sorting in ListDetailScreen --- .../app/data/repository/SyncRepository.kt | 47 +++++++++- .../app/ui/screens/ListDetailScreen.kt | 24 ++++- .../collabtable/app/ui/screens/ListsScreen.kt | 89 ++++++++++--------- 3 files changed, 114 insertions(+), 46 deletions(-) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt index e8d0af1..3cc120a 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt @@ -8,6 +8,7 @@ import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.utils.Logger import kotlinx.coroutines.Dispatchers +import java.net.UnknownHostException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -18,13 +19,19 @@ class SyncRepository(context: Context) { private val api = ApiClient.api private val prefs = appContext.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE) - @Volatile private var authBackoffUntil: Long = 0L - @Volatile private var authBackoffMs: Long = 0L + @Volatile private var authBackoffUntil: Long = 0L + @Volatile private var authBackoffMs: Long = 0L + @Volatile private var networkBackoffUntil: Long = 0L + @Volatile private var networkBackoffMs: Long = 0L fun resetAuthBackoff() { authBackoffMs = 0L authBackoffUntil = 0L } + private fun resetNetworkBackoff() { + networkBackoffMs = 0L + networkBackoffUntil = 0L + } // We keep separate watermarks to avoid clock-skew issues: // - server watermark: server-assigned timestamp for fetching server changes // - local watermark: device local time for selecting local changes to upload @@ -73,6 +80,10 @@ class SyncRepository(context: Context) { if (authBackoffUntil > now) { return@withContext Result.success(Unit) } + // Respect network backoff for transient DNS failures / unreachable host + if (networkBackoffUntil > now) { + return@withContext Result.success(Unit) + } val lastServerTs = getLastServerSyncTs() val lastLocalTs = getLastLocalSyncTs() // Capture a snapshot timestamp BEFORE reading local changes to avoid missing @@ -84,6 +95,16 @@ class SyncRepository(context: Context) { Logger.i("Sync", "[SYNC] Starting initial sync with server") } + // If our local DB appears empty but we have a nonzero server watermark (e.g., after reinstall + // or clearing local data), request a one-time full down-sync by resetting the effective + // lastSyncTimestamp to 0 for this request. This avoids permanently missing historical rows. + val hasAnyLists = database.listDao().getAllListIds().isNotEmpty() + val hasAnyItems = database.itemDao().getAllItemIds().isNotEmpty() + val effectiveLastServerTs = if (lastServerTs > 0 && (!hasAnyLists || !hasAnyItems)) 0L else lastServerTs + if (effectiveLastServerTs == 0L && lastServerTs > 0L) { + Logger.w("Sync", "Local store empty but watermark set; performing one-time full down-sync") + } + // Gather local changes since last sync val localLists = database.listDao().getListsUpdatedSince(lastLocalTs) .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } @@ -108,7 +129,7 @@ class SyncRepository(context: Context) { // Send to server and get updates val syncRequest = SyncRequest( - lastSyncTimestamp = lastServerTs, + lastSyncTimestamp = effectiveLastServerTs, lists = localLists, fields = localFields, items = localItems, @@ -116,7 +137,23 @@ class SyncRepository(context: Context) { ) // HTTP-only sync - val response = api.sync(syncRequest) + val response = try { + api.sync(syncRequest) + } catch (e: UnknownHostException) { + // Host could not be resolved; apply exponential backoff and surface a warning once. + networkBackoffMs = if (networkBackoffMs == 0L) 5_000L else (networkBackoffMs * 2).coerceAtMost(180_000L) + networkBackoffUntil = System.currentTimeMillis() + networkBackoffMs + Logger.w("Sync", "Network host unresolved (DNS). Backing off for ${networkBackoffMs/1000}s: ${e.message}") + return@withContext Result.failure(Exception("Network unreachable: unable to resolve host")) + } catch (e: Exception) { + if (e.message?.contains("Unable to resolve host", ignoreCase = true) == true) { + networkBackoffMs = if (networkBackoffMs == 0L) 5_000L else (networkBackoffMs * 2).coerceAtMost(180_000L) + networkBackoffUntil = System.currentTimeMillis() + networkBackoffMs + Logger.w("Sync", "DNS failure. Backing off for ${networkBackoffMs/1000}s: ${e.message}") + return@withContext Result.failure(Exception("Network unreachable: unable to resolve host")) + } + throw e + } if (!response.isSuccessful) { if (response.code() == 401) { // Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. @@ -218,6 +255,7 @@ class SyncRepository(context: Context) { // Successful sync clears any previous auth backoff resetAuthBackoff() + resetNetworkBackoff() return@withContext Result.success(Unit) } catch (e: Exception) { @@ -226,6 +264,7 @@ class SyncRepository(context: Context) { setLastServerSyncTs(0) setLastLocalSyncTs(0) } + // Do not reset network backoff here; it's managed where thrown. return@withContext Result.failure(e) } } 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 4b11907..00196c1 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 @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState @@ -126,6 +127,7 @@ fun ListDetailScreen( var showManageColumnsDialog by remember { mutableStateOf(false) } var showAddItemDialog by remember { mutableStateOf(false) } + var pendingScrollToBottom by remember { mutableStateOf(false) } var itemToEdit by remember { mutableStateOf(null) } var showSortDialog by remember { mutableStateOf(false) } var showGroupDialog by remember { mutableStateOf(false) } @@ -218,12 +220,15 @@ fun ListDetailScreen( stableItems } + // Default ordering: append new items at bottom by createdAt ascending. + // If an explicit sortField is chosen, use that instead. val sortedItems: List = if (sortField != null) { val base = filteredItems.sortedWith(compareBy { item -> valueFor(item, sortField) }) if (sortAscending) base else base.asReversed() } else { - filteredItems + // Items from DB may not be ordered; enforce createdAt ascending + filteredItems.sortedBy { it.item.createdAt } } val processedItems: List = sortedItems @@ -270,6 +275,7 @@ fun ListDetailScreen( modifier = Modifier .fillMaxWidth() + .horizontalScroll(rememberScrollState()) .background(MaterialTheme.colorScheme.surfaceVariant) .padding(horizontal = 8.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -508,8 +514,23 @@ fun ListDetailScreen( } } } else { + val listState = rememberLazyListState() + // If a new item was just added, scroll to bottom after composition + LaunchedEffect(processedItems.size, pendingScrollToBottom) { + if (pendingScrollToBottom) { + // totalItemsCount includes the trailing filler; scroll to the last real item index + val lastIndex = (listState.layoutInfo.totalItemsCount - 2).coerceAtLeast(0) + try { + listState.animateScrollToItem(lastIndex) + } catch (_: Exception) { + listState.scrollToItem(lastIndex) + } + pendingScrollToBottom = false + } + } LazyColumn( modifier = Modifier.fillMaxSize(), + state = listState, ) { groupedItems.entries.forEach { (groupName, groupItems) -> // Show group header if grouping is enabled @@ -597,6 +618,7 @@ fun ListDetailScreen( onAdd = { fieldValues -> viewModel.addItemWithValues(fieldValues) showAddItemDialog = false + pendingScrollToBottom = true }, ) } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt index b23cdac..9712b01 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt @@ -318,52 +318,59 @@ fun ListItem( onDeleteClick: () -> Unit, dragHandle: (@Composable () -> Unit)? = null, ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + // Expand the touch target to include the vertical spacing between rows without visually changing layout. + // We wrap the original row in a full-width Box with the same outer padding, then add a clickable + // modifier that uses additional vertical padding but negative padding inside to keep visual spacing. + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) // keep original horizontal spacing + .clickable(onClick = onListClick) // entire row (including margins) is tappable + .padding(vertical = 4.dp) // expand touch area (original visual was 8.dp inside Row) ) { - // Drag handle on the far left - if (dragHandle != null) { - dragHandle() - Spacer(modifier = Modifier.width(8.dp)) - } - - // Table name centered/left and clickable - Column( - modifier = - Modifier - .weight(1f) - .clickable(onClick = onListClick), + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), // actual visual spacing remains ~8dp total (4 + 4) + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text(text = list.name, style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(2.dp)) - val subtitle = remember(list.updatedAt, nowMs) { formatUpdatedAgo(list.updatedAt, nowMs) } - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + // Drag handle on the far left + if (dragHandle != null) { + dragHandle() + Spacer(modifier = Modifier.width(8.dp)) + } - // Actions on the right - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onEditClick) { - Icon( - Icons.Default.Edit, - contentDescription = "Edit", - tint = MaterialTheme.colorScheme.primary, + // Table name and subtitle + Column( + modifier = Modifier.weight(1f), + ) { + Text(text = list.name, style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(2.dp)) + val subtitle = remember(list.updatedAt, nowMs) { formatUpdatedAgo(list.updatedAt, nowMs) } + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - IconButton(onClick = onDeleteClick) { - Icon( - Icons.Default.Delete, - contentDescription = "Delete", - tint = MaterialTheme.colorScheme.error, - ) + + // Actions on the right (retain individual click targets) + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onEditClick) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit", + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = onDeleteClick) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error, + ) + } } } }