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 '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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
+5
-2
@@ -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) ->
|
||||||
|
|||||||
+20
-8
@@ -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
|
||||||
|
|||||||
+44
-11
@@ -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"
|
||||||
|
|||||||
+19
-7
@@ -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
|
||||||
|
|||||||
+35
-29
@@ -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)
|
||||||
|
|||||||
+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.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 A–Z"
|
PreferencesManager.SORT_NAME_ASC -> "Name A–Z"
|
||||||
@@ -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 (A–Z)") },
|
text = { Text("Name (A–Z)") },
|
||||||
onClick = { prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC); expanded = false },
|
onClick = {
|
||||||
|
prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC)
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Name (Z–A)") },
|
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)
|
// 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+3
-3
@@ -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
|
||||||
|
|||||||
+26
-11
@@ -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()
|
||||||
|
|||||||
+6
-11
@@ -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 ""
|
||||||
|
|||||||
+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
|
// Determine event type
|
||||||
when {
|
when {
|
||||||
list.isDeleted -> {
|
list.isDeleted -> {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user