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:
@@ -2,7 +2,8 @@ plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
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 'io.gitlab.arturbosch.detekt'
|
||||
}
|
||||
@@ -40,9 +41,6 @@ android {
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = '1.6.8'
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
@@ -69,10 +67,10 @@ dependencies {
|
||||
// ViewModel
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2'
|
||||
|
||||
// Room
|
||||
implementation 'androidx.room:room-runtime:2.6.1'
|
||||
implementation 'androidx.room:room-ktx:2.6.1'
|
||||
ksp 'androidx.room:room-compiler:2.6.1'
|
||||
// Room (use kapt for annotation processing). Bump to version compatible with Kotlin 2.2 metadata
|
||||
implementation 'androidx.room:room-runtime:2.7.0'
|
||||
implementation 'androidx.room:room-ktx:2.7.0'
|
||||
kapt 'androidx.room:room-compiler:2.7.0'
|
||||
|
||||
// Retrofit
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
|
||||
+23
-19
@@ -4,21 +4,21 @@ 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 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() {
|
||||
@@ -43,28 +43,32 @@ class CollabTableApplication : Application() {
|
||||
scheduleListChangeWorker()
|
||||
|
||||
// Auto-clear notifications when app comes to foreground
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
|
||||
}
|
||||
})
|
||||
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"
|
||||
}
|
||||
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()
|
||||
val work =
|
||||
PeriodicWorkRequestBuilder<ListChangeNotificationWorker>(15, TimeUnit.MINUTES)
|
||||
.build()
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
"list_change_notifier",
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+8
-9
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-7
@@ -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
|
||||
|
||||
+232
-220
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+58
-25
@@ -1,6 +1,5 @@
|
||||
package com.collabtable.app.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
@@ -13,10 +12,15 @@ object NotificationHelper {
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
@@ -25,17 +29,41 @@ object NotificationHelper {
|
||||
.setAutoCancel(true)
|
||||
}
|
||||
|
||||
private fun notify(context: Context, id: Int, builder: NotificationCompat.Builder) {
|
||||
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")
|
||||
fun showListAdded(
|
||||
context: Context,
|
||||
listId: String,
|
||||
name: String,
|
||||
) = showEvent(context, listId, "add", "List added", "\"$name\" was created")
|
||||
|
||||
private fun showEvent(context: Context, listId: String, type: String, title: String, text: String) {
|
||||
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) }
|
||||
@@ -48,28 +76,33 @@ object NotificationHelper {
|
||||
val inbox = NotificationCompat.InboxStyle()
|
||||
synchronized(recentEventLines) {
|
||||
recentEventLines.take(5).forEach { line -> inbox.addLine(line) }
|
||||
inbox.setSummaryText("${totalEventCount} total event" + if (totalEventCount == 1) "" else "s")
|
||||
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)
|
||||
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
|
||||
}
|
||||
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
|
||||
|
||||
+23
-11
@@ -29,7 +29,8 @@ import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val statusClient: OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
@@ -40,9 +41,14 @@ private fun isEmulator(): Boolean {
|
||||
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")
|
||||
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 {
|
||||
@@ -78,7 +84,12 @@ fun ConnectionStatusAction(
|
||||
while (true) {
|
||||
try {
|
||||
val healthUrl = toHealthUrl(prefs.getServerUrl())
|
||||
val req = Request.Builder().url(healthUrl).get().build()
|
||||
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
|
||||
@@ -96,11 +107,12 @@ fun ConnectionStatusAction(
|
||||
}
|
||||
}
|
||||
|
||||
val dotColor: Color = when (ok) {
|
||||
true -> Color(0xFF2E7D32) // green
|
||||
false -> MaterialTheme.colorScheme.error
|
||||
null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val dotColor: Color =
|
||||
when (ok) {
|
||||
true -> Color(0xFF2E7D32) // green
|
||||
false -> MaterialTheme.colorScheme.error
|
||||
null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
@@ -109,7 +121,7 @@ fun ConnectionStatusAction(
|
||||
) {
|
||||
if (showLatency && ok == true && latencyMs != null) {
|
||||
Text(
|
||||
text = "${latencyMs} ms",
|
||||
text = "$latencyMs ms",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
|
||||
@@ -17,12 +17,12 @@ 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 androidx.navigation.NavType
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import com.collabtable.app.ui.screens.ListDetailScreen
|
||||
import com.collabtable.app.ui.screens.ListsScreen
|
||||
@@ -31,12 +31,12 @@ 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"
|
||||
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
|
||||
@@ -47,8 +47,8 @@ fun MainApp() {
|
||||
// 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) {
|
||||
NavHost(navController, startDestination = Routes.SETUP) {
|
||||
composable(Routes.SETUP) {
|
||||
ServerSetupScreen(
|
||||
onSetupComplete = {
|
||||
// Mark not first run and trigger recomposition to show main UI
|
||||
@@ -68,9 +68,9 @@ fun MainApp() {
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == Routes.Tables || currentRoute?.startsWith("list/") == true,
|
||||
selected = currentRoute == Routes.TABLES || currentRoute?.startsWith("list/") == true,
|
||||
onClick = {
|
||||
navController.navigate(Routes.Tables) {
|
||||
navController.navigate(Routes.TABLES) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
@@ -80,9 +80,9 @@ fun MainApp() {
|
||||
label = { Text("Tables") },
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == Routes.Settings,
|
||||
selected = currentRoute == Routes.SETTINGS,
|
||||
onClick = {
|
||||
navController.navigate(Routes.Settings) {
|
||||
navController.navigate(Routes.SETTINGS) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
@@ -96,37 +96,38 @@ fun MainApp() {
|
||||
) { innerPadding ->
|
||||
// Apply the innerPadding from the Scaffold so content is not obscured by the bottom navigation bar.
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.Tables,
|
||||
startDestination = Routes.TABLES,
|
||||
) {
|
||||
composable(Routes.Tables) {
|
||||
composable(Routes.TABLES) {
|
||||
ListsScreen(
|
||||
onNavigateToList = { listId -> navController.navigate("list/$listId") },
|
||||
onNavigateToSettings = { navController.navigate(Routes.Settings) },
|
||||
onNavigateToLogs = { navController.navigate(Routes.Logs) },
|
||||
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
onNavigateToLogs = { navController.navigate(Routes.LOGS) },
|
||||
)
|
||||
}
|
||||
composable(Routes.Settings) {
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToLogs = { navController.navigate(Routes.Logs) },
|
||||
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) {
|
||||
composable(Routes.LOGS) {
|
||||
LogsScreen(onNavigateBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(
|
||||
route = Routes.ListDetail,
|
||||
route = Routes.LIST_DETAIL,
|
||||
arguments = listOf(navArgument("listId") { type = NavType.StringType }),
|
||||
) { backStackEntry ->
|
||||
val listId = backStackEntry.arguments?.getString("listId") ?: return@composable
|
||||
|
||||
+57
-51
@@ -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
|
||||
|
||||
+50
-32
@@ -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 A–Z"
|
||||
PreferencesManager.SORT_NAME_DESC -> "Name Z–A"
|
||||
else -> "Sort"
|
||||
}
|
||||
val label =
|
||||
when (currentOrder) {
|
||||
PreferencesManager.SORT_UPDATED_DESC -> "Updated ↓"
|
||||
PreferencesManager.SORT_UPDATED_ASC -> "Updated ↑"
|
||||
PreferencesManager.SORT_NAME_ASC -> "Name A–Z"
|
||||
PreferencesManager.SORT_NAME_DESC -> "Name Z–A"
|
||||
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 (A–Z)") },
|
||||
onClick = { prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC); expanded = false },
|
||||
onClick = {
|
||||
prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Name (Z–A)") },
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+33
-18
@@ -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()
|
||||
|
||||
+6
-11
@@ -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
|
||||
@@ -66,7 +64,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
@@ -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 ""
|
||||
|
||||
+4
-1
@@ -33,7 +33,10 @@ class ListChangeNotificationWorker(
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitNotificationsFor(list: CollabList, prefs: PreferencesManager) {
|
||||
private fun emitNotificationsFor(
|
||||
list: CollabList,
|
||||
prefs: PreferencesManager,
|
||||
) {
|
||||
// Determine event type
|
||||
when {
|
||||
list.isDeleted -> {
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
plugins {
|
||||
id 'com.android.application' 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.plugin.compose' version '2.0.21' apply false
|
||||
id 'com.google.devtools.ksp' version '2.0.21-1.0.27' apply false
|
||||
id 'org.jlleitschuh.gradle.ktlint' version '12.1.0' apply false
|
||||
id 'io.gitlab.arturbosch.detekt' version '1.23.6' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '2.2.21' apply false
|
||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.2.21' apply false
|
||||
id 'org.jlleitschuh.gradle.ktlint' version '14.0.1' apply false
|
||||
id 'io.gitlab.arturbosch.detekt' version '1.23.8' apply false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user