feature(AppPicker): Add installation time sorting option
This commit is contained in:
parent
9d0f163181
commit
d17bff1749
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue