diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/AppPickerScreen.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/AppPickerScreen.kt index 719ac88c..c0f009d5 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/AppPickerScreen.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/AppPickerScreen.kt @@ -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, diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/AppPickerState.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/AppPickerState.kt index 1a5a7da5..d180f789 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/AppPickerState.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/AppPickerState.kt @@ -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, + val sort: StateFlow, val content: Loadable>, ) { @Immutable @@ -21,6 +25,14 @@ data class AppPickerState( companion object } + @Immutable + data class Sort( + val sort: ImmutableList, + val clearSort: (() -> Unit)? = null, + ) { + companion object + } + @Immutable @optics data class Content( diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/AppPickerStateProducer.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/AppPickerStateProducer.kt index f12afdf5..328b0348 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/AppPickerStateProducer.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/AppPickerStateProducer.kt @@ -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): 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, 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, + ) + + 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() + 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.toItems(): List { val packageNameCollisions = mutableMapOf() 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 { 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, ) diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/model/AppPickerSortItem.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/model/AppPickerSortItem.kt new file mode 100644 index 00000000..7a69f432 --- /dev/null +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/apppicker/model/AppPickerSortItem.kt @@ -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 +} diff --git a/common/src/commonMain/composeResources/values/strings.xml b/common/src/commonMain/composeResources/values/strings.xml index 901319d7..2dee5f60 100644 --- a/common/src/commonMain/composeResources/values/strings.xml +++ b/common/src/commonMain/composeResources/values/strings.xml @@ -763,6 +763,9 @@ Modification date Newest First Oldest First + Installation date + Most Recent First + Oldest First Expiration date Newest First Oldest First diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/search/produceSimpleSearch.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/search/produceSimpleSearch.kt index 852c8c2a..47d51ca9 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/search/produceSimpleSearch.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/search/produceSimpleSearch.kt @@ -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, val queryState: MutableState, val queryIndexed: Flow, + val revisionFlow: Flow, ) { } fun RememberStateFlowScope.searchQueryHandle( key: String, + revisionFlow: Flow = 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 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 Flow>>.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 Flow>>.mapSearch( highlightContentColor = handle.scope.colorScheme.searchHighlightContentColor, transform = transform, ) - filteredItems to query.text.hashCode() + val revision = rev xor query.text.hashCode() + filteredItems to revision }