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'
|
||||
|
||||
@@ -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,16 +43,19 @@ class CollabTableApplication : Application() {
|
||||
scheduleListChangeWorker()
|
||||
|
||||
// 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) {
|
||||
NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val listEventsChannel = NotificationChannel(
|
||||
val listEventsChannel =
|
||||
NotificationChannel(
|
||||
NotificationHelper.CHANNEL_LIST_EVENTS,
|
||||
"List Events",
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
@@ -63,7 +66,8 @@ class CollabTableApplication : Application() {
|
||||
}
|
||||
|
||||
private fun scheduleListChangeWorker() {
|
||||
val work = PeriodicWorkRequestBuilder<ListChangeNotificationWorker>(15, TimeUnit.MINUTES)
|
||||
val work =
|
||||
PeriodicWorkRequestBuilder<ListChangeNotificationWorker>(15, TimeUnit.MINUTES)
|
||||
.build()
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
"list_change_notifier",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -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(
|
||||
Room
|
||||
.databaseBuilder(
|
||||
context.applicationContext,
|
||||
CollabTableDatabase::class.java,
|
||||
"collab_table_database",
|
||||
)
|
||||
.addMigrations(migration1To2, migration2To3)
|
||||
).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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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) ->
|
||||
|
||||
+20
-8
@@ -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
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -108,7 +113,8 @@ class SyncRepository(context: Context) {
|
||||
// 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 {
|
||||
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
|
||||
@@ -121,13 +127,17 @@ class SyncRepository(context: Context) {
|
||||
}
|
||||
|
||||
// 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 }
|
||||
val localFields = database.fieldDao().getFieldsUpdatedSince(lastLocalTs)
|
||||
val localFields =
|
||||
database.fieldDao().getFieldsUpdatedSince(lastLocalTs)
|
||||
.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 }
|
||||
val localValues = database.itemValueDao().getValuesUpdatedSince(lastLocalTs)
|
||||
val localValues =
|
||||
database.itemValueDao().getValuesUpdatedSince(lastLocalTs)
|
||||
.filter { it.updatedAt in (lastLocalTs + 1)..localSnapshotTs }
|
||||
|
||||
// Only log send when there are actual local changes
|
||||
@@ -152,7 +162,8 @@ class SyncRepository(context: Context) {
|
||||
)
|
||||
|
||||
// HTTP-only sync
|
||||
val response = try {
|
||||
val response =
|
||||
try {
|
||||
api.sync(syncRequest)
|
||||
} catch (e: UnknownHostException) {
|
||||
// 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 hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
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_ETHERNET)
|
||||
return hasInternet && isValidated && hasTransport
|
||||
|
||||
+44
-11
@@ -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,9 +76,10 @@ 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)
|
||||
val summary =
|
||||
NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("List activity")
|
||||
.setContentText("Recent list changes")
|
||||
@@ -63,8 +92,12 @@ object NotificationHelper {
|
||||
notify(context, SUMMARY_ID, summary)
|
||||
}
|
||||
|
||||
private fun recordEventLine(type: String, text: String) {
|
||||
val line = when (type) {
|
||||
private fun recordEventLine(
|
||||
type: String,
|
||||
text: String,
|
||||
) {
|
||||
val line =
|
||||
when (type) {
|
||||
"add" -> "Added • $text"
|
||||
"edit" -> "Edited • $text"
|
||||
"remove" -> "Removed • $text"
|
||||
|
||||
+19
-7
@@ -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,7 +107,8 @@ fun ConnectionStatusAction(
|
||||
}
|
||||
}
|
||||
|
||||
val dotColor: Color = when (ok) {
|
||||
val dotColor: Color =
|
||||
when (ok) {
|
||||
true -> Color(0xFF2E7D32) // green
|
||||
false -> MaterialTheme.colorScheme.error
|
||||
null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -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
|
||||
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
|
||||
|
||||
+35
-29
@@ -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,7 +185,8 @@ fun ListDetailScreen(
|
||||
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_item))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
@@ -201,7 +195,10 @@ fun ListDetailScreen(
|
||||
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,7 +403,8 @@ fun ListDetailScreen(
|
||||
onClick = {
|
||||
val newWidths = mutableMapOf<String, Float>()
|
||||
stableFields.forEach { field ->
|
||||
val headerPx = textMeasurer.measure(
|
||||
val headerPx =
|
||||
textMeasurer.measure(
|
||||
AnnotatedString(field.name),
|
||||
style = headerTextStyle,
|
||||
).size.width.toFloat()
|
||||
@@ -416,7 +414,8 @@ fun ListDetailScreen(
|
||||
val v = itemWithValues.values.find { it.fieldId == field.id }?.value
|
||||
val display = getDisplayTextForMeasure(field, v)
|
||||
if (display.isNotEmpty()) {
|
||||
val w = textMeasurer.measure(
|
||||
val w =
|
||||
textMeasurer.measure(
|
||||
AnnotatedString(display),
|
||||
style = bodyTextStyle,
|
||||
).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 contentDp = maxContentPx.toDp() + 8.dp + 8.dp + 2.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)
|
||||
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,7 +767,8 @@ fun FieldHeader(
|
||||
Text(
|
||||
text = field.name,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.clickable { onHeaderClick() },
|
||||
)
|
||||
@@ -797,7 +802,8 @@ 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 {
|
||||
autoScrollJob =
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
// Keep expanding a bit and reveal the end as space grows
|
||||
onWidthChange(autoStepDp)
|
||||
|
||||
+40
-22
@@ -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,7 +210,8 @@ fun ListsScreen(
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Controls above the list (align with table view UX)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
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
|
||||
// modifier that uses additional vertical padding but negative padding inside to keep visual spacing.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
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)
|
||||
// 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
|
||||
modifier =
|
||||
Modifier
|
||||
.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,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -446,7 +448,8 @@ private fun SortMenu(prefs: PreferencesManager) {
|
||||
val currentOrder by prefs.sortOrder.collectAsState(initial = prefs.getSortOrder())
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val label = when (currentOrder) {
|
||||
val label =
|
||||
when (currentOrder) {
|
||||
PreferencesManager.SORT_UPDATED_DESC -> "Updated ↓"
|
||||
PreferencesManager.SORT_UPDATED_ASC -> "Updated ↑"
|
||||
PreferencesManager.SORT_NAME_ASC -> "Name A–Z"
|
||||
@@ -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
|
||||
|
||||
+26
-11
@@ -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,7 +34,8 @@ class ServerSetupViewModel(
|
||||
val validationError: StateFlow<String?> = _validationError.asStateFlow()
|
||||
|
||||
private val okHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
@@ -99,17 +100,28 @@ class ServerSetupViewModel(
|
||||
}
|
||||
|
||||
// On physical devices, block local-only hosts that won't resolve
|
||||
val isEmulator = run {
|
||||
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")
|
||||
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 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