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 b17463a..4bb7be6 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 @@ -34,6 +34,12 @@ interface FieldDao { timestamp: Long, ) + @Query("UPDATE fields SET isDeleted = 1, updatedAt = :timestamp WHERE listId = :listId") + suspend fun softDeleteFieldsByList( + listId: String, + timestamp: Long, + ) + @Query("DELETE FROM fields WHERE id = :fieldId") suspend fun deleteField(fieldId: String) 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 af1b6af..5d27ea1 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 @@ -45,4 +45,10 @@ interface ItemDao { // Get all items updated since timestamp, including deleted ones for sync @Query("SELECT * FROM items WHERE updatedAt >= :since") suspend fun getItemsUpdatedSince(since: Long): List + + @Query("UPDATE items SET isDeleted = 1, updatedAt = :timestamp WHERE listId = :listId") + suspend fun softDeleteItemsByList( + listId: String, + timestamp: Long, + ) } 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 ac6d852..90fa4bf 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 @@ -92,7 +92,6 @@ fun ListsScreen( val context = LocalContext.current val prefsLocal = remember { PreferencesManager.getInstance(context) } ConnectionStatusAction(prefs = prefsLocal) - SortMenu(prefs = prefs) IconButton(onClick = onNavigateToSettings) { Icon( Icons.Default.Settings, @@ -201,32 +200,45 @@ fun ListsScreen( }, ) - LazyColumn( - modifier = - Modifier - .fillMaxSize() - .reorderable(reorderState), - state = reorderState.listState, - contentPadding = PaddingValues(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - itemsIndexed(working, key = { _, it -> it.id }) { _, list -> - ReorderableItem(reorderState, key = list.id) { _ -> - Box(modifier = Modifier.animateItemPlacement()) { - ListItem( - list = list, - onListClick = { onNavigateToList(list.id) }, - onEditClick = { listToEdit = list }, - onDeleteClick = { listToDelete = list }, - dragHandle = { - Icon( - imageVector = Icons.Default.DragHandle, - contentDescription = "Reorder", - modifier = Modifier.detectReorder(reorderState), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) + Column(modifier = Modifier.fillMaxSize()) { + // Controls above the list (align with table view UX) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + SortMenu(prefs = prefs) + } + + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .reorderable(reorderState), + state = reorderState.listState, + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + itemsIndexed(working, key = { _, it -> it.id }) { _, list -> + ReorderableItem(reorderState, key = list.id) { _ -> + Box(modifier = Modifier.animateItemPlacement()) { + ListItem( + list = list, + onListClick = { onNavigateToList(list.id) }, + onEditClick = { listToEdit = list }, + onDeleteClick = { listToDelete = list }, + dragHandle = { + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = "Reorder", + modifier = Modifier.detectReorder(reorderState), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } } } } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt index 34a0e1e..f1a026d 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt @@ -156,7 +156,12 @@ class ListsViewModel( val list = database.listDao().getListById(listId) if (list != null) { Logger.i("Tables", "🗑️ Deleting table: \"${list.name}\"") - database.listDao().softDeleteList(listId, System.currentTimeMillis()) + val ts = System.currentTimeMillis() + // Soft-delete the list, and cascade soft-deletes to its fields and items so sync uploads tombstones + database.listDao().softDeleteList(listId, ts) + // Cascade locally to ensure children are marked deleted and uploaded on next sync + database.fieldDao().softDeleteFieldsByList(listId, ts) + database.itemDao().softDeleteItemsByList(listId, ts) // Sync immediately after deleting performSync() } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt index 3eeb739..aaab44b 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt @@ -1,7 +1,6 @@ package com.collabtable.app.ui.screens import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -37,14 +36,12 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect 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.graphics.Color import com.collabtable.app.ui.components.ConnectionStatusAction import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -61,11 +58,7 @@ import com.collabtable.app.utils.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import java.util.concurrent.TimeUnit -import android.os.Build -import android.net.Uri + @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -90,50 +83,7 @@ fun SettingsScreen( var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) } var syncIntervalError by remember { mutableStateOf(null) } - // Test connection state - var isTesting by remember { mutableStateOf(false) } - var testOk by remember { mutableStateOf(null) } - var testMessage by remember { mutableStateOf(null) } - var lastLatencyMs by remember { mutableStateOf(null) } - - val testClient = remember { - OkHttpClient.Builder() - .connectTimeout(5, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.SECONDS) - .build() - } - - fun isEmulator(): Boolean { - val fp = Build.FINGERPRINT.lowercase() - val model = Build.MODEL.lowercase() - val brand = Build.BRAND.lowercase() - val device = Build.DEVICE.lowercase() - val product = Build.PRODUCT.lowercase() - return fp.contains("generic") || fp.contains("emulator") || - model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") || - brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk") - } - - fun toHealthUrl(rawApiUrl: String): String { - // Ensure we hit the unauthenticated /health endpoint - var url = rawApiUrl - // Emu remap for local hosts - try { - val uri = Uri.parse(url) - val host = uri.host?.lowercase() - val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal") - val scheme = uri.scheme ?: "http" - val portPart = if (uri.port != -1) ":${uri.port}" else "" - val base = if (needsRemap) "$scheme://10.0.2.2$portPart" else "$scheme://${uri.host ?: ""}$portPart" - // Replace any /api or /api/ segment at end with /health - val path = (uri.encodedPath ?: "/").trimEnd('/') - val healthPath = if (path.endsWith("/api")) "/health" else if (path.endsWith("/api/")) "/health" else "/health" - return base + healthPath - } catch (e: Exception) { - // Fallback: naive replace - return rawApiUrl.replace("/api/", "/health").replace("/api", "/health") - } - } + // Removed test connectivity logic; the connection indicator is shown via ConnectionStatusAction Scaffold( topBar = { @@ -146,9 +96,6 @@ fun SettingsScreen( }, actions = { ConnectionStatusAction(prefs = preferencesManager) - IconButton(onClick = onNavigateToLogs) { - Icon(Icons.Default.List, contentDescription = "Logs") - } }, colors = TopAppBarDefaults.topAppBarColors( @@ -350,93 +297,28 @@ fun SettingsScreen( } } - // Connection test + // Diagnostics / Logs + Text( + text = "Diagnostics", + style = MaterialTheme.typography.titleMedium, + ) Card( modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Column( modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "Server connectivity", + text = "View application logs", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { - isTesting = true - testOk = null - testMessage = null - lastLatencyMs = null - coroutineScope.launch { - val rawApi = preferencesManager.getServerUrl() - val healthUrl = toHealthUrl(rawApi) - val req = Request.Builder().url(healthUrl).get().build() - val start = System.nanoTime() - try { - val resp = withContext(Dispatchers.IO) { testClient.newCall(req).execute() } - val tookMs = (System.nanoTime() - start) / 1_000_000 - if (resp.isSuccessful) { - testOk = true - lastLatencyMs = tookMs - testMessage = "Reachable (${tookMs} ms)" - } else { - testOk = false - testMessage = "HTTP ${resp.code}" - } - resp.close() - } catch (e: Exception) { - testOk = false - testMessage = e.message ?: "Connection error" - } finally { - isTesting = false - } - } - }, - enabled = !isTesting, - ) { - if (isTesting) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Testing…") - } else { - Text("Test connection") - } - } - when (testOk) { - true -> Text( - text = testMessage ?: "Reachable", - color = Color(0xFF2E7D32), - style = MaterialTheme.typography.bodyMedium, - ) - false -> Text( - text = testMessage ?: "Not reachable", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - null -> {} - } - } - if (testOk == false) { - Text( - text = "Tip: On emulator use http://10.0.2.2:PORT/api/. On a device use your PC's LAN IP.", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Button(onClick = onNavigateToLogs, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Default.List, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Open Logs") } } } @@ -549,29 +431,7 @@ fun SettingsScreen( } } - // Perform a silent health check when entering the screen to set the indicator - LaunchedEffect(Unit) { - try { - val rawApi = preferencesManager.getServerUrl() - val healthUrl = toHealthUrl(rawApi) - val req = Request.Builder().url(healthUrl).get().build() - val start = System.nanoTime() - val resp = withContext(Dispatchers.IO) { testClient.newCall(req).execute() } - val tookMs = (System.nanoTime() - start) / 1_000_000 - if (resp.isSuccessful) { - testOk = true - lastLatencyMs = tookMs - testMessage = "Reachable (${tookMs} ms)" - } else { - testOk = false - testMessage = "HTTP ${resp.code}" - } - resp.close() - } catch (e: Exception) { - testOk = false - testMessage = e.message ?: "Connection error" - } - } + // Test connectivity removed; connection status is shown in the top app bar indicator } private fun formatServerUrlForDisplay(raw: String): String { var s = raw.trim() diff --git a/CollabTableServer/src/routes/syncRoutes.ts b/CollabTableServer/src/routes/syncRoutes.ts index 5420038..ecd3365 100644 --- a/CollabTableServer/src/routes/syncRoutes.ts +++ b/CollabTableServer/src/routes/syncRoutes.ts @@ -122,6 +122,18 @@ router.post('/sync', async (req: Request, res: Response) => { list.updatedAt, list.isDeleted ? 1 : 0 ]); + // If a list was deleted, cascade the deletion to child records on server + // - mark fields and items as deleted (tombstones) so other clients learn about them + // - remove item_values belonging to items under this list (no tombstone support for values) + if (list.isDeleted) { + try { + await tx.execute('UPDATE fields SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [list.updatedAt, list.id]); + await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [list.updatedAt, list.id]); + await tx.execute('DELETE FROM item_values WHERE itemId IN (SELECT id FROM items WHERE listId = ?)', [list.id]); + } catch (err) { + console.warn('[SYNC] Cascade delete for list failed:', String(err)); + } + } } } @@ -138,6 +150,14 @@ router.post('/sync', async (req: Request, res: Response) => { field.updatedAt, field.isDeleted ? 1 : 0 ]); + // If a field was deleted, remove any item_values referencing it so clients converge + if (field.isDeleted) { + try { + await tx.execute('DELETE FROM item_values WHERE fieldId = ?', [field.id]); + } catch (err) { + console.warn('[SYNC] Cascade delete for field failed:', String(err)); + } + } } } @@ -150,6 +170,14 @@ router.post('/sync', async (req: Request, res: Response) => { item.updatedAt, item.isDeleted ? 1 : 0 ]); + // If an item was deleted, remove its values (no tombstone support for values) + if (item.isDeleted) { + try { + await tx.execute('DELETE FROM item_values WHERE itemId = ?', [item.id]); + } catch (err) { + console.warn('[SYNC] Cascade delete for item failed:', String(err)); + } + } } } diff --git a/CollabTableServer/src/routes/webRoutes.ts b/CollabTableServer/src/routes/webRoutes.ts index 6dbe9df..3ed501e 100644 --- a/CollabTableServer/src/routes/webRoutes.ts +++ b/CollabTableServer/src/routes/webRoutes.ts @@ -275,7 +275,7 @@ router.get('/', (req: Request, res: Response) => {
ID: \${list.id}
Created: \${fmtDate(list.createdAt)}
-
Updated: \${fmtDate(list.updatedAt)}
+
Updated: \${fmtDate(list.effectiveUpdatedAt ?? list.updatedAt)}
@@ -368,7 +368,7 @@ router.get('/web/data', async (req: Request, res: Response) => { }; // Normalize key casing across SQLite (camelCase) and Postgres (lowercase) const pick = (obj: any, a: string, b: string) => obj[a] ?? obj[b]; - const formattedLists = (lists as any[]).map(list => ({ + let formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!pick(list, 'isDeleted', 'isdeleted'), createdAt: toMillis(pick(list, 'createdAt', 'createdat')), @@ -394,6 +394,42 @@ router.get('/web/data', async (req: Request, res: Response) => { fieldId: pick(v, 'fieldId', 'fieldid'), updatedAt: toMillis(pick(v, 'updatedAt', 'updatedat')) })); + // Compute effective updatedAt for lists based on latest change in the list itself or its children + const itemIdToListId = new Map(); + for (const it of formattedItems as any[]) { + if (it && it.id && it.listId) itemIdToListId.set(it.id, it.listId); + } + const listToMaxField = new Map(); + for (const f of formattedFields as any[]) { + if (!f || !f.listId) continue; + const t = typeof f.updatedAt === 'number' ? f.updatedAt : 0; + const prev = listToMaxField.get(f.listId) || 0; + if (t > prev) listToMaxField.set(f.listId, t); + } + const listToMaxItem = new Map(); + for (const it of formattedItems as any[]) { + if (!it || !it.listId) continue; + const t = typeof it.updatedAt === 'number' ? it.updatedAt : 0; + const prev = listToMaxItem.get(it.listId) || 0; + if (t > prev) listToMaxItem.set(it.listId, t); + } + const listToMaxValue = new Map(); + for (const val of formattedValues as any[]) { + if (!val || !val.itemId) continue; + const lId = itemIdToListId.get(val.itemId); + if (!lId) continue; + const t = typeof val.updatedAt === 'number' ? val.updatedAt : 0; + const prev = listToMaxValue.get(lId) || 0; + if (t > prev) listToMaxValue.set(lId, t); + } + formattedLists = formattedLists.map((l: any) => { + const fMax = listToMaxField.get(l.id) || 0; + const iMax = listToMaxItem.get(l.id) || 0; + const vMax = listToMaxValue.get(l.id) || 0; + const base = typeof l.updatedAt === 'number' ? l.updatedAt : 0; + const eff = Math.max(base, fMax, iMax, vMax); + return { ...l, effectiveUpdatedAt: eff === 0 ? l.updatedAt : eff }; + }); res.json({ lists: formattedLists,