feat: implement soft delete functionality for lists, fields, and items with cascading updates

This commit is contained in:
2025-10-27 13:45:47 +01:00
parent 5b06c3fae1
commit f48b9585ac
7 changed files with 137 additions and 184 deletions
@@ -34,6 +34,12 @@ interface FieldDao {
timestamp: Long, 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") @Query("DELETE FROM fields WHERE id = :fieldId")
suspend fun deleteField(fieldId: String) suspend fun deleteField(fieldId: String)
@@ -45,4 +45,10 @@ interface ItemDao {
// Get all items updated since timestamp, including deleted ones for sync // Get all items updated since timestamp, including deleted ones for sync
@Query("SELECT * FROM items WHERE updatedAt >= :since") @Query("SELECT * FROM items WHERE updatedAt >= :since")
suspend fun getItemsUpdatedSince(since: Long): List<Item> suspend fun getItemsUpdatedSince(since: Long): List<Item>
@Query("UPDATE items SET isDeleted = 1, updatedAt = :timestamp WHERE listId = :listId")
suspend fun softDeleteItemsByList(
listId: String,
timestamp: Long,
)
} }
@@ -92,7 +92,6 @@ fun ListsScreen(
val context = LocalContext.current val context = LocalContext.current
val prefsLocal = remember { PreferencesManager.getInstance(context) } val prefsLocal = remember { PreferencesManager.getInstance(context) }
ConnectionStatusAction(prefs = prefsLocal) ConnectionStatusAction(prefs = prefsLocal)
SortMenu(prefs = prefs)
IconButton(onClick = onNavigateToSettings) { IconButton(onClick = onNavigateToSettings) {
Icon( Icon(
Icons.Default.Settings, Icons.Default.Settings,
@@ -201,6 +200,18 @@ fun ListsScreen(
}, },
) )
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( LazyColumn(
modifier = modifier =
Modifier Modifier
@@ -234,6 +245,7 @@ fun ListsScreen(
} }
} }
} }
}
if (showCreateDialog) { if (showCreateDialog) {
CreateListDialog( CreateListDialog(
@@ -156,7 +156,12 @@ class ListsViewModel(
val list = database.listDao().getListById(listId) val list = database.listDao().getListById(listId)
if (list != null) { if (list != null) {
Logger.i("Tables", "🗑️ Deleting table: \"${list.name}\"") 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 // Sync immediately after deleting
performSync() performSync()
} }
@@ -1,7 +1,6 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -37,14 +36,12 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import com.collabtable.app.ui.components.ConnectionStatusAction import com.collabtable.app.ui.components.ConnectionStatusAction
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -61,11 +58,7 @@ import com.collabtable.app.utils.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -90,50 +83,7 @@ fun SettingsScreen(
var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) } var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) }
var syncIntervalError by remember { mutableStateOf<String?>(null) } var syncIntervalError by remember { mutableStateOf<String?>(null) }
// Test connection state // Removed test connectivity logic; the connection indicator is shown via ConnectionStatusAction
var isTesting by remember { mutableStateOf(false) }
var testOk by remember { mutableStateOf<Boolean?>(null) }
var testMessage by remember { mutableStateOf<String?>(null) }
var lastLatencyMs by remember { mutableStateOf<Long?>(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")
}
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -146,9 +96,6 @@ fun SettingsScreen(
}, },
actions = { actions = {
ConnectionStatusAction(prefs = preferencesManager) ConnectionStatusAction(prefs = preferencesManager)
IconButton(onClick = onNavigateToLogs) {
Icon(Icons.Default.List, contentDescription = "Logs")
}
}, },
colors = colors =
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
@@ -350,93 +297,28 @@ fun SettingsScreen(
} }
} }
// Connection test // Diagnostics / Logs
Text(
text = "Diagnostics",
style = MaterialTheme.typography.titleMedium,
)
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) { ) {
Column( Column(
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = "Server connectivity", text = "View application logs",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Row( Button(onClick = onNavigateToLogs, modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth(), Icon(Icons.Default.List, contentDescription = null)
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)) Spacer(modifier = Modifier.width(8.dp))
Text("Testing…") Text("Open Logs")
} 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,
)
} }
} }
} }
@@ -549,29 +431,7 @@ fun SettingsScreen(
} }
} }
// Perform a silent health check when entering the screen to set the indicator // Test connectivity removed; connection status is shown in the top app bar 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"
}
}
} }
private fun formatServerUrlForDisplay(raw: String): String { private fun formatServerUrlForDisplay(raw: String): String {
var s = raw.trim() var s = raw.trim()
@@ -122,6 +122,18 @@ router.post('/sync', async (req: Request, res: Response) => {
list.updatedAt, list.updatedAt,
list.isDeleted ? 1 : 0 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.updatedAt,
field.isDeleted ? 1 : 0 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.updatedAt,
item.isDeleted ? 1 : 0 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));
}
}
} }
} }
+38 -2
View File
@@ -275,7 +275,7 @@ router.get('/', (req: Request, res: Response) => {
</div> </div>
<div class="list-id">ID: \${list.id}</div> <div class="list-id">ID: \${list.id}</div>
<div class="list-id">Created: \${fmtDate(list.createdAt)}</div> <div class="list-id">Created: \${fmtDate(list.createdAt)}</div>
<div class="list-id">Updated: \${fmtDate(list.updatedAt)}</div> <div class="list-id">Updated: \${fmtDate(list.effectiveUpdatedAt ?? list.updatedAt)}</div>
</div> </div>
</div> </div>
@@ -368,7 +368,7 @@ router.get('/web/data', async (req: Request, res: Response) => {
}; };
// Normalize key casing across SQLite (camelCase) and Postgres (lowercase) // Normalize key casing across SQLite (camelCase) and Postgres (lowercase)
const pick = (obj: any, a: string, b: string) => obj[a] ?? obj[b]; 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, ...list,
isDeleted: !!pick(list, 'isDeleted', 'isdeleted'), isDeleted: !!pick(list, 'isDeleted', 'isdeleted'),
createdAt: toMillis(pick(list, 'createdAt', 'createdat')), createdAt: toMillis(pick(list, 'createdAt', 'createdat')),
@@ -394,6 +394,42 @@ router.get('/web/data', async (req: Request, res: Response) => {
fieldId: pick(v, 'fieldId', 'fieldid'), fieldId: pick(v, 'fieldId', 'fieldid'),
updatedAt: toMillis(pick(v, 'updatedAt', 'updatedat')) 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<string, string>();
for (const it of formattedItems as any[]) {
if (it && it.id && it.listId) itemIdToListId.set(it.id, it.listId);
}
const listToMaxField = new Map<string, number>();
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<string, number>();
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<string, number>();
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({ res.json({
lists: formattedLists, lists: formattedLists,