feat: update dependencies and enhance sync logic for improved performance and reliability

This commit is contained in:
2025-11-08 21:24:38 +01:00
parent 0bc3b3b06f
commit aa2542a0ac
9 changed files with 164 additions and 105 deletions
+10 -9
View File
@@ -1,25 +1,26 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
id 'com.google.devtools.ksp'
id 'org.jlleitschuh.gradle.ktlint'
id 'io.gitlab.arturbosch.detekt'
}
android {
namespace 'com.collabtable.app'
compileSdk 34
namespace = 'com.collabtable.app'
compileSdk = 34
defaultConfig {
applicationId "com.collabtable.app"
minSdk 26
targetSdk 34
applicationId = "com.collabtable.app"
minSdk = 26
targetSdk = 34
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
useSupportLibrary = true
}
}
@@ -37,10 +38,10 @@ android {
jvmTarget = '21'
}
buildFeatures {
compose true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion '1.5.4'
kotlinCompilerExtensionVersion = '1.6.8'
}
packaging {
resources {
@@ -56,7 +57,7 @@ dependencies {
implementation 'androidx.activity:activity-compose:1.8.1'
// Compose
implementation platform('androidx.compose:compose-bom:2023.10.01')
implementation platform('androidx.compose:compose-bom:2024.06.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
@@ -2,11 +2,26 @@ package com.collabtable.app
import android.app.Application
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.repository.SyncRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class CollabTableApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize API client with saved server URL
ApiClient.initialize(this)
// Kick off a one-time full down-sync on cold app start if local DB is empty but server has data.
// This complements in-screen sync logic and ensures earliest possible hydration.
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
// Force a full down-sync on app start when server credentials are present.
SyncRepository(this@CollabTableApplication).performSync(forceFullDown = true)
} catch (_: Exception) {
// Silent; UI layers will surface subsequent sync issues.
}
}
}
}
@@ -1,6 +1,8 @@
package com.collabtable.app.data.repository
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.room.withTransaction
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.api.SyncRequest
@@ -63,11 +65,17 @@ class SyncRepository(context: Context) {
prefs.edit().putLong("last_local_sync_ts", ts).apply()
}
suspend fun performSync(): Result<Unit> =
suspend fun performSync(forceFullDown: Boolean = false): Result<Unit> =
withContext(Dispatchers.IO) {
// Prevent overlapping syncs across screens/viewmodels
syncMutex.withLock {
try {
// Only attempt network sync when app is in foreground to avoid background DNS issues
// Foreground check removed (lifecycle owner not available in this module); rely solely on network availability.
// Skip sync when there's no validated network available
if (!isNetworkAvailable()) {
return@withContext Result.success(Unit)
}
// If not authenticated, skip making network calls (e.g., after leaving server)
val prefMgr = PreferencesManager.getInstance(appContext)
val currentPassword = prefMgr.getServerPassword()?.trim()
@@ -100,9 +108,16 @@ 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 = if (lastServerTs > 0 && (!hasAnyLists || !hasAnyItems)) 0L else lastServerTs
val effectiveLastServerTs = when {
// App start can request a full down-sync regardless of local contents
forceFullDown && lastServerTs > 0L -> 0L
// Fallback: if local looks empty but we have a watermark, do a one-time full down-sync
lastServerTs > 0L && (!hasAnyLists || !hasAnyItems) -> 0L
else -> lastServerTs
}
if (effectiveLastServerTs == 0L && lastServerTs > 0L) {
Logger.w("Sync", "Local store empty but watermark set; performing one-time full down-sync")
val reason = if (forceFullDown) "startup forced" else "local empty"
Logger.w("Sync", "$reason full down-sync requested")
}
// Gather local changes since last sync
@@ -273,4 +288,16 @@ class SyncRepository(context: Context) {
companion object {
private val syncMutex = Mutex()
}
private fun isNetworkAvailable(): Boolean {
val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(network) ?: return false
val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val hasTransport = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
return hasInternet && isValidated && hasTransport
}
}
@@ -66,11 +66,13 @@ 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
@@ -124,15 +126,15 @@ fun ListDetailScreen(
// Use derivedStateOf to create stable references
val stableFields by remember { derivedStateOf { fields } }
val stableItems by remember { derivedStateOf { items } }
var showManageColumnsDialog by remember { mutableStateOf(false) }
var showAddItemDialog by remember { mutableStateOf(false) }
var pendingScrollToBottom by remember { mutableStateOf(false) }
var itemToEdit by remember { mutableStateOf<ItemWithValues?>(null) }
var showSortDialog by remember { mutableStateOf(false) }
var showGroupDialog by remember { mutableStateOf(false) }
var showFilterDialog by remember { mutableStateOf(false) }
var showRenameListDialog by remember { mutableStateOf(false) }
var showManageColumnsDialog by remember { mutableStateOf(false) }
var showAddItemDialog by remember { mutableStateOf(false) }
// Flag to trigger scroll to bottom after adding an item
var pendingScrollToBottom by remember { mutableStateOf(false) }
// Filter/Sort state
var sortField by remember { mutableStateOf<Field?>(null) }
@@ -162,7 +164,7 @@ fun ListDetailScreen(
// Scaffold with top bar and FAB
Scaffold(
topBar = {
SmallTopAppBar(
TopAppBar(
title = {
Row(
modifier = Modifier.clickable { showRenameListDialog = true },
@@ -183,9 +185,9 @@ fun ListDetailScreen(
}
},
actions = {
val context = LocalContext.current
val prefs = remember { PreferencesManager.getInstance(context) }
ConnectionStatusAction(prefs = prefs)
val localCtx = LocalContext.current
val localPrefs = remember { PreferencesManager.getInstance(localCtx) }
ConnectionStatusAction(prefs = localPrefs)
IconButton(onClick = { showManageColumnsDialog = true }) {
Icon(Icons.Default.Settings, contentDescription = "Manage Columns")
}
@@ -450,36 +452,7 @@ fun ListDetailScreen(
)
}
// Field headers with long-press to delete and resize handles
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(horizontalScrollState),
verticalAlignment = Alignment.CenterVertically,
) {
stableFields.forEach { field ->
key(field.id) {
FieldHeader(
field = field,
width = fieldWidths[field.id] ?: 150.dp,
onWidthChange = { delta ->
val currentWidth = fieldWidths[field.id] ?: 150.dp
// Enforce only a minimum width; remove the previous max (400dp)
val newWidth = (currentWidth.value + delta).coerceAtLeast(100f)
fieldWidths[field.id] = newWidth.dp
// Persist updated widths for this listId
prefs.setColumnWidths(
listId,
fieldWidths.mapValues { it.value.value },
)
},
scrollState = horizontalScrollState,
isLast = (field.id == stableFields.lastOrNull()?.id),
)
}
}
}
// Header will be rendered as a stickyHeader inside the LazyColumn below
// Items list with synchronized scrolling
if (stableItems.isEmpty()) {
@@ -528,11 +501,21 @@ fun ListDetailScreen(
pendingScrollToBottom = false
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
) {
groupedItems.entries.forEach { (groupName, groupItems) ->
// Measure header height so we can allow content to scroll "behind" it
// but still make the last item fully visible by providing extra bottom padding.
var headerHeightPx by remember { mutableStateOf(0) }
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(
top = 0.dp,
bottom = with(density) { headerHeightPx.toDp() },
),
) {
groupedItems.entries.forEach { (groupName, groupItems) ->
// Show group header if grouping is enabled
if (groupByField != null) {
item(key = "group_$groupName") {
@@ -565,26 +548,61 @@ fun ListDetailScreen(
}
}
// Filler area that occupies remaining viewport height and supports the same
// horizontal scroll physics (drag + fling) as the header/rows.
item(key = "hscroll_filler") {
val fillerScrollableState = rememberScrollableState { delta ->
// Mirror Row.horizontalScroll: rely on reverseDirection for LTR/RTL,
// and dispatch deltas directly to the shared ScrollState.
horizontalScrollState.dispatchRawDelta(delta)
// Filler area that occupies remaining viewport height and supports the same
// horizontal scroll physics (drag + fling) as the header/rows.
item(key = "hscroll_filler") {
val fillerScrollableState = rememberScrollableState { delta ->
// Mirror Row.horizontalScroll: rely on reverseDirection for LTR/RTL,
// and dispatch deltas directly to the shared ScrollState.
horizontalScrollState.dispatchRawDelta(delta)
}
Box(
modifier =
Modifier
.fillMaxWidth()
.fillParentMaxHeight()
.scrollable(
state = fillerScrollableState,
orientation = Orientation.Horizontal,
reverseDirection = LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Ltr,
flingBehavior = androidx.compose.foundation.gestures.ScrollableDefaults.flingBehavior(),
),
)
}
}
// Overlaid header: content scrolls behind this until the end,
// thanks to the extra bottom padding applied above.
Row(
modifier =
Modifier
.fillMaxWidth()
.onGloballyPositioned { coordinates -> headerHeightPx = coordinates.size.height }
.background(MaterialTheme.colorScheme.surface)
.horizontalScroll(horizontalScrollState)
.align(Alignment.TopStart)
.zIndex(1f),
verticalAlignment = Alignment.CenterVertically,
) {
stableFields.forEach { field ->
key(field.id) {
FieldHeader(
field = field,
width = fieldWidths[field.id] ?: 150.dp,
onWidthChange = { delta ->
val currentWidth = fieldWidths[field.id] ?: 150.dp
val newWidth = (currentWidth.value + delta).coerceAtLeast(100f)
fieldWidths[field.id] = newWidth.dp
prefs.setColumnWidths(
listId,
fieldWidths.mapValues { it.value.value },
)
},
scrollState = horizontalScrollState,
isLast = (field.id == stableFields.lastOrNull()?.id),
)
}
}
Box(
modifier =
Modifier
.fillMaxWidth()
.fillParentMaxHeight()
.scrollable(
state = fillerScrollableState,
orientation = Orientation.Horizontal,
reverseDirection = LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Ltr,
flingBehavior = androidx.compose.foundation.gestures.ScrollableDefaults.flingBehavior(),
),
)
}
}
}
@@ -1678,7 +1696,7 @@ fun EditFieldDialog(
onUpdate: (String, String, String) -> Unit,
) {
var name by remember { mutableStateOf(field.name) }
var selectedFieldType by remember { mutableStateOf(field.fieldType?.uppercase() ?: "TEXT") }
var selectedFieldType by remember { mutableStateOf(field.fieldType.uppercase()) }
var dropdownOptions by remember { mutableStateOf(field.getDropdownOptions().joinToString(", ")) }
var currency by remember { mutableStateOf(field.getCurrency()) }
var expanded by remember { mutableStateOf(false) }
@@ -2478,8 +2496,6 @@ fun FieldInput(
val options = field.getAutocompleteOptions()
if (options.isNotEmpty()) {
Text("Suggestions: ${options.take(3).joinToString(", ")}")
} else {
null
}
},
)
@@ -2853,6 +2869,7 @@ fun ManageColumnsDialog(
}
}
@Suppress("UNUSED_PARAMETER")
@Composable
fun ColumnItem(
field: Field,
@@ -11,6 +11,7 @@ import com.collabtable.app.data.model.Item
import com.collabtable.app.data.model.ItemValue
import com.collabtable.app.data.model.ItemWithValues
import com.collabtable.app.data.repository.SyncRepository
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import java.util.UUID
@OptIn(FlowPreview::class)
class ListDetailViewModel(
private val database: CollabTableDatabase,
private val listId: String,
@@ -17,13 +17,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.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.FilterList
import androidx.compose.material3.Badge
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
@@ -122,7 +122,7 @@ fun LogsScreen(onNavigateBack: () -> Unit) {
title = { Text("Logs (${filteredLogs.size}/${logs.size})") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
@@ -340,7 +340,7 @@ fun FilterBottomSheet(
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
// Time range filter
Text(
@@ -371,7 +371,7 @@ fun FilterBottomSheet(
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
// Tag filters
Text(
@@ -443,7 +443,7 @@ fun FilterBottomSheet(
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
// Reset button
Button(
@@ -6,16 +6,16 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.filled.ArrowBack
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.List
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
import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -30,9 +30,9 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.TextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
@@ -42,21 +42,17 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.collabtable.app.ui.components.ConnectionStatusAction
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.input.PasswordVisualTransformation
import com.collabtable.app.R
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.ui.components.ConnectionStatusAction
import com.collabtable.app.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -95,7 +91,7 @@ fun SettingsScreen(
title = { Text(stringResource(R.string.settings)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
@@ -165,19 +161,19 @@ fun SettingsScreen(
selected = themeMode == PreferencesManager.THEME_MODE_SYSTEM,
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_SYSTEM) },
label = { Text("System") },
leadingIcon = { Icon(Icons.Default.SettingsBrightness, contentDescription = null) },
leadingIcon = { Icon(Icons.Filled.SettingsBrightness, contentDescription = null) },
)
FilterChip(
selected = themeMode == PreferencesManager.THEME_MODE_LIGHT,
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_LIGHT) },
label = { Text("Light") },
leadingIcon = { Icon(Icons.Default.LightMode, contentDescription = null) },
leadingIcon = { Icon(Icons.Filled.Brightness7, contentDescription = null) },
)
FilterChip(
selected = themeMode == PreferencesManager.THEME_MODE_DARK,
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_DARK) },
label = { Text("Dark") },
leadingIcon = { Icon(Icons.Default.DarkMode, contentDescription = null) },
leadingIcon = { Icon(Icons.Filled.Brightness4, contentDescription = null) },
)
}
@@ -300,7 +296,7 @@ fun SettingsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = onNavigateToLogs, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.List, contentDescription = null)
Icon(Icons.AutoMirrored.Filled.List, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Open Logs")
}
+5 -4
View File
@@ -1,9 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.3.0' apply false
id 'com.android.library' version '8.3.0' apply false
id 'org.jetbrains.kotlin.android' version '1.9.20' apply false
id 'com.google.devtools.ksp' version '1.9.20-1.0.14' apply false
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
}
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME