feat: enhance data access objects with additional Room annotations and improve database migration logic

This commit is contained in:
2025-10-25 20:19:51 +02:00
parent 56be50646b
commit aecee4420d
18 changed files with 387 additions and 123 deletions
+12
View File
@@ -0,0 +1,12 @@
root = true
[*.{kt,kts}]
ktlint_code_style = ktlint_official
indent_size = 4
ij_kotlin_allow_trailing_comma = true
# KtLint >= 0.49: disable specific rules via fully qualified rule ids
ktlint_standard_function-naming = disabled
ktlint_standard_max-line-length = enabled
max_line_length = 180
ij_kotlin_name_count_to_use_star_import = 999
ij_kotlin_name_count_to_use_star_import_for_members = 999
+5
View File
@@ -105,3 +105,8 @@ detekt {
buildUponDefaultConfig = true buildUponDefaultConfig = true
allRules = false allRules = false
} }
// Ensure Detekt uses a supported JVM target
tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach {
jvmTarget = "21"
}
@@ -1,6 +1,10 @@
package com.collabtable.app.data.dao package com.collabtable.app.data.dao
import androidx.room.* import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import com.collabtable.app.data.model.Field import com.collabtable.app.data.model.Field
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -1,6 +1,10 @@
package com.collabtable.app.data.dao package com.collabtable.app.data.dao
import androidx.room.* import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import com.collabtable.app.data.model.Item import com.collabtable.app.data.model.Item
import com.collabtable.app.data.model.ItemWithValues import com.collabtable.app.data.model.ItemWithValues
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -1,6 +1,9 @@
package com.collabtable.app.data.dao package com.collabtable.app.data.dao
import androidx.room.* import androidx.room.Dao
import androidx.room.Query
import androidx.room.Update
import androidx.room.Upsert
import com.collabtable.app.data.model.ItemValue import com.collabtable.app.data.model.ItemValue
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -1,6 +1,10 @@
package com.collabtable.app.data.dao package com.collabtable.app.data.dao
import androidx.room.* import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.model.ListWithFields import com.collabtable.app.data.model.ListWithFields
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -6,10 +6,16 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.collabtable.app.data.dao.* import com.collabtable.app.data.dao.FieldDao
import com.collabtable.app.data.model.* import com.collabtable.app.data.dao.ItemDao
import com.collabtable.app.data.dao.ItemValueDao
import com.collabtable.app.data.dao.ListDao
import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.model.Field
import com.collabtable.app.data.model.Item
import com.collabtable.app.data.model.ItemValue
val MIGRATION_1_2 = val migration1To2 =
object : Migration(1, 2) { object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE fields ADD COLUMN fieldType TEXT NOT NULL DEFAULT 'STRING'") database.execSQL("ALTER TABLE fields ADD COLUMN fieldType TEXT NOT NULL DEFAULT 'STRING'")
@@ -17,7 +23,7 @@ val MIGRATION_1_2 =
} }
} }
val MIGRATION_2_3 = val migration2To3 =
object : Migration(2, 3) { object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
// Local-only column for manual reordering; nullable so existing rows remain unaffected // Local-only column for manual reordering; nullable so existing rows remain unaffected
@@ -46,28 +52,28 @@ abstract class CollabTableDatabase : RoomDatabase() {
companion object { companion object {
@Volatile @Volatile
private var INSTANCE: CollabTableDatabase? = null private var dbInstance: CollabTableDatabase? = null
fun getDatabase(context: Context): CollabTableDatabase { fun getDatabase(context: Context): CollabTableDatabase {
return INSTANCE ?: synchronized(this) { return 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(MIGRATION_1_2, MIGRATION_2_3) .addMigrations(migration1To2, migration2To3)
.build() .build()
INSTANCE = instance dbInstance = instance
instance instance
} }
} }
fun clearDatabase(context: Context) { fun clearDatabase(context: Context) {
synchronized(this) { synchronized(this) {
INSTANCE?.close() dbInstance?.close()
context.deleteDatabase("collab_table_database") context.deleteDatabase("collab_table_database")
INSTANCE = null dbInstance = null
} }
} }
} }
@@ -61,7 +61,8 @@ data class Field(
val listId: String, val listId: String,
val name: String, val name: String,
val fieldType: String = "TEXT", val fieldType: String = "TEXT",
val fieldOptions: String = "", // JSON string for dropdown options, currency symbol, etc. // JSON string for dropdown options, currency symbol, etc.
val fieldOptions: String = "",
val order: Int, val order: Int,
val createdAt: Long, val createdAt: Long,
val updatedAt: Long, val updatedAt: Long,
@@ -132,17 +132,17 @@ class PreferencesManager(context: Context) {
private const val KEY_THEME_MODE = "theme_mode" // system|light|dark private const val KEY_THEME_MODE = "theme_mode" // system|light|dark
private const val KEY_DYNAMIC_COLOR = "dynamic_color" private const val KEY_DYNAMIC_COLOR = "dynamic_color"
private const val KEY_AMOLED_DARK = "amoled_dark" private const val KEY_AMOLED_DARK = "amoled_dark"
private const val KEY_SORT_ORDER = "sort_order" private const val KEY_SORT_ORDER = "sort_order"
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/" private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
const val THEME_MODE_SYSTEM = "system" const val THEME_MODE_SYSTEM = "system"
const val THEME_MODE_LIGHT = "light" const val THEME_MODE_LIGHT = "light"
const val THEME_MODE_DARK = "dark" const val THEME_MODE_DARK = "dark"
// Sort orders // Sort orders
const val SORT_UPDATED_DESC = "updated_desc" // Newest first (default) const val SORT_UPDATED_DESC = "updated_desc" // Newest first (default)
const val SORT_UPDATED_ASC = "updated_asc" // Oldest first const val SORT_UPDATED_ASC = "updated_asc" // Oldest first
const val SORT_NAME_ASC = "name_asc" // A-Z const val SORT_NAME_ASC = "name_asc" // A-Z
const val SORT_NAME_DESC = "name_desc" // Z-A const val SORT_NAME_DESC = "name_desc" // Z-A
@Volatile @Volatile
private var instance: PreferencesManager? = null private var instance: PreferencesManager? = null
@@ -44,7 +44,9 @@ class SyncRepository(context: Context) {
if (localTotal > 0) { if (localTotal > 0) {
Logger.i( Logger.i(
"Sync", "Sync",
"[OUT] Sending changes (lists=${localLists.size}, fields=${localFields.size}, items=${localItems.size}, values=${localValues.size})", "[OUT] Sending changes " +
"(lists=${localLists.size}, fields=${localFields.size}, " +
"items=${localItems.size}, values=${localValues.size})",
) )
} }
@@ -78,23 +80,41 @@ class SyncRepository(context: Context) {
response.body()!! response.body()!!
} }
// Only log receive when there are actual server changes // Only log receive when there are actual server changes
val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size val inTotal =
syncResponse.lists.size +
syncResponse.fields.size +
syncResponse.items.size +
syncResponse.itemValues.size
if (inTotal > 0) { if (inTotal > 0) {
Logger.i( Logger.i(
"Sync", "Sync",
"[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})", "[IN] Received changes " +
"(lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, " +
"items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})",
) )
} }
// Apply server changes to local database atomically in correct order // Apply server changes to local database atomically in correct order
database.withTransaction { database.withTransaction {
// 1) Upsert lists first, preserving local orderIndex when present // 1) Upsert lists first, preserving local orderIndex when present
val idOrderMap = database.listDao().getListIdsAndOrder().associate { it.id to it.orderIndex } val localIdToOrder = database.listDao().getListIdsAndOrder().associate { it.id to it.orderIndex }
val listsPreservingOrder = syncResponse.lists.map { incoming -> // Build a map of local updatedAt to avoid overwriting newer local changes with older server data
val localOrder = idOrderMap[incoming.id] val localUpdatedMap = database.listDao().getListsUpdatedSince(0).associateBy({ it.id }, { it.updatedAt })
if (localOrder != null) incoming.copy(orderIndex = localOrder) else incoming
val listsPreservingOrder =
syncResponse.lists
.filter { incoming ->
val localUpdated = localUpdatedMap[incoming.id]
localUpdated == null || incoming.updatedAt >= localUpdated
}
.map { incoming ->
val localOrder = localIdToOrder[incoming.id]
if (localOrder != null) incoming.copy(orderIndex = localOrder) else incoming
}
if (listsPreservingOrder.isNotEmpty()) {
database.listDao().insertLists(listsPreservingOrder)
} }
database.listDao().insertLists(listsPreservingOrder)
// Build set of existing list ids to guard child inserts // Build set of existing list ids to guard child inserts
val existingListIds = database.listDao().getAllListIds().toSet() val existingListIds = database.listDao().getAllListIds().toSet()
@@ -1,12 +1,26 @@
package com.collabtable.app.ui.screens @file:Suppress("ktlint:standard:no-wildcard-imports")
package com.collabtable.app.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border 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.layout.* 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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@@ -16,14 +30,34 @@ 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.* import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
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.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.List
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.Star
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.DatePicker import androidx.compose.runtime.Composable
import androidx.compose.material3.DatePickerDialog import androidx.compose.runtime.LaunchedEffect
import androidx.compose.material3.TimePicker import androidx.compose.runtime.collectAsState
import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.derivedStateOf
import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.getValue
import androidx.compose.runtime.* import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@@ -45,8 +79,13 @@ import com.collabtable.app.R
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 org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Calendar
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
@@ -2464,12 +2503,21 @@ fun ManageColumnsDialog(
Divider() Divider()
// Column list // Column list with drag-and-drop reordering
val reorderState =
rememberReorderableLazyListState(onMove = { from, to ->
val item = reorderedFields.removeAt(from.index)
val target = if (to.index > reorderedFields.size) reorderedFields.size else to.index
reorderedFields.add(target, item)
})
LazyColumn( LazyColumn(
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth()
.reorderable(reorderState),
state = reorderState.listState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -2477,25 +2525,35 @@ fun ManageColumnsDialog(
items = reorderedFields, items = reorderedFields,
key = { _, field -> field.id }, key = { _, field -> field.id },
) { index, field -> ) { index, field ->
ColumnItem( ReorderableItem(reorderState, key = field.id) { _ ->
field = field, ColumnItem(
onEdit = { fieldToEdit = field }, field = field,
onDelete = { fieldToDelete = field }, onEdit = { fieldToEdit = field },
onMoveUp = { onDelete = { fieldToDelete = field },
if (index > 0) { onMoveUp = {
val item = reorderedFields.removeAt(index) if (index > 0) {
reorderedFields.add(index - 1, item) val item = reorderedFields.removeAt(index)
} reorderedFields.add(index - 1, item)
}, }
onMoveDown = { },
if (index < reorderedFields.size - 1) { onMoveDown = {
val item = reorderedFields.removeAt(index) if (index < reorderedFields.size - 1) {
reorderedFields.add(index + 1, item) val item = reorderedFields.removeAt(index)
} reorderedFields.add(index + 1, item)
}, }
canMoveUp = index > 0, },
canMoveDown = index < reorderedFields.size - 1, canMoveUp = index > 0,
) canMoveDown = index < reorderedFields.size - 1,
dragHandle = {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = "Reorder",
modifier = Modifier.detectReorder(reorderState),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
} }
} }
@@ -2587,6 +2645,7 @@ fun ColumnItem(
onMoveDown: () -> Unit, onMoveDown: () -> Unit,
canMoveUp: Boolean, canMoveUp: Boolean,
canMoveDown: Boolean, canMoveDown: Boolean,
dragHandle: (@Composable () -> Unit)? = null,
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -2604,39 +2663,44 @@ fun ColumnItem(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Move up/down buttons // Drag handle and optional move buttons
Column { Column(horizontalAlignment = Alignment.CenterHorizontally) {
IconButton( if (dragHandle != null) {
onClick = onMoveUp, dragHandle()
enabled = canMoveUp,
modifier = Modifier.size(32.dp),
) {
Icon(
Icons.Default.KeyboardArrowUp,
contentDescription = "Move up",
tint =
if (canMoveUp) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
},
)
} }
IconButton( Row {
onClick = onMoveDown, IconButton(
enabled = canMoveDown, onClick = onMoveUp,
modifier = Modifier.size(32.dp), enabled = canMoveUp,
) { modifier = Modifier.size(32.dp),
Icon( ) {
Icons.Default.KeyboardArrowDown, Icon(
contentDescription = "Move down", Icons.Default.KeyboardArrowUp,
tint = contentDescription = "Move up",
if (canMoveDown) { tint =
MaterialTheme.colorScheme.onSurfaceVariant if (canMoveUp) {
} else { MaterialTheme.colorScheme.onSurfaceVariant
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) } else {
}, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
) },
)
}
IconButton(
onClick = onMoveDown,
enabled = canMoveDown,
modifier = Modifier.size(32.dp),
) {
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = "Move down",
tint =
if (canMoveDown) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
},
)
}
} }
} }
@@ -2662,8 +2726,10 @@ fun ColumnItem(
// Selection types // Selection types
com.collabtable.app.data.model.FieldType.CHECKBOX -> "Checkbox" com.collabtable.app.data.model.FieldType.CHECKBOX -> "Checkbox"
com.collabtable.app.data.model.FieldType.SWITCH -> "Switch" com.collabtable.app.data.model.FieldType.SWITCH -> "Switch"
com.collabtable.app.data.model.FieldType.DROPDOWN -> "Dropdown (${field.getDropdownOptions().size} options)" com.collabtable.app.data.model.FieldType.DROPDOWN ->
com.collabtable.app.data.model.FieldType.AUTOCOMPLETE -> "Autocomplete (${field.getAutocompleteOptions().size} options)" "Dropdown (${field.getDropdownOptions().size} options)"
com.collabtable.app.data.model.FieldType.AUTOCOMPLETE ->
"Autocomplete (${field.getAutocompleteOptions().size} options)"
// Link types // Link types
com.collabtable.app.data.model.FieldType.URL -> "URL" com.collabtable.app.data.model.FieldType.URL -> "URL"
@@ -5,7 +5,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.room.withTransaction import androidx.room.withTransaction
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.* import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.model.Field
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 com.collabtable.app.data.repository.SyncRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -15,8 +15,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DragHandle
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.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.Refresh
@@ -37,6 +37,7 @@ import androidx.compose.material3.TextButton
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.compose.runtime.LaunchedEffect
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
@@ -144,23 +145,56 @@ fun ListsScreen(
) )
} }
} else { } else {
val reorderState = rememberReorderableLazyListState(onMove = { from, to -> // Maintain a working copy for smooth drag animations; commit to DB on drag end
val fromIdx = from.index var working by remember { mutableStateOf(lists) }
val toIdx = to.index var dragging by remember { mutableStateOf(false) }
if (fromIdx != toIdx) { // Keep working list in sync when not dragging
viewModel.reorder(fromIdx, toIdx) LaunchedEffect(lists, dragging) {
} if (!dragging) working = lists
}) }
fun MutableList<CollabList>.move(
from: Int,
to: Int,
) {
if (from == to) return
val item = removeAt(from)
add(
if (to > size) {
size
} else if (to < 0) {
0
} else {
to
},
item,
)
}
val reorderState =
rememberReorderableLazyListState(
onMove = { from, to ->
dragging = true
val newList = working.toMutableList()
newList.move(from.index, to.index)
working = newList
},
onDragEnd = { _, _ ->
dragging = false
viewModel.commitReorder(working.map { it.id })
},
)
LazyColumn( LazyColumn(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.reorderable(reorderState), .fillMaxSize()
.reorderable(reorderState),
state = reorderState.listState, state = reorderState.listState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
itemsIndexed(lists, key = { _, it -> it.id }) { index, list -> itemsIndexed(working, key = { _, it -> it.id }) { index, list ->
ReorderableItem(reorderState, key = list.id) { isDragging -> ReorderableItem(reorderState, key = list.id) { isDragging ->
ListItem( ListItem(
list = list, list = list,
@@ -250,9 +284,10 @@ fun ListItem(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column( Column(
modifier = Modifier modifier =
.weight(1f) Modifier
.clickable(onClick = onListClick), .weight(1f)
.clickable(onClick = onListClick),
) { ) {
Text(text = list.name, style = MaterialTheme.typography.titleMedium) Text(text = list.name, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
@@ -365,7 +400,10 @@ private fun SortMenu(prefs: PreferencesManager) {
onDismissRequest = { expanded = false }, onDismissRequest = { expanded = false },
) { ) {
@Composable @Composable
fun ItemOption(label: String, value: String) { fun ItemOption(
label: String,
value: String,
) {
androidx.compose.material3.DropdownMenuItem( androidx.compose.material3.DropdownMenuItem(
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -65,7 +65,10 @@ class ListsViewModel(
} }
} }
fun reorder(fromIndex: Int, toIndex: Int) { fun reorder(
fromIndex: Int,
toIndex: Int,
) {
viewModelScope.launch { viewModelScope.launch {
val current = _lists.value.toMutableList() val current = _lists.value.toMutableList()
if (fromIndex !in current.indices || toIndex !in current.indices) return@launch if (fromIndex !in current.indices || toIndex !in current.indices) return@launch
@@ -88,6 +91,13 @@ class ListsViewModel(
} }
} }
fun commitReorder(newOrderIds: List<String>) {
viewModelScope.launch {
val pairs = newOrderIds.mapIndexed { idx, id -> id to idx.toLong() }
database.listDao().updateOrderIndexes(pairs)
}
}
private suspend fun startPeriodicSync() { private suspend fun startPeriodicSync() {
while (true) { while (true) {
kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync
@@ -1,7 +1,15 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* 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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -12,8 +20,30 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
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.* import androidx.compose.material3.Badge
import androidx.compose.runtime.* 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
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.graphics.Color import androidx.compose.ui.graphics.Color
@@ -1,14 +1,38 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.* import androidx.compose.material3.Button
import androidx.compose.runtime.* import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -1,14 +1,44 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.SettingsBrightness import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.material3.* import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.runtime.* import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -232,7 +262,9 @@ fun SettingsScreen(
title = { Text("Leave Server?") }, title = { Text("Leave Server?") },
text = { text = {
Text( Text(
"All your data will be synced to the server before disconnecting. After leaving, all local data will be deleted and you'll need to set up a new connection. This action cannot be undone.", "All your data will be synced to the server before disconnecting. " +
"After leaving, all local data will be deleted and you'll need to set up a new connection. " +
"This action cannot be undone.",
) )
}, },
confirmButton = { confirmButton = {
@@ -5,7 +5,8 @@ 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 java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import java.util.Locale
data class LogEntry( data class LogEntry(
val timestamp: Long, val timestamp: Long,