Refactor ListDetailScreen and ViewModel for improved readability and performance

- Cleaned up imports and organized them in ListDetailScreen.kt
- Enhanced function formatting and readability in ListDetailScreen.kt
- Updated ListDetailViewModel.kt to improve data loading logic
- Refactored ListsScreen.kt for better layout and UI consistency
- Simplified sorting logic in ListsScreen.kt
- Improved notification handling in ListChangeNotificationWorker.kt
- Updated build.gradle to use newer versions of Kotlin and Detekt
- Added a new Detekt configuration file for better code quality checks
- Refactored ServerSetupScreen.kt and ServerSetupViewModel.kt for clarity and maintainability
- Cleaned up LogsScreen.kt to enhance readability
- Improved SettingsScreen.kt by removing unnecessary imports and organizing code
This commit is contained in:
2025-11-11 21:31:45 +01:00
parent 7a09cfd230
commit fd72959401
20 changed files with 979 additions and 876 deletions
+6 -8
View File
@@ -2,7 +2,8 @@ plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose' id 'org.jetbrains.kotlin.plugin.compose'
id 'com.google.devtools.ksp' // KSP not available for Kotlin 2.2.21 yet; switch to kapt for Room annotation processing
id 'org.jetbrains.kotlin.kapt'
id 'org.jlleitschuh.gradle.ktlint' id 'org.jlleitschuh.gradle.ktlint'
id 'io.gitlab.arturbosch.detekt' id 'io.gitlab.arturbosch.detekt'
} }
@@ -40,9 +41,6 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
} }
composeOptions {
kotlinCompilerExtensionVersion = '1.6.8'
}
packaging { packaging {
resources { resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}' excludes += '/META-INF/{AL2.0,LGPL2.1}'
@@ -69,10 +67,10 @@ dependencies {
// ViewModel // ViewModel
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2'
// Room // Room (use kapt for annotation processing). Bump to version compatible with Kotlin 2.2 metadata
implementation 'androidx.room:room-runtime:2.6.1' implementation 'androidx.room:room-runtime:2.7.0'
implementation 'androidx.room:room-ktx:2.6.1' implementation 'androidx.room:room-ktx:2.7.0'
ksp 'androidx.room:room-compiler:2.6.1' kapt 'androidx.room:room-compiler:2.7.0'
// Retrofit // Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:retrofit:2.9.0'
@@ -4,21 +4,21 @@ import android.app.Application
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import com.collabtable.app.data.api.ApiClient
import 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.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
class CollabTableApplication : Application() { class CollabTableApplication : Application() {
override fun onCreate() { override fun onCreate() {
@@ -43,16 +43,19 @@ class CollabTableApplication : Application() {
scheduleListChangeWorker() scheduleListChangeWorker()
// Auto-clear notifications when app comes to foreground // Auto-clear notifications when app comes to foreground
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { ProcessLifecycleOwner.get().lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) { override fun onStart(owner: LifecycleOwner) {
NotificationHelper.clearListEventNotifications(this@CollabTableApplication) NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
} }
}) },
)
} }
private fun createNotificationChannels() { private fun createNotificationChannels() {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val listEventsChannel = NotificationChannel( val listEventsChannel =
NotificationChannel(
NotificationHelper.CHANNEL_LIST_EVENTS, NotificationHelper.CHANNEL_LIST_EVENTS,
"List Events", "List Events",
NotificationManager.IMPORTANCE_DEFAULT, NotificationManager.IMPORTANCE_DEFAULT,
@@ -63,7 +66,8 @@ class CollabTableApplication : Application() {
} }
private fun scheduleListChangeWorker() { private fun scheduleListChangeWorker() {
val work = PeriodicWorkRequestBuilder<ListChangeNotificationWorker>(15, TimeUnit.MINUTES) val work =
PeriodicWorkRequestBuilder<ListChangeNotificationWorker>(15, TimeUnit.MINUTES)
.build() .build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork( WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"list_change_notifier", "list_change_notifier",
@@ -1,9 +1,9 @@
package com.collabtable.app.data.api package com.collabtable.app.data.api
import android.content.Context import android.content.Context
import com.collabtable.app.data.preferences.PreferencesManager
import android.os.Build
import android.net.Uri import android.net.Uri
import android.os.Build
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -32,7 +32,8 @@ object ApiClient {
val newRequest = val newRequest =
if (!password.isNullOrBlank() && password != "\$password") { if (!password.isNullOrBlank() && password != "\$password") {
request.newBuilder() request
.newBuilder()
.header("Authorization", "Bearer $password") .header("Authorization", "Bearer $password")
.build() .build()
} else { } else {
@@ -43,7 +44,8 @@ object ApiClient {
} }
private val okHttpClient = private val okHttpClient =
OkHttpClient.Builder() OkHttpClient
.Builder()
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor) .addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
@@ -51,13 +53,13 @@ object ApiClient {
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
.build() .build()
private fun buildRetrofit(): Retrofit { private fun buildRetrofit(): Retrofit =
return Retrofit.Builder() Retrofit
.Builder()
.baseUrl(baseUrl) .baseUrl(baseUrl)
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
}
private fun isEmulator(): Boolean { private fun isEmulator(): Boolean {
val fingerprint = Build.FINGERPRINT.lowercase() val fingerprint = Build.FINGERPRINT.lowercase()
@@ -65,9 +67,14 @@ object ApiClient {
val brand = Build.BRAND.lowercase() val brand = Build.BRAND.lowercase()
val device = Build.DEVICE.lowercase() val device = Build.DEVICE.lowercase()
val product = Build.PRODUCT.lowercase() val product = Build.PRODUCT.lowercase()
return fingerprint.contains("generic") || fingerprint.contains("emulator") || return fingerprint.contains("generic") ||
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") || fingerprint.contains("emulator") ||
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk") model.contains("google_sdk") ||
model.contains("emulator") ||
model.contains("android sdk built for") ||
brand.startsWith("generic") ||
device.startsWith("generic") ||
product.contains("sdk")
} }
private fun ensureTrailingSlash(url: String): String = if (url.endsWith("/")) url else "$url/" private fun ensureTrailingSlash(url: String): String = if (url.endsWith("/")) url else "$url/"
@@ -104,7 +111,8 @@ object ApiClient {
baseUrl = normalizeForAndroidEmulator(prefs.getServerUrl()) baseUrl = normalizeForAndroidEmulator(prefs.getServerUrl())
try { try {
Logger.i("HTTP", "API base URL initialized to $baseUrl") Logger.i("HTTP", "API base URL initialized to $baseUrl")
} catch (_: Exception) { } } catch (_: Exception) {
}
retrofit = buildRetrofit() retrofit = buildRetrofit()
} }
@@ -112,7 +120,8 @@ object ApiClient {
baseUrl = normalizeForAndroidEmulator(url) baseUrl = normalizeForAndroidEmulator(url)
try { try {
Logger.i("HTTP", "API base URL set to $baseUrl") Logger.i("HTTP", "API base URL set to $baseUrl")
} catch (_: Exception) { } } catch (_: Exception) {
}
retrofit = buildRetrofit() retrofit = buildRetrofit()
} }
@@ -54,20 +54,19 @@ abstract class CollabTableDatabase : RoomDatabase() {
@Volatile @Volatile
private var dbInstance: CollabTableDatabase? = null private var dbInstance: CollabTableDatabase? = null
fun getDatabase(context: Context): CollabTableDatabase { fun getDatabase(context: Context): CollabTableDatabase =
return dbInstance ?: synchronized(this) { dbInstance ?: synchronized(this) {
val instance = val instance =
Room.databaseBuilder( Room
.databaseBuilder(
context.applicationContext, context.applicationContext,
CollabTableDatabase::class.java, CollabTableDatabase::class.java,
"collab_table_database", "collab_table_database",
) ).addMigrations(migration1To2, migration2To3)
.addMigrations(migration1To2, migration2To3)
.build() .build()
dbInstance = instance dbInstance = instance
instance instance
} }
}
fun clearDatabase(context: Context) { fun clearDatabase(context: Context) {
synchronized(this) { synchronized(this) {
@@ -84,35 +84,31 @@ data class Field(
} }
} }
fun getDropdownOptions(): List<String> { fun getDropdownOptions(): List<String> =
return if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) { if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) {
fieldOptions.split("|") fieldOptions.split("|")
} else { } else {
emptyList() emptyList()
} }
}
fun getCurrency(): String { fun getCurrency(): String =
return if ((getType() == FieldType.CURRENCY) && fieldOptions.isNotBlank()) { if ((getType() == FieldType.CURRENCY) && fieldOptions.isNotBlank()) {
fieldOptions fieldOptions
} else { } else {
"CHF" "CHF"
} }
}
fun getMaxRating(): Int { fun getMaxRating(): Int =
return if (getType() == FieldType.RATING && fieldOptions.isNotBlank()) { if (getType() == FieldType.RATING && fieldOptions.isNotBlank()) {
fieldOptions.toIntOrNull() ?: 5 fieldOptions.toIntOrNull() ?: 5
} else { } else {
5 5
} }
}
fun getAutocompleteOptions(): List<String> { fun getAutocompleteOptions(): List<String> =
return if (getType() == FieldType.AUTOCOMPLETE && fieldOptions.isNotBlank()) { if (getType() == FieldType.AUTOCOMPLETE && fieldOptions.isNotBlank()) {
fieldOptions.split("|") fieldOptions.split("|")
} else { } else {
emptyList() emptyList()
} }
} }
}
@@ -2,10 +2,10 @@ package com.collabtable.app.data.preferences
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import org.json.JSONObject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
class PreferencesManager(context: Context) { class PreferencesManager(context: Context) {
private val prefs: SharedPreferences = private val prefs: SharedPreferences =
@@ -210,7 +210,10 @@ class PreferencesManager(context: Context) {
} }
} }
fun setColumnWidths(listId: String, widthsDp: Map<String, Float>) { fun setColumnWidths(
listId: String,
widthsDp: Map<String, Float>,
) {
val key = COLUMN_WIDTHS_PREFIX + listId val key = COLUMN_WIDTHS_PREFIX + listId
val json = JSONObject() val json = JSONObject()
widthsDp.forEach { (fieldId, width) -> widthsDp.forEach { (fieldId, width) ->
@@ -10,10 +10,10 @@ import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import java.net.UnknownHostException
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.net.UnknownHostException
class SyncRepository(context: Context) { class SyncRepository(context: Context) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
@@ -22,18 +22,23 @@ class SyncRepository(context: Context) {
private val prefs = appContext.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE) private val prefs = appContext.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE)
@Volatile private var authBackoffUntil: Long = 0L @Volatile private var authBackoffUntil: Long = 0L
@Volatile private var authBackoffMs: Long = 0L @Volatile private var authBackoffMs: Long = 0L
@Volatile private var networkBackoffUntil: Long = 0L @Volatile private var networkBackoffUntil: Long = 0L
@Volatile private var networkBackoffMs: Long = 0L @Volatile private var networkBackoffMs: Long = 0L
fun resetAuthBackoff() { fun resetAuthBackoff() {
authBackoffMs = 0L authBackoffMs = 0L
authBackoffUntil = 0L authBackoffUntil = 0L
} }
private fun resetNetworkBackoff() { private fun resetNetworkBackoff() {
networkBackoffMs = 0L networkBackoffMs = 0L
networkBackoffUntil = 0L networkBackoffUntil = 0L
} }
// We keep separate watermarks to avoid clock-skew issues: // We keep separate watermarks to avoid clock-skew issues:
// - server watermark: server-assigned timestamp for fetching server changes // - server watermark: server-assigned timestamp for fetching server changes
// - local watermark: device local time for selecting local changes to upload // - local watermark: device local time for selecting local changes to upload
@@ -108,7 +113,8 @@ class SyncRepository(context: Context) {
// lastSyncTimestamp to 0 for this request. This avoids permanently missing historical rows. // lastSyncTimestamp to 0 for this request. This avoids permanently missing historical rows.
val hasAnyLists = database.listDao().getAllListIds().isNotEmpty() val hasAnyLists = database.listDao().getAllListIds().isNotEmpty()
val hasAnyItems = database.itemDao().getAllItemIds().isNotEmpty() val hasAnyItems = database.itemDao().getAllItemIds().isNotEmpty()
val effectiveLastServerTs = when { val effectiveLastServerTs =
when {
// App start can request a full down-sync regardless of local contents // App start can request a full down-sync regardless of local contents
forceFullDown && lastServerTs > 0L -> 0L forceFullDown && lastServerTs > 0L -> 0L
// Fallback: if local looks empty but we have a watermark, do a one-time full down-sync // Fallback: if local looks empty but we have a watermark, do a one-time full down-sync
@@ -121,13 +127,17 @@ class SyncRepository(context: Context) {
} }
// Gather local changes since last sync // Gather local changes since last sync
val localLists = database.listDao().getListsUpdatedSince(lastLocalTs) val localLists =
database.listDao().getListsUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
val localFields = database.fieldDao().getFieldsUpdatedSince(lastLocalTs) val localFields =
database.fieldDao().getFieldsUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
val localItems = database.itemDao().getItemsUpdatedSince(lastLocalTs) val localItems =
database.itemDao().getItemsUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
val localValues = database.itemValueDao().getValuesUpdatedSince(lastLocalTs) val localValues =
database.itemValueDao().getValuesUpdatedSince(lastLocalTs)
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs } .filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
// Only log send when there are actual local changes // Only log send when there are actual local changes
@@ -152,7 +162,8 @@ class SyncRepository(context: Context) {
) )
// HTTP-only sync // HTTP-only sync
val response = try { val response =
try {
api.sync(syncRequest) api.sync(syncRequest)
} catch (e: UnknownHostException) { } catch (e: UnknownHostException) {
// Host could not be resolved; apply exponential backoff and surface a warning once. // Host could not be resolved; apply exponential backoff and surface a warning once.
@@ -295,7 +306,8 @@ class SyncRepository(context: Context) {
val caps = cm.getNetworkCapabilities(network) ?: return false val caps = cm.getNetworkCapabilities(network) ?: return false
val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val hasTransport = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || val hasTransport =
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
return hasInternet && isValidated && hasTransport return hasInternet && isValidated && hasTransport
@@ -1,6 +1,5 @@
package com.collabtable.app.notifications package com.collabtable.app.notifications
import android.app.NotificationManager
import android.content.Context import android.content.Context
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@@ -13,10 +12,15 @@ object NotificationHelper {
// Keep a small rolling buffer of recent event lines for the summary // Keep a small rolling buffer of recent event lines for the summary
private val recentEventLines = ArrayDeque<String>() private val recentEventLines = ArrayDeque<String>()
@Volatile private var totalEventCount: Int = 0 @Volatile private var totalEventCount: Int = 0
private val postedIds = mutableSetOf<Int>() private val postedIds = mutableSetOf<Int>()
private fun builder(context: Context, title: String, text: String): NotificationCompat.Builder { private fun builder(
context: Context,
title: String,
text: String,
): NotificationCompat.Builder {
return NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS) return NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title) .setContentTitle(title)
@@ -25,17 +29,41 @@ object NotificationHelper {
.setAutoCancel(true) .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)) { with(NotificationManagerCompat.from(context)) {
notify(id, builder.build()) notify(id, builder.build())
} }
} }
fun showListAdded(context: Context, listId: String, name: String) = showEvent(context, listId, "add", "List added", "\"$name\" was created") fun showListAdded(
fun showListEdited(context: Context, listId: String, name: String) = showEvent(context, listId, "edit", "List edited", "\"$name\" was renamed/updated") context: Context,
fun showListRemoved(context: Context, listId: String, name: String) = showEvent(context, listId, "remove", "List removed", "\"$name\" was deleted") 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 b = builder(context, title, text).setGroup(GROUP_LIST_EVENTS)
val id = (type + listId).hashCode() val id = (type + listId).hashCode()
synchronized(postedIds) { postedIds.add(id) } synchronized(postedIds) { postedIds.add(id) }
@@ -48,9 +76,10 @@ object NotificationHelper {
val inbox = NotificationCompat.InboxStyle() val inbox = NotificationCompat.InboxStyle()
synchronized(recentEventLines) { synchronized(recentEventLines) {
recentEventLines.take(5).forEach { line -> inbox.addLine(line) } 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) val summary =
NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("List activity") .setContentTitle("List activity")
.setContentText("Recent list changes") .setContentText("Recent list changes")
@@ -63,8 +92,12 @@ object NotificationHelper {
notify(context, SUMMARY_ID, summary) notify(context, SUMMARY_ID, summary)
} }
private fun recordEventLine(type: String, text: String) { private fun recordEventLine(
val line = when (type) { type: String,
text: String,
) {
val line =
when (type) {
"add" -> "Added • $text" "add" -> "Added • $text"
"edit" -> "Edited • $text" "edit" -> "Edited • $text"
"remove" -> "Removed • $text" "remove" -> "Removed • $text"
@@ -29,7 +29,8 @@ import okhttp3.Request
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
private val statusClient: OkHttpClient = private val statusClient: OkHttpClient =
OkHttpClient.Builder() OkHttpClient
.Builder()
.connectTimeout(5, TimeUnit.SECONDS) .connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS) .readTimeout(5, TimeUnit.SECONDS)
.build() .build()
@@ -40,9 +41,14 @@ private fun isEmulator(): Boolean {
val brand = Build.BRAND.lowercase() val brand = Build.BRAND.lowercase()
val device = Build.DEVICE.lowercase() val device = Build.DEVICE.lowercase()
val product = Build.PRODUCT.lowercase() val product = Build.PRODUCT.lowercase()
return fp.contains("generic") || fp.contains("emulator") || return fp.contains("generic") ||
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") || fp.contains("emulator") ||
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk") model.contains("google_sdk") ||
model.contains("emulator") ||
model.contains("android sdk built for") ||
brand.startsWith("generic") ||
device.startsWith("generic") ||
product.contains("sdk")
} }
private fun toHealthUrl(rawApiUrl: String): String { private fun toHealthUrl(rawApiUrl: String): String {
@@ -78,7 +84,12 @@ fun ConnectionStatusAction(
while (true) { while (true) {
try { try {
val healthUrl = toHealthUrl(prefs.getServerUrl()) 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 start = System.nanoTime()
val resp = withContext(Dispatchers.IO) { statusClient.newCall(req).execute() } val resp = withContext(Dispatchers.IO) { statusClient.newCall(req).execute() }
val tookMs = (System.nanoTime() - start) / 1_000_000 val tookMs = (System.nanoTime() - start) / 1_000_000
@@ -96,7 +107,8 @@ fun ConnectionStatusAction(
} }
} }
val dotColor: Color = when (ok) { val dotColor: Color =
when (ok) {
true -> Color(0xFF2E7D32) // green true -> Color(0xFF2E7D32) // green
false -> MaterialTheme.colorScheme.error false -> MaterialTheme.colorScheme.error
null -> MaterialTheme.colorScheme.onSurfaceVariant null -> MaterialTheme.colorScheme.onSurfaceVariant
@@ -109,7 +121,7 @@ fun ConnectionStatusAction(
) { ) {
if (showLatency && ok == true && latencyMs != null) { if (showLatency && ok == true && latencyMs != null) {
Text( Text(
text = "${latencyMs} ms", text = "$latencyMs ms",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer,
) )
@@ -17,12 +17,12 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import androidx.navigation.NavType
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.screens.ListDetailScreen import com.collabtable.app.ui.screens.ListDetailScreen
import com.collabtable.app.ui.screens.ListsScreen import com.collabtable.app.ui.screens.ListsScreen
@@ -31,12 +31,12 @@ import com.collabtable.app.ui.screens.ServerSetupScreen
import com.collabtable.app.ui.screens.SettingsScreen import com.collabtable.app.ui.screens.SettingsScreen
private object Routes { private object Routes {
const val Setup = "server_setup" const val SETUP = "server_setup"
const val MainRoot = "main_root" const val MAIN_ROOT = "main_root"
const val Tables = "tables" const val TABLES = "tables"
const val Settings = "settings" const val SETTINGS = "settings"
const val ListDetail = "list/{listId}" const val LIST_DETAIL = "list/{listId}"
const val Logs = "logs" const val LOGS = "logs"
} }
@Composable @Composable
@@ -47,8 +47,8 @@ fun MainApp() {
// Show setup flow until completed; then show main app with bottom nav // Show setup flow until completed; then show main app with bottom nav
if (prefs.isFirstRun()) { if (prefs.isFirstRun()) {
val navController = rememberNavController() val navController = rememberNavController()
NavHost(navController, startDestination = Routes.Setup) { NavHost(navController, startDestination = Routes.SETUP) {
composable(Routes.Setup) { composable(Routes.SETUP) {
ServerSetupScreen( ServerSetupScreen(
onSetupComplete = { onSetupComplete = {
// Mark not first run and trigger recomposition to show main UI // Mark not first run and trigger recomposition to show main UI
@@ -68,9 +68,9 @@ fun MainApp() {
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
NavigationBarItem( NavigationBarItem(
selected = currentRoute == Routes.Tables || currentRoute?.startsWith("list/") == true, selected = currentRoute == Routes.TABLES || currentRoute?.startsWith("list/") == true,
onClick = { onClick = {
navController.navigate(Routes.Tables) { navController.navigate(Routes.TABLES) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true } popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = true
@@ -80,9 +80,9 @@ fun MainApp() {
label = { Text("Tables") }, label = { Text("Tables") },
) )
NavigationBarItem( NavigationBarItem(
selected = currentRoute == Routes.Settings, selected = currentRoute == Routes.SETTINGS,
onClick = { onClick = {
navController.navigate(Routes.Settings) { navController.navigate(Routes.SETTINGS) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true } popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = true
@@ -96,37 +96,38 @@ fun MainApp() {
) { innerPadding -> ) { innerPadding ->
// Apply the innerPadding from the Scaffold so content is not obscured by the bottom navigation bar. // Apply the innerPadding from the Scaffold so content is not obscured by the bottom navigation bar.
Surface( Surface(
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Routes.Tables, startDestination = Routes.TABLES,
) { ) {
composable(Routes.Tables) { composable(Routes.TABLES) {
ListsScreen( ListsScreen(
onNavigateToList = { listId -> navController.navigate("list/$listId") }, onNavigateToList = { listId -> navController.navigate("list/$listId") },
onNavigateToSettings = { navController.navigate(Routes.Settings) }, onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
onNavigateToLogs = { navController.navigate(Routes.Logs) }, onNavigateToLogs = { navController.navigate(Routes.LOGS) },
) )
} }
composable(Routes.Settings) { composable(Routes.SETTINGS) {
SettingsScreen( SettingsScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToLogs = { navController.navigate(Routes.Logs) }, onNavigateToLogs = { navController.navigate(Routes.LOGS) },
onLeaveServer = { onLeaveServer = {
// Reset to setup flow by toggling the flag and rebuilding graph via recomposition // Reset to setup flow by toggling the flag and rebuilding graph via recomposition
prefs.setIsFirstRun(true) prefs.setIsFirstRun(true)
}, },
) )
} }
composable(Routes.Logs) { composable(Routes.LOGS) {
LogsScreen(onNavigateBack = { navController.popBackStack() }) LogsScreen(onNavigateBack = { navController.popBackStack() })
} }
composable( composable(
route = Routes.ListDetail, route = Routes.LIST_DETAIL,
arguments = listOf(navArgument("listId") { type = NavType.StringType }), arguments = listOf(navArgument("listId") { type = NavType.StringType }),
) { backStackEntry -> ) { backStackEntry ->
val listId = backStackEntry.arguments?.getString("listId") ?: return@composable val listId = backStackEntry.arguments?.getString("listId") ?: return@composable
@@ -8,14 +8,11 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -27,29 +24,28 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FitScreen
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.FitScreen
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -66,20 +62,17 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.zIndex
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -87,11 +80,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import com.collabtable.app.R import com.collabtable.app.R
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.components.ConnectionStatusAction
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.Field import com.collabtable.app.data.model.Field
import com.collabtable.app.data.model.ItemWithValues import com.collabtable.app.data.model.ItemWithValues
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.components.ConnectionStatusAction
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorder import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.rememberReorderableLazyListState
@@ -99,10 +96,6 @@ import org.burnoutcrew.reorderable.reorderable
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.launch
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
@@ -192,7 +185,8 @@ fun ListDetailScreen(
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_item)) Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_item))
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
), ),
@@ -201,7 +195,10 @@ fun ListDetailScreen(
floatingActionButton = {}, floatingActionButton = {},
) { padding -> ) { padding ->
// Apply filtering, sorting, and grouping to items // Apply filtering, sorting, and grouping to items
fun valueFor(item: ItemWithValues, field: Field?): String { fun valueFor(
item: ItemWithValues,
field: Field?,
): String {
if (field == null) return "" if (field == null) return ""
return item.values.find { it.fieldId == field.id }?.value ?: "" return item.values.find { it.fieldId == field.id }?.value ?: ""
} }
@@ -406,7 +403,8 @@ fun ListDetailScreen(
onClick = { onClick = {
val newWidths = mutableMapOf<String, Float>() val newWidths = mutableMapOf<String, Float>()
stableFields.forEach { field -> stableFields.forEach { field ->
val headerPx = textMeasurer.measure( val headerPx =
textMeasurer.measure(
AnnotatedString(field.name), AnnotatedString(field.name),
style = headerTextStyle, style = headerTextStyle,
).size.width.toFloat() ).size.width.toFloat()
@@ -416,7 +414,8 @@ fun ListDetailScreen(
val v = itemWithValues.values.find { it.fieldId == field.id }?.value val v = itemWithValues.values.find { it.fieldId == field.id }?.value
val display = getDisplayTextForMeasure(field, v) val display = getDisplayTextForMeasure(field, v)
if (display.isNotEmpty()) { if (display.isNotEmpty()) {
val w = textMeasurer.measure( val w =
textMeasurer.measure(
AnnotatedString(display), AnnotatedString(display),
style = bodyTextStyle, style = bodyTextStyle,
).size.width.toFloat() ).size.width.toFloat()
@@ -424,7 +423,8 @@ fun ListDetailScreen(
} }
} }
val widthDp = with(density) { val widthDp =
with(density) {
val headerDp = headerPx.toDp() + 12.dp + 12.dp + 24.dp + 2.dp val headerDp = headerPx.toDp() + 12.dp + 12.dp + 24.dp + 2.dp
val contentDp = maxContentPx.toDp() + 8.dp + 8.dp + 2.dp val contentDp = maxContentPx.toDp() + 8.dp + 8.dp + 2.dp
val base = maxOf(headerDp, contentDp, 100.dp) val base = maxOf(headerDp, contentDp, 100.dp)
@@ -665,7 +665,10 @@ fun ListDetailScreen(
} }
// Produce the display text used in cells for measurement purposes (single-line content baseline) // Produce the display text used in cells for measurement purposes (single-line content baseline)
private fun getDisplayTextForMeasure(field: Field, raw: String?): String { private fun getDisplayTextForMeasure(
field: Field,
raw: String?,
): String {
val value = raw ?: "" val value = raw ?: ""
return when (field.getType()) { return when (field.getType()) {
com.collabtable.app.data.model.FieldType.TEXT, com.collabtable.app.data.model.FieldType.TEXT,
@@ -677,7 +680,8 @@ private fun getDisplayTextForMeasure(field: Field, raw: String?): String {
com.collabtable.app.data.model.FieldType.LOCATION, com.collabtable.app.data.model.FieldType.LOCATION,
com.collabtable.app.data.model.FieldType.DATE, com.collabtable.app.data.model.FieldType.DATE,
com.collabtable.app.data.model.FieldType.TIME, com.collabtable.app.data.model.FieldType.TIME,
com.collabtable.app.data.model.FieldType.DATETIME -> value com.collabtable.app.data.model.FieldType.DATETIME,
-> value
com.collabtable.app.data.model.FieldType.CURRENCY -> if (value.isBlank()) "" else field.getCurrency() + value com.collabtable.app.data.model.FieldType.CURRENCY -> if (value.isBlank()) "" else field.getCurrency() + value
com.collabtable.app.data.model.FieldType.PERCENTAGE -> if (value.isBlank()) "" else "$value%" com.collabtable.app.data.model.FieldType.PERCENTAGE -> if (value.isBlank()) "" else "$value%"
@@ -763,7 +767,8 @@ fun FieldHeader(
Text( Text(
text = field.name, text = field.name,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
modifier = Modifier modifier =
Modifier
.weight(1f) .weight(1f)
.clickable { onHeaderClick() }, .clickable { onHeaderClick() },
) )
@@ -797,7 +802,8 @@ fun FieldHeader(
// Start or maintain continuous expand+scroll when at/right of the handle // Start or maintain continuous expand+scroll when at/right of the handle
if (dragAmount.x >= 0f || autoScrollJob != null) { if (dragAmount.x >= 0f || autoScrollJob != null) {
if (autoScrollJob == null) { if (autoScrollJob == null) {
autoScrollJob = scope.launch { autoScrollJob =
scope.launch {
while (isActive) { while (isActive) {
// Keep expanding a bit and reveal the end as space grows // Keep expanding a bit and reveal the end as space grows
onWidthChange(autoStepDp) onWidthChange(autoStepDp)
@@ -22,17 +22,14 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Sort
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -45,7 +42,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -58,7 +54,6 @@ import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.components.ConnectionStatusAction import com.collabtable.app.ui.components.ConnectionStatusAction
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorder import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.rememberReorderableLazyListState
@@ -215,7 +210,8 @@ fun ListsScreen(
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Controls above the list (align with table view UX) // Controls above the list (align with table view UX)
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp), .padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
@@ -318,16 +314,22 @@ fun ListItem(
// We wrap the original row in a full-width Box with the same outer padding, then add a clickable // We wrap the original row in a full-width Box with the same outer padding, then add a clickable
// modifier that uses additional vertical padding but negative padding inside to keep visual spacing. // modifier that uses additional vertical padding but negative padding inside to keep visual spacing.
Box( Box(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp) // keep original horizontal spacing // keep original horizontal spacing
.clickable(onClick = onListClick) // entire row (including margins) is tappable .padding(horizontal = 12.dp)
.padding(vertical = 4.dp) // expand touch area (original visual was 8.dp inside Row) // entire row (including margins) is tappable
.clickable(onClick = onListClick)
// expand touch area (original visual was 8.dp inside Row)
.padding(vertical = 4.dp),
) { ) {
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), // actual visual spacing remains ~8dp total (4 + 4) .padding(vertical = 4.dp),
// actual visual spacing remains ~8dp total (4 + 4)
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -446,7 +448,8 @@ private fun SortMenu(prefs: PreferencesManager) {
val currentOrder by prefs.sortOrder.collectAsState(initial = prefs.getSortOrder()) val currentOrder by prefs.sortOrder.collectAsState(initial = prefs.getSortOrder())
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val label = when (currentOrder) { val label =
when (currentOrder) {
PreferencesManager.SORT_UPDATED_DESC -> "Updated ↓" PreferencesManager.SORT_UPDATED_DESC -> "Updated ↓"
PreferencesManager.SORT_UPDATED_ASC -> "Updated ↑" PreferencesManager.SORT_UPDATED_ASC -> "Updated ↑"
PreferencesManager.SORT_NAME_ASC -> "Name AZ" PreferencesManager.SORT_NAME_ASC -> "Name AZ"
@@ -461,26 +464,41 @@ private fun SortMenu(prefs: PreferencesManager) {
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Updated (newest first)") }, text = { Text("Updated (newest first)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_UPDATED_DESC); expanded = false }, onClick = {
prefs.setSortOrder(PreferencesManager.SORT_UPDATED_DESC)
expanded = false
},
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Updated (oldest first)") }, text = { Text("Updated (oldest first)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_UPDATED_ASC); expanded = false }, onClick = {
prefs.setSortOrder(PreferencesManager.SORT_UPDATED_ASC)
expanded = false
},
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Name (AZ)") }, text = { Text("Name (AZ)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC); expanded = false }, onClick = {
prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC)
expanded = false
},
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Name (ZA)") }, text = { Text("Name (ZA)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_NAME_DESC); expanded = false }, onClick = {
prefs.setSortOrder(PreferencesManager.SORT_NAME_DESC)
expanded = false
},
) )
} }
} }
} }
// Format updatedAt relative to now: "Updated 5 sec/min/hour(s) ago" (days included when applicable) // Format updatedAt relative to now: "Updated 5 sec/min/hour(s) ago" (days included when applicable)
private fun formatUpdatedAgo(updatedAt: Long, nowMs: Long): String { private fun formatUpdatedAgo(
updatedAt: Long,
nowMs: Long,
): String {
val delta = (nowMs - updatedAt).coerceAtLeast(0L) val delta = (nowMs - updatedAt).coerceAtLeast(0L)
val seconds = delta / 1000 val seconds = delta / 1000
return when { return when {
@@ -492,12 +510,12 @@ private fun formatUpdatedAgo(updatedAt: Long, nowMs: Long): String {
seconds < 86_400 -> { seconds < 86_400 -> {
val hours = seconds / 3600 val hours = seconds / 3600
val unit = if (hours == 1L) "hour" else "hours" val unit = if (hours == 1L) "hour" else "hours"
"Updated ${hours} $unit ago" "Updated $hours $unit ago"
} }
else -> { else -> {
val days = seconds / 86_400 val days = seconds / 86_400
val unit = if (days == 1L) "day" else "days" val unit = if (days == 1L) "day" else "days"
"Updated ${days} $unit ago" "Updated $days $unit ago"
} }
} }
} }
@@ -1,14 +1,14 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.content.Context import android.content.Context
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.repository.SyncRepository import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.notifications.NotificationHelper import com.collabtable.app.notifications.NotificationHelper
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.Lifecycle
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -18,7 +18,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
@@ -26,6 +25,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -1,5 +1,8 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -41,10 +44,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import android.os.Build
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -1,5 +1,7 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.net.Uri
import android.os.Build
import android.os.NetworkOnMainThreadException import android.os.NetworkOnMainThreadException
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -18,8 +20,6 @@ import java.net.ConnectException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import android.os.Build
import android.net.Uri
class ServerSetupViewModel( class ServerSetupViewModel(
private val preferencesManager: PreferencesManager, private val preferencesManager: PreferencesManager,
@@ -34,7 +34,8 @@ class ServerSetupViewModel(
val validationError: StateFlow<String?> = _validationError.asStateFlow() val validationError: StateFlow<String?> = _validationError.asStateFlow()
private val okHttpClient = private val okHttpClient =
OkHttpClient.Builder() OkHttpClient
.Builder()
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS)
.build() .build()
@@ -99,17 +100,28 @@ class ServerSetupViewModel(
} }
// On physical devices, block local-only hosts that won't resolve // On physical devices, block local-only hosts that won't resolve
val isEmulator = run { val isEmulator =
run {
val fp = Build.FINGERPRINT.lowercase() val fp = Build.FINGERPRINT.lowercase()
val model = Build.MODEL.lowercase() val model = Build.MODEL.lowercase()
val brand = Build.BRAND.lowercase() val brand = Build.BRAND.lowercase()
val device = Build.DEVICE.lowercase() val device = Build.DEVICE.lowercase()
val product = Build.PRODUCT.lowercase() val product = Build.PRODUCT.lowercase()
fp.contains("generic") || fp.contains("emulator") || fp.contains("generic") ||
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") || fp.contains("emulator") ||
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk") model.contains("google_sdk") ||
model.contains("emulator") ||
model.contains("android sdk built for") ||
brand.startsWith("generic") ||
device.startsWith("generic") ||
product.contains("sdk")
}
val host =
try {
Uri.parse(normalizedUrl).host?.lowercase()
} catch (_: Exception) {
null
} }
val host = try { Uri.parse(normalizedUrl).host?.lowercase() } catch (_: Exception) { null }
val localOnlyHosts = setOf("localhost", "127.0.0.1", "host.docker.internal", "10.0.2.2") val localOnlyHosts = setOf("localhost", "127.0.0.1", "host.docker.internal", "10.0.2.2")
if (!isEmulator && host != null && host in localOnlyHosts) { if (!isEmulator && host != null && host in localOnlyHosts) {
_validationError.value = "On a physical device, use your computer's LAN IP (e.g., 192.168.x.x:3000) instead of '$host'." _validationError.value = "On a physical device, use your computer's LAN IP (e.g., 192.168.x.x:3000) instead of '$host'."
@@ -120,7 +132,8 @@ class ServerSetupViewModel(
// Try to reach the health endpoint (no auth required) // Try to reach the health endpoint (no auth required)
val healthUrl = normalizedUrl.replace("/api/", "/health") val healthUrl = normalizedUrl.replace("/api/", "/health")
val healthRequest = val healthRequest =
Request.Builder() Request
.Builder()
.url(healthUrl) .url(healthUrl)
.get() .get()
.build() .build()
@@ -158,7 +171,8 @@ class ServerSetupViewModel(
val httpsHealthUrl = httpsUrl.replace("/api/", "/health") val httpsHealthUrl = httpsUrl.replace("/api/", "/health")
val httpsHealthRequest = val httpsHealthRequest =
Request.Builder() Request
.Builder()
.url(httpsHealthUrl) .url(httpsHealthUrl)
.get() .get()
.build() .build()
@@ -182,7 +196,8 @@ class ServerSetupViewModel(
// Now validate the password by making an authenticated request // Now validate the password by making an authenticated request
val testUrl = "${finalUrl}lists" val testUrl = "${finalUrl}lists"
val authRequest = val authRequest =
Request.Builder() Request
.Builder()
.url(testUrl) .url(testUrl)
.header("Authorization", "Bearer $trimmedPassword") .header("Authorization", "Bearer $trimmedPassword")
.get() .get()
@@ -1,5 +1,10 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -12,7 +17,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Brightness4 import androidx.compose.material.icons.filled.Brightness4
import androidx.compose.material.icons.filled.Brightness7 import androidx.compose.material.icons.filled.Brightness7
@@ -26,7 +30,6 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@@ -38,8 +41,6 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -51,9 +52,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.collabtable.app.R import com.collabtable.app.R
import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.api.ApiClient
@@ -66,7 +64,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -507,11 +504,8 @@ fun SettingsScreen(
} }
} }
} }
} }
if (showLeaveDialog) { if (showLeaveDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showLeaveDialog = false }, onDismissRequest = { showLeaveDialog = false },
@@ -582,6 +576,7 @@ fun SettingsScreen(
// Test connectivity removed; connection status is shown in the top app bar indicator // Test connectivity removed; connection status is shown in the top app bar indicator
} }
private fun formatServerUrlForDisplay(raw: String): String { private fun formatServerUrlForDisplay(raw: String): String {
var s = raw.trim() var s = raw.trim()
if (s.isEmpty()) return "" if (s.isEmpty()) return ""
@@ -33,7 +33,10 @@ class ListChangeNotificationWorker(
} }
} }
private fun emitNotificationsFor(list: CollabList, prefs: PreferencesManager) { private fun emitNotificationsFor(
list: CollabList,
prefs: PreferencesManager,
) {
// Determine event type // Determine event type
when { when {
list.isDeleted -> { list.isDeleted -> {
+4 -5
View File
@@ -2,9 +2,8 @@
plugins { plugins {
id 'com.android.application' version '8.8.0' apply false id 'com.android.application' version '8.8.0' apply false
id 'com.android.library' version '8.8.0' apply false id 'com.android.library' version '8.8.0' apply false
id 'org.jetbrains.kotlin.android' version '2.0.21' apply false id 'org.jetbrains.kotlin.android' version '2.2.21' apply false
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.21' apply false id 'org.jetbrains.kotlin.plugin.compose' version '2.2.21' apply false
id 'com.google.devtools.ksp' version '2.0.21-1.0.27' apply false id 'org.jlleitschuh.gradle.ktlint' version '14.0.1' apply false
id 'org.jlleitschuh.gradle.ktlint' version '12.1.0' apply false id 'io.gitlab.arturbosch.detekt' version '1.23.8' apply false
id 'io.gitlab.arturbosch.detekt' version '1.23.6' apply false
} }
View File