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