Refactor ListDetailScreen and ViewModel for improved readability and performance

- Cleaned up imports and organized them in ListDetailScreen.kt
- Enhanced function formatting and readability in ListDetailScreen.kt
- Updated ListDetailViewModel.kt to improve data loading logic
- Refactored ListsScreen.kt for better layout and UI consistency
- Simplified sorting logic in ListsScreen.kt
- Improved notification handling in ListChangeNotificationWorker.kt
- Updated build.gradle to use newer versions of Kotlin and Detekt
- Added a new Detekt configuration file for better code quality checks
- Refactored ServerSetupScreen.kt and ServerSetupViewModel.kt for clarity and maintainability
- Cleaned up LogsScreen.kt to enhance readability
- Improved SettingsScreen.kt by removing unnecessary imports and organizing code
This commit is contained in:
2025-11-11 21:31:45 +01:00
parent 7a09cfd230
commit fd72959401
20 changed files with 979 additions and 876 deletions
+6 -8
View File
@@ -2,7 +2,8 @@ plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose' id 'org.jetbrains.kotlin.plugin.compose'
id 'com.google.devtools.ksp' // KSP not available for Kotlin 2.2.21 yet; switch to kapt for Room annotation processing
id 'org.jetbrains.kotlin.kapt'
id 'org.jlleitschuh.gradle.ktlint' id 'org.jlleitschuh.gradle.ktlint'
id 'io.gitlab.arturbosch.detekt' id 'io.gitlab.arturbosch.detekt'
} }
@@ -40,9 +41,6 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
} }
composeOptions {
kotlinCompilerExtensionVersion = '1.6.8'
}
packaging { packaging {
resources { resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}' excludes += '/META-INF/{AL2.0,LGPL2.1}'
@@ -69,10 +67,10 @@ dependencies {
// ViewModel // ViewModel
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2'
// Room // Room (use kapt for annotation processing). Bump to version compatible with Kotlin 2.2 metadata
implementation 'androidx.room:room-runtime:2.6.1' implementation 'androidx.room:room-runtime:2.7.0'
implementation 'androidx.room:room-ktx:2.6.1' implementation 'androidx.room:room-ktx:2.7.0'
ksp 'androidx.room:room-compiler:2.6.1' kapt 'androidx.room:room-compiler:2.7.0'
// Retrofit // Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:retrofit:2.9.0'
@@ -1,74 +1,78 @@
package com.collabtable.app package com.collabtable.app
import android.app.Application import android.app.Application
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import com.collabtable.app.data.api.ApiClient import androidx.lifecycle.DefaultLifecycleObserver
import com.collabtable.app.data.repository.SyncRepository import androidx.lifecycle.LifecycleOwner
import com.collabtable.app.notifications.NotificationHelper import androidx.lifecycle.ProcessLifecycleOwner
import com.collabtable.app.work.ListChangeNotificationWorker import androidx.work.ExistingPeriodicWorkPolicy
import androidx.lifecycle.DefaultLifecycleObserver import androidx.work.PeriodicWorkRequestBuilder
import androidx.lifecycle.LifecycleOwner import androidx.work.WorkManager
import androidx.lifecycle.ProcessLifecycleOwner import com.collabtable.app.data.api.ApiClient
import androidx.work.ExistingPeriodicWorkPolicy import com.collabtable.app.data.repository.SyncRepository
import androidx.work.PeriodicWorkRequestBuilder import com.collabtable.app.notifications.NotificationHelper
import androidx.work.WorkManager import com.collabtable.app.work.ListChangeNotificationWorker
import java.util.concurrent.TimeUnit import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch
import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit
class CollabTableApplication : Application() { class CollabTableApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Initialize API client with saved server URL // Initialize API client with saved server URL
ApiClient.initialize(this) ApiClient.initialize(this)
// Kick off a one-time full down-sync on cold app start if local DB is empty but server has data. // Kick off a one-time full down-sync on cold app start if local DB is empty but server has data.
// This complements in-screen sync logic and ensures earliest possible hydration. // This complements in-screen sync logic and ensures earliest possible hydration.
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try { try {
// Force a full down-sync on app start when server credentials are present. // Force a full down-sync on app start when server credentials are present.
SyncRepository(this@CollabTableApplication).performSync(forceFullDown = true) SyncRepository(this@CollabTableApplication).performSync(forceFullDown = true)
} catch (_: Exception) { } catch (_: Exception) {
// Silent; UI layers will surface subsequent sync issues. // Silent; UI layers will surface subsequent sync issues.
} }
} }
// Create notification channel(s) for list events // Create notification channel(s) for list events
createNotificationChannels() createNotificationChannels()
// Schedule periodic background check for list changes to surface notifications // Schedule periodic background check for list changes to surface notifications
scheduleListChangeWorker() scheduleListChangeWorker()
// Auto-clear notifications when app comes to foreground // Auto-clear notifications when app comes to foreground
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { ProcessLifecycleOwner.get().lifecycle.addObserver(
override fun onStart(owner: LifecycleOwner) { object : DefaultLifecycleObserver {
NotificationHelper.clearListEventNotifications(this@CollabTableApplication) override fun onStart(owner: LifecycleOwner) {
} NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
}) }
} },
)
private fun createNotificationChannels() { }
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val listEventsChannel = NotificationChannel( private fun createNotificationChannels() {
NotificationHelper.CHANNEL_LIST_EVENTS, val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
"List Events", val listEventsChannel =
NotificationManager.IMPORTANCE_DEFAULT, NotificationChannel(
).apply { NotificationHelper.CHANNEL_LIST_EVENTS,
description = "Notifications for list add, edit, and remove events" "List Events",
} NotificationManager.IMPORTANCE_DEFAULT,
manager.createNotificationChannel(listEventsChannel) ).apply {
} description = "Notifications for list add, edit, and remove events"
}
private fun scheduleListChangeWorker() { manager.createNotificationChannel(listEventsChannel)
val work = PeriodicWorkRequestBuilder<ListChangeNotificationWorker>(15, TimeUnit.MINUTES) }
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork( private fun scheduleListChangeWorker() {
"list_change_notifier", val work =
ExistingPeriodicWorkPolicy.UPDATE, PeriodicWorkRequestBuilder<ListChangeNotificationWorker>(15, TimeUnit.MINUTES)
work, .build()
) WorkManager.getInstance(this).enqueueUniquePeriodicWork(
} "list_change_notifier",
} ExistingPeriodicWorkPolicy.UPDATE,
work,
)
}
}
@@ -1,9 +1,9 @@
package com.collabtable.app.data.api package com.collabtable.app.data.api
import android.content.Context import android.content.Context
import com.collabtable.app.data.preferences.PreferencesManager
import android.os.Build
import android.net.Uri import android.net.Uri
import android.os.Build
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -32,7 +32,8 @@ object ApiClient {
val newRequest = val newRequest =
if (!password.isNullOrBlank() && password != "\$password") { if (!password.isNullOrBlank() && password != "\$password") {
request.newBuilder() request
.newBuilder()
.header("Authorization", "Bearer $password") .header("Authorization", "Bearer $password")
.build() .build()
} else { } else {
@@ -43,7 +44,8 @@ object ApiClient {
} }
private val okHttpClient = private val okHttpClient =
OkHttpClient.Builder() OkHttpClient
.Builder()
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor) .addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
@@ -51,13 +53,13 @@ object ApiClient {
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
.build() .build()
private fun buildRetrofit(): Retrofit { private fun buildRetrofit(): Retrofit =
return Retrofit.Builder() Retrofit
.Builder()
.baseUrl(baseUrl) .baseUrl(baseUrl)
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
}
private fun isEmulator(): Boolean { private fun isEmulator(): Boolean {
val fingerprint = Build.FINGERPRINT.lowercase() val fingerprint = Build.FINGERPRINT.lowercase()
@@ -65,9 +67,14 @@ object ApiClient {
val brand = Build.BRAND.lowercase() val brand = Build.BRAND.lowercase()
val device = Build.DEVICE.lowercase() val device = Build.DEVICE.lowercase()
val product = Build.PRODUCT.lowercase() val product = Build.PRODUCT.lowercase()
return fingerprint.contains("generic") || fingerprint.contains("emulator") || return fingerprint.contains("generic") ||
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") || fingerprint.contains("emulator") ||
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk") model.contains("google_sdk") ||
model.contains("emulator") ||
model.contains("android sdk built for") ||
brand.startsWith("generic") ||
device.startsWith("generic") ||
product.contains("sdk")
} }
private fun ensureTrailingSlash(url: String): String = if (url.endsWith("/")) url else "$url/" private fun ensureTrailingSlash(url: String): String = if (url.endsWith("/")) url else "$url/"
@@ -104,7 +111,8 @@ object ApiClient {
baseUrl = normalizeForAndroidEmulator(prefs.getServerUrl()) baseUrl = normalizeForAndroidEmulator(prefs.getServerUrl())
try { try {
Logger.i("HTTP", "API base URL initialized to $baseUrl") Logger.i("HTTP", "API base URL initialized to $baseUrl")
} catch (_: Exception) { } } catch (_: Exception) {
}
retrofit = buildRetrofit() retrofit = buildRetrofit()
} }
@@ -112,7 +120,8 @@ object ApiClient {
baseUrl = normalizeForAndroidEmulator(url) baseUrl = normalizeForAndroidEmulator(url)
try { try {
Logger.i("HTTP", "API base URL set to $baseUrl") Logger.i("HTTP", "API base URL set to $baseUrl")
} catch (_: Exception) { } } catch (_: Exception) {
}
retrofit = buildRetrofit() retrofit = buildRetrofit()
} }
@@ -54,20 +54,19 @@ abstract class CollabTableDatabase : RoomDatabase() {
@Volatile @Volatile
private var dbInstance: CollabTableDatabase? = null private var dbInstance: CollabTableDatabase? = null
fun getDatabase(context: Context): CollabTableDatabase { fun getDatabase(context: Context): CollabTableDatabase =
return dbInstance ?: synchronized(this) { dbInstance ?: synchronized(this) {
val instance = val instance =
Room.databaseBuilder( Room
context.applicationContext, .databaseBuilder(
CollabTableDatabase::class.java, context.applicationContext,
"collab_table_database", CollabTableDatabase::class.java,
) "collab_table_database",
.addMigrations(migration1To2, migration2To3) ).addMigrations(migration1To2, migration2To3)
.build() .build()
dbInstance = instance dbInstance = instance
instance instance
} }
}
fun clearDatabase(context: Context) { fun clearDatabase(context: Context) {
synchronized(this) { synchronized(this) {
@@ -84,35 +84,31 @@ data class Field(
} }
} }
fun getDropdownOptions(): List<String> { fun getDropdownOptions(): List<String> =
return if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) { if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) {
fieldOptions.split("|") fieldOptions.split("|")
} else { } else {
emptyList() emptyList()
} }
}
fun getCurrency(): String { fun getCurrency(): String =
return if ((getType() == FieldType.CURRENCY) && fieldOptions.isNotBlank()) { if ((getType() == FieldType.CURRENCY) && fieldOptions.isNotBlank()) {
fieldOptions fieldOptions
} else { } else {
"CHF" "CHF"
} }
}
fun getMaxRating(): Int { fun getMaxRating(): Int =
return if (getType() == FieldType.RATING && fieldOptions.isNotBlank()) { if (getType() == FieldType.RATING && fieldOptions.isNotBlank()) {
fieldOptions.toIntOrNull() ?: 5 fieldOptions.toIntOrNull() ?: 5
} else { } else {
5 5
} }
}
fun getAutocompleteOptions(): List<String> { fun getAutocompleteOptions(): List<String> =
return if (getType() == FieldType.AUTOCOMPLETE && fieldOptions.isNotBlank()) { if (getType() == FieldType.AUTOCOMPLETE && fieldOptions.isNotBlank()) {
fieldOptions.split("|") fieldOptions.split("|")
} else { } else {
emptyList() emptyList()
} }
}
} }
@@ -2,10 +2,10 @@ package com.collabtable.app.data.preferences
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import org.json.JSONObject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
class PreferencesManager(context: Context) { class PreferencesManager(context: Context) {
private val prefs: SharedPreferences = private val prefs: SharedPreferences =
@@ -210,7 +210,10 @@ class PreferencesManager(context: Context) {
} }
} }
fun setColumnWidths(listId: String, widthsDp: Map<String, Float>) { fun setColumnWidths(
listId: String,
widthsDp: Map<String, Float>,
) {
val key = COLUMN_WIDTHS_PREFIX + listId val key = COLUMN_WIDTHS_PREFIX + listId
val json = JSONObject() val json = JSONObject()
widthsDp.forEach { (fieldId, width) -> widthsDp.forEach { (fieldId, width) ->
@@ -229,11 +232,11 @@ class PreferencesManager(context: Context) {
private const val KEY_DYNAMIC_COLOR = "dynamic_color" private const val KEY_DYNAMIC_COLOR = "dynamic_color"
private const val KEY_AMOLED_DARK = "amoled_dark" private const val KEY_AMOLED_DARK = "amoled_dark"
private const val KEY_SORT_ORDER = "sort_order" private const val KEY_SORT_ORDER = "sort_order"
private const val KEY_SYNC_POLL_INTERVAL_MS = "sync_poll_interval_ms" private const val KEY_SYNC_POLL_INTERVAL_MS = "sync_poll_interval_ms"
private const val KEY_NOTIFY_LIST_ADDED = "notify_list_added" private const val KEY_NOTIFY_LIST_ADDED = "notify_list_added"
private const val KEY_NOTIFY_LIST_EDITED = "notify_list_edited" private const val KEY_NOTIFY_LIST_EDITED = "notify_list_edited"
private const val KEY_NOTIFY_LIST_REMOVED = "notify_list_removed" private const val KEY_NOTIFY_LIST_REMOVED = "notify_list_removed"
private const val KEY_LAST_LIST_NOTIFY_CHECK_TS = "last_list_notify_check_ts" private const val KEY_LAST_LIST_NOTIFY_CHECK_TS = "last_list_notify_check_ts"
private const val COLUMN_WIDTHS_PREFIX = "column_widths_" // + listId private const val COLUMN_WIDTHS_PREFIX = "column_widths_" // + listId
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/" private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
private const val DEFAULT_SYNC_POLL_INTERVAL_MS = 250L private const val DEFAULT_SYNC_POLL_INTERVAL_MS = 250L
@@ -10,10 +10,10 @@ import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import java.net.UnknownHostException
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.net.UnknownHostException
class SyncRepository(context: Context) { class SyncRepository(context: Context) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
@@ -22,18 +22,23 @@ class SyncRepository(context: Context) {
private val prefs = appContext.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE) private val prefs = appContext.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE)
@Volatile private var authBackoffUntil: Long = 0L @Volatile private var authBackoffUntil: Long = 0L
@Volatile private var authBackoffMs: Long = 0L @Volatile private var authBackoffMs: Long = 0L
@Volatile private var networkBackoffUntil: Long = 0L @Volatile private var networkBackoffUntil: Long = 0L
@Volatile private var networkBackoffMs: Long = 0L @Volatile private var networkBackoffMs: Long = 0L
fun resetAuthBackoff() { fun resetAuthBackoff() {
authBackoffMs = 0L authBackoffMs = 0L
authBackoffUntil = 0L authBackoffUntil = 0L
} }
private fun resetNetworkBackoff() {
networkBackoffMs = 0L private fun resetNetworkBackoff() {
networkBackoffUntil = 0L networkBackoffMs = 0L
} networkBackoffUntil = 0L
}
// We keep separate watermarks to avoid clock-skew issues: // We keep separate watermarks to avoid clock-skew issues:
// - server watermark: server-assigned timestamp for fetching server changes // - server watermark: server-assigned timestamp for fetching server changes
// - local watermark: device local time for selecting local changes to upload // - local watermark: device local time for selecting local changes to upload
@@ -69,219 +74,225 @@ class SyncRepository(context: Context) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// Prevent overlapping syncs across screens/viewmodels // Prevent overlapping syncs across screens/viewmodels
syncMutex.withLock { syncMutex.withLock {
try { try {
// Only attempt network sync when app is in foreground to avoid background DNS issues // Only attempt network sync when app is in foreground to avoid background DNS issues
// Foreground check removed (lifecycle owner not available in this module); rely solely on network availability. // Foreground check removed (lifecycle owner not available in this module); rely solely on network availability.
// Skip sync when there's no validated network available // Skip sync when there's no validated network available
if (!isNetworkAvailable()) { if (!isNetworkAvailable()) {
return@withContext Result.success(Unit) return@withContext Result.success(Unit)
}
// If not authenticated, skip making network calls (e.g., after leaving server)
val prefMgr = PreferencesManager.getInstance(appContext)
val currentPassword = prefMgr.getServerPassword()?.trim()
if (currentPassword.isNullOrBlank() || currentPassword == "\$password") {
// Quietly skip to avoid spamming server with missing Authorization
return@withContext Result.success(Unit)
}
// Respect auth backoff window to avoid spamming the server on repeated 401s
val now = System.currentTimeMillis()
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
// updates that happen during an in-flight sync. We'll advance the local watermark
// to this snapshot once the sync completes successfully.
val localSnapshotTs = System.currentTimeMillis()
val isInitialSync = lastServerTs == 0L
if (isInitialSync) {
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 = when {
// App start can request a full down-sync regardless of local contents
forceFullDown && lastServerTs > 0L -> 0L
// Fallback: if local looks empty but we have a watermark, do a one-time full down-sync
lastServerTs > 0L && (!hasAnyLists || !hasAnyItems) -> 0L
else -> lastServerTs
}
if (effectiveLastServerTs == 0L && lastServerTs > 0L) {
val reason = if (forceFullDown) "startup forced" else "local empty"
Logger.w("Sync", "$reason full down-sync requested")
}
// Gather local changes since last sync
val localLists = database.listDao().getListsUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
val localFields = database.fieldDao().getFieldsUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
val localItems = database.itemDao().getItemsUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
val localValues = database.itemValueDao().getValuesUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
// Only log send when there are actual local changes
val localTotal = localLists.size + localFields.size + localItems.size + localValues.size
if (localTotal > 0) {
Logger.i(
"Sync",
"[OUT] Sending changes " +
"(lists=${localLists.size}, fields=${localFields.size}, " +
"items=${localItems.size}, values=${localValues.size})",
)
}
// Send to server and get updates
val syncRequest =
SyncRequest(
lastSyncTimestamp = effectiveLastServerTs,
lists = localLists,
fields = localFields,
items = localItems,
itemValues = localValues,
)
// HTTP-only sync
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 not authenticated, skip making network calls (e.g., after leaving server)
} val prefMgr = PreferencesManager.getInstance(appContext)
if (!response.isSuccessful) { val currentPassword = prefMgr.getServerPassword()?.trim()
if (response.code() == 401) { if (currentPassword.isNullOrBlank() || currentPassword == "\$password") {
// Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. // Quietly skip to avoid spamming server with missing Authorization
return@withContext Result.success(Unit)
}
// Respect auth backoff window to avoid spamming the server on repeated 401s
val now = System.currentTimeMillis()
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
// updates that happen during an in-flight sync. We'll advance the local watermark
// to this snapshot once the sync completes successfully.
val localSnapshotTs = System.currentTimeMillis()
val isInitialSync = lastServerTs == 0L
if (isInitialSync) {
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 =
when {
// App start can request a full down-sync regardless of local contents
forceFullDown && lastServerTs > 0L -> 0L
// Fallback: if local looks empty but we have a watermark, do a one-time full down-sync
lastServerTs > 0L && (!hasAnyLists || !hasAnyItems) -> 0L
else -> lastServerTs
}
if (effectiveLastServerTs == 0L && lastServerTs > 0L) {
val reason = if (forceFullDown) "startup forced" else "local empty"
Logger.w("Sync", "$reason full down-sync requested")
}
// Gather local changes since last sync
val localLists =
database.listDao().getListsUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
val localFields =
database.fieldDao().getFieldsUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
val localItems =
database.itemDao().getItemsUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
val localValues =
database.itemValueDao().getValuesUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
// Only log send when there are actual local changes
val localTotal = localLists.size + localFields.size + localItems.size + localValues.size
if (localTotal > 0) {
Logger.i(
"Sync",
"[OUT] Sending changes " +
"(lists=${localLists.size}, fields=${localFields.size}, " +
"items=${localItems.size}, values=${localValues.size})",
)
}
// Send to server and get updates
val syncRequest =
SyncRequest(
lastSyncTimestamp = effectiveLastServerTs,
lists = localLists,
fields = localFields,
items = localItems,
itemValues = localValues,
)
// HTTP-only sync
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.
setLastServerSyncTs(0)
setLastLocalSyncTs(0)
// Apply exponential backoff on repeated auth failures
authBackoffMs = if (authBackoffMs == 0L) 2_000L else (authBackoffMs * 2).coerceAtMost(300_000L)
authBackoffUntil = System.currentTimeMillis() + authBackoffMs
return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password."))
}
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
}
val syncResponse = response.body()!!
// Only log receive when there are actual server changes
val inTotal =
syncResponse.lists.size +
syncResponse.fields.size +
syncResponse.items.size +
syncResponse.itemValues.size
if (inTotal > 0) {
Logger.i(
"Sync",
"[IN] Received changes " +
"(lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, " +
"items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})",
)
}
// Apply server changes to local database atomically in correct order
database.withTransaction {
// 1) Upsert lists first, preserving local orderIndex when present
val localIdToOrder = database.listDao().getListIdsAndOrder().associate { it.id to it.orderIndex }
// Build a map of local updatedAt to avoid overwriting newer local changes with older server data
val localUpdatedMap = database.listDao().getListsUpdatedSince(0).associateBy({ it.id }, { it.updatedAt })
val listsPreservingOrder =
syncResponse.lists
.filter { incoming ->
val localUpdated = localUpdatedMap[incoming.id]
localUpdated == null || incoming.updatedAt >= localUpdated
}
.map { incoming ->
val localOrder = localIdToOrder[incoming.id]
if (localOrder != null) incoming.copy(orderIndex = localOrder) else incoming
}
if (listsPreservingOrder.isNotEmpty()) {
database.listDao().insertLists(listsPreservingOrder)
}
// Build set of existing list ids to guard child inserts
val existingListIds = database.listDao().getAllListIds().toSet()
// 2) Filter and upsert fields whose parent list exists
val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds }
if (filteredFields.size != syncResponse.fields.size) {
val dropped = syncResponse.fields.size - filteredFields.size
Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors")
}
if (filteredFields.isNotEmpty()) {
database.fieldDao().insertFields(filteredFields)
}
// 3) Filter and upsert items whose parent list exists
val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds }
if (filteredItems.size != syncResponse.items.size) {
val dropped = syncResponse.items.size - filteredItems.size
Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors")
}
if (filteredItems.isNotEmpty()) {
database.itemDao().insertItems(filteredItems)
}
// 4) Filter item values to only those whose parents (item and field) exist locally
val existingItemIds = database.itemDao().getAllItemIds().toSet()
val existingFieldIds = database.fieldDao().getAllFieldIds().toSet()
val filteredValues =
syncResponse.itemValues.filter { v ->
v.itemId in existingItemIds && v.fieldId in existingFieldIds
}
if (filteredValues.size != syncResponse.itemValues.size) {
val dropped = syncResponse.itemValues.size - filteredValues.size
Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors")
}
if (filteredValues.isNotEmpty()) {
database.itemValueDao().insertValues(filteredValues)
}
}
// Update watermarks: server ts from response, local ts from device clock now
setLastServerSyncTs(syncResponse.serverTimestamp)
// Advance local watermark to the snapshot taken before reading local changes.
// This avoids missing updates that occurred while the sync was in-flight.
setLastLocalSyncTs(localSnapshotTs)
if (isInitialSync) {
Logger.i("Sync", "[SYNC] Initial sync completed")
}
// Successful sync clears any previous auth backoff
resetAuthBackoff()
resetNetworkBackoff()
return@withContext Result.success(Unit)
} catch (e: Exception) {
// If WS indicated unauthorized, align behavior with HTTP path
if (e.message?.contains("Unauthorized") == true) {
setLastServerSyncTs(0) setLastServerSyncTs(0)
setLastLocalSyncTs(0) setLastLocalSyncTs(0)
// Apply exponential backoff on repeated auth failures
authBackoffMs = if (authBackoffMs == 0L) 2_000L else (authBackoffMs * 2).coerceAtMost(300_000L)
authBackoffUntil = System.currentTimeMillis() + authBackoffMs
return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password."))
} }
return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) // Do not reset network backoff here; it's managed where thrown.
return@withContext Result.failure(e)
} }
val syncResponse = response.body()!!
// Only log receive when there are actual server changes
val inTotal =
syncResponse.lists.size +
syncResponse.fields.size +
syncResponse.items.size +
syncResponse.itemValues.size
if (inTotal > 0) {
Logger.i(
"Sync",
"[IN] Received changes " +
"(lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, " +
"items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})",
)
}
// Apply server changes to local database atomically in correct order
database.withTransaction {
// 1) Upsert lists first, preserving local orderIndex when present
val localIdToOrder = database.listDao().getListIdsAndOrder().associate { it.id to it.orderIndex }
// Build a map of local updatedAt to avoid overwriting newer local changes with older server data
val localUpdatedMap = database.listDao().getListsUpdatedSince(0).associateBy({ it.id }, { it.updatedAt })
val listsPreservingOrder =
syncResponse.lists
.filter { incoming ->
val localUpdated = localUpdatedMap[incoming.id]
localUpdated == null || incoming.updatedAt >= localUpdated
}
.map { incoming ->
val localOrder = localIdToOrder[incoming.id]
if (localOrder != null) incoming.copy(orderIndex = localOrder) else incoming
}
if (listsPreservingOrder.isNotEmpty()) {
database.listDao().insertLists(listsPreservingOrder)
}
// Build set of existing list ids to guard child inserts
val existingListIds = database.listDao().getAllListIds().toSet()
// 2) Filter and upsert fields whose parent list exists
val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds }
if (filteredFields.size != syncResponse.fields.size) {
val dropped = syncResponse.fields.size - filteredFields.size
Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors")
}
if (filteredFields.isNotEmpty()) {
database.fieldDao().insertFields(filteredFields)
}
// 3) Filter and upsert items whose parent list exists
val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds }
if (filteredItems.size != syncResponse.items.size) {
val dropped = syncResponse.items.size - filteredItems.size
Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors")
}
if (filteredItems.isNotEmpty()) {
database.itemDao().insertItems(filteredItems)
}
// 4) Filter item values to only those whose parents (item and field) exist locally
val existingItemIds = database.itemDao().getAllItemIds().toSet()
val existingFieldIds = database.fieldDao().getAllFieldIds().toSet()
val filteredValues =
syncResponse.itemValues.filter { v ->
v.itemId in existingItemIds && v.fieldId in existingFieldIds
}
if (filteredValues.size != syncResponse.itemValues.size) {
val dropped = syncResponse.itemValues.size - filteredValues.size
Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors")
}
if (filteredValues.isNotEmpty()) {
database.itemValueDao().insertValues(filteredValues)
}
}
// Update watermarks: server ts from response, local ts from device clock now
setLastServerSyncTs(syncResponse.serverTimestamp)
// Advance local watermark to the snapshot taken before reading local changes.
// This avoids missing updates that occurred while the sync was in-flight.
setLastLocalSyncTs(localSnapshotTs)
if (isInitialSync) {
Logger.i("Sync", "[SYNC] Initial sync completed")
}
// Successful sync clears any previous auth backoff
resetAuthBackoff()
resetNetworkBackoff()
return@withContext Result.success(Unit)
} catch (e: Exception) {
// If WS indicated unauthorized, align behavior with HTTP path
if (e.message?.contains("Unauthorized") == true) {
setLastServerSyncTs(0)
setLastLocalSyncTs(0)
}
// Do not reset network backoff here; it's managed where thrown.
return@withContext Result.failure(e)
}
} }
} }
@@ -295,9 +306,10 @@ class SyncRepository(context: Context) {
val caps = cm.getNetworkCapabilities(network) ?: return false val caps = cm.getNetworkCapabilities(network) ?: return false
val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val hasTransport = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || val hasTransport =
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
return hasInternet && isValidated && hasTransport return hasInternet && isValidated && hasTransport
} }
} }
@@ -1,93 +1,126 @@
package com.collabtable.app.notifications package com.collabtable.app.notifications
import android.app.NotificationManager import android.content.Context
import android.content.Context import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.NotificationManagerCompat import com.collabtable.app.R
import com.collabtable.app.R
object NotificationHelper {
object NotificationHelper { const val CHANNEL_LIST_EVENTS = "list_events"
const val CHANNEL_LIST_EVENTS = "list_events" private const val GROUP_LIST_EVENTS = "group_list_events"
private const val GROUP_LIST_EVENTS = "group_list_events" private const val SUMMARY_ID = 1000
private const val SUMMARY_ID = 1000
// Keep a small rolling buffer of recent event lines for the summary
// Keep a small rolling buffer of recent event lines for the summary private val recentEventLines = ArrayDeque<String>()
private val recentEventLines = ArrayDeque<String>()
@Volatile private var totalEventCount: Int = 0 @Volatile private var totalEventCount: Int = 0
private val postedIds = mutableSetOf<Int>() private val postedIds = mutableSetOf<Int>()
private fun builder(context: Context, title: String, text: String): NotificationCompat.Builder { private fun builder(
return NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS) context: Context,
.setSmallIcon(R.mipmap.ic_launcher) title: String,
.setContentTitle(title) text: String,
.setContentText(text) ): NotificationCompat.Builder {
.setPriority(NotificationCompat.PRIORITY_DEFAULT) return NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS)
.setAutoCancel(true) .setSmallIcon(R.mipmap.ic_launcher)
} .setContentTitle(title)
.setContentText(text)
private fun notify(context: Context, id: Int, builder: NotificationCompat.Builder) { .setPriority(NotificationCompat.PRIORITY_DEFAULT)
with(NotificationManagerCompat.from(context)) { .setAutoCancel(true)
notify(id, builder.build()) }
}
} private fun notify(
context: Context,
fun showListAdded(context: Context, listId: String, name: String) = showEvent(context, listId, "add", "List added", "\"$name\" was created") id: Int,
fun showListEdited(context: Context, listId: String, name: String) = showEvent(context, listId, "edit", "List edited", "\"$name\" was renamed/updated") builder: NotificationCompat.Builder,
fun showListRemoved(context: Context, listId: String, name: String) = showEvent(context, listId, "remove", "List removed", "\"$name\" was deleted") ) {
with(NotificationManagerCompat.from(context)) {
private fun showEvent(context: Context, listId: String, type: String, title: String, text: String) { notify(id, builder.build())
val b = builder(context, title, text).setGroup(GROUP_LIST_EVENTS) }
val id = (type + listId).hashCode() }
synchronized(postedIds) { postedIds.add(id) }
notify(context, id, b) fun showListAdded(
recordEventLine(type, text) context: Context,
postSummary(context) listId: String,
} name: String,
) = showEvent(context, listId, "add", "List added", "\"$name\" was created")
private fun postSummary(context: Context) {
val inbox = NotificationCompat.InboxStyle() fun showListEdited(
synchronized(recentEventLines) { context: Context,
recentEventLines.take(5).forEach { line -> inbox.addLine(line) } listId: String,
inbox.setSummaryText("${totalEventCount} total event" + if (totalEventCount == 1) "" else "s") name: String,
} ) = showEvent(context, listId, "edit", "List edited", "\"$name\" was renamed/updated")
val summary = NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS)
.setSmallIcon(R.mipmap.ic_launcher) fun showListRemoved(
.setContentTitle("List activity") context: Context,
.setContentText("Recent list changes") listId: String,
.setStyle(inbox) name: String,
.setGroup(GROUP_LIST_EVENTS) ) = showEvent(context, listId, "remove", "List removed", "\"$name\" was deleted")
.setGroupSummary(true)
.setAutoCancel(true) private fun showEvent(
.setPriority(NotificationCompat.PRIORITY_DEFAULT) context: Context,
.setNumber(totalEventCount) listId: String,
notify(context, SUMMARY_ID, summary) type: String,
} title: String,
text: String,
private fun recordEventLine(type: String, text: String) { ) {
val line = when (type) { val b = builder(context, title, text).setGroup(GROUP_LIST_EVENTS)
"add" -> "Added • $text" val id = (type + listId).hashCode()
"edit" -> "Edited • $text" synchronized(postedIds) { postedIds.add(id) }
"remove" -> "Removed • $text" notify(context, id, b)
else -> text recordEventLine(type, text)
} postSummary(context)
synchronized(recentEventLines) { }
recentEventLines.addFirst(line)
// Keep only the last 8 lines to bound memory and UI clutter private fun postSummary(context: Context) {
while (recentEventLines.size > 8) recentEventLines.removeLast() val inbox = NotificationCompat.InboxStyle()
} synchronized(recentEventLines) {
totalEventCount += 1 recentEventLines.take(5).forEach { line -> inbox.addLine(line) }
} inbox.setSummaryText("$totalEventCount total event" + if (totalEventCount == 1) "" else "s")
}
fun clearListEventNotifications(context: Context) { val summary =
val mgr = NotificationManagerCompat.from(context) NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS)
synchronized(postedIds) { .setSmallIcon(R.mipmap.ic_launcher)
postedIds.forEach { id -> mgr.cancel(id) } .setContentTitle("List activity")
postedIds.clear() .setContentText("Recent list changes")
} .setStyle(inbox)
mgr.cancel(SUMMARY_ID) .setGroup(GROUP_LIST_EVENTS)
synchronized(recentEventLines) { .setGroupSummary(true)
recentEventLines.clear() .setAutoCancel(true)
totalEventCount = 0 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
} .setNumber(totalEventCount)
} notify(context, SUMMARY_ID, summary)
} }
private fun recordEventLine(
type: String,
text: String,
) {
val line =
when (type) {
"add" -> "Added • $text"
"edit" -> "Edited • $text"
"remove" -> "Removed • $text"
else -> text
}
synchronized(recentEventLines) {
recentEventLines.addFirst(line)
// Keep only the last 8 lines to bound memory and UI clutter
while (recentEventLines.size > 8) recentEventLines.removeLast()
}
totalEventCount += 1
}
fun clearListEventNotifications(context: Context) {
val mgr = NotificationManagerCompat.from(context)
synchronized(postedIds) {
postedIds.forEach { id -> mgr.cancel(id) }
postedIds.clear()
}
mgr.cancel(SUMMARY_ID)
synchronized(recentEventLines) {
recentEventLines.clear()
totalEventCount = 0
}
}
}
@@ -1,124 +1,136 @@
package com.collabtable.app.ui.components package com.collabtable.app.ui.components
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
private val statusClient: OkHttpClient = private val statusClient: OkHttpClient =
OkHttpClient.Builder() OkHttpClient
.connectTimeout(5, TimeUnit.SECONDS) .Builder()
.readTimeout(5, TimeUnit.SECONDS) .connectTimeout(5, TimeUnit.SECONDS)
.build() .readTimeout(5, TimeUnit.SECONDS)
.build()
private fun isEmulator(): Boolean {
val fp = Build.FINGERPRINT.lowercase() private fun isEmulator(): Boolean {
val model = Build.MODEL.lowercase() val fp = Build.FINGERPRINT.lowercase()
val brand = Build.BRAND.lowercase() val model = Build.MODEL.lowercase()
val device = Build.DEVICE.lowercase() val brand = Build.BRAND.lowercase()
val product = Build.PRODUCT.lowercase() val device = Build.DEVICE.lowercase()
return fp.contains("generic") || fp.contains("emulator") || val product = Build.PRODUCT.lowercase()
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") || return fp.contains("generic") ||
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk") fp.contains("emulator") ||
} model.contains("google_sdk") ||
model.contains("emulator") ||
private fun toHealthUrl(rawApiUrl: String): String { model.contains("android sdk built for") ||
// Ensure we hit the unauthenticated /health endpoint and remap localhost on emulator brand.startsWith("generic") ||
return try { device.startsWith("generic") ||
val uri = Uri.parse(rawApiUrl) product.contains("sdk")
val host = uri.host?.lowercase() }
val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal")
val scheme = uri.scheme ?: "http" private fun toHealthUrl(rawApiUrl: String): String {
val portPart = if (uri.port != -1) ":${uri.port}" else "" // Ensure we hit the unauthenticated /health endpoint and remap localhost on emulator
val base = if (needsRemap) "$scheme://10.0.2.2$portPart" else "$scheme://${uri.host ?: ""}$portPart" return try {
val path = (uri.encodedPath ?: "/").trimEnd('/') val uri = Uri.parse(rawApiUrl)
val healthPath = if (path.endsWith("/api") || path.endsWith("/api/")) "/health" else "/health" val host = uri.host?.lowercase()
base + healthPath val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal")
} catch (e: Exception) { val scheme = uri.scheme ?: "http"
rawApiUrl.replace("/api/", "/health").replace("/api", "/health") val portPart = if (uri.port != -1) ":${uri.port}" else ""
} val base = if (needsRemap) "$scheme://10.0.2.2$portPart" else "$scheme://${uri.host ?: ""}$portPart"
} val path = (uri.encodedPath ?: "/").trimEnd('/')
val healthPath = if (path.endsWith("/api") || path.endsWith("/api/")) "/health" else "/health"
@Composable base + healthPath
fun ConnectionStatusAction( } catch (e: Exception) {
prefs: PreferencesManager, rawApiUrl.replace("/api/", "/health").replace("/api", "/health")
modifier: Modifier = Modifier, }
showLatency: Boolean = true, }
) {
var ok by remember { mutableStateOf<Boolean?>(null) } @Composable
var latencyMs by remember { mutableStateOf<Long?>(null) } fun ConnectionStatusAction(
prefs: PreferencesManager,
val intervalMs by prefs.syncPollIntervalMs.collectAsState() modifier: Modifier = Modifier,
showLatency: Boolean = true,
// Periodic health check aligned to polling interval ) {
LaunchedEffect(intervalMs) { var ok by remember { mutableStateOf<Boolean?>(null) }
while (true) { var latencyMs by remember { mutableStateOf<Long?>(null) }
try {
val healthUrl = toHealthUrl(prefs.getServerUrl()) val intervalMs by prefs.syncPollIntervalMs.collectAsState()
val req = Request.Builder().url(healthUrl).get().build()
val start = System.nanoTime() // Periodic health check aligned to polling interval
val resp = withContext(Dispatchers.IO) { statusClient.newCall(req).execute() } LaunchedEffect(intervalMs) {
val tookMs = (System.nanoTime() - start) / 1_000_000 while (true) {
if (resp.isSuccessful) { try {
ok = true val healthUrl = toHealthUrl(prefs.getServerUrl())
latencyMs = tookMs val req =
} else { Request
ok = false .Builder()
} .url(healthUrl)
resp.close() .get()
} catch (e: Exception) { .build()
ok = false val start = System.nanoTime()
} val resp = withContext(Dispatchers.IO) { statusClient.newCall(req).execute() }
delay(intervalMs) val tookMs = (System.nanoTime() - start) / 1_000_000
} if (resp.isSuccessful) {
} ok = true
latencyMs = tookMs
val dotColor: Color = when (ok) { } else {
true -> Color(0xFF2E7D32) // green ok = false
false -> MaterialTheme.colorScheme.error }
null -> MaterialTheme.colorScheme.onSurfaceVariant resp.close()
} } catch (e: Exception) {
ok = false
Row( }
modifier = modifier, delay(intervalMs)
verticalAlignment = Alignment.CenterVertically, }
horizontalArrangement = Arrangement.spacedBy(6.dp), }
) {
if (showLatency && ok == true && latencyMs != null) { val dotColor: Color =
Text( when (ok) {
text = "${latencyMs} ms", true -> Color(0xFF2E7D32) // green
style = MaterialTheme.typography.labelSmall, false -> MaterialTheme.colorScheme.error
color = MaterialTheme.colorScheme.onPrimaryContainer, null -> MaterialTheme.colorScheme.onSurfaceVariant
) }
}
Box( Row(
modifier = modifier = modifier,
Modifier verticalAlignment = Alignment.CenterVertically,
.size(12.dp) horizontalArrangement = Arrangement.spacedBy(6.dp),
.background(dotColor, shape = androidx.compose.foundation.shape.CircleShape), ) {
) if (showLatency && ok == true && latencyMs != null) {
} Text(
} text = "$latencyMs ms",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
Box(
modifier =
Modifier
.size(12.dp)
.background(dotColor, shape = androidx.compose.foundation.shape.CircleShape),
)
}
}
@@ -1,138 +1,139 @@
package com.collabtable.app.ui.navigation package com.collabtable.app.ui.navigation
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument import androidx.navigation.compose.rememberNavController
import androidx.navigation.NavType import androidx.navigation.navArgument
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.screens.ListDetailScreen import com.collabtable.app.ui.screens.ListDetailScreen
import com.collabtable.app.ui.screens.ListsScreen import com.collabtable.app.ui.screens.ListsScreen
import com.collabtable.app.ui.screens.LogsScreen import com.collabtable.app.ui.screens.LogsScreen
import com.collabtable.app.ui.screens.ServerSetupScreen import com.collabtable.app.ui.screens.ServerSetupScreen
import com.collabtable.app.ui.screens.SettingsScreen import com.collabtable.app.ui.screens.SettingsScreen
private object Routes { private object Routes {
const val Setup = "server_setup" const val SETUP = "server_setup"
const val MainRoot = "main_root" const val MAIN_ROOT = "main_root"
const val Tables = "tables" const val TABLES = "tables"
const val Settings = "settings" const val SETTINGS = "settings"
const val ListDetail = "list/{listId}" const val LIST_DETAIL = "list/{listId}"
const val Logs = "logs" const val LOGS = "logs"
} }
@Composable @Composable
fun MainApp() { fun MainApp() {
val context = LocalContext.current val context = LocalContext.current
val prefs = remember { PreferencesManager.getInstance(context) } val prefs = remember { PreferencesManager.getInstance(context) }
// Show setup flow until completed; then show main app with bottom nav // Show setup flow until completed; then show main app with bottom nav
if (prefs.isFirstRun()) { if (prefs.isFirstRun()) {
val navController = rememberNavController() val navController = rememberNavController()
NavHost(navController, startDestination = Routes.Setup) { NavHost(navController, startDestination = Routes.SETUP) {
composable(Routes.Setup) { composable(Routes.SETUP) {
ServerSetupScreen( ServerSetupScreen(
onSetupComplete = { onSetupComplete = {
// Mark not first run and trigger recomposition to show main UI // Mark not first run and trigger recomposition to show main UI
prefs.setIsFirstRun(false) prefs.setIsFirstRun(false)
}, },
) )
} }
} }
return return
} }
val navController = rememberNavController() val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route val currentRoute = backStackEntry?.destination?.route
Scaffold( Scaffold(
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
NavigationBarItem( NavigationBarItem(
selected = currentRoute == Routes.Tables || currentRoute?.startsWith("list/") == true, selected = currentRoute == Routes.TABLES || currentRoute?.startsWith("list/") == true,
onClick = { onClick = {
navController.navigate(Routes.Tables) { navController.navigate(Routes.TABLES) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true } popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = true
} }
}, },
icon = { androidx.compose.material3.Icon(Icons.Default.Home, contentDescription = null) }, icon = { androidx.compose.material3.Icon(Icons.Default.Home, contentDescription = null) },
label = { Text("Tables") }, label = { Text("Tables") },
) )
NavigationBarItem( NavigationBarItem(
selected = currentRoute == Routes.Settings, selected = currentRoute == Routes.SETTINGS,
onClick = { onClick = {
navController.navigate(Routes.Settings) { navController.navigate(Routes.SETTINGS) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true } popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = true
} }
}, },
icon = { androidx.compose.material3.Icon(Icons.Default.Settings, contentDescription = null) }, icon = { androidx.compose.material3.Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Settings") }, label = { Text("Settings") },
) )
} }
}, },
) { innerPadding -> ) { innerPadding ->
// Apply the innerPadding from the Scaffold so content is not obscured by the bottom navigation bar. // Apply the innerPadding from the Scaffold so content is not obscured by the bottom navigation bar.
Surface( Surface(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(innerPadding), .fillMaxSize()
color = MaterialTheme.colorScheme.background, .padding(innerPadding),
) { color = MaterialTheme.colorScheme.background,
NavHost( ) {
navController = navController, NavHost(
startDestination = Routes.Tables, navController = navController,
) { startDestination = Routes.TABLES,
composable(Routes.Tables) { ) {
ListsScreen( composable(Routes.TABLES) {
onNavigateToList = { listId -> navController.navigate("list/$listId") }, ListsScreen(
onNavigateToSettings = { navController.navigate(Routes.Settings) }, onNavigateToList = { listId -> navController.navigate("list/$listId") },
onNavigateToLogs = { navController.navigate(Routes.Logs) }, onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
) onNavigateToLogs = { navController.navigate(Routes.LOGS) },
} )
composable(Routes.Settings) { }
SettingsScreen( composable(Routes.SETTINGS) {
onNavigateBack = { navController.popBackStack() }, SettingsScreen(
onNavigateToLogs = { navController.navigate(Routes.Logs) }, onNavigateBack = { navController.popBackStack() },
onLeaveServer = { onNavigateToLogs = { navController.navigate(Routes.LOGS) },
// Reset to setup flow by toggling the flag and rebuilding graph via recomposition onLeaveServer = {
prefs.setIsFirstRun(true) // Reset to setup flow by toggling the flag and rebuilding graph via recomposition
}, prefs.setIsFirstRun(true)
) },
} )
composable(Routes.Logs) { }
LogsScreen(onNavigateBack = { navController.popBackStack() }) composable(Routes.LOGS) {
} LogsScreen(onNavigateBack = { navController.popBackStack() })
composable( }
route = Routes.ListDetail, composable(
arguments = listOf(navArgument("listId") { type = NavType.StringType }), route = Routes.LIST_DETAIL,
) { backStackEntry -> arguments = listOf(navArgument("listId") { type = NavType.StringType }),
val listId = backStackEntry.arguments?.getString("listId") ?: return@composable ) { backStackEntry ->
ListDetailScreen(listId = listId, onNavigateBack = { navController.popBackStack() }) val listId = backStackEntry.arguments?.getString("listId") ?: return@composable
} ListDetailScreen(listId = listId, onNavigateBack = { navController.popBackStack() })
} }
} }
} }
} }
}
@@ -8,14 +8,11 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -27,29 +24,28 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FitScreen
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.FitScreen
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -66,20 +62,17 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.zIndex
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -87,11 +80,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import com.collabtable.app.R import com.collabtable.app.R
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.components.ConnectionStatusAction
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.Field import com.collabtable.app.data.model.Field
import com.collabtable.app.data.model.ItemWithValues import com.collabtable.app.data.model.ItemWithValues
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.components.ConnectionStatusAction
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorder import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.rememberReorderableLazyListState
@@ -99,10 +96,6 @@ import org.burnoutcrew.reorderable.reorderable
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.launch
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
@@ -192,16 +185,20 @@ fun ListDetailScreen(
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_item)) Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_item))
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors =
containerColor = MaterialTheme.colorScheme.primaryContainer, TopAppBarDefaults.topAppBarColors(
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
), titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) )
}, },
floatingActionButton = {}, floatingActionButton = {},
) { padding -> ) { padding ->
// Apply filtering, sorting, and grouping to items // Apply filtering, sorting, and grouping to items
fun valueFor(item: ItemWithValues, field: Field?): String { fun valueFor(
item: ItemWithValues,
field: Field?,
): String {
if (field == null) return "" if (field == null) return ""
return item.values.find { it.fieldId == field.id }?.value ?: "" return item.values.find { it.fieldId == field.id }?.value ?: ""
} }
@@ -406,30 +403,33 @@ fun ListDetailScreen(
onClick = { onClick = {
val newWidths = mutableMapOf<String, Float>() val newWidths = mutableMapOf<String, Float>()
stableFields.forEach { field -> stableFields.forEach { field ->
val headerPx = textMeasurer.measure( val headerPx =
AnnotatedString(field.name), textMeasurer.measure(
style = headerTextStyle, AnnotatedString(field.name),
).size.width.toFloat() style = headerTextStyle,
).size.width.toFloat()
var maxContentPx = 0f var maxContentPx = 0f
stableItems.forEach { itemWithValues -> stableItems.forEach { itemWithValues ->
val v = itemWithValues.values.find { it.fieldId == field.id }?.value val v = itemWithValues.values.find { it.fieldId == field.id }?.value
val display = getDisplayTextForMeasure(field, v) val display = getDisplayTextForMeasure(field, v)
if (display.isNotEmpty()) { if (display.isNotEmpty()) {
val w = textMeasurer.measure( val w =
AnnotatedString(display), textMeasurer.measure(
style = bodyTextStyle, AnnotatedString(display),
).size.width.toFloat() style = bodyTextStyle,
).size.width.toFloat()
if (w > maxContentPx) maxContentPx = w if (w > maxContentPx) maxContentPx = w
} }
} }
val widthDp = with(density) { val widthDp =
val headerDp = headerPx.toDp() + 12.dp + 12.dp + 24.dp + 2.dp with(density) {
val contentDp = maxContentPx.toDp() + 8.dp + 8.dp + 2.dp val headerDp = headerPx.toDp() + 12.dp + 12.dp + 24.dp + 2.dp
val base = maxOf(headerDp, contentDp, 100.dp) val contentDp = maxContentPx.toDp() + 8.dp + 8.dp + 2.dp
(base + 6.dp).value val base = maxOf(headerDp, contentDp, 100.dp)
} (base + 6.dp).value
}
newWidths[field.id] = widthDp newWidths[field.id] = widthDp
} }
newWidths.forEach { (id, w) -> fieldWidths[id] = w.dp } newWidths.forEach { (id, w) -> fieldWidths[id] = w.dp }
@@ -665,7 +665,10 @@ fun ListDetailScreen(
} }
// Produce the display text used in cells for measurement purposes (single-line content baseline) // Produce the display text used in cells for measurement purposes (single-line content baseline)
private fun getDisplayTextForMeasure(field: Field, raw: String?): String { private fun getDisplayTextForMeasure(
field: Field,
raw: String?,
): String {
val value = raw ?: "" val value = raw ?: ""
return when (field.getType()) { return when (field.getType()) {
com.collabtable.app.data.model.FieldType.TEXT, com.collabtable.app.data.model.FieldType.TEXT,
@@ -677,7 +680,8 @@ private fun getDisplayTextForMeasure(field: Field, raw: String?): String {
com.collabtable.app.data.model.FieldType.LOCATION, com.collabtable.app.data.model.FieldType.LOCATION,
com.collabtable.app.data.model.FieldType.DATE, com.collabtable.app.data.model.FieldType.DATE,
com.collabtable.app.data.model.FieldType.TIME, com.collabtable.app.data.model.FieldType.TIME,
com.collabtable.app.data.model.FieldType.DATETIME -> value com.collabtable.app.data.model.FieldType.DATETIME,
-> value
com.collabtable.app.data.model.FieldType.CURRENCY -> if (value.isBlank()) "" else field.getCurrency() + value com.collabtable.app.data.model.FieldType.CURRENCY -> if (value.isBlank()) "" else field.getCurrency() + value
com.collabtable.app.data.model.FieldType.PERCENTAGE -> if (value.isBlank()) "" else "$value%" com.collabtable.app.data.model.FieldType.PERCENTAGE -> if (value.isBlank()) "" else "$value%"
@@ -763,9 +767,10 @@ fun FieldHeader(
Text( Text(
text = field.name, text = field.name,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
modifier = Modifier modifier =
.weight(1f) Modifier
.clickable { onHeaderClick() }, .weight(1f)
.clickable { onHeaderClick() },
) )
// Resize handle // Resize handle
@@ -797,14 +802,15 @@ fun FieldHeader(
// Start or maintain continuous expand+scroll when at/right of the handle // Start or maintain continuous expand+scroll when at/right of the handle
if (dragAmount.x >= 0f || autoScrollJob != null) { if (dragAmount.x >= 0f || autoScrollJob != null) {
if (autoScrollJob == null) { if (autoScrollJob == null) {
autoScrollJob = scope.launch { autoScrollJob =
while (isActive) { scope.launch {
// Keep expanding a bit and reveal the end as space grows while (isActive) {
onWidthChange(autoStepDp) // Keep expanding a bit and reveal the end as space grows
scrollState.scrollTo(scrollState.maxValue) onWidthChange(autoStepDp)
delay(16) scrollState.scrollTo(scrollState.maxValue)
delay(16)
}
} }
}
} }
} }
// Cancel only on a clear leftward move to avoid jitter stopping expansion // Cancel only on a clear leftward move to avoid jitter stopping expansion
@@ -22,17 +22,14 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Sort
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -45,7 +42,6 @@ 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.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -58,7 +54,6 @@ import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.components.ConnectionStatusAction import com.collabtable.app.ui.components.ConnectionStatusAction
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorder import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.rememberReorderableLazyListState
@@ -215,9 +210,10 @@ fun ListsScreen(
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Controls above the list (align with table view UX) // Controls above the list (align with table view UX)
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(horizontal = 12.dp, vertical = 6.dp), .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -318,16 +314,22 @@ fun ListItem(
// We wrap the original row in a full-width Box with the same outer padding, then add a clickable // 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. // modifier that uses additional vertical padding but negative padding inside to keep visual spacing.
Box( Box(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(horizontal = 12.dp) // keep original horizontal spacing .fillMaxWidth()
.clickable(onClick = onListClick) // entire row (including margins) is tappable // keep original horizontal spacing
.padding(vertical = 4.dp) // expand touch area (original visual was 8.dp inside Row) .padding(horizontal = 12.dp)
// entire row (including margins) is tappable
.clickable(onClick = onListClick)
// expand touch area (original visual was 8.dp inside Row)
.padding(vertical = 4.dp),
) { ) {
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(vertical = 4.dp), // actual visual spacing remains ~8dp total (4 + 4) .fillMaxWidth()
.padding(vertical = 4.dp),
// actual visual spacing remains ~8dp total (4 + 4)
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -446,13 +448,14 @@ private fun SortMenu(prefs: PreferencesManager) {
val currentOrder by prefs.sortOrder.collectAsState(initial = prefs.getSortOrder()) val currentOrder by prefs.sortOrder.collectAsState(initial = prefs.getSortOrder())
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val label = when (currentOrder) { val label =
PreferencesManager.SORT_UPDATED_DESC -> "Updated ↓" when (currentOrder) {
PreferencesManager.SORT_UPDATED_ASC -> "Updated " PreferencesManager.SORT_UPDATED_DESC -> "Updated "
PreferencesManager.SORT_NAME_ASC -> "Name AZ" PreferencesManager.SORT_UPDATED_ASC -> "Updated ↑"
PreferencesManager.SORT_NAME_DESC -> "Name ZA" PreferencesManager.SORT_NAME_ASC -> "Name AZ"
else -> "Sort" PreferencesManager.SORT_NAME_DESC -> "Name ZA"
} else -> "Sort"
}
Box { Box {
TextButton(onClick = { expanded = true }) { TextButton(onClick = { expanded = true }) {
@@ -461,26 +464,41 @@ private fun SortMenu(prefs: PreferencesManager) {
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Updated (newest first)") }, text = { Text("Updated (newest first)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_UPDATED_DESC); expanded = false }, onClick = {
prefs.setSortOrder(PreferencesManager.SORT_UPDATED_DESC)
expanded = false
},
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Updated (oldest first)") }, text = { Text("Updated (oldest first)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_UPDATED_ASC); expanded = false }, onClick = {
prefs.setSortOrder(PreferencesManager.SORT_UPDATED_ASC)
expanded = false
},
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Name (AZ)") }, text = { Text("Name (AZ)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC); expanded = false }, onClick = {
prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC)
expanded = false
},
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Name (ZA)") }, text = { Text("Name (ZA)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_NAME_DESC); expanded = false }, onClick = {
prefs.setSortOrder(PreferencesManager.SORT_NAME_DESC)
expanded = false
},
) )
} }
} }
} }
// Format updatedAt relative to now: "Updated 5 sec/min/hour(s) ago" (days included when applicable) // Format updatedAt relative to now: "Updated 5 sec/min/hour(s) ago" (days included when applicable)
private fun formatUpdatedAgo(updatedAt: Long, nowMs: Long): String { private fun formatUpdatedAgo(
updatedAt: Long,
nowMs: Long,
): String {
val delta = (nowMs - updatedAt).coerceAtLeast(0L) val delta = (nowMs - updatedAt).coerceAtLeast(0L)
val seconds = delta / 1000 val seconds = delta / 1000
return when { return when {
@@ -492,12 +510,12 @@ private fun formatUpdatedAgo(updatedAt: Long, nowMs: Long): String {
seconds < 86_400 -> { seconds < 86_400 -> {
val hours = seconds / 3600 val hours = seconds / 3600
val unit = if (hours == 1L) "hour" else "hours" val unit = if (hours == 1L) "hour" else "hours"
"Updated ${hours} $unit ago" "Updated $hours $unit ago"
} }
else -> { else -> {
val days = seconds / 86_400 val days = seconds / 86_400
val unit = if (days == 1L) "day" else "days" val unit = if (days == 1L) "day" else "days"
"Updated ${days} $unit ago" "Updated $days $unit ago"
} }
} }
} }
@@ -1,14 +1,14 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.content.Context import android.content.Context
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.repository.SyncRepository import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.notifications.NotificationHelper import com.collabtable.app.notifications.NotificationHelper
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.Lifecycle
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -18,7 +18,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
@@ -26,6 +25,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -1,5 +1,8 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -41,10 +44,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import android.os.Build
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -1,5 +1,7 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.net.Uri
import android.os.Build
import android.os.NetworkOnMainThreadException import android.os.NetworkOnMainThreadException
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -18,8 +20,6 @@ import java.net.ConnectException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import android.os.Build
import android.net.Uri
class ServerSetupViewModel( class ServerSetupViewModel(
private val preferencesManager: PreferencesManager, private val preferencesManager: PreferencesManager,
@@ -34,14 +34,15 @@ class ServerSetupViewModel(
val validationError: StateFlow<String?> = _validationError.asStateFlow() val validationError: StateFlow<String?> = _validationError.asStateFlow()
private val okHttpClient = private val okHttpClient =
OkHttpClient.Builder() OkHttpClient
.Builder()
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS)
.build() .build()
fun validateAndSaveServerUrl( fun validateAndSaveServerUrl(
url: String, url: String,
password: String, password: String,
) { ) {
viewModelScope.launch { viewModelScope.launch {
_isValidating.value = true _isValidating.value = true
@@ -99,17 +100,28 @@ class ServerSetupViewModel(
} }
// On physical devices, block local-only hosts that won't resolve // On physical devices, block local-only hosts that won't resolve
val isEmulator = run { val isEmulator =
val fp = Build.FINGERPRINT.lowercase() run {
val model = Build.MODEL.lowercase() val fp = Build.FINGERPRINT.lowercase()
val brand = Build.BRAND.lowercase() val model = Build.MODEL.lowercase()
val device = Build.DEVICE.lowercase() val brand = Build.BRAND.lowercase()
val product = Build.PRODUCT.lowercase() val device = Build.DEVICE.lowercase()
fp.contains("generic") || fp.contains("emulator") || val product = Build.PRODUCT.lowercase()
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") || fp.contains("generic") ||
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk") fp.contains("emulator") ||
} model.contains("google_sdk") ||
val host = try { Uri.parse(normalizedUrl).host?.lowercase() } catch (_: Exception) { null } model.contains("emulator") ||
model.contains("android sdk built for") ||
brand.startsWith("generic") ||
device.startsWith("generic") ||
product.contains("sdk")
}
val host =
try {
Uri.parse(normalizedUrl).host?.lowercase()
} catch (_: Exception) {
null
}
val localOnlyHosts = setOf("localhost", "127.0.0.1", "host.docker.internal", "10.0.2.2") val localOnlyHosts = setOf("localhost", "127.0.0.1", "host.docker.internal", "10.0.2.2")
if (!isEmulator && host != null && host in localOnlyHosts) { if (!isEmulator && host != null && host in localOnlyHosts) {
_validationError.value = "On a physical device, use your computer's LAN IP (e.g., 192.168.x.x:3000) instead of '$host'." _validationError.value = "On a physical device, use your computer's LAN IP (e.g., 192.168.x.x:3000) instead of '$host'."
@@ -120,7 +132,8 @@ class ServerSetupViewModel(
// Try to reach the health endpoint (no auth required) // Try to reach the health endpoint (no auth required)
val healthUrl = normalizedUrl.replace("/api/", "/health") val healthUrl = normalizedUrl.replace("/api/", "/health")
val healthRequest = val healthRequest =
Request.Builder() Request
.Builder()
.url(healthUrl) .url(healthUrl)
.get() .get()
.build() .build()
@@ -158,7 +171,8 @@ class ServerSetupViewModel(
val httpsHealthUrl = httpsUrl.replace("/api/", "/health") val httpsHealthUrl = httpsUrl.replace("/api/", "/health")
val httpsHealthRequest = val httpsHealthRequest =
Request.Builder() Request
.Builder()
.url(httpsHealthUrl) .url(httpsHealthUrl)
.get() .get()
.build() .build()
@@ -182,7 +196,8 @@ class ServerSetupViewModel(
// Now validate the password by making an authenticated request // Now validate the password by making an authenticated request
val testUrl = "${finalUrl}lists" val testUrl = "${finalUrl}lists"
val authRequest = val authRequest =
Request.Builder() Request
.Builder()
.url(testUrl) .url(testUrl)
.header("Authorization", "Bearer $trimmedPassword") .header("Authorization", "Bearer $trimmedPassword")
.get() .get()
@@ -1,5 +1,10 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -12,7 +17,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Brightness4 import androidx.compose.material.icons.filled.Brightness4
import androidx.compose.material.icons.filled.Brightness7 import androidx.compose.material.icons.filled.Brightness7
@@ -26,7 +30,6 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@@ -38,8 +41,6 @@ import androidx.compose.material3.TextField
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.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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
@@ -51,9 +52,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.collabtable.app.R import com.collabtable.app.R
import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.api.ApiClient
@@ -65,7 +63,6 @@ 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
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -507,11 +504,8 @@ fun SettingsScreen(
} }
} }
} }
} }
if (showLeaveDialog) { if (showLeaveDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showLeaveDialog = false }, onDismissRequest = { showLeaveDialog = false },
@@ -582,6 +576,7 @@ fun SettingsScreen(
// Test connectivity removed; connection status is shown in the top app bar indicator // Test connectivity removed; connection status is shown in the top app bar indicator
} }
private fun formatServerUrlForDisplay(raw: String): String { private fun formatServerUrlForDisplay(raw: String): String {
var s = raw.trim() var s = raw.trim()
if (s.isEmpty()) return "" if (s.isEmpty()) return ""
@@ -1,56 +1,59 @@
package com.collabtable.app.work package com.collabtable.app.work
import android.content.Context import android.content.Context
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.notifications.NotificationHelper import com.collabtable.app.notifications.NotificationHelper
class ListChangeNotificationWorker( class ListChangeNotificationWorker(
appContext: Context, appContext: Context,
workerParams: WorkerParameters, workerParams: WorkerParameters,
) : CoroutineWorker(appContext, workerParams) { ) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val prefs = PreferencesManager.getInstance(applicationContext) val prefs = PreferencesManager.getInstance(applicationContext)
val db = CollabTableDatabase.getDatabase(applicationContext) val db = CollabTableDatabase.getDatabase(applicationContext)
val since = prefs.getLastListNotifyCheckTimestamp() val since = prefs.getLastListNotifyCheckTimestamp()
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
return try { return try {
val changed = db.listDao().getListsUpdatedSince(since) val changed = db.listDao().getListsUpdatedSince(since)
if (changed.isNotEmpty()) { if (changed.isNotEmpty()) {
changed.forEach { list -> changed.forEach { list ->
emitNotificationsFor(list, prefs) emitNotificationsFor(list, prefs)
} }
} }
// Update checkpoint regardless to avoid duplicate spam // Update checkpoint regardless to avoid duplicate spam
prefs.setLastListNotifyCheckTimestamp(now) prefs.setLastListNotifyCheckTimestamp(now)
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Result.retry() Result.retry()
} }
} }
private fun emitNotificationsFor(list: CollabList, prefs: PreferencesManager) { private fun emitNotificationsFor(
// Determine event type list: CollabList,
when { prefs: PreferencesManager,
list.isDeleted -> { ) {
if (prefs.notifyListRemoved.value) { // Determine event type
NotificationHelper.showListRemoved(applicationContext, list.id, list.name) when {
} list.isDeleted -> {
} if (prefs.notifyListRemoved.value) {
list.createdAt == list.updatedAt -> { NotificationHelper.showListRemoved(applicationContext, list.id, list.name)
if (prefs.notifyListAdded.value) { }
NotificationHelper.showListAdded(applicationContext, list.id, list.name) }
} list.createdAt == list.updatedAt -> {
} if (prefs.notifyListAdded.value) {
else -> { NotificationHelper.showListAdded(applicationContext, list.id, list.name)
if (prefs.notifyListEdited.value) { }
NotificationHelper.showListEdited(applicationContext, list.id, list.name) }
} else -> {
} if (prefs.notifyListEdited.value) {
} NotificationHelper.showListEdited(applicationContext, list.id, list.name)
} }
} }
}
}
}
+4 -5
View File
@@ -2,9 +2,8 @@
plugins { plugins {
id 'com.android.application' version '8.8.0' apply false id 'com.android.application' version '8.8.0' apply false
id 'com.android.library' version '8.8.0' apply false id 'com.android.library' version '8.8.0' apply false
id 'org.jetbrains.kotlin.android' version '2.0.21' apply false id 'org.jetbrains.kotlin.android' version '2.2.21' apply false
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.21' apply false id 'org.jetbrains.kotlin.plugin.compose' version '2.2.21' apply false
id 'com.google.devtools.ksp' version '2.0.21-1.0.27' apply false id 'org.jlleitschuh.gradle.ktlint' version '14.0.1' apply false
id 'org.jlleitschuh.gradle.ktlint' version '12.1.0' apply false id 'io.gitlab.arturbosch.detekt' version '1.23.8' apply false
id 'io.gitlab.arturbosch.detekt' version '1.23.6' apply false
} }
View File