feat: implement manual reordering of lists with persistent order index and sorting preferences

This commit is contained in:
2025-10-25 19:19:20 +02:00
parent 886c665db6
commit 56be50646b
8 changed files with 243 additions and 47 deletions
@@ -5,9 +5,14 @@ import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.model.ListWithFields
import kotlinx.coroutines.flow.Flow
data class ListIdAndOrder(
val id: String,
val orderIndex: Long?,
)
@Dao
interface ListDao {
@Query("SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC")
@Query("SELECT * FROM lists WHERE isDeleted = 0")
fun getAllLists(): Flow<List<CollabList>>
@Transaction
@@ -20,6 +25,9 @@ interface ListDao {
@Query("SELECT id FROM lists")
suspend fun getAllListIds(): List<String>
@Query("SELECT id, orderIndex FROM lists")
suspend fun getListIdsAndOrder(): List<ListIdAndOrder>
@Upsert
suspend fun insertList(list: CollabList)
@@ -29,6 +37,19 @@ interface ListDao {
@Update
suspend fun updateList(list: CollabList)
@Query("UPDATE lists SET orderIndex = :orderIndex WHERE id = :listId")
suspend fun updateListOrderIndex(
listId: String,
orderIndex: Long,
)
@Transaction
suspend fun updateOrderIndexes(order: List<Pair<String, Long>>) {
order.forEach { (id, idx) ->
updateListOrderIndex(id, idx)
}
}
@Query("UPDATE lists SET isDeleted = 1, updatedAt = :timestamp WHERE id = :listId")
suspend fun softDeleteList(
listId: String,
@@ -17,6 +17,14 @@ val MIGRATION_1_2 =
}
}
val MIGRATION_2_3 =
object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
// Local-only column for manual reordering; nullable so existing rows remain unaffected
database.execSQL("ALTER TABLE lists ADD COLUMN orderIndex INTEGER")
}
}
@Database(
entities = [
CollabList::class,
@@ -24,7 +32,7 @@ val MIGRATION_1_2 =
Item::class,
ItemValue::class,
],
version = 2,
version = 3,
exportSchema = false,
)
abstract class CollabTableDatabase : RoomDatabase() {
@@ -48,7 +56,7 @@ abstract class CollabTableDatabase : RoomDatabase() {
CollabTableDatabase::class.java,
"collab_table_database",
)
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
INSTANCE = instance
instance
@@ -10,4 +10,6 @@ data class CollabList(
val createdAt: Long,
val updatedAt: Long,
val isDeleted: Boolean = false,
// Local-only ordering for manual reordering (not synced)
val orderIndex: Long? = null,
)
@@ -22,6 +22,9 @@ class PreferencesManager(context: Context) {
private val _amoledDark = MutableStateFlow(isAmoledDarkEnabled())
val amoledDark: StateFlow<Boolean> = _amoledDark.asStateFlow()
private val _sortOrder = MutableStateFlow(getSortOrder())
val sortOrder: StateFlow<String> = _sortOrder.asStateFlow()
fun getServerUrl(): String {
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
}
@@ -106,6 +109,21 @@ class PreferencesManager(context: Context) {
_amoledDark.value = enabled
}
// Sorting preferences for tables overview
fun getSortOrder(): String {
return prefs.getString(KEY_SORT_ORDER, SORT_UPDATED_DESC) ?: SORT_UPDATED_DESC
}
fun setSortOrder(order: String) {
val normalized =
when (order) {
SORT_UPDATED_DESC, SORT_UPDATED_ASC, SORT_NAME_ASC, SORT_NAME_DESC -> order
else -> SORT_UPDATED_DESC
}
prefs.edit().putString(KEY_SORT_ORDER, normalized).apply()
_sortOrder.value = normalized
}
companion object {
private const val KEY_SERVER_URL = "server_url"
private const val KEY_FIRST_RUN = "first_run"
@@ -114,11 +132,18 @@ class PreferencesManager(context: Context) {
private const val KEY_THEME_MODE = "theme_mode" // system|light|dark
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
private const val KEY_AMOLED_DARK = "amoled_dark"
private const val KEY_SORT_ORDER = "sort_order"
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
const val THEME_MODE_SYSTEM = "system"
const val THEME_MODE_LIGHT = "light"
const val THEME_MODE_DARK = "dark"
// Sort orders
const val SORT_UPDATED_DESC = "updated_desc" // Newest first (default)
const val SORT_UPDATED_ASC = "updated_asc" // Oldest first
const val SORT_NAME_ASC = "name_asc" // A-Z
const val SORT_NAME_DESC = "name_desc" // Z-A
@Volatile
private var instance: PreferencesManager? = null
@@ -88,8 +88,13 @@ class SyncRepository(context: Context) {
// Apply server changes to local database atomically in correct order
database.withTransaction {
// 1) Upsert lists first
database.listDao().insertLists(syncResponse.lists)
// 1) Upsert lists first, preserving local orderIndex when present
val idOrderMap = database.listDao().getListIdsAndOrder().associate { it.id to it.orderIndex }
val listsPreservingOrder = syncResponse.lists.map { incoming ->
val localOrder = idOrderMap[incoming.id]
if (localOrder != null) incoming.copy(orderIndex = localOrder) else incoming
}
database.listDao().insertLists(listsPreservingOrder)
// Build set of existing list ids to guard child inserts
val existingListIds = database.listDao().getAllListIds().toSet()
@@ -1,18 +1,47 @@
package com.collabtable.app.ui.screens
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
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.Edit
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material.icons.filled.Sort
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
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.OutlinedTextField
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.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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -21,8 +50,11 @@ import androidx.compose.ui.unit.dp
import com.collabtable.app.R
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList
import java.text.SimpleDateFormat
import java.util.*
import com.collabtable.app.data.preferences.PreferencesManager
import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -34,6 +66,7 @@ fun ListsScreen(
val context = LocalContext.current
val database = remember { CollabTableDatabase.getDatabase(context) }
val viewModel = remember { ListsViewModel(database, context) }
val prefs = remember { PreferencesManager.getInstance(context) }
val lists by viewModel.lists.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
@@ -53,8 +86,12 @@ fun ListsScreen(
IconButton(onClick = onNavigateToLogs) {
Icon(Icons.Default.List, contentDescription = "Logs")
}
SortMenu(prefs = prefs)
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
Icon(
Icons.Default.Settings,
contentDescription = stringResource(R.string.settings),
)
}
},
colors =
@@ -65,9 +102,7 @@ fun ListsScreen(
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showCreateDialog = true },
) {
FloatingActionButton(onClick = { showCreateDialog = true }) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.create_list))
}
},
@@ -79,7 +114,6 @@ fun ListsScreen(
.padding(padding),
) {
if (isLoading && lists.isEmpty()) {
// Show loading indicator during initial sync
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -93,7 +127,6 @@ fun ListsScreen(
)
}
} else if (lists.isEmpty()) {
// Show empty state
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -111,19 +144,39 @@ fun ListsScreen(
)
}
} else {
// Show lists
val reorderState = rememberReorderableLazyListState(onMove = { from, to ->
val fromIdx = from.index
val toIdx = to.index
if (fromIdx != toIdx) {
viewModel.reorder(fromIdx, toIdx)
}
})
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.reorderable(reorderState),
state = reorderState.listState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(lists, key = { it.id }) { list ->
ListItem(
list = list,
onListClick = { onNavigateToList(list.id) },
onEditClick = { listToEdit = list },
onDeleteClick = { listToDelete = list },
)
itemsIndexed(lists, key = { _, it -> it.id }) { index, list ->
ReorderableItem(reorderState, key = list.id) { isDragging ->
ListItem(
list = list,
onListClick = { onNavigateToList(list.id) },
onEditClick = { listToEdit = list },
onDeleteClick = { listToDelete = list },
dragHandle = {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = "Reorder",
modifier = Modifier.detectReorder(reorderState),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
}
}
}
@@ -181,12 +234,12 @@ fun ListItem(
onListClick: () -> Unit,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit,
dragHandle: (@Composable () -> Unit)? = null,
) {
Card(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onListClick),
.fillMaxWidth(),
) {
Row(
modifier =
@@ -196,19 +249,19 @@ fun ListItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = list.name,
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatDate(if (list.updatedAt > 0L) list.updatedAt else list.createdAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(
modifier = Modifier
.weight(1f)
.clickable(onClick = onListClick),
) {
Text(text = list.name, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(2.dp))
}
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
if (dragHandle != null) {
dragHandle()
Spacer(modifier = Modifier.height(0.dp).padding(end = 4.dp))
}
IconButton(onClick = onEditClick) {
Icon(
Icons.Default.Edit,
@@ -219,7 +272,7 @@ fun ListItem(
IconButton(onClick = onDeleteClick) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.delete),
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error,
)
}
@@ -297,8 +350,43 @@ private fun RenameListDialog(
)
}
private fun formatDate(timestamp: Long): String {
if (timestamp <= 0L) return ""
val sdf = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
@Composable
private fun SortMenu(prefs: PreferencesManager) {
val context = LocalContext.current
var expanded by remember { mutableStateOf(false) }
val currentOrder by prefs.sortOrder.collectAsState(initial = prefs.getSortOrder())
Box {
IconButton(onClick = { expanded = true }) {
Icon(Icons.Default.Sort, contentDescription = "Sort")
}
androidx.compose.material3.DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
@Composable
fun ItemOption(label: String, value: String) {
androidx.compose.material3.DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label)
if (currentOrder == value) {
Spacer(modifier = Modifier.weight(1f))
Text("")
}
}
},
onClick = {
prefs.setSortOrder(value)
expanded = false
},
)
}
ItemOption("Updated (newest first)", PreferencesManager.SORT_UPDATED_DESC)
ItemOption("Updated (oldest first)", PreferencesManager.SORT_UPDATED_ASC)
ItemOption("Name (AZ)", PreferencesManager.SORT_NAME_ASC)
ItemOption("Name (ZA)", PreferencesManager.SORT_NAME_DESC)
}
}
}
@@ -10,6 +10,7 @@ import com.collabtable.app.utils.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.util.UUID
@@ -38,12 +39,55 @@ class ListsViewModel(
private fun loadLists() {
viewModelScope.launch {
database.listDao().getAllLists().collect { listData ->
_lists.value = listData
val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
val listsFlow = database.listDao().getAllLists()
val sortFlow = prefs.sortOrder
combine(listsFlow, sortFlow) { listData, sortOrder ->
val anyManual = listData.any { it.orderIndex != null }
if (anyManual) {
listData.sortedWith(compareBy(nullsLast()) { it.orderIndex })
} else {
when (sortOrder) {
com.collabtable.app.data.preferences.PreferencesManager.SORT_NAME_ASC ->
listData.sortedBy { it.name.lowercase() }
com.collabtable.app.data.preferences.PreferencesManager.SORT_NAME_DESC ->
listData.sortedByDescending { it.name.lowercase() }
com.collabtable.app.data.preferences.PreferencesManager.SORT_UPDATED_ASC ->
listData.sortedBy { it.updatedAt }
else ->
listData.sortedByDescending { it.updatedAt }
}
}
}.collect { sorted ->
_lists.value = sorted
}
}
}
fun reorder(fromIndex: Int, toIndex: Int) {
viewModelScope.launch {
val current = _lists.value.toMutableList()
if (fromIndex !in current.indices || toIndex !in current.indices) return@launch
// Ensure we have a baseline orderIndex to start from if null
if (current.all { it.orderIndex == null }) {
current.forEachIndexed { idx, item ->
database.listDao().updateListOrderIndex(item.id, idx.toLong())
}
}
val item = current.removeAt(fromIndex)
current.add(toIndex, item)
// Renumber sequentially to avoid large gaps
val newOrder: List<Pair<String, Long>> = current.mapIndexed { idx, l -> l.id to idx.toLong() }
database.listDao().updateOrderIndexes(newOrder)
// Trigger a fresh load (flows will emit on DB change)
}
}
private suspend fun startPeriodicSync() {
while (true) {
kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync