feature(AppPicker): Add installation time sorting option

This commit is contained in:
Artem Chepurnoy 2024-06-04 23:01:37 +03:00
parent 9d0f163181
commit d17bff1749
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
6 changed files with 340 additions and 26 deletions

View File

@ -18,6 +18,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
@ -35,6 +36,7 @@ import com.artemchep.keyguard.feature.home.vault.component.SearchTextField
import com.artemchep.keyguard.feature.home.vault.component.surfaceColorAtElevation
import com.artemchep.keyguard.feature.navigation.NavigationIcon
import com.artemchep.keyguard.feature.navigation.RouteResultTransmitter
import com.artemchep.keyguard.feature.search.sort.SortButton
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.res.*
import com.artemchep.keyguard.ui.DefaultProgressBar
@ -88,6 +90,13 @@ fun ChangePasswordScreen(
}
LaunchedEffect(listRevision) {
// Scroll to the start of the list if the list has
// no real content.
if (listRevision == null) {
listState.scrollToItem(0, 0)
return@LaunchedEffect
}
// TODO: How do you wait till the layout state start to represent
// the actual data?
val listSize =
@ -136,6 +145,14 @@ fun ChangePasswordScreen(
icon = {
NavigationIcon()
},
actions = {
val content = loadableState.getOrNull()
?: return@CustomToolbarContent
val sort by content.sort.collectAsState()
AppPickerSortButton(
state = sort,
)
},
)
val query = filterState.value?.query
@ -227,6 +244,20 @@ fun ChangePasswordScreen(
}
}
@Composable
private fun AppPickerSortButton(
modifier: Modifier = Modifier,
state: AppPickerState.Sort,
) {
val filters = state.sort
val clearFilters = state.clearSort
SortButton(
modifier = modifier,
items = filters,
onClear = clearFilters,
)
}
@Composable
private fun NoItemsPlaceholder(
modifier: Modifier = Modifier,

View File

@ -5,12 +5,16 @@ import androidx.compose.ui.text.AnnotatedString
import arrow.core.Either
import arrow.optics.optics
import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.feature.apppicker.model.AppPickerSortItem
import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
import com.artemchep.keyguard.feature.favicon.AppIconUrl
import com.artemchep.keyguard.feature.home.vault.model.SortItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.StateFlow
data class AppPickerState(
val filter: StateFlow<Filter>,
val sort: StateFlow<Sort>,
val content: Loadable<Either<Throwable, Content>>,
) {
@Immutable
@ -21,6 +25,14 @@ data class AppPickerState(
companion object
}
@Immutable
data class Sort(
val sort: ImmutableList<AppPickerSortItem>,
val clearSort: (() -> Unit)? = null,
) {
companion object
}
@Immutable
@optics
data class Content(

View File

@ -4,15 +4,22 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ApplicationInfo
import android.os.Parcelable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.SortByAlpha
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.AnnotatedString
import arrow.core.partially1
import com.artemchep.keyguard.android.util.broadcastFlow
import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.usecase.UnlockUseCase
import com.artemchep.keyguard.feature.apppicker.model.AppPickerSortItem
import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
import com.artemchep.keyguard.feature.favicon.AppIconUrl
import com.artemchep.keyguard.feature.home.vault.search.IndexedText
import com.artemchep.keyguard.feature.localization.TextHolder
import com.artemchep.keyguard.feature.navigation.RouteResultTransmitter
import com.artemchep.keyguard.feature.navigation.state.navigatePopSelf
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
@ -20,14 +27,95 @@ import com.artemchep.keyguard.feature.search.search.IndexedModel
import com.artemchep.keyguard.feature.search.search.mapSearch
import com.artemchep.keyguard.feature.search.search.searchFilter
import com.artemchep.keyguard.feature.search.search.searchQueryHandle
import com.artemchep.keyguard.platform.parcelize.LeIgnoredOnParcel
import com.artemchep.keyguard.platform.parcelize.LeParcelable
import com.artemchep.keyguard.platform.parcelize.LeParcelize
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.res.*
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.jetbrains.compose.resources.StringResource
import org.kodein.di.compose.localDI
import org.kodein.di.direct
import org.kodein.di.instance
@LeParcelize
@Serializable
data class AppPickerComparatorHolder(
val comparator: AppPickerSort,
val reversed: Boolean = false,
) : LeParcelable {
companion object {
fun of(map: Map<String, Any?>): AppPickerComparatorHolder {
return AppPickerComparatorHolder(
comparator = AppPickerSort.valueOf(map["comparator"].toString())
?: AppPickerAlphabeticalSort,
reversed = map["reversed"].toString() == "true",
)
}
}
fun toMap() = mapOf(
"comparator" to comparator.id,
"reversed" to reversed,
)
}
interface AppPickerSort : Comparator<AppInfo>, Parcelable {
val id: String
companion object {
fun valueOf(
name: String,
): AppPickerSort? = when (name) {
AppPickerAlphabeticalSort.id -> AppPickerAlphabeticalSort
AppPickerInstallTimeSort.id -> AppPickerInstallTimeSort
else -> null
}
}
}
@LeParcelize
@Serializable
data object AppPickerAlphabeticalSort : AppPickerSort {
@LeIgnoredOnParcel
@Transient
override val id: String = "alphabetical"
override fun compare(
a: AppInfo,
b: AppInfo,
): Int = kotlin.run {
val aTitle = a.label
val bTitle = b.label
aTitle.compareTo(bTitle, ignoreCase = true)
}
}
@LeParcelize
@Serializable
data object AppPickerInstallTimeSort : AppPickerSort {
@LeIgnoredOnParcel
@Transient
override val id: String = "install_time"
override fun compare(
a: AppInfo,
b: AppInfo,
): Int = kotlin.run {
val aInstallTime = a.installTime
val bInstallTime = b.installTime
-aInstallTime.compareTo(bInstallTime)
}
}
private class AppPickerUiException(
msg: String,
cause: Throwable,
@ -54,7 +142,26 @@ fun produceAppPickerState(
unlockUseCase,
),
) {
val queryHandle = searchQueryHandle("query")
val sortDefault = AppPickerComparatorHolder(
comparator = AppPickerAlphabeticalSort,
)
val sortSink = mutablePersistedFlow(
key = "sort",
serialize = { _, value ->
value.toMap()
},
deserialize = { _, value ->
AppPickerComparatorHolder.of(value)
},
) {
sortDefault
}
val queryHandle = searchQueryHandle(
key = "query",
revisionFlow = sortSink
.map { it.hashCode() },
)
val queryFlow = searchFilter(queryHandle) { model, revision ->
AppPickerState.Filter(
revision = revision,
@ -62,10 +169,121 @@ fun produceAppPickerState(
)
}
val appsComparator = Comparator { a: AppInfo, b: AppInfo ->
a.label.compareTo(b.label, ignoreCase = true)
fun onClearSort() {
sortSink.value = sortDefault
}
fun createComparatorAction(
id: String,
title: StringResource,
icon: ImageVector? = null,
config: AppPickerComparatorHolder,
) = AppPickerSortItem.Item(
id = id,
config = config,
title = TextHolder.Res(title),
icon = icon,
onClick = {
sortSink.value = config
},
checked = false,
)
data class AppPickerComparatorSortGroup(
val item: AppPickerSortItem.Item,
val subItems: List<AppPickerSortItem.Item>,
)
val cam = mapOf(
AppPickerAlphabeticalSort to AppPickerComparatorSortGroup(
item = createComparatorAction(
id = "title",
icon = Icons.Outlined.SortByAlpha,
title = Res.string.sortby_title_title,
config = AppPickerComparatorHolder(
comparator = AppPickerAlphabeticalSort,
),
),
subItems = listOf(
createComparatorAction(
id = "title_normal",
title = Res.string.sortby_title_normal_mode,
config = AppPickerComparatorHolder(
comparator = AppPickerAlphabeticalSort,
),
),
createComparatorAction(
id = "title_rev",
title = Res.string.sortby_title_reverse_mode,
config = AppPickerComparatorHolder(
comparator = AppPickerAlphabeticalSort,
reversed = true,
),
),
),
),
AppPickerInstallTimeSort to AppPickerComparatorSortGroup(
item = createComparatorAction(
id = "modify_date",
icon = Icons.Outlined.CalendarMonth,
title = Res.string.sortby_installation_date_title,
config = AppPickerComparatorHolder(
comparator = AppPickerInstallTimeSort,
),
),
subItems = listOf(
createComparatorAction(
id = "modify_date_normal",
title = Res.string.sortby_installation_date_normal_mode,
config = AppPickerComparatorHolder(
comparator = AppPickerInstallTimeSort,
),
),
createComparatorAction(
id = "modify_date_rev",
title = Res.string.sortby_installation_date_reverse_mode,
config = AppPickerComparatorHolder(
comparator = AppPickerInstallTimeSort,
reversed = true,
),
),
),
),
)
val sortFlow = sortSink
.map { orderConfig ->
val mainItems = cam.values
.map { it.item }
.map { item ->
val checked = item.config.comparator == orderConfig.comparator
item.copy(checked = checked)
}
val subItems = cam[orderConfig.comparator]?.subItems.orEmpty()
.map { item ->
val checked = item.config == orderConfig
item.copy(checked = checked)
}
val out = mutableListOf<AppPickerSortItem>()
out += mainItems
if (subItems.isNotEmpty()) {
out += AppPickerSortItem.Section(
id = "sub_items_section",
text = TextHolder.Res(Res.string.options),
)
out += subItems
}
AppPickerState.Sort(
sort = out.toPersistentList(),
clearSort = if (orderConfig != sortDefault) {
::onClearSort
} else null,
)
}
.stateIn(screenScope)
fun onClick(appInfo: AppInfo) {
val uri = "androidapp://${appInfo.packageName}"
val result = AppPickerResult.Confirm(uri)
@ -76,7 +294,6 @@ fun produceAppPickerState(
fun List<AppInfo>.toItems(): List<AppPickerState.Item> {
val packageNameCollisions = mutableMapOf<String, Int>()
return this
.sortedWith(appsComparator)
.map { appInfo ->
val key = kotlin.run {
val newPackageNameCollisionCounter = packageNameCollisions
@ -100,8 +317,13 @@ fun produceAppPickerState(
}
val itemsFlow = getAppsFlow(context.context)
.map { apps ->
apps
.combine(sortSink) { items, sort ->
val comparator = Comparator<AppInfo> { a, b ->
val result = sort.comparator.compare(a, b)
if (sort.reversed) -result else result
}
val sortedItems = items
.sortedWith(comparator)
.toItems()
// Index for the search.
.map { item ->
@ -110,6 +332,7 @@ fun produceAppPickerState(
indexedText = IndexedText.invoke(item.name.text),
)
}
sortedItems
}
.mapSearch(
handle = queryHandle,
@ -140,6 +363,7 @@ fun produceAppPickerState(
.map { content ->
val state = AppPickerState(
filter = queryFlow,
sort = sortFlow,
content = content,
)
Loadable.Ok(state)
@ -178,17 +402,20 @@ private fun getApps(
val apps = pm.queryIntentActivities(intent, 0)
apps
.map { info ->
val packageInfo = pm.getPackageInfo(info.activityInfo.packageName, 0)
val applicationInfo = pm.getApplicationInfo(info.activityInfo.packageName, 0)
val system = run {
val ai = pm.getApplicationInfo(info.activityInfo.packageName, 0)
val mask =
ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
ai.flags.and(mask) != 0
applicationInfo.flags.and(mask) != 0
}
val label = info.loadLabel(pm)?.toString().orEmpty()
val installTime = packageInfo.firstInstallTime
AppInfo(
packageName = info.activityInfo.packageName,
label = label,
system = system,
installTime = installTime,
)
}
}
@ -197,4 +424,5 @@ data class AppInfo(
val packageName: String,
val label: String,
val system: Boolean,
val installTime: Long,
)

View File

@ -0,0 +1,28 @@
package com.artemchep.keyguard.feature.apppicker.model
import androidx.compose.ui.graphics.vector.ImageVector
import arrow.optics.optics
import com.artemchep.keyguard.feature.apppicker.AppPickerComparatorHolder
import com.artemchep.keyguard.feature.localization.TextHolder
import com.artemchep.keyguard.feature.search.sort.model.SortItemModel
@optics
sealed interface AppPickerSortItem : SortItemModel {
companion object;
data class Section(
override val id: String,
override val text: TextHolder? = null,
) : AppPickerSortItem, SortItemModel.Section {
companion object
}
data class Item(
override val id: String,
val config: AppPickerComparatorHolder,
override val icon: ImageVector? = null,
override val title: TextHolder,
override val checked: Boolean,
override val onClick: (() -> Unit)? = null,
) : AppPickerSortItem, SortItemModel.Item
}

View File

@ -763,6 +763,9 @@
<string name="sortby_modification_date_title">Modification date</string>
<string name="sortby_modification_date_normal_mode">Newest First</string>
<string name="sortby_modification_date_reverse_mode">Oldest First</string>
<string name="sortby_installation_date_title">Installation date</string>
<string name="sortby_installation_date_normal_mode">Most Recent First</string>
<string name="sortby_installation_date_reverse_mode">Oldest First</string>
<string name="sortby_expiration_date_title">Expiration date</string>
<string name="sortby_expiration_date_normal_mode">Newest First</string>
<string name="sortby_expiration_date_reverse_mode">Oldest First</string>

View File

@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
@ -21,11 +22,13 @@ class SearchQueryHandle(
val querySink: MutableStateFlow<String>,
val queryState: MutableState<String>,
val queryIndexed: Flow<IndexedText?>,
val revisionFlow: Flow<Int>,
) {
}
fun RememberStateFlowScope.searchQueryHandle(
key: String,
revisionFlow: Flow<Int> = flowOf(0),
): SearchQueryHandle {
val querySink = mutablePersistedFlow(key) { "" }
val queryState = mutableComposeState(querySink)
@ -45,35 +48,43 @@ fun RememberStateFlowScope.searchQueryHandle(
querySink = querySink,
queryState = queryState,
queryIndexed = queryIndexedFlow,
revisionFlow = revisionFlow,
)
}
suspend fun <T> RememberStateFlowScope.searchFilter(
handle: SearchQueryHandle,
transform: (TextFieldModel2, Int) -> T,
) = handle.querySink
.map { query ->
val revision = query.trim().hashCode()
val model = TextFieldModel2(
state = handle.queryState,
text = query,
onChange = handle.queryState::value::set,
)
transform(
model,
revision,
)
}
) = combine(
handle.querySink,
handle.revisionFlow,
) { query, rev ->
val revision = rev xor query.trim().hashCode()
val model = TextFieldModel2(
state = handle.queryState,
text = query,
onChange = handle.queryState::value::set,
)
transform(
model,
revision,
)
}
.stateIn(screenScope)
fun <T> Flow<List<IndexedModel<T>>>.mapSearch(
handle: SearchQueryHandle,
transform: (T, IndexedText.FindResult) -> T,
) = this
.combine(handle.queryIndexed) { items, query -> items to query }
.mapLatest { (items, query) ->
) = combine(
this,
handle.queryIndexed,
handle.revisionFlow,
) { items, query, rev ->
Triple(items, query, rev)
}
.mapLatest { (items, query, rev) ->
if (query == null) {
return@mapLatest items.map { it.model } to 0
return@mapLatest items.map { it.model } to rev
}
val filteredItems = items
@ -83,6 +94,7 @@ fun <T> Flow<List<IndexedModel<T>>>.mapSearch(
highlightContentColor = handle.scope.colorScheme.searchHighlightContentColor,
transform = transform,
)
filteredItems to query.text.hashCode()
val revision = rev xor query.text.hashCode()
filteredItems to revision
}