Reorder list

This commit is contained in:
Rafał Kobyłko 2023-04-08 19:43:37 +02:00
parent 91fb8328f5
commit 108654541e
6 changed files with 260 additions and 172 deletions

View File

@ -46,7 +46,7 @@ internal class ServicesRepositoryImpl(
override fun observeServicesTicker(): Flow<List<Service>> {
return combine(
isTickerEnabled,
tickerFlow(1000L),
tickerFlow(1_000L),
observeServices(),
) { a, b, c -> Pair(a, c) }
// .filter { it.first } // TODO: ticker

View File

@ -1,5 +1,7 @@
package com.twofasapp.feature.home.ui.services
import com.twofasapp.data.services.domain.Group
import com.twofasapp.data.services.domain.Service
import com.twofasapp.designsystem.lazy.ListItem
sealed class ServicesListItem(
@ -11,6 +13,6 @@ sealed class ServicesListItem(
object EmptySearch : ServicesListItem("EmptySearch", "EmptySearch")
object SyncNoticeBar : ServicesListItem("SyncNoticeBar", "SyncNoticeBar")
object SyncReminder : ServicesListItem("SyncReminder", "SyncReminder")
data class Service(val id: Long) : ServicesListItem("Service:$id", "Service")
data class Group(val id: String?) : ServicesListItem("Group:${id ?: "Default"}", "Group")
data class ServiceItem(val service: Service) : ServicesListItem("Service:${service.id}", "Service")
data class GroupItem(val group: Group) : ServicesListItem("Group:${group.id ?: "Default"}", "Group")
}

View File

@ -104,7 +104,7 @@ internal fun ServicesRoute(
onMoveDownGroup = { viewModel.moveDownGroup(it) },
onEditGroup = { id, name -> viewModel.editGroup(id, name) },
onDeleteGroup = { viewModel.deleteGroup(it) },
onSwapServices = { from, to -> viewModel.swapServices(from, to) },
onDragEnd = { viewModel.onDragEnd(it) },
onSortChange = { viewModel.updateSort(it) },
onSearchQueryChange = { viewModel.search(it) },
onSearchFocusChange = { viewModel.searchFocused(it) },
@ -130,7 +130,7 @@ private fun ServicesScreen(
onMoveDownGroup: (String) -> Unit = {},
onEditGroup: (String, String) -> Unit = { _, _ -> },
onDeleteGroup: (String) -> Unit = {},
onSwapServices: (Int, Int) -> Unit = { _, _ -> },
onDragEnd: (List<ServicesListItem>) -> Unit = { },
onSortChange: (Int) -> Unit = {},
onSearchQueryChange: (String) -> Unit,
onSearchFocusChange: (Boolean) -> Unit,
@ -155,18 +155,29 @@ private fun ServicesScreen(
val activity = LocalContext.currentActivity
val scope = rememberCoroutineScope()
var isDragging by remember { mutableStateOf(false) }
val data = remember { mutableStateOf(List(100) { "Item $it" }) }
val reorderableData = remember { mutableStateOf(uiState.items) }
val listState = rememberLazyListState()
val reorderableState = rememberReorderableLazyListState(listState = listState, onMove = { from, to ->
onSwapServices(from.index, to.index)
println("Order: From: $from -> To: $to")
// onSwapServices((from.key as String).split(":")[1].toLong(), (to.key as String).split(":")[1].toLong())
// println("order: ${listState.layoutInfo.visibleItemsInfo.map { it.key }}")
// data.value = data.value.toMutableList().apply {
// add(to.index, removeAt(from.index))
// }
})
val reorderableState = rememberReorderableLazyListState(
listState = listState,
onMove = { from, to ->
isDragging = true
val fromItem = reorderableData.value[from.index]
val toItem = reorderableData.value[to.index]
if (fromItem is ServicesListItem.ServiceItem) {
if (toItem is ServicesListItem.ServiceItem || (toItem is ServicesListItem.GroupItem && toItem.group.id != null)) {
reorderableData.value = reorderableData.value.toMutableList().apply {
add(to.index, removeAt(from.index))
}
}
}
},
onDragEnd = { _, _ ->
isDragging = false
onDragEnd(reorderableData.value)
},
)
val serviceContainerColor = if (uiState.totalGroups == 1) {
TwTheme.color.background
@ -177,7 +188,7 @@ private fun ServicesScreen(
val serviceContainerColorBlinking = remember { Animatable(serviceContainerColor) }
var recentlyAddedService by remember { mutableStateOf<Long?>(null) }
reorderableData.value = uiState.items
uiState.events.firstOrNull()?.let {
when (it) {
@ -358,34 +369,36 @@ private fun ServicesScreen(
return@LazyColumn
}
if (uiState.showSyncNoticeBar) {
listItem(ServicesListItem.SyncNoticeBar) {
SyncNoticeBar(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
onOpenBackupClick = onOpenBackupClick,
)
}
}
reorderableData.value.forEach { item ->
if (uiState.showSyncReminder) {
listItem(ServicesListItem.SyncReminder) {
SyncReminder(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
onOpenBackupClick = onOpenBackupClick,
onDismissClick = onDismissSyncReminderClick,
)
}
}
when (item) {
ServicesListItem.SyncNoticeBar -> {
listItem(item) {
SyncNoticeBar(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
onOpenBackupClick = onOpenBackupClick,
)
}
}
uiState.groups.forEach { group ->
ServicesListItem.SyncReminder -> {
listItem(item) {
SyncReminder(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
onOpenBackupClick = onOpenBackupClick,
onDismissClick = onDismissSyncReminderClick,
)
}
}
if (uiState.groups.size > 1) {
if (group.id != null || (uiState.services.count { it.groupId == null } > 0)) {
listItem(ServicesListItem.Group(group.id)) {
is ServicesListItem.GroupItem -> {
val group = item.group
listItem(item) {
ServicesGroup(
id = group.id,
name = group.name ?: TwLocale.strings.servicesMyTokens,
@ -394,7 +407,13 @@ private fun ServicesScreen(
editMode = uiState.isInEditMode,
modifier = Modifier
.animateContentSize()
.animateItemPlacement(),
.then(
if (isDragging) {
Modifier
} else {
Modifier.animateItemPlacement()
}
),
onClick = { onToggleGroupExpand(group.id) },
onExpandClick = { onToggleGroupExpand(group.id) },
onMoveUpClick = { onMoveUpGroup(group.id.orEmpty()) },
@ -410,20 +429,26 @@ private fun ServicesScreen(
)
}
}
}
if (group.isExpanded || uiState.isInEditMode) {
uiState.services.filter { it.groupId == group.id }.forEach { service ->
listItem(ServicesListItem.Service(service.id)) {
is ServicesListItem.ServiceItem -> {
val service = item.service
listItem(item) {
ReorderableItem(
state = reorderableState,
key = ServicesListItem.Service(service.id).key,
key = item.key,
modifier = Modifier
.animateItemPlacement()
.animateContentSize(),
.animateContentSize()
.then(
if (isDragging) {
Modifier
} else {
Modifier.animateItemPlacement()
}
),
) { isDragging ->
val state = service.asState()
DsService(
state = state,
style = when (uiState.appSettings.servicesStyle) {
@ -457,96 +482,98 @@ private fun ServicesScreen(
}
}
}
else -> Unit
}
}
}
}
}
if (showAddGroupDialog) {
InputDialog(title = TwLocale.strings.groupsAdd,
onDismissRequest = { showAddGroupDialog = false },
positive = TwLocale.strings.commonAdd,
negative = TwLocale.strings.commonCancel,
hint = TwLocale.strings.groupsName,
showCounter = true,
minLength = 1,
maxLength = 32,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
keyboardType = KeyboardType.Text,
),
onPositiveClick = { onAddGroup(it) })
}
if (showAddGroupDialog) {
InputDialog(title = TwLocale.strings.groupsAdd,
onDismissRequest = { showAddGroupDialog = false },
positive = TwLocale.strings.commonAdd,
negative = TwLocale.strings.commonCancel,
hint = TwLocale.strings.groupsName,
showCounter = true,
minLength = 1,
maxLength = 32,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
keyboardType = KeyboardType.Text,
),
onPositiveClick = { onAddGroup(it) })
}
if (showEditGroupDialog) {
InputDialog(title = TwLocale.strings.groupsEdit,
onDismissRequest = { showEditGroupDialog = false },
positive = TwLocale.strings.commonSave,
negative = TwLocale.strings.commonCancel,
hint = TwLocale.strings.groupsName,
prefill = clickedGroup?.name.orEmpty(),
showCounter = true,
minLength = 1,
maxLength = 32,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
keyboardType = KeyboardType.Text,
),
onPositiveClick = { onEditGroup(clickedGroup?.id.orEmpty(), it) })
}
if (showEditGroupDialog) {
InputDialog(title = TwLocale.strings.groupsEdit,
onDismissRequest = { showEditGroupDialog = false },
positive = TwLocale.strings.commonSave,
negative = TwLocale.strings.commonCancel,
hint = TwLocale.strings.groupsName,
prefill = clickedGroup?.name.orEmpty(),
showCounter = true,
minLength = 1,
maxLength = 32,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
keyboardType = KeyboardType.Text,
),
onPositiveClick = { onEditGroup(clickedGroup?.id.orEmpty(), it) })
}
if (showDeleteGroupDialog) {
ConfirmDialog(
onDismissRequest = { showDeleteGroupDialog = false },
title = TwLocale.strings.commonDelete,
body = TwLocale.strings.groupsDelete,
onConfirm = { onDeleteGroup(clickedGroup?.id.orEmpty()) },
)
}
if (showDeleteGroupDialog) {
ConfirmDialog(
onDismissRequest = { showDeleteGroupDialog = false },
title = TwLocale.strings.commonDelete,
body = TwLocale.strings.groupsDelete,
onConfirm = { onDeleteGroup(clickedGroup?.id.orEmpty()) },
)
}
if (showSortDialog) {
ListRadioDialog(
onDismissRequest = { showSortDialog = false },
title = TwLocale.strings.servicesSortBy,
options = TwLocale.strings.servicesSortByOptions,
selectedIndex = when (uiState.appSettings.servicesSort) {
ServicesSort.Alphabetical -> 0
ServicesSort.Manual -> 1
},
onOptionSelected = { index, _ -> onSortChange(index) }
)
}
if (showSortDialog) {
ListRadioDialog(
onDismissRequest = { showSortDialog = false },
title = TwLocale.strings.servicesSortBy,
options = TwLocale.strings.servicesSortByOptions,
selectedIndex = when (uiState.appSettings.servicesSort) {
ServicesSort.Alphabetical -> 0
ServicesSort.Manual -> 1
},
onOptionSelected = { index, _ -> onSortChange(index) }
)
}
if (showQrFromGalleryDialog) {
ConfirmDialog(
onDismissRequest = { showQrFromGalleryDialog = false },
title = TwLocale.strings.servicesQrFromGalleryTitle,
positive = TwLocale.strings.servicesQrFromGalleryCta,
negative = null,
bodyAnnotated = buildAnnotatedString {
append(TwLocale.strings.servicesQrFromGalleryBody1)
if (showQrFromGalleryDialog) {
ConfirmDialog(
onDismissRequest = { showQrFromGalleryDialog = false },
title = TwLocale.strings.servicesQrFromGalleryTitle,
positive = TwLocale.strings.servicesQrFromGalleryCta,
negative = null,
bodyAnnotated = buildAnnotatedString {
append(TwLocale.strings.servicesQrFromGalleryBody1)
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(TwLocale.strings.servicesQrFromGalleryBody2)
}
append(TwLocale.strings.servicesQrFromGalleryBody3)
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(TwLocale.strings.servicesQrFromGalleryBody2)
}
)
}
if (askForPermission) {
RequestPermission(
permission = Manifest.permission.CAMERA,
onGranted = {
askForPermission = false
listener.openAddQrService(activity)
},
onDismissRequest = { askForPermission = false },
rationaleTitle = TwLocale.strings.permissionCameraTitle,
rationaleText = TwLocale.strings.permissionCameraBody,
)
}
append(TwLocale.strings.servicesQrFromGalleryBody3)
}
)
}
if (askForPermission) {
RequestPermission(
permission = Manifest.permission.CAMERA,
onGranted = {
askForPermission = false
listener.openAddQrService(activity)
},
onDismissRequest = { askForPermission = false },
rationaleTitle = TwLocale.strings.permissionCameraTitle,
rationaleText = TwLocale.strings.permissionCameraBody,
)
}
}

View File

@ -1,11 +1,9 @@
package com.twofasapp.feature.home.ui.services
import com.twofasapp.data.services.domain.Group
import com.twofasapp.data.services.domain.Service
import com.twofasapp.data.session.domain.AppSettings
data class ServicesUiState(
val groups: List<Group> = emptyList(),
val services: List<Service> = emptyList(),
val totalGroups: Int = 0,
val totalServices: Int = 0,
@ -17,10 +15,10 @@ data class ServicesUiState(
val showSyncReminder: Boolean = true,
val appSettings: AppSettings = AppSettings(),
val events: List<ServicesStateEvent> = listOf(),
val items: List<ServicesListItem> = emptyList()
val items: List<ServicesListItem> = mutableListOf()
) {
fun getService(id: Long): Service? {
return services.firstOrNull() { it.id == id }
return services.firstOrNull { it.id == id }
}
}

View File

@ -11,9 +11,11 @@ import com.twofasapp.data.session.SessionRepository
import com.twofasapp.data.session.SettingsRepository
import com.twofasapp.data.session.domain.AppSettings
import com.twofasapp.data.session.domain.ServicesSort
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import java.util.Collections
@Suppress("UNCHECKED_CAST")
internal class ServicesViewModel(
@ -60,11 +62,6 @@ internal class ServicesViewModel(
uiState.update { state ->
state.copy(
groups = if (result.searchQuery.isEmpty()) {
result.groups
} else {
result.groups.map { it.copy(isExpanded = true) }
},
services = result.services
.sortedBy {
when (result.appSettings.servicesSort) {
@ -81,19 +78,44 @@ internal class ServicesViewModel(
isInEditMode = result.isInEditMode,
appSettings = result.appSettings,
items = buildList {
result.groups.forEach { group ->
if (showSyncNoticeBar) {
add(ServicesListItem.SyncNoticeBar)
if (showSyncNoticeBar) {
add(ServicesListItem.SyncNoticeBar)
}
if (showSyncReminder) {
add(ServicesListItem.SyncReminder)
}
val groupedServices: Map<Group, List<Service>> = buildMap {
result.groups.forEach { group ->
put(
key = group,
value = result.services
.filter { it.groupId == group.id }
.sortedBy {
when (result.appSettings.servicesSort) {
ServicesSort.Alphabetical -> it.name.lowercase()
ServicesSort.Manual -> null
}
}
.filter { service -> service.isMatchingQuery(result.searchQuery) }
)
}
}
groupedServices.forEach { (group, services) ->
if (groupedServices.size > 1) {
if (group.id != null || services.isNotEmpty() || result.searchQuery.isNotEmpty()) {
add(ServicesListItem.GroupItem(result.groups.first { it.id == group.id }))
}
}
if (showSyncReminder) {
add(ServicesListItem.SyncReminder)
}
add(ServicesListItem.Group(group.id))
result.services.filter { it.groupId == group.id }.forEach { service ->
add(ServicesListItem.Service(service.id))
if (group.isExpanded || result.isInEditMode) {
services.forEach { service ->
add(ServicesListItem.ServiceItem(service))
}
}
}
}
@ -154,31 +176,48 @@ internal class ServicesViewModel(
}
fun swapServices(from: Int, to: Int) {
var items = uiState.value.items.toMutableList()
val items = uiState.value.items.toMutableList()
val fromItem = items[from]
val toItem = items[to]
if (fromItem is ServicesListItem.Service && toItem is ServicesListItem.Service) {
// Swap items
items = items.apply { add(to, removeAt(from)) }
if (fromItem is ServicesListItem.ServiceItem && toItem is ServicesListItem.ServiceItem) {
servicesRepository.updateServicesOrder(
ids = items.filterIsInstance<ServicesListItem.Service>().map { it.id }
)
Collections.swap(uiState.value.items, from, to)
// uiState.update { it.copy(items = items) }
// servicesRepository.updateServicesOrder(
// ids = items.filterIsInstance<ServicesListItem.ServiceItem>().map { it.service.id }
// )
}
if (fromItem is ServicesListItem.Service && toItem is ServicesListItem.Group) {
val groupId = if (from < to) {
toItem.id
} else {
items.subList(0, to)
.filterIsInstance<ServicesListItem.Group>()
.asReversed()
.firstOrNull()?.id
}
launchScoped { servicesRepository.setServiceGroup(fromItem.id, groupId) }
}
// var items = uiState.value.items.toMutableList()
// val fromItem = items[from]
// val toItem = items[to]
//
// if (fromItem is ServicesListItem.ServiceItem && toItem is ServicesListItem.ServiceItem) {
// // Swap items
// items = items.apply { add(to, removeAt(from)) }
//
// servicesRepository.updateServicesOrder(
// ids = items.filterIsInstance<ServicesListItem.ServiceItem>().map { it.id }
// )
// }
//
// if (fromItem is ServicesListItem.ServiceItem && toItem is ServicesListItem.GroupItem) {
// val groupId = if (from < to) {
// toItem.id
// } else {
// items.subList(0, to)
// .filterIsInstance<ServicesListItem.GroupItem>()
// .asReversed()
// .firstOrNull()?.id
// }
//
// launchScoped { servicesRepository.setServiceGroup(fromItem.id, groupId) }
// }
}
fun updateSort(index: Int) {
@ -216,6 +255,28 @@ internal class ServicesViewModel(
tags.contains(query.lowercase())
}
fun onDragEnd(data: List<ServicesListItem>) {
launchScoped(Dispatchers.IO) {
var groupId: String? = null
data.forEach { item ->
if (item is ServicesListItem.GroupItem) {
groupId = item.group.id
}
if (item is ServicesListItem.ServiceItem && item.service.groupId != groupId) {
launchScoped {
servicesRepository.setServiceGroup(item.service.id, groupId)
}
}
}
servicesRepository.updateServicesOrder(
ids = data.filterIsInstance<ServicesListItem.ServiceItem>().map { it.service.id }
)
}
}
data class CombinedResult(
val groups: List<Group>,
val services: List<Service>,

View File

@ -2,10 +2,10 @@
accompanist = "0.27.0"
agp = "7.4.1"
coil = "2.2.2"
compose = "1.4.0-rc01"
composeActivity = "1.6.1"
composeCompiler = "1.4.3"
core = "1.9.0"
compose = "1.4.1"
composeActivity = "1.7.0"
composeCompiler = "1.4.4"
core = "1.10.0"
espresso = "3.5.1"
koin = "3.3.2"
koinAndroid = "3.4.1"
@ -15,9 +15,9 @@ kotlinCoroutines = "1.6.4"
kotlinKsp = "1.8.10-1.0.9"
ktlint = "3.12.0"
ktor = "2.2.2"
material3 = "1.1.0-alpha08"
room = "2.5.0"
viewModel = "2.6.0"
material3 = "1.1.0-beta02"
room = "2.5.1"
viewModel = "2.6.1"
[libraries]
appcompat = "androidx.appcompat:appcompat:1.6.1"