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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier 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.home.vault.component.surfaceColorAtElevation
import com.artemchep.keyguard.feature.navigation.NavigationIcon import com.artemchep.keyguard.feature.navigation.NavigationIcon
import com.artemchep.keyguard.feature.navigation.RouteResultTransmitter 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.Res
import com.artemchep.keyguard.res.* import com.artemchep.keyguard.res.*
import com.artemchep.keyguard.ui.DefaultProgressBar import com.artemchep.keyguard.ui.DefaultProgressBar
@ -88,6 +90,13 @@ fun ChangePasswordScreen(
} }
LaunchedEffect(listRevision) { 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 // TODO: How do you wait till the layout state start to represent
// the actual data? // the actual data?
val listSize = val listSize =
@ -136,6 +145,14 @@ fun ChangePasswordScreen(
icon = { icon = {
NavigationIcon() NavigationIcon()
}, },
actions = {
val content = loadableState.getOrNull()
?: return@CustomToolbarContent
val sort by content.sort.collectAsState()
AppPickerSortButton(
state = sort,
)
},
) )
val query = filterState.value?.query 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 @Composable
private fun NoItemsPlaceholder( private fun NoItemsPlaceholder(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View File

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

View File

@ -4,15 +4,22 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.ApplicationInfo 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.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import arrow.core.partially1 import arrow.core.partially1
import com.artemchep.keyguard.android.util.broadcastFlow import com.artemchep.keyguard.android.util.broadcastFlow
import com.artemchep.keyguard.common.model.Loadable import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.usecase.UnlockUseCase 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.crashlytics.crashlyticsAttempt
import com.artemchep.keyguard.feature.favicon.AppIconUrl import com.artemchep.keyguard.feature.favicon.AppIconUrl
import com.artemchep.keyguard.feature.home.vault.search.IndexedText 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.RouteResultTransmitter
import com.artemchep.keyguard.feature.navigation.state.navigatePopSelf import com.artemchep.keyguard.feature.navigation.state.navigatePopSelf
import com.artemchep.keyguard.feature.navigation.state.produceScreenState 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.mapSearch
import com.artemchep.keyguard.feature.search.search.searchFilter import com.artemchep.keyguard.feature.search.search.searchFilter
import com.artemchep.keyguard.feature.search.search.searchQueryHandle 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.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart 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.compose.localDI
import org.kodein.di.direct import org.kodein.di.direct
import org.kodein.di.instance 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( private class AppPickerUiException(
msg: String, msg: String,
cause: Throwable, cause: Throwable,
@ -54,7 +142,26 @@ fun produceAppPickerState(
unlockUseCase, 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 -> val queryFlow = searchFilter(queryHandle) { model, revision ->
AppPickerState.Filter( AppPickerState.Filter(
revision = revision, revision = revision,
@ -62,10 +169,121 @@ fun produceAppPickerState(
) )
} }
val appsComparator = Comparator { a: AppInfo, b: AppInfo -> fun onClearSort() {
a.label.compareTo(b.label, ignoreCase = true) 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) { fun onClick(appInfo: AppInfo) {
val uri = "androidapp://${appInfo.packageName}" val uri = "androidapp://${appInfo.packageName}"
val result = AppPickerResult.Confirm(uri) val result = AppPickerResult.Confirm(uri)
@ -76,7 +294,6 @@ fun produceAppPickerState(
fun List<AppInfo>.toItems(): List<AppPickerState.Item> { fun List<AppInfo>.toItems(): List<AppPickerState.Item> {
val packageNameCollisions = mutableMapOf<String, Int>() val packageNameCollisions = mutableMapOf<String, Int>()
return this return this
.sortedWith(appsComparator)
.map { appInfo -> .map { appInfo ->
val key = kotlin.run { val key = kotlin.run {
val newPackageNameCollisionCounter = packageNameCollisions val newPackageNameCollisionCounter = packageNameCollisions
@ -100,8 +317,13 @@ fun produceAppPickerState(
} }
val itemsFlow = getAppsFlow(context.context) val itemsFlow = getAppsFlow(context.context)
.map { apps -> .combine(sortSink) { items, sort ->
apps 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() .toItems()
// Index for the search. // Index for the search.
.map { item -> .map { item ->
@ -110,6 +332,7 @@ fun produceAppPickerState(
indexedText = IndexedText.invoke(item.name.text), indexedText = IndexedText.invoke(item.name.text),
) )
} }
sortedItems
} }
.mapSearch( .mapSearch(
handle = queryHandle, handle = queryHandle,
@ -140,6 +363,7 @@ fun produceAppPickerState(
.map { content -> .map { content ->
val state = AppPickerState( val state = AppPickerState(
filter = queryFlow, filter = queryFlow,
sort = sortFlow,
content = content, content = content,
) )
Loadable.Ok(state) Loadable.Ok(state)
@ -178,17 +402,20 @@ private fun getApps(
val apps = pm.queryIntentActivities(intent, 0) val apps = pm.queryIntentActivities(intent, 0)
apps apps
.map { info -> .map { info ->
val packageInfo = pm.getPackageInfo(info.activityInfo.packageName, 0)
val applicationInfo = pm.getApplicationInfo(info.activityInfo.packageName, 0)
val system = run { val system = run {
val ai = pm.getApplicationInfo(info.activityInfo.packageName, 0)
val mask = val mask =
ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP 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 label = info.loadLabel(pm)?.toString().orEmpty()
val installTime = packageInfo.firstInstallTime
AppInfo( AppInfo(
packageName = info.activityInfo.packageName, packageName = info.activityInfo.packageName,
label = label, label = label,
system = system, system = system,
installTime = installTime,
) )
} }
} }
@ -197,4 +424,5 @@ data class AppInfo(
val packageName: String, val packageName: String,
val label: String, val label: String,
val system: Boolean, 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_title">Modification date</string>
<string name="sortby_modification_date_normal_mode">Newest First</string> <string name="sortby_modification_date_normal_mode">Newest First</string>
<string name="sortby_modification_date_reverse_mode">Oldest 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_title">Expiration date</string>
<string name="sortby_expiration_date_normal_mode">Newest First</string> <string name="sortby_expiration_date_normal_mode">Newest First</string>
<string name="sortby_expiration_date_reverse_mode">Oldest 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.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@ -21,11 +22,13 @@ class SearchQueryHandle(
val querySink: MutableStateFlow<String>, val querySink: MutableStateFlow<String>,
val queryState: MutableState<String>, val queryState: MutableState<String>,
val queryIndexed: Flow<IndexedText?>, val queryIndexed: Flow<IndexedText?>,
val revisionFlow: Flow<Int>,
) { ) {
} }
fun RememberStateFlowScope.searchQueryHandle( fun RememberStateFlowScope.searchQueryHandle(
key: String, key: String,
revisionFlow: Flow<Int> = flowOf(0),
): SearchQueryHandle { ): SearchQueryHandle {
val querySink = mutablePersistedFlow(key) { "" } val querySink = mutablePersistedFlow(key) { "" }
val queryState = mutableComposeState(querySink) val queryState = mutableComposeState(querySink)
@ -45,35 +48,43 @@ fun RememberStateFlowScope.searchQueryHandle(
querySink = querySink, querySink = querySink,
queryState = queryState, queryState = queryState,
queryIndexed = queryIndexedFlow, queryIndexed = queryIndexedFlow,
revisionFlow = revisionFlow,
) )
} }
suspend fun <T> RememberStateFlowScope.searchFilter( suspend fun <T> RememberStateFlowScope.searchFilter(
handle: SearchQueryHandle, handle: SearchQueryHandle,
transform: (TextFieldModel2, Int) -> T, transform: (TextFieldModel2, Int) -> T,
) = handle.querySink ) = combine(
.map { query -> handle.querySink,
val revision = query.trim().hashCode() handle.revisionFlow,
val model = TextFieldModel2( ) { query, rev ->
state = handle.queryState, val revision = rev xor query.trim().hashCode()
text = query, val model = TextFieldModel2(
onChange = handle.queryState::value::set, state = handle.queryState,
) text = query,
transform( onChange = handle.queryState::value::set,
model, )
revision, transform(
) model,
} revision,
)
}
.stateIn(screenScope) .stateIn(screenScope)
fun <T> Flow<List<IndexedModel<T>>>.mapSearch( fun <T> Flow<List<IndexedModel<T>>>.mapSearch(
handle: SearchQueryHandle, handle: SearchQueryHandle,
transform: (T, IndexedText.FindResult) -> T, transform: (T, IndexedText.FindResult) -> T,
) = this ) = combine(
.combine(handle.queryIndexed) { items, query -> items to query } this,
.mapLatest { (items, query) -> handle.queryIndexed,
handle.revisionFlow,
) { items, query, rev ->
Triple(items, query, rev)
}
.mapLatest { (items, query, rev) ->
if (query == null) { if (query == null) {
return@mapLatest items.map { it.model } to 0 return@mapLatest items.map { it.model } to rev
} }
val filteredItems = items val filteredItems = items
@ -83,6 +94,7 @@ fun <T> Flow<List<IndexedModel<T>>>.mapSearch(
highlightContentColor = handle.scope.colorScheme.searchHighlightContentColor, highlightContentColor = handle.scope.colorScheme.searchHighlightContentColor,
transform = transform, transform = transform,
) )
filteredItems to query.text.hashCode() val revision = rev xor query.text.hashCode()
filteredItems to revision
} }