From 56be50646bdb6e3ef8e88c2b3938016241137009 Mon Sep 17 00:00:00 2001 From: gabriel20xx Date: Sat, 25 Oct 2025 19:19:20 +0200 Subject: [PATCH] feat: implement manual reordering of lists with persistent order index and sorting preferences --- CollabTableAndroid/app/build.gradle | 3 + .../com/collabtable/app/data/dao/ListDao.kt | 23 ++- .../app/data/database/CollabTableDatabase.kt | 12 +- .../collabtable/app/data/model/CollabList.kt | 2 + .../data/preferences/PreferencesManager.kt | 25 +++ .../app/data/repository/SyncRepository.kt | 9 +- .../collabtable/app/ui/screens/ListsScreen.kt | 168 +++++++++++++----- .../app/ui/screens/ListsViewModel.kt | 48 ++++- 8 files changed, 243 insertions(+), 47 deletions(-) diff --git a/CollabTableAndroid/app/build.gradle b/CollabTableAndroid/app/build.gradle index 896ad19..4fdf57c 100644 --- a/CollabTableAndroid/app/build.gradle +++ b/CollabTableAndroid/app/build.gradle @@ -88,6 +88,9 @@ dependencies { androidTestImplementation 'androidx.compose.ui:ui-test-junit4' debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' + + // Reorderable LazyColumn for drag-and-drop + implementation 'org.burnoutcrew.composereorderable:reorderable:0.9.6' } // Ktlint configuration diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ListDao.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ListDao.kt index 876d4c2..cf624cd 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ListDao.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ListDao.kt @@ -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> @Transaction @@ -20,6 +25,9 @@ interface ListDao { @Query("SELECT id FROM lists") suspend fun getAllListIds(): List + @Query("SELECT id, orderIndex FROM lists") + suspend fun getListIdsAndOrder(): List + @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>) { + order.forEach { (id, idx) -> + updateListOrderIndex(id, idx) + } + } + @Query("UPDATE lists SET isDeleted = 1, updatedAt = :timestamp WHERE id = :listId") suspend fun softDeleteList( listId: String, diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt index ea7313c..325f9fc 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt @@ -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 diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/CollabList.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/CollabList.kt index dcb68e8..c2e2f03 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/CollabList.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/CollabList.kt @@ -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, ) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt index 6e9e4f1..e39233b 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt @@ -22,6 +22,9 @@ class PreferencesManager(context: Context) { private val _amoledDark = MutableStateFlow(isAmoledDarkEnabled()) val amoledDark: StateFlow = _amoledDark.asStateFlow() + private val _sortOrder = MutableStateFlow(getSortOrder()) + val sortOrder: StateFlow = _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 diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt index 45af0f6..f66b845 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt @@ -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() diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt index 1445595..1769f18 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt @@ -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 (A–Z)", PreferencesManager.SORT_NAME_ASC) + ItemOption("Name (Z–A)", PreferencesManager.SORT_NAME_DESC) + } + } } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt index ad5bcfa..a76ba11 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt @@ -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> = 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