Refactor ListDetailScreen and ViewModel for improved readability and performance

- Cleaned up imports and organized them in ListDetailScreen.kt
- Enhanced function formatting and readability in ListDetailScreen.kt
- Updated ListDetailViewModel.kt to improve data loading logic
- Refactored ListsScreen.kt for better layout and UI consistency
- Simplified sorting logic in ListsScreen.kt
- Improved notification handling in ListChangeNotificationWorker.kt
- Updated build.gradle to use newer versions of Kotlin and Detekt
- Added a new Detekt configuration file for better code quality checks
- Refactored ServerSetupScreen.kt and ServerSetupViewModel.kt for clarity and maintainability
- Cleaned up LogsScreen.kt to enhance readability
- Improved SettingsScreen.kt by removing unnecessary imports and organizing code
This commit is contained in:
2025-11-11 21:31:45 +01:00
parent 7a09cfd230
commit fd72959401
20 changed files with 979 additions and 876 deletions
+6 -8
View File
@@ -2,7 +2,8 @@ plugins {
id 'com.android.application'
id '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()
}
@@ -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()
}
}
}
@@ -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) ->
@@ -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,19 +162,20 @@ 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.
networkBackoffMs = if (networkBackoffMs == 0L) 5_000L else (networkBackoffMs * 2).coerceAtMost(180_000L)
networkBackoffUntil = System.currentTimeMillis() + networkBackoffMs
Logger.w("Sync", "Network host unresolved (DNS). Backing off for ${networkBackoffMs/1000}s: ${e.message}")
Logger.w("Sync", "Network host unresolved (DNS). Backing off for ${networkBackoffMs / 1000}s: ${e.message}")
return@withContext Result.failure(Exception("Network unreachable: unable to resolve host"))
} catch (e: Exception) {
if (e.message?.contains("Unable to resolve host", ignoreCase = true) == true) {
networkBackoffMs = if (networkBackoffMs == 0L) 5_000L else (networkBackoffMs * 2).coerceAtMost(180_000L)
networkBackoffUntil = System.currentTimeMillis() + networkBackoffMs
Logger.w("Sync", "DNS failure. Backing off for ${networkBackoffMs/1000}s: ${e.message}")
Logger.w("Sync", "DNS failure. Backing off for ${networkBackoffMs / 1000}s: ${e.message}")
return@withContext Result.failure(Exception("Network unreachable: unable to resolve host"))
}
throw e
@@ -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
@@ -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"
@@ -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
@@ -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)
@@ -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 AZ"
@@ -461,26 +464,41 @@ private fun SortMenu(prefs: PreferencesManager) {
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("Updated (newest first)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_UPDATED_DESC); expanded = false },
onClick = {
prefs.setSortOrder(PreferencesManager.SORT_UPDATED_DESC)
expanded = false
},
)
DropdownMenuItem(
text = { Text("Updated (oldest first)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_UPDATED_ASC); expanded = false },
onClick = {
prefs.setSortOrder(PreferencesManager.SORT_UPDATED_ASC)
expanded = false
},
)
DropdownMenuItem(
text = { Text("Name (AZ)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC); expanded = false },
onClick = {
prefs.setSortOrder(PreferencesManager.SORT_NAME_ASC)
expanded = false
},
)
DropdownMenuItem(
text = { Text("Name (ZA)") },
onClick = { prefs.setSortOrder(PreferencesManager.SORT_NAME_DESC); expanded = false },
onClick = {
prefs.setSortOrder(PreferencesManager.SORT_NAME_DESC)
expanded = false
},
)
}
}
}
// Format updatedAt relative to now: "Updated 5 sec/min/hour(s) ago" (days included when applicable)
private fun formatUpdatedAgo(updatedAt: Long, nowMs: Long): String {
private fun formatUpdatedAgo(
updatedAt: Long,
nowMs: Long,
): String {
val delta = (nowMs - updatedAt).coerceAtLeast(0L)
val seconds = delta / 1000
return when {
@@ -492,12 +510,12 @@ private fun formatUpdatedAgo(updatedAt: Long, nowMs: Long): String {
seconds < 86_400 -> {
val hours = seconds / 3600
val unit = if (hours == 1L) "hour" else "hours"
"Updated ${hours} $unit ago"
"Updated $hours $unit ago"
}
else -> {
val days = seconds / 86_400
val unit = if (days == 1L) "day" else "days"
"Updated ${days} $unit ago"
"Updated $days $unit ago"
}
}
}
@@ -1,14 +1,14 @@
package com.collabtable.app.ui.screens
import android.content.Context
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.notifications.NotificationHelper
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.Lifecycle
import com.collabtable.app.utils.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -18,7 +18,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.Badge
@@ -26,6 +25,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -1,5 +1,8 @@
package com.collabtable.app.ui.screens
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -41,10 +44,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.collabtable.app.data.preferences.PreferencesManager
import android.os.Build
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -1,5 +1,7 @@
package com.collabtable.app.ui.screens
import android.net.Uri
import android.os.Build
import android.os.NetworkOnMainThreadException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -18,8 +20,6 @@ import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit
import android.os.Build
import android.net.Uri
class ServerSetupViewModel(
private val preferencesManager: PreferencesManager,
@@ -34,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()
@@ -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 ""
@@ -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 -> {
+4 -5
View File
@@ -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
}
View File