Skip to content

Commit

Permalink
add grouping of data when sorting is enabled
Browse files Browse the repository at this point in the history
  • Loading branch information
avan1235 committed Oct 14, 2023
1 parent 078279f commit 6941d6b
Show file tree
Hide file tree
Showing 13 changed files with 211 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ abstract class AddOtpProviderComponentImpl(
private val navigateOnCancelClicked: () -> Unit,
) : AbstractComponent(componentContext), AddOtpProviderComponent {

protected val secureStorage: StateFlowSettings<UserOtpCodeData> = get(USER_OTP_CODE_DATA_MODULE_QUALIFIER)
protected val secureStorage: StateFlowSettings<StoredOtpCodeData> = get(USER_OTP_CODE_DATA_MODULE_QUALIFIER)

protected fun notifyInvalid(fieldName: String) {
toast(message = stringResource(OpenOtpResources.strings.invalid_field_name_provided_formatted, fieldName))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import ml.dev.kotlin.openotp.USER_OTP_CODE_DATA_MODULE_QUALIFIER
import ml.dev.kotlin.openotp.USER_PREFERENCES_MODULE_QUALIFIER
import ml.dev.kotlin.openotp.otp.HotpData
import ml.dev.kotlin.openotp.otp.OtpData
import ml.dev.kotlin.openotp.otp.TotpData
import ml.dev.kotlin.openotp.otp.UserOtpCodeData
import ml.dev.kotlin.openotp.otp.*
import ml.dev.kotlin.openotp.shared.OpenOtpResources
import ml.dev.kotlin.openotp.ui.component.DragDropListData
import ml.dev.kotlin.openotp.ui.component.DragDropListData.Grouped
import ml.dev.kotlin.openotp.ui.component.DragDropListData.Listed
import ml.dev.kotlin.openotp.util.StateFlowSettings
import ml.dev.kotlin.openotp.util.currentEpochMilliseconds
import ml.dev.kotlin.openotp.util.unit
Expand All @@ -31,9 +31,10 @@ interface MainComponent {

val timestamp: Value<Long>
val confirmOtpDataDelete: Value<Boolean>
val codeData: Value<UserOtpCodeData>
val codeData: Value<PresentedOtpCodeData>
val isSearchActive: Value<Boolean>
val isDragAndDropEnabled: Value<Boolean>
val showSortedGroupsHeaders: Value<Boolean>
val navigateToScanQRCodeWhenCameraPermissionChanged: Value<Boolean>

fun onRequestedCameraPermission()
Expand Down Expand Up @@ -68,7 +69,9 @@ class MainComponentImpl(
val changed: Boolean = false,
)

private val userOtpCodeData: StateFlowSettings<UserOtpCodeData> = get(USER_OTP_CODE_DATA_MODULE_QUALIFIER)
private val appContext: OpenOtpAppComponentContext = get()

private val userOtpCodeData: StateFlowSettings<StoredOtpCodeData> = get(USER_OTP_CODE_DATA_MODULE_QUALIFIER)

private val userPreferences: StateFlowSettings<UserPreferencesModel> = get(USER_PREFERENCES_MODULE_QUALIFIER)

Expand All @@ -84,17 +87,13 @@ class MainComponentImpl(
override val navigateToScanQRCodeWhenCameraPermissionChanged: Value<Boolean> =
_cameraPermissionRequest.map { it.changed }.asValue()

override val codeData: Value<UserOtpCodeData> = combine(
override val codeData: Value<PresentedOtpCodeData> = combine(
userOtpCodeData.stateFlow,
userPreferences.stateFlow.map { it.sortOtpDataBy },
userPreferences.stateFlow.map { it.sortOtpDataReversed },
userPreferences.stateFlow.map { it.sortOtpDataNullsFirst },
) transform@{ codes, sortBy, sortReversed, sortNullsFirst ->
val selector = sortBy.selector ?: return@transform codes
val comparator: Comparator<String?> = if (sortNullsFirst) nullsFirst() else nullsLast()
val otpDataComparator: Comparator<OtpData> = compareBy(comparator) { selector(it)?.toLowerCase(Locale.current) }
val reversedComparator = if (sortReversed) otpDataComparator.reversed() else otpDataComparator
codes.sortedWith(reversedComparator)
) { codes, sortBy, sortReversed, sortNullsFirst ->
appContext.sortOtpCodeDataWithRules(codes, sortBy, sortReversed, sortNullsFirst)
}.asValue()

private val _isSearchActive: MutableStateFlow<Boolean> = MutableStateFlow(false)
Expand All @@ -103,6 +102,9 @@ class MainComponentImpl(
override val isDragAndDropEnabled: Value<Boolean> =
userPreferences.stateFlow.map { it.sortOtpDataBy == SortOtpDataBy.Dont }.asValue()

override val showSortedGroupsHeaders: Value<Boolean> =
userPreferences.stateFlow.map { it.showSortedGroupsHeaders }.asValue()

private val searchBackCallback: BackCallback = BackCallback {
_isSearchActive.value = false
}
Expand Down Expand Up @@ -181,3 +183,24 @@ class MainComponentImpl(
}

private const val MAX_CAMERA_PERMISSION_SILENT_REQUESTS: Int = 2

private fun OpenOtpAppComponentContext.sortOtpCodeDataWithRules(
codes: StoredOtpCodeData,
sortBy: SortOtpDataBy,
sortReversed: Boolean,
sortNullsFirst: Boolean,
): DragDropListData<OtpData> {
val selector = sortBy.selector ?: return Listed(codes)
val comparator: Comparator<String?> = if (sortNullsFirst) nullsFirst() else nullsLast()
val otpDataComparator: Comparator<OtpData> = compareBy(comparator) { selector(it)?.toLowerCase(Locale.current) }
val reversedComparator = if (sortReversed) otpDataComparator.reversed() else otpDataComparator
val sorted = codes.sortedWith(reversedComparator)

val groupedCodes = linkedMapOf<String, MutableList<OtpData>>()
for (otpData in sorted) {
val groupName = selector(otpData) ?: with(sortBy) { defaultGroupName }
groupedCodes[groupName] ?: ArrayList<OtpData>().also { groupedCodes[groupName] = it } += otpData
}
val groups = groupedCodes.map { Grouped.Group(it.key, it.value) }
return Grouped(groups)
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class ScanQRCodeComponentImpl(
private val navigateOnCancel: (message: String?) -> Unit,
) : AbstractComponent(componentContext), ScanQRCodeComponent {

private val userOtpCodeData: StateFlowSettings<UserOtpCodeData> = get(USER_OTP_CODE_DATA_MODULE_QUALIFIER)
private val userOtpCodeData: StateFlowSettings<StoredOtpCodeData> = get(USER_OTP_CODE_DATA_MODULE_QUALIFIER)

override fun onQRCodeScanned(result: QRResult): Boolean = when (result) {
is QRResult.QRError -> navigateOnCancel(invalidQRCodeMessage).letFalse()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface SettingsComponent {
val canReorderDataManually: Value<Boolean>
val sortOtpDataNullsFirst: Value<Boolean>
val sortOtpDataReversed: Value<Boolean>
val showSortedGroupsHeaders: Value<Boolean>
val requireAuthentication: Value<Boolean>
val isAuthenticationAvailable: Boolean

Expand All @@ -28,6 +29,8 @@ interface SettingsComponent {

fun onSortReversedChange(reversed: Boolean)

fun onShowSortedGroupsHeadersChange(show: Boolean)

fun onRequireAuthenticationChange(require: Boolean)

fun onExitSettings()
Expand Down Expand Up @@ -60,6 +63,9 @@ class SettingsComponentImpl(
override val sortOtpDataReversed: Value<Boolean> =
userPreferences.stateFlow.map { it.sortOtpDataReversed }.asValue()

override val showSortedGroupsHeaders: Value<Boolean> =
userPreferences.stateFlow.map { it.showSortedGroupsHeaders }.asValue()

override val requireAuthentication: Value<Boolean> =
userPreferences.stateFlow.map { it.requireAuthentication }.asValue()

Expand All @@ -86,6 +92,10 @@ class SettingsComponentImpl(
userPreferences.updateInScope { it.copy(sortOtpDataReversed = reversed) }
}

override fun onShowSortedGroupsHeadersChange(show: Boolean) {
userPreferences.updateInScope { it.copy(showSortedGroupsHeaders = show) }
}

override fun onRequireAuthenticationChange(require: Boolean) {
userPreferences.updateInScope { it.copy(requireAuthentication = require) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ enum class OpenOtpAppTheme : Named {
}

@Serializable
enum class SortOtpDataBy(val selector: ((OtpData) -> String?)?) : Named {
enum class SortOtpDataBy(
val selector: ((OtpData) -> String?)?,
) : Named {
Dont(selector = null),
Issuer(selector = { data -> data.issuer?.takeIf { it.isNotBlank() } }),
AccountName(selector = { data -> data.accountName?.takeIf { it.isNotBlank() } });
Expand All @@ -50,6 +52,13 @@ enum class SortOtpDataBy(val selector: ((OtpData) -> String?)?) : Named {
Issuer -> stringResource(OpenOtpResources.strings.issuer_sort_name)
AccountName -> stringResource(OpenOtpResources.strings.account_name_sort_name)
}

val OpenOtpAppComponentContext.defaultGroupName: String
get() = when (this@SortOtpDataBy) {
Dont -> stringResource(OpenOtpResources.strings.default_group_name_dont_sort_name)
Issuer -> stringResource(OpenOtpResources.strings.default_group_name_issuer_sort_name)
AccountName -> stringResource(OpenOtpResources.strings.default_group_name_account_name_sort_name)
}
}

@Serializable
Expand All @@ -60,6 +69,7 @@ data class UserPreferencesModel(
val sortOtpDataReversed: Boolean = false,
val confirmOtpDataDelete: Boolean = true,
val requireAuthentication: Boolean = false,
val showSortedGroupsHeaders: Boolean = true,
)

private val LightColors: ColorScheme = lightColorScheme(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.benasher44.uuid.uuid4
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import ml.dev.kotlin.openotp.ui.component.DragDropListData

@Immutable
@Serializable
Expand Down Expand Up @@ -103,4 +104,6 @@ data class TotpData(
}
}

typealias UserOtpCodeData = List<OtpData>
typealias StoredOtpCodeData = List<OtpData>

typealias PresentedOtpCodeData = DragDropListData<OtpData>
Original file line number Diff line number Diff line change
@@ -1,37 +1,78 @@
package ml.dev.kotlin.openotp.ui.component

import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import ml.dev.kotlin.openotp.util.runIfNonNull
import kotlin.math.roundToInt

interface DragDropListData<out T : Any> {
data class Listed<U : Any>(override val items: List<U>) : DragDropListData<U> {
override val isEmpty: Boolean = items.isEmpty()

override fun filter(predicate: (U) -> Boolean): DragDropListData<U> =
Listed(items.filter(predicate))
}

data class Grouped<U : Any>(val groups: List<Group<U>>) : DragDropListData<U> {
data class Group<V>(val groupName: String, val items: List<V>)

override val isEmpty: Boolean = groups.all { it.items.isEmpty() }

override fun filter(predicate: (U) -> Boolean): DragDropListData<U> =
Grouped(groups.map { Group(it.groupName, it.items.filter(predicate)) })

override val items: List<U> by lazy { groups.flatMap { it.items } }
}

val isEmpty: Boolean

val items: List<T>

fun filter(predicate: (T) -> Boolean): DragDropListData<T>

companion object {
private val EMPTY: DragDropListData<Nothing> = Listed(emptyList())

fun <U : Any> emptyDragDropListData(): DragDropListData<U> = EMPTY
}
}

@Composable
internal fun <T : Any> DragDropList(
items: List<T>,
items: DragDropListData<T>,
key: (T) -> Any,
modifier: Modifier,
enabled: Boolean,
showHeaders: Boolean,
dragDropState: DragDropState,
headerColor: Color = MaterialTheme.colorScheme.background,
itemContent: @Composable LazyItemScope.(item: T, modifier: Modifier) -> Unit,
) {
var overscrollJob by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()

LazyColumn(
modifier = when {
!enabled -> modifier
else -> modifier
enabled && items is DragDropListData.Listed -> modifier
.pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
Expand Down Expand Up @@ -62,18 +103,52 @@ internal fun <T : Any> DragDropList(
}
)
}

else -> modifier
},
state = dragDropState.listState,
) {
itemsIndexed(
items = items,
key = { _, item -> key(item) }
) { index, item ->
DraggableItem(
dragDropState = dragDropState,
index = index,
) { modifier ->
itemContent(item, modifier)
when {
showHeaders && items is DragDropListData.Grouped -> items.groups.forEach { group ->
if (group.items.isNotEmpty()) stickyHeader(group.groupName) {
Text(
text = group.groupName,
style = MaterialTheme.typography.labelLarge,
color = contentColorFor(headerColor),
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.background(headerColor)
.padding(
bottom = 12.dp,
start = 12.dp,
end = 12.dp,
)
)
}
itemsIndexed(
items = group.items,
key = { _, item -> key(item) }
) { index, item ->
DraggableItem(
dragDropState = dragDropState,
index = index,
) { modifier ->
itemContent(item, modifier)
}
}
}

else -> itemsIndexed(
items = items.items,
key = { _, item -> key(item) }
) { index, item ->
DraggableItem(
dragDropState = dragDropState,
index = index,
) { modifier ->
itemContent(item, modifier)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.zIndex
import dev.icerock.moko.resources.compose.stringResource
import ml.dev.kotlin.openotp.otp.OtpData
import ml.dev.kotlin.openotp.otp.UserOtpCodeData
import ml.dev.kotlin.openotp.otp.PresentedOtpCodeData
import ml.dev.kotlin.openotp.shared.OpenOtpResources
import ml.dev.kotlin.openotp.ui.component.DragDropListData.Companion.emptyDragDropListData

@Composable
internal fun FilteredOtpCodeItems(
codeData: UserOtpCodeData,
codeData: PresentedOtpCodeData,
timestamp: Long,
confirmCodeDismiss: Boolean,
isSearchActive: Boolean,
Expand All @@ -45,8 +46,8 @@ internal fun FilteredOtpCodeItems(
contentAlignment = Alignment.TopCenter,
) {
var searchQuery by rememberSaveable { mutableStateOf("") }
val filteredCodeData = remember(searchQuery, codeData) {
if (searchQuery.isEmpty()) emptyList() else codeData.filter {
val filteredCodeData = remember(searchQuery, codeData) filtered@{
if (searchQuery.isEmpty()) emptyDragDropListData() else codeData.filter {
it.accountName?.contains(searchQuery, ignoreCase = true) == true ||
it.issuer?.contains(searchQuery, ignoreCase = true) == true
}
Expand Down Expand Up @@ -109,6 +110,7 @@ internal fun FilteredOtpCodeItems(
timestamp = timestamp,
confirmCodeDismiss = confirmCodeDismiss,
isDragAndDropEnabled = false,
showSortedGroupsHeaders = false,
onOtpCodeDataDismiss = onOtpCodeDataDismiss,
onRestartCode = onRestartCode,
copyOtpCode = copyOtpCode,
Expand Down
Loading

0 comments on commit 6941d6b

Please sign in to comment.