feat: implement manual reordering of lists with persistent order index and sorting preferences
This commit is contained in:
@@ -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,
|
||||
|
||||
+10
-2
@@ -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,
|
||||
)
|
||||
|
||||
+25
@@ -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
|
||||
|
||||
|
||||
+7
-2
@@ -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()
|
||||
|
||||
+128
-40
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user