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
@@ -1,74 +1,78 @@
package com.collabtable.app
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.notifications.NotificationHelper
import com.collabtable.app.work.ListChangeNotificationWorker
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class CollabTableApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize API client with saved server URL
ApiClient.initialize(this)
// 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.
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
// Force a full down-sync on app start when server credentials are present.
SyncRepository(this@CollabTableApplication).performSync(forceFullDown = true)
} catch (_: Exception) {
// Silent; UI layers will surface subsequent sync issues.
}
}
// Create notification channel(s) for list events
createNotificationChannels()
// Schedule periodic background check for list changes to surface notifications
scheduleListChangeWorker()
// Auto-clear notifications when app comes to foreground
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
}
})
}
private fun createNotificationChannels() {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val listEventsChannel = NotificationChannel(
NotificationHelper.CHANNEL_LIST_EVENTS,
"List Events",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Notifications for list add, edit, and remove events"
}
manager.createNotificationChannel(listEventsChannel)
}
private fun scheduleListChangeWorker() {
val work = PeriodicWorkRequestBuilder<ListChangeNotificationWorker>(15, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"list_change_notifier",
ExistingPeriodicWorkPolicy.UPDATE,
work,
)
}
}
package com.collabtable.app
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.notifications.NotificationHelper
import com.collabtable.app.work.ListChangeNotificationWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
class CollabTableApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize API client with saved server URL
ApiClient.initialize(this)
// 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.
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
// Force a full down-sync on app start when server credentials are present.
SyncRepository(this@CollabTableApplication).performSync(forceFullDown = true)
} catch (_: Exception) {
// Silent; UI layers will surface subsequent sync issues.
}
}
// Create notification channel(s) for list events
createNotificationChannels()
// Schedule periodic background check for list changes to surface notifications
scheduleListChangeWorker()
// Auto-clear notifications when app comes to foreground
ProcessLifecycleOwner.get().lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
}
},
)
}
private fun createNotificationChannels() {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val listEventsChannel =
NotificationChannel(
NotificationHelper.CHANNEL_LIST_EVENTS,
"List Events",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Notifications for list add, edit, and remove events"
}
manager.createNotificationChannel(listEventsChannel)
}
private fun scheduleListChangeWorker() {
val work =
PeriodicWorkRequestBuilder<ListChangeNotificationWorker>(15, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"list_change_notifier",
ExistingPeriodicWorkPolicy.UPDATE,
work,
)
}
}
@@ -1,9 +1,9 @@
package com.collabtable.app.data.api
import android.content.Context
import com.collabtable.app.data.preferences.PreferencesManager
import android.os.Build
import android.net.Uri
import android.os.Build
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.utils.Logger
import okhttp3.Interceptor
import okhttp3.OkHttpClient
@@ -32,7 +32,8 @@ object ApiClient {
val newRequest =
if (!password.isNullOrBlank() && password != "\$password") {
request.newBuilder()
request
.newBuilder()
.header("Authorization", "Bearer $password")
.build()
} else {
@@ -43,7 +44,8 @@ object ApiClient {
}
private val okHttpClient =
OkHttpClient.Builder()
OkHttpClient
.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
@@ -51,13 +53,13 @@ object ApiClient {
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private fun buildRetrofit(): Retrofit {
return Retrofit.Builder()
private fun buildRetrofit(): Retrofit =
Retrofit
.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
private fun isEmulator(): Boolean {
val fingerprint = Build.FINGERPRINT.lowercase()
@@ -65,9 +67,14 @@ object ApiClient {
val brand = Build.BRAND.lowercase()
val device = Build.DEVICE.lowercase()
val product = Build.PRODUCT.lowercase()
return fingerprint.contains("generic") || fingerprint.contains("emulator") ||
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") ||
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk")
return fingerprint.contains("generic") ||
fingerprint.contains("emulator") ||
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/"
@@ -104,7 +111,8 @@ object ApiClient {
baseUrl = normalizeForAndroidEmulator(prefs.getServerUrl())
try {
Logger.i("HTTP", "API base URL initialized to $baseUrl")
} catch (_: Exception) { }
} catch (_: Exception) {
}
retrofit = buildRetrofit()
}
@@ -112,7 +120,8 @@ object ApiClient {
baseUrl = normalizeForAndroidEmulator(url)
try {
Logger.i("HTTP", "API base URL set to $baseUrl")
} catch (_: Exception) { }
} catch (_: Exception) {
}
retrofit = buildRetrofit()
}
@@ -54,20 +54,19 @@ abstract class CollabTableDatabase : RoomDatabase() {
@Volatile
private var dbInstance: CollabTableDatabase? = null
fun getDatabase(context: Context): CollabTableDatabase {
return dbInstance ?: synchronized(this) {
fun getDatabase(context: Context): CollabTableDatabase =
dbInstance ?: synchronized(this) {
val instance =
Room.databaseBuilder(
context.applicationContext,
CollabTableDatabase::class.java,
"collab_table_database",
)
.addMigrations(migration1To2, migration2To3)
Room
.databaseBuilder(
context.applicationContext,
CollabTableDatabase::class.java,
"collab_table_database",
).addMigrations(migration1To2, migration2To3)
.build()
dbInstance = instance
instance
}
}
fun clearDatabase(context: Context) {
synchronized(this) {
@@ -84,35 +84,31 @@ data class Field(
}
}
fun getDropdownOptions(): List<String> {
return if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) {
fun getDropdownOptions(): List<String> =
if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) {
fieldOptions.split("|")
} else {
emptyList()
}
}
fun getCurrency(): String {
return if ((getType() == FieldType.CURRENCY) && fieldOptions.isNotBlank()) {
fun getCurrency(): String =
if ((getType() == FieldType.CURRENCY) && fieldOptions.isNotBlank()) {
fieldOptions
} else {
"CHF"
}
}
fun getMaxRating(): Int {
return if (getType() == FieldType.RATING && fieldOptions.isNotBlank()) {
fun getMaxRating(): Int =
if (getType() == FieldType.RATING && fieldOptions.isNotBlank()) {
fieldOptions.toIntOrNull() ?: 5
} else {
5
}
}
fun getAutocompleteOptions(): List<String> {
return if (getType() == FieldType.AUTOCOMPLETE && fieldOptions.isNotBlank()) {
fun getAutocompleteOptions(): List<String> =
if (getType() == FieldType.AUTOCOMPLETE && fieldOptions.isNotBlank()) {
fieldOptions.split("|")
} else {
emptyList()
}
}
}
@@ -2,10 +2,10 @@ package com.collabtable.app.data.preferences
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONObject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
class PreferencesManager(context: Context) {
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 json = JSONObject()
widthsDp.forEach { (fieldId, width) ->
@@ -229,11 +232,11 @@ class PreferencesManager(context: Context) {
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
private const val KEY_AMOLED_DARK = "amoled_dark"
private const val KEY_SORT_ORDER = "sort_order"
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_EDITED = "notify_list_edited"
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_SYNC_POLL_INTERVAL_MS = "sync_poll_interval_ms"
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_REMOVED = "notify_list_removed"
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 DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
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.utils.Logger
import kotlinx.coroutines.Dispatchers
import java.net.UnknownHostException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.net.UnknownHostException
class SyncRepository(context: Context) {
private val appContext = context.applicationContext
@@ -22,18 +22,23 @@ class SyncRepository(context: Context) {
private val prefs = appContext.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE)
@Volatile private var authBackoffUntil: Long = 0L
@Volatile private var authBackoffMs: Long = 0L
@Volatile private var networkBackoffUntil: Long = 0L
@Volatile private var networkBackoffMs: Long = 0L
fun resetAuthBackoff() {
authBackoffMs = 0L
authBackoffUntil = 0L
}
private fun resetNetworkBackoff() {
networkBackoffMs = 0L
networkBackoffUntil = 0L
}
fun resetAuthBackoff() {
authBackoffMs = 0L
authBackoffUntil = 0L
}
private fun resetNetworkBackoff() {
networkBackoffMs = 0L
networkBackoffUntil = 0L
}
// We keep separate watermarks to avoid clock-skew issues:
// - server watermark: server-assigned timestamp for fetching server changes
// - local watermark: device local time for selecting local changes to upload
@@ -69,219 +74,225 @@ class SyncRepository(context: Context) {
withContext(Dispatchers.IO) {
// Prevent overlapping syncs across screens/viewmodels
syncMutex.withLock {
try {
// 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.
// Skip sync when there's no validated network available
if (!isNetworkAvailable()) {
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"))
try {
// 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.
// Skip sync when there's no validated network available
if (!isNetworkAvailable()) {
return@withContext Result.success(Unit)
}
throw e
}
if (!response.isSuccessful) {
if (response.code() == 401) {
// Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error.
// 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 (!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)
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 hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val hasTransport = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
val hasTransport =
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
return hasInternet && isValidated && hasTransport
}
}
@@ -1,93 +1,126 @@
package com.collabtable.app.notifications
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.collabtable.app.R
object NotificationHelper {
const val CHANNEL_LIST_EVENTS = "list_events"
private const val GROUP_LIST_EVENTS = "group_list_events"
private const val SUMMARY_ID = 1000
// Keep a small rolling buffer of recent event lines for the summary
private val recentEventLines = ArrayDeque<String>()
@Volatile private var totalEventCount: Int = 0
private val postedIds = mutableSetOf<Int>()
private fun builder(context: Context, title: String, text: String): NotificationCompat.Builder {
return NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
}
private fun notify(context: Context, id: Int, builder: NotificationCompat.Builder) {
with(NotificationManagerCompat.from(context)) {
notify(id, builder.build())
}
}
fun showListAdded(context: Context, listId: String, name: String) = showEvent(context, listId, "add", "List added", "\"$name\" was created")
fun showListEdited(context: Context, listId: String, name: String) = showEvent(context, listId, "edit", "List edited", "\"$name\" was renamed/updated")
fun showListRemoved(context: Context, listId: String, name: String) = showEvent(context, listId, "remove", "List removed", "\"$name\" was deleted")
private fun showEvent(context: Context, listId: String, type: String, title: String, text: String) {
val b = builder(context, title, text).setGroup(GROUP_LIST_EVENTS)
val id = (type + listId).hashCode()
synchronized(postedIds) { postedIds.add(id) }
notify(context, id, b)
recordEventLine(type, text)
postSummary(context)
}
private fun postSummary(context: Context) {
val inbox = NotificationCompat.InboxStyle()
synchronized(recentEventLines) {
recentEventLines.take(5).forEach { line -> inbox.addLine(line) }
inbox.setSummaryText("${totalEventCount} total event" + if (totalEventCount == 1) "" else "s")
}
val summary = NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("List activity")
.setContentText("Recent list changes")
.setStyle(inbox)
.setGroup(GROUP_LIST_EVENTS)
.setGroupSummary(true)
.setAutoCancel(true)
.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
}
}
}
package com.collabtable.app.notifications
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.collabtable.app.R
object NotificationHelper {
const val CHANNEL_LIST_EVENTS = "list_events"
private const val GROUP_LIST_EVENTS = "group_list_events"
private const val SUMMARY_ID = 1000
// Keep a small rolling buffer of recent event lines for the summary
private val recentEventLines = ArrayDeque<String>()
@Volatile private var totalEventCount: Int = 0
private val postedIds = mutableSetOf<Int>()
private fun builder(
context: Context,
title: String,
text: String,
): NotificationCompat.Builder {
return NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
}
private fun notify(
context: Context,
id: Int,
builder: NotificationCompat.Builder,
) {
with(NotificationManagerCompat.from(context)) {
notify(id, builder.build())
}
}
fun showListAdded(
context: Context,
listId: String,
name: String,
) = showEvent(context, listId, "add", "List added", "\"$name\" was created")
fun showListEdited(
context: Context,
listId: String,
name: String,
) = showEvent(context, listId, "edit", "List edited", "\"$name\" was renamed/updated")
fun showListRemoved(
context: Context,
listId: String,
name: String,
) = showEvent(context, listId, "remove", "List removed", "\"$name\" was deleted")
private fun showEvent(
context: Context,
listId: String,
type: String,
title: String,
text: String,
) {
val b = builder(context, title, text).setGroup(GROUP_LIST_EVENTS)
val id = (type + listId).hashCode()
synchronized(postedIds) { postedIds.add(id) }
notify(context, id, b)
recordEventLine(type, text)
postSummary(context)
}
private fun postSummary(context: Context) {
val inbox = NotificationCompat.InboxStyle()
synchronized(recentEventLines) {
recentEventLines.take(5).forEach { line -> inbox.addLine(line) }
inbox.setSummaryText("$totalEventCount total event" + if (totalEventCount == 1) "" else "s")
}
val summary =
NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("List activity")
.setContentText("Recent list changes")
.setStyle(inbox)
.setGroup(GROUP_LIST_EVENTS)
.setGroupSummary(true)
.setAutoCancel(true)
.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
import android.net.Uri
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.collabtable.app.data.preferences.PreferencesManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
private val statusClient: OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
private fun isEmulator(): Boolean {
val fp = Build.FINGERPRINT.lowercase()
val model = Build.MODEL.lowercase()
val brand = Build.BRAND.lowercase()
val device = Build.DEVICE.lowercase()
val product = Build.PRODUCT.lowercase()
return fp.contains("generic") || fp.contains("emulator") ||
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") ||
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk")
}
private fun toHealthUrl(rawApiUrl: String): String {
// Ensure we hit the unauthenticated /health endpoint and remap localhost on emulator
return try {
val uri = Uri.parse(rawApiUrl)
val host = uri.host?.lowercase()
val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal")
val scheme = uri.scheme ?: "http"
val portPart = if (uri.port != -1) ":${uri.port}" else ""
val base = if (needsRemap) "$scheme://10.0.2.2$portPart" else "$scheme://${uri.host ?: ""}$portPart"
val path = (uri.encodedPath ?: "/").trimEnd('/')
val healthPath = if (path.endsWith("/api") || path.endsWith("/api/")) "/health" else "/health"
base + healthPath
} catch (e: Exception) {
rawApiUrl.replace("/api/", "/health").replace("/api", "/health")
}
}
@Composable
fun ConnectionStatusAction(
prefs: PreferencesManager,
modifier: Modifier = Modifier,
showLatency: Boolean = true,
) {
var ok by remember { mutableStateOf<Boolean?>(null) }
var latencyMs by remember { mutableStateOf<Long?>(null) }
val intervalMs by prefs.syncPollIntervalMs.collectAsState()
// Periodic health check aligned to polling interval
LaunchedEffect(intervalMs) {
while (true) {
try {
val healthUrl = toHealthUrl(prefs.getServerUrl())
val req = Request.Builder().url(healthUrl).get().build()
val start = System.nanoTime()
val resp = withContext(Dispatchers.IO) { statusClient.newCall(req).execute() }
val tookMs = (System.nanoTime() - start) / 1_000_000
if (resp.isSuccessful) {
ok = true
latencyMs = tookMs
} else {
ok = false
}
resp.close()
} catch (e: Exception) {
ok = false
}
delay(intervalMs)
}
}
val dotColor: Color = when (ok) {
true -> Color(0xFF2E7D32) // green
false -> MaterialTheme.colorScheme.error
null -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
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),
)
}
}
package com.collabtable.app.ui.components
import android.net.Uri
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.collabtable.app.data.preferences.PreferencesManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
private val statusClient: OkHttpClient =
OkHttpClient
.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
private fun isEmulator(): Boolean {
val fp = Build.FINGERPRINT.lowercase()
val model = Build.MODEL.lowercase()
val brand = Build.BRAND.lowercase()
val device = Build.DEVICE.lowercase()
val product = Build.PRODUCT.lowercase()
return fp.contains("generic") ||
fp.contains("emulator") ||
model.contains("google_sdk") ||
model.contains("emulator") ||
model.contains("android sdk built for") ||
brand.startsWith("generic") ||
device.startsWith("generic") ||
product.contains("sdk")
}
private fun toHealthUrl(rawApiUrl: String): String {
// Ensure we hit the unauthenticated /health endpoint and remap localhost on emulator
return try {
val uri = Uri.parse(rawApiUrl)
val host = uri.host?.lowercase()
val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal")
val scheme = uri.scheme ?: "http"
val portPart = if (uri.port != -1) ":${uri.port}" else ""
val base = if (needsRemap) "$scheme://10.0.2.2$portPart" else "$scheme://${uri.host ?: ""}$portPart"
val path = (uri.encodedPath ?: "/").trimEnd('/')
val healthPath = if (path.endsWith("/api") || path.endsWith("/api/")) "/health" else "/health"
base + healthPath
} catch (e: Exception) {
rawApiUrl.replace("/api/", "/health").replace("/api", "/health")
}
}
@Composable
fun ConnectionStatusAction(
prefs: PreferencesManager,
modifier: Modifier = Modifier,
showLatency: Boolean = true,
) {
var ok by remember { mutableStateOf<Boolean?>(null) }
var latencyMs by remember { mutableStateOf<Long?>(null) }
val intervalMs by prefs.syncPollIntervalMs.collectAsState()
// Periodic health check aligned to polling interval
LaunchedEffect(intervalMs) {
while (true) {
try {
val healthUrl = toHealthUrl(prefs.getServerUrl())
val req =
Request
.Builder()
.url(healthUrl)
.get()
.build()
val start = System.nanoTime()
val resp = withContext(Dispatchers.IO) { statusClient.newCall(req).execute() }
val tookMs = (System.nanoTime() - start) / 1_000_000
if (resp.isSuccessful) {
ok = true
latencyMs = tookMs
} else {
ok = false
}
resp.close()
} catch (e: Exception) {
ok = false
}
delay(intervalMs)
}
}
val dotColor: Color =
when (ok) {
true -> Color(0xFF2E7D32) // green
false -> MaterialTheme.colorScheme.error
null -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.NavType
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.screens.ListDetailScreen
import com.collabtable.app.ui.screens.ListsScreen
import com.collabtable.app.ui.screens.LogsScreen
import com.collabtable.app.ui.screens.ServerSetupScreen
import com.collabtable.app.ui.screens.SettingsScreen
private object Routes {
const val Setup = "server_setup"
const val MainRoot = "main_root"
const val Tables = "tables"
const val Settings = "settings"
const val ListDetail = "list/{listId}"
const val Logs = "logs"
}
@Composable
fun MainApp() {
val context = LocalContext.current
val prefs = remember { PreferencesManager.getInstance(context) }
// Show setup flow until completed; then show main app with bottom nav
if (prefs.isFirstRun()) {
val navController = rememberNavController()
NavHost(navController, startDestination = Routes.Setup) {
composable(Routes.Setup) {
ServerSetupScreen(
onSetupComplete = {
// Mark not first run and trigger recomposition to show main UI
prefs.setIsFirstRun(false)
},
)
}
}
return
}
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = currentRoute == Routes.Tables || currentRoute?.startsWith("list/") == true,
onClick = {
navController.navigate(Routes.Tables) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { androidx.compose.material3.Icon(Icons.Default.Home, contentDescription = null) },
label = { Text("Tables") },
)
NavigationBarItem(
selected = currentRoute == Routes.Settings,
onClick = {
navController.navigate(Routes.Settings) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { androidx.compose.material3.Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Settings") },
)
}
},
) { innerPadding ->
// Apply the innerPadding from the Scaffold so content is not obscured by the bottom navigation bar.
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
color = MaterialTheme.colorScheme.background,
) {
NavHost(
navController = navController,
startDestination = Routes.Tables,
) {
composable(Routes.Tables) {
ListsScreen(
onNavigateToList = { listId -> navController.navigate("list/$listId") },
onNavigateToSettings = { navController.navigate(Routes.Settings) },
onNavigateToLogs = { navController.navigate(Routes.Logs) },
)
}
composable(Routes.Settings) {
SettingsScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToLogs = { navController.navigate(Routes.Logs) },
onLeaveServer = {
// Reset to setup flow by toggling the flag and rebuilding graph via recomposition
prefs.setIsFirstRun(true)
},
)
}
composable(Routes.Logs) {
LogsScreen(onNavigateBack = { navController.popBackStack() })
}
composable(
route = Routes.ListDetail,
arguments = listOf(navArgument("listId") { type = NavType.StringType }),
) { backStackEntry ->
val listId = backStackEntry.arguments?.getString("listId") ?: return@composable
ListDetailScreen(listId = listId, onNavigateBack = { navController.popBackStack() })
}
}
}
}
}
package com.collabtable.app.ui.navigation
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.screens.ListDetailScreen
import com.collabtable.app.ui.screens.ListsScreen
import com.collabtable.app.ui.screens.LogsScreen
import com.collabtable.app.ui.screens.ServerSetupScreen
import com.collabtable.app.ui.screens.SettingsScreen
private object Routes {
const val SETUP = "server_setup"
const val MAIN_ROOT = "main_root"
const val TABLES = "tables"
const val SETTINGS = "settings"
const val LIST_DETAIL = "list/{listId}"
const val LOGS = "logs"
}
@Composable
fun MainApp() {
val context = LocalContext.current
val prefs = remember { PreferencesManager.getInstance(context) }
// Show setup flow until completed; then show main app with bottom nav
if (prefs.isFirstRun()) {
val navController = rememberNavController()
NavHost(navController, startDestination = Routes.SETUP) {
composable(Routes.SETUP) {
ServerSetupScreen(
onSetupComplete = {
// Mark not first run and trigger recomposition to show main UI
prefs.setIsFirstRun(false)
},
)
}
}
return
}
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = currentRoute == Routes.TABLES || currentRoute?.startsWith("list/") == true,
onClick = {
navController.navigate(Routes.TABLES) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { androidx.compose.material3.Icon(Icons.Default.Home, contentDescription = null) },
label = { Text("Tables") },
)
NavigationBarItem(
selected = currentRoute == Routes.SETTINGS,
onClick = {
navController.navigate(Routes.SETTINGS) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { androidx.compose.material3.Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Settings") },
)
}
},
) { innerPadding ->
// Apply the innerPadding from the Scaffold so content is not obscured by the bottom navigation bar.
Surface(
modifier =
Modifier
.fillMaxSize()
.padding(innerPadding),
color = MaterialTheme.colorScheme.background,
) {
NavHost(
navController = navController,
startDestination = Routes.TABLES,
) {
composable(Routes.TABLES) {
ListsScreen(
onNavigateToList = { listId -> navController.navigate("list/$listId") },
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
onNavigateToLogs = { navController.navigate(Routes.LOGS) },
)
}
composable(Routes.SETTINGS) {
SettingsScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToLogs = { navController.navigate(Routes.LOGS) },
onLeaveServer = {
// Reset to setup flow by toggling the flag and rebuilding graph via recomposition
prefs.setIsFirstRun(true)
},
)
}
composable(Routes.LOGS) {
LogsScreen(onNavigateBack = { navController.popBackStack() })
}
composable(
route = Routes.LIST_DETAIL,
arguments = listOf(navArgument("listId") { type = NavType.StringType }),
) { backStackEntry ->
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.gestures.detectDragGestures
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
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.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.Close
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DragHandle
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.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.material3.*
import androidx.compose.runtime.Composable
@@ -66,20 +62,17 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.zIndex
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
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.font.FontWeight
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.withStyle
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.DialogProperties
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.model.Field
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.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
@@ -99,10 +96,6 @@ import org.burnoutcrew.reorderable.reorderable
import java.text.SimpleDateFormat
import java.util.Calendar
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)
@Composable
@@ -192,16 +185,20 @@ fun ListDetailScreen(
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_item))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
},
floatingActionButton = {},
) { padding ->
// 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 ""
return item.values.find { it.fieldId == field.id }?.value ?: ""
}
@@ -406,30 +403,33 @@ fun ListDetailScreen(
onClick = {
val newWidths = mutableMapOf<String, Float>()
stableFields.forEach { field ->
val headerPx = textMeasurer.measure(
AnnotatedString(field.name),
style = headerTextStyle,
).size.width.toFloat()
val headerPx =
textMeasurer.measure(
AnnotatedString(field.name),
style = headerTextStyle,
).size.width.toFloat()
var maxContentPx = 0f
stableItems.forEach { itemWithValues ->
val v = itemWithValues.values.find { it.fieldId == field.id }?.value
val display = getDisplayTextForMeasure(field, v)
if (display.isNotEmpty()) {
val w = textMeasurer.measure(
AnnotatedString(display),
style = bodyTextStyle,
).size.width.toFloat()
val w =
textMeasurer.measure(
AnnotatedString(display),
style = bodyTextStyle,
).size.width.toFloat()
if (w > maxContentPx) maxContentPx = w
}
}
val widthDp = with(density) {
val headerDp = headerPx.toDp() + 12.dp + 12.dp + 24.dp + 2.dp
val contentDp = maxContentPx.toDp() + 8.dp + 8.dp + 2.dp
val base = maxOf(headerDp, contentDp, 100.dp)
(base + 6.dp).value
}
val widthDp =
with(density) {
val headerDp = headerPx.toDp() + 12.dp + 12.dp + 24.dp + 2.dp
val contentDp = maxContentPx.toDp() + 8.dp + 8.dp + 2.dp
val base = maxOf(headerDp, contentDp, 100.dp)
(base + 6.dp).value
}
newWidths[field.id] = widthDp
}
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)
private fun getDisplayTextForMeasure(field: Field, raw: String?): String {
private fun getDisplayTextForMeasure(
field: Field,
raw: String?,
): String {
val value = raw ?: ""
return when (field.getType()) {
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.DATE,
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.PERCENTAGE -> if (value.isBlank()) "" else "$value%"
@@ -763,9 +767,10 @@ fun FieldHeader(
Text(
text = field.name,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.weight(1f)
.clickable { onHeaderClick() },
modifier =
Modifier
.weight(1f)
.clickable { onHeaderClick() },
)
// Resize handle
@@ -797,14 +802,15 @@ fun FieldHeader(
// Start or maintain continuous expand+scroll when at/right of the handle
if (dragAmount.x >= 0f || autoScrollJob != null) {
if (autoScrollJob == null) {
autoScrollJob = scope.launch {
while (isActive) {
// Keep expanding a bit and reveal the end as space grows
onWidthChange(autoStepDp)
scrollState.scrollTo(scrollState.maxValue)
delay(16)
autoScrollJob =
scope.launch {
while (isActive) {
// Keep expanding a bit and reveal the end as space grows
onWidthChange(autoStepDp)
scrollState.scrollTo(scrollState.maxValue)
delay(16)
}
}
}
}
}
// 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.Edit
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.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -45,7 +42,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.ui.components.ConnectionStatusAction
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
@@ -215,9 +210,10 @@ fun ListsScreen(
Column(modifier = Modifier.fillMaxSize()) {
// Controls above the list (align with table view UX)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.Start,
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
// modifier that uses additional vertical padding but negative padding inside to keep visual spacing.
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp) // keep original horizontal spacing
.clickable(onClick = onListClick) // entire row (including margins) is tappable
.padding(vertical = 4.dp) // expand touch area (original visual was 8.dp inside Row)
modifier =
Modifier
.fillMaxWidth()
// keep original horizontal spacing
.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(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp), // actual visual spacing remains ~8dp total (4 + 4)
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
// actual visual spacing remains ~8dp total (4 + 4)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -446,13 +448,14 @@ private fun SortMenu(prefs: PreferencesManager) {
val currentOrder by prefs.sortOrder.collectAsState(initial = prefs.getSortOrder())
var expanded by remember { mutableStateOf(false) }
val label = when (currentOrder) {
PreferencesManager.SORT_UPDATED_DESC -> "Updated ↓"
PreferencesManager.SORT_UPDATED_ASC -> "Updated "
PreferencesManager.SORT_NAME_ASC -> "Name AZ"
PreferencesManager.SORT_NAME_DESC -> "Name ZA"
else -> "Sort"
}
val label =
when (currentOrder) {
PreferencesManager.SORT_UPDATED_DESC -> "Updated "
PreferencesManager.SORT_UPDATED_ASC -> "Updated ↑"
PreferencesManager.SORT_NAME_ASC -> "Name AZ"
PreferencesManager.SORT_NAME_DESC -> "Name ZA"
else -> "Sort"
}
Box {
TextButton(onClick = { expanded = true }) {
@@ -461,26 +464,41 @@ private fun SortMenu(prefs: PreferencesManager) {
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("Updated (newest first)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_UPDATED_DESC); expanded = false },
onClick = {
prefs.setSortOrder(PreferencesManager.SORT_UPDATED_DESC)
expanded = false
},
)
DropdownMenuItem(
text = { Text("Updated (oldest first)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_UPDATED_ASC); expanded = false },
onClick = {
prefs.setSortOrder(PreferencesManager.SORT_UPDATED_ASC)
expanded = false
},
)
DropdownMenuItem(
text = { Text("Name (AZ)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC); expanded = false },
onClick = {
prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC)
expanded = false
},
)
DropdownMenuItem(
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)
private fun formatUpdatedAgo(updatedAt: Long, nowMs: Long): String {
private fun formatUpdatedAgo(
updatedAt: Long,
nowMs: Long,
): String {
val delta = (nowMs - updatedAt).coerceAtLeast(0L)
val seconds = delta / 1000
return when {
@@ -492,12 +510,12 @@ private fun formatUpdatedAgo(updatedAt: Long, nowMs: Long): String {
seconds < 86_400 -> {
val hours = seconds / 3600
val unit = if (hours == 1L) "hour" else "hours"
"Updated ${hours} $unit ago"
"Updated $hours $unit ago"
}
else -> {
val days = seconds / 86_400
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
import android.content.Context
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.notifications.NotificationHelper
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.Lifecycle
import com.collabtable.app.utils.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -18,7 +18,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
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.FilterList
import androidx.compose.material3.Badge
@@ -26,6 +25,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -1,5 +1,8 @@
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.Column
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.style.TextAlign
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 android.os.Build
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -1,5 +1,7 @@
package com.collabtable.app.ui.screens
import android.net.Uri
import android.os.Build
import android.os.NetworkOnMainThreadException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -18,8 +20,6 @@ import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit
import android.os.Build
import android.net.Uri
class ServerSetupViewModel(
private val preferencesManager: PreferencesManager,
@@ -34,14 +34,15 @@ class ServerSetupViewModel(
val validationError: StateFlow<String?> = _validationError.asStateFlow()
private val okHttpClient =
OkHttpClient.Builder()
OkHttpClient
.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
fun validateAndSaveServerUrl(
url: String,
password: String,
password: String,
) {
viewModelScope.launch {
_isValidating.value = true
@@ -99,17 +100,28 @@ class ServerSetupViewModel(
}
// On physical devices, block local-only hosts that won't resolve
val isEmulator = run {
val fp = Build.FINGERPRINT.lowercase()
val model = Build.MODEL.lowercase()
val brand = Build.BRAND.lowercase()
val device = Build.DEVICE.lowercase()
val product = Build.PRODUCT.lowercase()
fp.contains("generic") || fp.contains("emulator") ||
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") ||
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk")
}
val host = try { Uri.parse(normalizedUrl).host?.lowercase() } catch (_: Exception) { null }
val isEmulator =
run {
val fp = Build.FINGERPRINT.lowercase()
val model = Build.MODEL.lowercase()
val brand = Build.BRAND.lowercase()
val device = Build.DEVICE.lowercase()
val product = Build.PRODUCT.lowercase()
fp.contains("generic") ||
fp.contains("emulator") ||
model.contains("google_sdk") ||
model.contains("emulator") ||
model.contains("android sdk built for") ||
brand.startsWith("generic") ||
device.startsWith("generic") ||
product.contains("sdk")
}
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")
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'."
@@ -120,7 +132,8 @@ class ServerSetupViewModel(
// Try to reach the health endpoint (no auth required)
val healthUrl = normalizedUrl.replace("/api/", "/health")
val healthRequest =
Request.Builder()
Request
.Builder()
.url(healthUrl)
.get()
.build()
@@ -158,7 +171,8 @@ class ServerSetupViewModel(
val httpsHealthUrl = httpsUrl.replace("/api/", "/health")
val httpsHealthRequest =
Request.Builder()
Request
.Builder()
.url(httpsHealthUrl)
.get()
.build()
@@ -182,7 +196,8 @@ class ServerSetupViewModel(
// Now validate the password by making an authenticated request
val testUrl = "${finalUrl}lists"
val authRequest =
Request.Builder()
Request
.Builder()
.url(testUrl)
.header("Authorization", "Bearer $trimmedPassword")
.get()
@@ -1,5 +1,10 @@
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.Column
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.verticalScroll
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.filled.Brightness4
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.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
@@ -38,8 +41,6 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.getValue
import androidx.compose.runtime.mutableStateOf
@@ -51,9 +52,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.collabtable.app.R
import com.collabtable.app.data.api.ApiClient
@@ -65,7 +63,6 @@ import com.collabtable.app.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -507,11 +504,8 @@ fun SettingsScreen(
}
}
}
}
if (showLeaveDialog) {
AlertDialog(
onDismissRequest = { showLeaveDialog = false },
@@ -582,6 +576,7 @@ fun SettingsScreen(
// Test connectivity removed; connection status is shown in the top app bar indicator
}
private fun formatServerUrlForDisplay(raw: String): String {
var s = raw.trim()
if (s.isEmpty()) return ""
@@ -1,56 +1,59 @@
package com.collabtable.app.work
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.notifications.NotificationHelper
class ListChangeNotificationWorker(
appContext: Context,
workerParams: WorkerParameters,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val prefs = PreferencesManager.getInstance(applicationContext)
val db = CollabTableDatabase.getDatabase(applicationContext)
val since = prefs.getLastListNotifyCheckTimestamp()
val now = System.currentTimeMillis()
return try {
val changed = db.listDao().getListsUpdatedSince(since)
if (changed.isNotEmpty()) {
changed.forEach { list ->
emitNotificationsFor(list, prefs)
}
}
// Update checkpoint regardless to avoid duplicate spam
prefs.setLastListNotifyCheckTimestamp(now)
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
private fun emitNotificationsFor(list: CollabList, prefs: PreferencesManager) {
// Determine event type
when {
list.isDeleted -> {
if (prefs.notifyListRemoved.value) {
NotificationHelper.showListRemoved(applicationContext, list.id, list.name)
}
}
list.createdAt == list.updatedAt -> {
if (prefs.notifyListAdded.value) {
NotificationHelper.showListAdded(applicationContext, list.id, list.name)
}
}
else -> {
if (prefs.notifyListEdited.value) {
NotificationHelper.showListEdited(applicationContext, list.id, list.name)
}
}
}
}
}
package com.collabtable.app.work
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.notifications.NotificationHelper
class ListChangeNotificationWorker(
appContext: Context,
workerParams: WorkerParameters,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val prefs = PreferencesManager.getInstance(applicationContext)
val db = CollabTableDatabase.getDatabase(applicationContext)
val since = prefs.getLastListNotifyCheckTimestamp()
val now = System.currentTimeMillis()
return try {
val changed = db.listDao().getListsUpdatedSince(since)
if (changed.isNotEmpty()) {
changed.forEach { list ->
emitNotificationsFor(list, prefs)
}
}
// Update checkpoint regardless to avoid duplicate spam
prefs.setLastListNotifyCheckTimestamp(now)
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
private fun emitNotificationsFor(
list: CollabList,
prefs: PreferencesManager,
) {
// Determine event type
when {
list.isDeleted -> {
if (prefs.notifyListRemoved.value) {
NotificationHelper.showListRemoved(applicationContext, list.id, list.name)
}
}
list.createdAt == list.updatedAt -> {
if (prefs.notifyListAdded.value) {
NotificationHelper.showListAdded(applicationContext, list.id, list.name)
}
}
else -> {
if (prefs.notifyListEdited.value) {
NotificationHelper.showListEdited(applicationContext, list.id, list.name)
}
}
}
}
}