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
+3
View File
@@ -88,6 +88,9 @@ dependencies {
androidTestImplementation 'androidx.compose.ui:ui-test-junit4' androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest' debugImplementation 'androidx.compose.ui:ui-test-manifest'
// Reorderable LazyColumn for drag-and-drop
implementation 'org.burnoutcrew.composereorderable:reorderable:0.9.6'
} }
// Ktlint configuration // Ktlint configuration
@@ -5,9 +5,14 @@ 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
data class ListIdAndOrder(
val id: String,
val orderIndex: Long?,
)
@Dao @Dao
interface ListDao { 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>> fun getAllLists(): Flow<List<CollabList>>
@Transaction @Transaction
@@ -20,6 +25,9 @@ interface ListDao {
@Query("SELECT id FROM lists") @Query("SELECT id FROM lists")
suspend fun getAllListIds(): List<String> suspend fun getAllListIds(): List<String>
@Query("SELECT id, orderIndex FROM lists")
suspend fun getListIdsAndOrder(): List<ListIdAndOrder>
@Upsert @Upsert
suspend fun insertList(list: CollabList) suspend fun insertList(list: CollabList)
@@ -29,6 +37,19 @@ interface ListDao {
@Update @Update
suspend fun updateList(list: CollabList) 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") @Query("UPDATE lists SET isDeleted = 1, updatedAt = :timestamp WHERE id = :listId")
suspend fun softDeleteList( suspend fun softDeleteList(
listId: String, 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( @Database(
entities = [ entities = [
CollabList::class, CollabList::class,
@@ -24,7 +32,7 @@ val MIGRATION_1_2 =
Item::class, Item::class,
ItemValue::class, ItemValue::class,
], ],
version = 2, version = 3,
exportSchema = false, exportSchema = false,
) )
abstract class CollabTableDatabase : RoomDatabase() { abstract class CollabTableDatabase : RoomDatabase() {
@@ -48,7 +56,7 @@ abstract class CollabTableDatabase : RoomDatabase() {
CollabTableDatabase::class.java, CollabTableDatabase::class.java,
"collab_table_database", "collab_table_database",
) )
.addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build() .build()
INSTANCE = instance INSTANCE = instance
instance instance
@@ -10,4 +10,6 @@ data class CollabList(
val createdAt: Long, val createdAt: Long,
val updatedAt: Long, val updatedAt: Long,
val isDeleted: Boolean = false, 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()) private val _amoledDark = MutableStateFlow(isAmoledDarkEnabled())
val amoledDark: StateFlow<Boolean> = _amoledDark.asStateFlow() val amoledDark: StateFlow<Boolean> = _amoledDark.asStateFlow()
private val _sortOrder = MutableStateFlow(getSortOrder())
val sortOrder: StateFlow<String> = _sortOrder.asStateFlow()
fun getServerUrl(): String { fun getServerUrl(): String {
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
} }
@@ -106,6 +109,21 @@ class PreferencesManager(context: Context) {
_amoledDark.value = enabled _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 { companion object {
private const val KEY_SERVER_URL = "server_url" private const val KEY_SERVER_URL = "server_url"
private const val KEY_FIRST_RUN = "first_run" 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_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 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
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 @Volatile
private var instance: PreferencesManager? = null private var instance: PreferencesManager? = null
@@ -88,8 +88,13 @@ class SyncRepository(context: Context) {
// 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 // 1) Upsert lists first, preserving local orderIndex when present
database.listDao().insertLists(syncResponse.lists) 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 // Build set of existing list ids to guard child inserts
val existingListIds = database.listDao().getAllListIds().toSet() val existingListIds = database.listDao().getAllListIds().toSet()
@@ -1,18 +1,47 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import androidx.compose.foundation.clickable 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.LazyColumn
import androidx.compose.foundation.lazy.items 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.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
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material.icons.filled.Sort
import androidx.compose.runtime.* 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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.R
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.model.CollabList
import java.text.SimpleDateFormat import com.collabtable.app.data.preferences.PreferencesManager
import java.util.* import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -34,6 +66,7 @@ fun ListsScreen(
val context = LocalContext.current val context = LocalContext.current
val database = remember { CollabTableDatabase.getDatabase(context) } val database = remember { CollabTableDatabase.getDatabase(context) }
val viewModel = remember { ListsViewModel(database, context) } val viewModel = remember { ListsViewModel(database, context) }
val prefs = remember { PreferencesManager.getInstance(context) }
val lists by viewModel.lists.collectAsState() val lists by viewModel.lists.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() val isLoading by viewModel.isLoading.collectAsState()
@@ -53,8 +86,12 @@ fun ListsScreen(
IconButton(onClick = onNavigateToLogs) { IconButton(onClick = onNavigateToLogs) {
Icon(Icons.Default.List, contentDescription = "Logs") Icon(Icons.Default.List, contentDescription = "Logs")
} }
SortMenu(prefs = prefs)
IconButton(onClick = onNavigateToSettings) { IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings)) Icon(
Icons.Default.Settings,
contentDescription = stringResource(R.string.settings),
)
} }
}, },
colors = colors =
@@ -65,9 +102,7 @@ fun ListsScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(onClick = { showCreateDialog = true }) {
onClick = { showCreateDialog = true },
) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.create_list)) Icon(Icons.Default.Add, contentDescription = stringResource(R.string.create_list))
} }
}, },
@@ -79,7 +114,6 @@ fun ListsScreen(
.padding(padding), .padding(padding),
) { ) {
if (isLoading && lists.isEmpty()) { if (isLoading && lists.isEmpty()) {
// Show loading indicator during initial sync
Column( Column(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -93,7 +127,6 @@ fun ListsScreen(
) )
} }
} else if (lists.isEmpty()) { } else if (lists.isEmpty()) {
// Show empty state
Column( Column(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -111,19 +144,39 @@ fun ListsScreen(
) )
} }
} else { } 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( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.reorderable(reorderState),
state = reorderState.listState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(lists, key = { it.id }) { list -> itemsIndexed(lists, key = { _, it -> it.id }) { index, list ->
ReorderableItem(reorderState, key = list.id) { isDragging ->
ListItem( ListItem(
list = list, list = list,
onListClick = { onNavigateToList(list.id) }, onListClick = { onNavigateToList(list.id) },
onEditClick = { listToEdit = list }, onEditClick = { listToEdit = list },
onDeleteClick = { listToDelete = 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, onListClick: () -> Unit,
onEditClick: () -> Unit, onEditClick: () -> Unit,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
dragHandle: (@Composable () -> Unit)? = null,
) { ) {
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth(),
.clickable(onClick = onListClick),
) { ) {
Row( Row(
modifier = modifier =
@@ -196,19 +249,19 @@ fun ListItem(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(
Text( modifier = Modifier
text = list.name, .weight(1f)
style = MaterialTheme.typography.titleMedium, .clickable(onClick = onListClick),
) ) {
Spacer(modifier = Modifier.height(4.dp)) Text(text = list.name, style = MaterialTheme.typography.titleMedium)
Text( Spacer(modifier = Modifier.height(2.dp))
text = formatDate(if (list.updatedAt > 0L) list.updatedAt else list.createdAt), }
style = MaterialTheme.typography.bodySmall, Row(verticalAlignment = Alignment.CenterVertically) {
color = MaterialTheme.colorScheme.onSurfaceVariant, if (dragHandle != null) {
) dragHandle()
Spacer(modifier = Modifier.height(0.dp).padding(end = 4.dp))
} }
Row {
IconButton(onClick = onEditClick) { IconButton(onClick = onEditClick) {
Icon( Icon(
Icons.Default.Edit, Icons.Default.Edit,
@@ -219,7 +272,7 @@ fun ListItem(
IconButton(onClick = onDeleteClick) { IconButton(onClick = onDeleteClick) {
Icon( Icon(
Icons.Default.Delete, Icons.Default.Delete,
contentDescription = stringResource(R.string.delete), contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
) )
} }
@@ -297,8 +350,43 @@ private fun RenameListDialog(
) )
} }
private fun formatDate(timestamp: Long): String { @Composable
if (timestamp <= 0L) return "" private fun SortMenu(prefs: PreferencesManager) {
val sdf = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()) val context = LocalContext.current
return sdf.format(Date(timestamp)) 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
@@ -38,10 +39,53 @@ class ListsViewModel(
private fun loadLists() { private fun loadLists() {
viewModelScope.launch { viewModelScope.launch {
database.listDao().getAllLists().collect { listData -> val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
_lists.value = listData 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() { private suspend fun startPeriodicSync() {