From ebe5fc13c465afeb06a12c12787e0361b67b541b Mon Sep 17 00:00:00 2001 From: Gabriel20xx Date: Wed, 3 Jun 2026 17:30:24 +0200 Subject: [PATCH] feat: enhance notification handling and improve code structure across multiple components --- CollabTableAndroid/app/detekt-baseline.xml | 28 +++ .../collabtable/app/CollabTableApplication.kt | 1 + .../app/data/api/CollabTableApi.kt | 2 +- .../com/collabtable/app/data/dao/FieldDao.kt | 6 +- .../data/preferences/PreferencesManager.kt | 11 +- .../app/notifications/NotificationHelper.kt | 13 ++ .../app/notifications/NotificationPoller.kt | 197 +++++++++--------- .../app/ui/screens/ListDetailScreen.kt | 53 +++-- .../collabtable/app/ui/screens/ListsScreen.kt | 55 +++-- .../app/ui/screens/ListsViewModel.kt | 61 +++--- .../app/ui/screens/SettingsScreen.kt | 5 +- CollabTableServer/src/index.ts | 2 +- CollabTableServer/src/routes/itemRoutes.ts | 8 +- CollabTableServer/src/routes/syncRoutes.ts | 16 +- 14 files changed, 270 insertions(+), 188 deletions(-) create mode 100644 CollabTableAndroid/app/detekt-baseline.xml diff --git a/CollabTableAndroid/app/detekt-baseline.xml b/CollabTableAndroid/app/detekt-baseline.xml new file mode 100644 index 0000000..8a90cd1 --- /dev/null +++ b/CollabTableAndroid/app/detekt-baseline.xml @@ -0,0 +1,28 @@ + + + + + ComplexCondition:Logger.kt$Logger$last != null && last.level == level && last.tag == tag && last.message == message && (now - last.timestamp) <= DEDUPE_WINDOW_MS + ExplicitItLambdaParameter:ListsScreen.kt${ _, it -> it.id } + ReturnCount:ListsViewModel.kt$ListsViewModel$suspend fun exportListToCsv( listId: String, listName: String, targetTreeUri: Uri? = null, ): Result<String> + ReturnCount:SyncRepository.kt$SyncRepository$private fun isNetworkAvailable(): Boolean + SwallowedException:ApiClient.kt$ApiClient$e: Exception + SwallowedException:ConnectionStatus.kt$e: Exception + SwallowedException:Field.kt$Field$e: Exception + SwallowedException:ListChangeNotificationWorker.kt$ListChangeNotificationWorker$e: Exception + SwallowedException:ListDetailScreen.kt$e: Exception + SwallowedException:PreferencesManager.kt$PreferencesManager$e: Exception + SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: ConnectException + SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: Exception + SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: NetworkOnMainThreadException + SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: SocketTimeoutException + SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: UnknownHostException + UnusedParameter:ListDetailScreen.kt$onAlignmentChange: (String) -> Unit = {} + UnusedParameter:ListDetailScreen.kt$onUpdateAlignment: (String, String) -> Unit + UnusedParameter:ListsScreen.kt$onNavigateToLogs: () -> Unit = {} + UnusedParameter:ListsScreen.kt$onNavigateToSettings: () -> Unit + UnusedParameter:SettingsScreen.kt$onNavigateBack: () -> Unit + UnusedPrivateMember:ListDetailScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun FilterSortDialog( fields: List<Field>, currentSortField: Field?, currentSortAscending: Boolean, currentGroupByField: Field?, currentFilterField: Field?, currentFilterValue: String, onDismiss: () -> Unit, onApply: (sortField: Field?, sortAscending: Boolean, groupByField: Field?, filterField: Field?, filterValue: String) -> Unit, onClearAll: () -> Unit, ) + UnusedPrivateMember:ListDetailViewModel.kt$ListDetailViewModel$private fun isInForeground(): Boolean + + diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt index 9e9b48a..8c5490d 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt @@ -53,6 +53,7 @@ class CollabTableApplication : Application() { NotificationHelper.clearListEventNotifications(this@CollabTableApplication) NotificationPoller.start(this@CollabTableApplication) } + override fun onStop(owner: LifecycleOwner) { NotificationPoller.stop() } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt index 1012e1c..d0100ad 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt @@ -6,8 +6,8 @@ import com.collabtable.app.data.model.Item import com.collabtable.app.data.model.ItemValue import retrofit2.Response import retrofit2.http.Body -import retrofit2.http.POST import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.Query data class SyncRequest( 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 c338193..0d06abc 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 @@ -48,7 +48,11 @@ interface FieldDao { suspend fun getFieldsUpdatedSince(since: Long): List @Query("UPDATE fields SET `order` = :newOrder, updatedAt = :timestamp WHERE id = :fieldId") - suspend fun updateFieldOrder(fieldId: String, newOrder: Int, timestamp: Long) + suspend fun updateFieldOrder( + fieldId: String, + newOrder: Int, + timestamp: Long, + ) // Reorder fields in a single transaction for clarity and consistency @Transaction diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt index 725f455..14a626a 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt @@ -16,8 +16,8 @@ class PreferencesManager( private val _serverUrl = MutableStateFlow(getServerUrl()) val serverUrl: StateFlow = _serverUrl.asStateFlow() - private val _isFirstRun = MutableStateFlow(isFirstRun()) - val isFirstRunFlow: StateFlow = _isFirstRun.asStateFlow() + private val _isFirstRunFlow = MutableStateFlow(isFirstRun()) + val isFirstRunFlow: StateFlow = _isFirstRunFlow.asStateFlow() private val _themeMode = MutableStateFlow(getThemeMode()) val themeMode: StateFlow = _themeMode.asStateFlow() @@ -67,7 +67,7 @@ class PreferencesManager( fun setIsFirstRun(isFirstRun: Boolean) { prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply() - _isFirstRun.value = isFirstRun + _isFirstRunFlow.value = isFirstRun } fun getServerPassword(): String? = prefs.getString(KEY_SERVER_PASSWORD, null) @@ -272,7 +272,10 @@ class PreferencesManager( fun getDeviceId(): String { val existing = prefs.getString(KEY_DEVICE_ID, null) if (!existing.isNullOrBlank()) return existing - val id = java.util.UUID.randomUUID().toString() + val id = + java.util.UUID + .randomUUID() + .toString() prefs.edit().putString(KEY_DEVICE_ID, id).apply() return id } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationHelper.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationHelper.kt index c8070aa..14351ee 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationHelper.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationHelper.kt @@ -1,8 +1,12 @@ package com.collabtable.app.notifications +import android.Manifest import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import com.collabtable.app.R object NotificationHelper { @@ -34,6 +38,15 @@ object NotificationHelper { id: Int, builder: NotificationCompat.Builder, ) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } with(NotificationManagerCompat.from(context)) { notify(id, builder.build()) } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationPoller.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationPoller.kt index b58ce57..daffcc8 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationPoller.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationPoller.kt @@ -1,95 +1,102 @@ -package com.collabtable.app.notifications - -import android.content.Context -import com.collabtable.app.data.api.ApiClient -import com.collabtable.app.data.api.CollabTableApi -import com.collabtable.app.data.api.NotificationEvent -import com.collabtable.app.data.database.CollabTableDatabase -import com.collabtable.app.data.preferences.PreferencesManager -import com.collabtable.app.utils.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -object NotificationPoller { - @Volatile private var job: Job? = null - - fun start(context: Context) { - if (job?.isActive == true) return - val appCtx = context.applicationContext - val prefs = PreferencesManager.getInstance(appCtx) - val api = ApiClient.api - val db = CollabTableDatabase.getDatabase(appCtx) - job = CoroutineScope(Dispatchers.IO).launch { - while (isActive) { - try { - // Skip if password missing (auth disabled) - val pwd = prefs.getServerPassword()?.trim() - if (pwd.isNullOrBlank() || pwd == "\$password") { - delay(2_000) - continue - } - val since = prefs.getLastListNotifyCheckTimestamp() - val resp = api.pollNotifications(since) - if (resp.isSuccessful) { - val body = resp.body() - val events = body?.notifications.orEmpty() - if (events.isNotEmpty()) { - // Map and emit based on preferences - events.forEach { ev -> - val listId = ev.listId ?: return@forEach - val list = db.listDao().getListById(listId) ?: return@forEach - handleEvent(appCtx, ev, list.name, prefs) - } - } - // Advance checkpoint to server timestamp to avoid re-processing - val stamp = body?.serverTimestamp ?: System.currentTimeMillis() - prefs.setLastListNotifyCheckTimestamp(stamp) - } else if (resp.code() == 401) { - // Unauthorized; back off by advancing timestamp minimally to avoid tight loop - delay(5_000) - } - } catch (e: Exception) { - try { Logger.w("NotifPoll", "Polling failed: ${e.message}") } catch (_: Exception) {} - // brief backoff - delay(1_500) - } - val interval = prefs.syncPollIntervalMs.value - delay(interval) - } - } - } - - fun stop() { - job?.cancel() - job = null - } - - private fun handleEvent( - context: Context, - ev: NotificationEvent, - listName: String, - prefs: PreferencesManager, - ) { - // Respect notification switches by grouping semantics - when (ev.entityType.lowercase()) { - "list" -> when (ev.eventType.lowercase()) { - "created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(context, ev.listId ?: "", listName) - "updated" -> if (prefs.notifyListEdited.value) NotificationHelper.showListEdited(context, ev.listId ?: "", listName) - "deleted" -> if (prefs.notifyListRemoved.value) NotificationHelper.showListRemoved(context, ev.listId ?: "", listName) - } - // Treat fields/items/value changes as content updates - "field", "item", "value" -> if (prefs.notifyListContentUpdated.value) { - NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName) - } - else -> when (ev.eventType.lowercase()) { - "listcontentupdated" -> if (prefs.notifyListContentUpdated.value) { - NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName) - } - } - } - } -} +package com.collabtable.app.notifications + +import android.content.Context +import com.collabtable.app.data.api.ApiClient +import com.collabtable.app.data.api.NotificationEvent +import com.collabtable.app.data.database.CollabTableDatabase +import com.collabtable.app.data.preferences.PreferencesManager +import com.collabtable.app.utils.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +object NotificationPoller { + @Volatile private var job: Job? = null + + fun start(context: Context) { + if (job?.isActive == true) return + val appCtx = context.applicationContext + val prefs = PreferencesManager.getInstance(appCtx) + val api = ApiClient.api + val db = CollabTableDatabase.getDatabase(appCtx) + job = + CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + try { + // Skip if password missing (auth disabled) + val pwd = prefs.getServerPassword()?.trim() + if (pwd.isNullOrBlank() || pwd == "\$password") { + delay(2_000) + continue + } + val since = prefs.getLastListNotifyCheckTimestamp() + val resp = api.pollNotifications(since) + if (resp.isSuccessful) { + val body = resp.body() + val events = body?.notifications.orEmpty() + if (events.isNotEmpty()) { + // Map and emit based on preferences + events.forEach { ev -> + val listId = ev.listId ?: return@forEach + val list = db.listDao().getListById(listId) ?: return@forEach + handleEvent(appCtx, ev, list.name, prefs) + } + } + // Advance checkpoint to server timestamp to avoid re-processing + val stamp = body?.serverTimestamp ?: System.currentTimeMillis() + prefs.setLastListNotifyCheckTimestamp(stamp) + } else if (resp.code() == 401) { + // Unauthorized; back off by advancing timestamp minimally to avoid tight loop + delay(5_000) + } + } catch (e: Exception) { + try { + Logger.w("NotifPoll", "Polling failed: ${e.message}") + } catch (_: Exception) { + } + // brief backoff + delay(1_500) + } + val interval = prefs.syncPollIntervalMs.value + delay(interval) + } + } + } + + fun stop() { + job?.cancel() + job = null + } + + private fun handleEvent( + context: Context, + ev: NotificationEvent, + listName: String, + prefs: PreferencesManager, + ) { + // Respect notification switches by grouping semantics + when (ev.entityType.lowercase()) { + "list" -> + when (ev.eventType.lowercase()) { + "created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(context, ev.listId ?: "", listName) + "updated" -> if (prefs.notifyListEdited.value) NotificationHelper.showListEdited(context, ev.listId ?: "", listName) + "deleted" -> if (prefs.notifyListRemoved.value) NotificationHelper.showListRemoved(context, ev.listId ?: "", listName) + } + // Treat fields/items/value changes as content updates + "field", "item", "value" -> + if (prefs.notifyListContentUpdated.value) { + NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName) + } + else -> + when (ev.eventType.lowercase()) { + "listcontentupdated" -> + if (prefs.notifyListContentUpdated.value) { + NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName) + } + } + } + } +} 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 75d2036..c15af1a 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 @@ -51,7 +51,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateListOf @@ -226,6 +225,7 @@ fun ListDetailScreen( floatingActionButton = {}, ) { padding -> // Apply filtering, sorting and grouping off the main thread and memoize the result + @Suppress("ProduceStateDoesNotAssignValue") val transformed by produceState( initialValue = TransformedItems(emptyList(), mapOf("_all" to emptyList())), stableItems, @@ -235,7 +235,7 @@ fun ListDetailScreen( filterField, filterValue, ) { - value = + val transformedItems = withContext(Dispatchers.Default) { transformItems( items = stableItems, @@ -246,6 +246,7 @@ fun ListDetailScreen( filterValue = filterValue, ) } + value = transformedItems } val processedItems = transformed.processed @@ -550,17 +551,19 @@ fun ListDetailScreen( } stableItems.isEmpty() -> { Box( - modifier = Modifier - .fillMaxSize(), + modifier = + Modifier + .fillMaxSize(), contentAlignment = Alignment.Center, ) { Text( text = stringResource(R.string.no_items), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), textAlign = TextAlign.Center, ) } @@ -1046,8 +1049,7 @@ fun ItemRow( .border( width = 1.dp, color = MaterialTheme.colorScheme.outline, - ) - .fillMaxHeight() + ).fillMaxHeight() // Consistent minimum top/bottom padding regardless of row height .padding(horizontal = 8.dp, vertical = 8.dp), // Center horizontally based on alignment while always vertically centering @@ -1105,7 +1107,8 @@ fun ItemRow( } com.collabtable.app.data.model.FieldType.SELECT, - com.collabtable.app.data.model.FieldType.MULTI_SELECT -> { + com.collabtable.app.data.model.FieldType.MULTI_SELECT, + -> { Text( text = value?.value ?: "", style = MaterialTheme.typography.bodyMedium, @@ -2365,9 +2368,10 @@ fun FieldInput( com.collabtable.app.data.model.FieldType.MULTI_SELECT -> { val options = field.getSelectOptions() var expanded by remember { mutableStateOf(false) } - val selectedOptions = remember(value) { - value.split(", ").filter { it.isNotBlank() }.toSet() - } + val selectedOptions = + remember(value) { + value.split(", ").filter { it.isNotBlank() }.toSet() + } ExposedDropdownMenuBox( expanded = expanded, @@ -2394,19 +2398,20 @@ fun FieldInput( options.forEach { option -> val isSelected = selectedOptions.contains(option) DropdownMenuItem( - text = { + text = { Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = isSelected, onCheckedChange = null) Spacer(modifier = Modifier.width(8.dp)) - Text(option) + Text(option) } }, onClick = { - val newSelected = if (isSelected) { - selectedOptions - option - } else { - selectedOptions + option - } + val newSelected = + if (isSelected) { + selectedOptions - option + } else { + selectedOptions + option + } val orderedNewSelected = options.filter { it in newSelected } onValueChange(orderedNewSelected.joinToString(", ")) }, @@ -4083,11 +4088,3 @@ private fun RenameListDialog( }, ) } - - - - - - - - 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 e4d37a5..9dd098e 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 @@ -3,7 +3,9 @@ package com.collabtable.app.ui.screens import android.Manifest +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult @@ -29,9 +31,6 @@ import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Share -import android.net.Uri -import android.content.Intent -import androidx.documentfile.provider.DocumentFile import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -60,6 +59,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.documentfile.provider.DocumentFile import com.collabtable.app.R import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.model.CollabList @@ -349,32 +349,39 @@ fun ListsScreen( } // Export dialog (top-level) separate from create/edit/delete dialogs listToExport?.let { list -> - val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> - if (uri != null) { - // Take persistable permission for potential reuse during this session - try { - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - context.contentResolver.takePersistableUriPermission(uri, flags) - } catch (_: Exception) { } - exportTargetUri = uri + val folderPicker = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri != null) { + // Take persistable permission for potential reuse during this session + try { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, flags) + } catch (_: Exception) { + } + exportTargetUri = uri + } } - } ExportListDialog( list = list, - selectedDirectoryLabel = exportTargetUri?.let { uri -> - DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)" - } ?: "App storage (default)", + selectedDirectoryLabel = + exportTargetUri?.let { uri -> + DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)" + } ?: "App storage (default)", onSelectDirectory = { folderPicker.launch(null) }, - onDismiss = { listToExport = null; exportTargetUri = null }, + onDismiss = { + listToExport = null + exportTargetUri = null + }, onConfirmExport = { format -> if (format == "CSV") { coroutineScope.launch { val result = viewModel.exportListToCsv(list.id, list.name, exportTargetUri) - result.onSuccess { path -> - Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show() - }.onFailure { - Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show() - } + result + .onSuccess { path -> + Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show() + }.onFailure { + Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show() + } listToExport = null exportTargetUri = null } @@ -500,7 +507,6 @@ fun CreateListDialog( } }, ) - } @Composable @@ -612,7 +618,10 @@ private fun ExportListDialog( DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenuItem( text = { Text(stringResource(R.string.format_csv)) }, - onClick = { selectedFormat = "CSV"; expanded = false }, + onClick = { + selectedFormat = "CSV" + expanded = false + }, ) } } 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 26e3b47..82e251c 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 @@ -1,6 +1,8 @@ package com.collabtable.app.ui.screens import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ViewModel @@ -19,12 +21,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.io.File -import android.net.Uri -import androidx.documentfile.provider.DocumentFile import java.text.SimpleDateFormat import java.util.Date -import java.util.UUID import java.util.Locale +import java.util.UUID class ListsViewModel( private val database: CollabTableDatabase, @@ -239,34 +239,41 @@ class ListsViewModel( targetTreeUri: Uri? = null, ): Result { return try { - // Load fields (ordered) and items with values - val fields: List = database.fieldDao().getFieldsForList(listId).first() - val items: List = database.itemDao().getItemsWithValuesForList(listId).first() + // Load fields (ordered) and items with values + val fields: List = database.fieldDao().getFieldsForList(listId).first() + val items: List = database.itemDao().getItemsWithValuesForList(listId).first() - // Build header - val headers = fields.map { escapeCsv(it.name) } - val fieldIdOrder = fields.map { it.id } + // Build header + val headers = fields.map { escapeCsv(it.name) } + val fieldIdOrder = fields.map { it.id } - val rows = items.map { iwv -> - val valueMap = iwv.values.associateBy { it.fieldId } - fieldIdOrder.joinToString(",") { fid -> - val raw = valueMap[fid]?.value ?: "" - escapeCsv(raw) - } - } + val rows = + items.map { iwv -> + val valueMap = iwv.values.associateBy { it.fieldId } + fieldIdOrder.joinToString(",") { fid -> + val raw = valueMap[fid]?.value ?: "" + escapeCsv(raw) + } + } - val csv = buildString { - append(headers.joinToString(",")) - append('\n') - rows.forEachIndexed { idx, row -> - append(row) - if (idx != rows.lastIndex) append('\n') - } - } + val csv = + buildString { + append(headers.joinToString(",")) + append('\n') + rows.forEachIndexed { idx, row -> + append(row) + if (idx != rows.lastIndex) append('\n') + } + } - val safeBase = listName.lowercase(Locale.US).replace("[^a-z0-9]+".toRegex(), "_").trim('_').ifBlank { "table" } - val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val fileName = "${safeBase}_${ts}.csv" + val safeBase = + listName + .lowercase(Locale.US) + .replace("[^a-z0-9]+".toRegex(), "_") + .trim('_') + .ifBlank { "table" } + val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val fileName = "${safeBase}_$ts.csv" if (targetTreeUri != null) { val docTree = DocumentFile.fromTreeUri(context, targetTreeUri) if (docTree == null || !docTree.isDirectory || !docTree.canWrite()) { 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 3f2ad89..0465ea7 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 @@ -453,7 +453,7 @@ fun SettingsScreen( modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( + Text( text = "View application logs", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -538,7 +538,8 @@ fun SettingsScreen( onDismissRequest = { showLeaveDialog = false }, title = { Text(stringResource(R.string.leave_server_dialog_title)) }, text = { - Text(stringResource(R.string.leave_server_dialog_body), + Text( + stringResource(R.string.leave_server_dialog_body), ) }, confirmButton = { diff --git a/CollabTableServer/src/index.ts b/CollabTableServer/src/index.ts index 8f4a49d..e4989a0 100644 --- a/CollabTableServer/src/index.ts +++ b/CollabTableServer/src/index.ts @@ -26,7 +26,7 @@ function sleep(ms: number) { async function initializeDatabaseWithRetry() { let attempt = 0; - while (true) { + for (;;) { attempt += 1; try { diff --git a/CollabTableServer/src/routes/itemRoutes.ts b/CollabTableServer/src/routes/itemRoutes.ts index c71b0b7..acf8c0c 100644 --- a/CollabTableServer/src/routes/itemRoutes.ts +++ b/CollabTableServer/src/routes/itemRoutes.ts @@ -57,7 +57,9 @@ router.post('/values', async (req: Request, res: Response) => { if (listId) { await enqueueNotification(dbAdapter, (req as any).deviceId, 'listContentUpdated', 'value', id, listId, Date.now()); } - } catch {} + } catch (notificationError) { + void notificationError; + } res.json(itemValue); } catch (error) { res.status(500).json({ error: 'Failed to save item value' }); @@ -101,7 +103,9 @@ router.delete('/:id', async (req: Request, res: Response) => { const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [req.params.id]); const listId = item ? ((item as any).listId ?? (item as any).listid) : undefined; await enqueueNotification(dbAdapter, (req as any).deviceId, 'deleted', 'item', req.params.id, listId, updatedAt); - } catch {} + } catch (notificationError) { + void notificationError; + } res.json({ message: 'Item deleted successfully' }); } catch (error) { res.status(500).json({ error: 'Failed to delete item' }); diff --git a/CollabTableServer/src/routes/syncRoutes.ts b/CollabTableServer/src/routes/syncRoutes.ts index fcd0afb..a02d3a2 100644 --- a/CollabTableServer/src/routes/syncRoutes.ts +++ b/CollabTableServer/src/routes/syncRoutes.ts @@ -167,7 +167,9 @@ router.post('/sync', async (req: Request, res: Response) => { try { const ev = list.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated'); await enqueueNotification(tx, deviceId, ev, 'list', list.id, list.id, listTs.updatedAt); - } catch {} + } catch (notificationError) { + void notificationError; + } // 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) @@ -201,7 +203,9 @@ router.post('/sync', async (req: Request, res: Response) => { try { const ev = field.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated'); await enqueueNotification(tx, deviceId, ev, 'field', field.id, field.listId, fieldTs.updatedAt); - } catch {} + } catch (notificationError) { + void notificationError; + } if (field.isDeleted) { // Remove item_values for this field try { @@ -239,7 +243,9 @@ router.post('/sync', async (req: Request, res: Response) => { try { const ev = item.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated'); await enqueueNotification(tx, deviceId, ev, 'item', item.id, item.listId, itemTs.updatedAt); - } catch {} + } catch (notificationError) { + void notificationError; + } // If an item was deleted, remove its values (no tombstone support for values) if (item.isDeleted) { try { @@ -324,7 +330,9 @@ router.post('/sync', async (req: Request, res: Response) => { if (lId) { await enqueueNotification(tx, deviceId, 'listContentUpdated', 'value', value.id, lId, valueUpdatedAt); } - } catch {} + } catch (notificationError) { + void notificationError; + } } catch (err: any) { // Catch FK violation just in case race or deletion happened inside same sync if (err && err.code === '23503') {