diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/model/AppInfo.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/model/AppInfo.kt new file mode 100644 index 0000000..c0afbcf --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/model/AppInfo.kt @@ -0,0 +1,9 @@ +package ru.hepolise.volumekeytrackcontrol.ui.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class AppInfo( + val name: String, + val packageName: String +) \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/AppFilterScreen.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/AppFilterScreen.kt index f0e3ab5..02f7541 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/AppFilterScreen.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/AppFilterScreen.kt @@ -2,7 +2,6 @@ package ru.hepolise.volumekeytrackcontrol.ui.screen import android.content.Context import android.content.SharedPreferences -import android.content.pm.PackageManager import android.graphics.Bitmap import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope @@ -11,16 +10,22 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -31,6 +36,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Clear @@ -41,23 +47,26 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -66,8 +75,19 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -78,10 +98,11 @@ import androidx.core.content.edit import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import coil.compose.AsyncImage -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import ru.hepolise.volumekeytrackcontrol.ui.debounce +import ru.hepolise.volumekeytrackcontrol.ui.model.AppInfo +import ru.hepolise.volumekeytrackcontrol.ui.viewmodel.AppFilterViewModel import ru.hepolise.volumekeytrackcontrol.ui.viewmodel.AppIconViewModel import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.BLACK_LIST_APPS @@ -92,20 +113,23 @@ import ru.hepolise.volumekeytrackcontrolmodule.R private const val MAX_APPS = 100 +private val LETTERS = ('A'..'Z').toList() + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppFilterScreen( filterType: SharedPreferencesUtil.AppFilterType, sharedPreferences: SharedPreferences, navController: NavController? = null, - viewModel: AppIconViewModel = viewModel(), + viewModel: AppFilterViewModel = viewModel(), + iconViewModel: AppIconViewModel = viewModel(), ) { val context = LocalContext.current - val iconMap by viewModel.iconMap.collectAsState() + val iconMap by iconViewModel.iconMap.collectAsState() val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current - - var isRefreshing by remember { mutableStateOf(false) } + val apps by viewModel.apps.collectAsState() + val isRefreshing by viewModel.isRefreshing.collectAsState() var searchQuery by remember { mutableStateOf("") } val debouncedQuery by remember(searchQuery) { @@ -114,9 +138,17 @@ fun AppFilterScreen( }.debounce(300, scope) } - var apps by remember { mutableStateOf>(emptyList()) } val selectedApps = remember { mutableStateListOf() } + val appState by remember { + derivedStateOf { + AppListState( + allApps = apps, + selectedPackages = selectedApps.toSet() + ) + } + } + val snackbarHostState = remember { SnackbarHostState() } var isSnackbarVisible by remember { mutableStateOf(false) } @@ -148,20 +180,13 @@ fun AppFilterScreen( val buttonContainerColor = MaterialTheme.colorScheme.primary val buttonContentColor = MaterialTheme.colorScheme.onPrimary - val onRefresh: () -> Unit = { - isRefreshing = true - apps = emptyList() - scope.launch { - apps = withContext(Dispatchers.IO) { - getAllApps(context) - } - isRefreshing = false - } + val onRefresh: (Boolean) -> Unit = { refresh -> + viewModel.loadApps(context, refresh) } LaunchedEffect(Unit) { selectedApps.addAll(sharedPreferences.getApps(filterType)) - onRefresh() + onRefresh(false) } fun saveApps() { @@ -203,16 +228,54 @@ fun AppFilterScreen( saveApps() } - val filteredApps by remember(apps, debouncedQuery) { + val filteredApps by remember(appState, debouncedQuery) { derivedStateOf { - apps.filter { app -> - debouncedQuery.isEmpty() || listOf(app.name, app.packageName).any { - it.contains(debouncedQuery, ignoreCase = true) + appState.allApps + .asSequence() + .filter { app -> + debouncedQuery.isEmpty() || + app.name.contains(debouncedQuery, ignoreCase = true) || + app.packageName.contains(debouncedQuery, ignoreCase = true) + } + .sortedWith( + AppListComparator(appState.selectedPackages) + ) + .toList() + } + } + + val letterToIndexMap by remember(filteredApps) { + derivedStateOf { + buildMap { + filteredApps.forEachIndexed { index, app -> + if (selectedApps.contains(app.packageName)) { + return@forEachIndexed + } + val firstLetter = app.name.firstOrNull()?.uppercaseChar() + if (firstLetter != null && firstLetter !in this) { + put(firstLetter, index) + } + } + } + } + } + + var activeLetter by remember { mutableStateOf(null) } + var showLetterPopup by remember { mutableStateOf(false) } + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (source == NestedScrollSource.UserInput) { + activeLetter = null } + focusManager.clearFocus() + return Offset.Zero } } } + if (showClearDialog) { ClearAppsAlertDialog( size = selectedApps.size, @@ -286,7 +349,7 @@ fun AppFilterScreen( Box(modifier = Modifier.weight(1f)) { PullToRefreshBox( isRefreshing = isRefreshing, - onRefresh = onRefresh, + onRefresh = { onRefresh(true) }, modifier = Modifier .fillMaxWidth() ) { @@ -295,15 +358,29 @@ fun AppFilterScreen( state = lazyListState, modifier = Modifier .fillMaxSize() + .nestedScroll(nestedScrollConnection) ) { items( items = filteredApps, key = { it.packageName }, contentType = { "App" } ) { app -> + val index = filteredApps.indexOf(app) + val isFirstUnselected = index > 0 && + selectedApps.contains(filteredApps[index - 1].packageName) && + !selectedApps.contains(app.packageName) + + if (isFirstUnselected) { + HorizontalDivider( + thickness = 1.dp, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier + .animateItem() .fillMaxWidth() .clickable { focusManager.clearFocus() @@ -312,7 +389,7 @@ fun AppFilterScreen( .padding(8.dp) ) { LaunchedEffect(app.packageName) { - viewModel.loadIcon(app.packageName) + iconViewModel.loadIcon(app.packageName) } val icon = iconMap[app.packageName] AppIcon(icon, app.name) @@ -339,6 +416,21 @@ fun AppFilterScreen( } } } + + if (!isRefreshing) { + AppLetters(activeLetter) { char, showLetter -> + showLetterPopup = showLetter + if (char != null && char != activeLetter) { + activeLetter = char + letterToIndexMap[activeLetter]?.let { index -> + scope.launch { + lazyListState.animateScrollToItem(index) + } + } + } + } + } + AnimatedVisibility( visible = selectedApps.isNotEmpty() && !isRefreshing, enter = slideInVertically { height -> height } + fadeIn(), @@ -403,27 +495,10 @@ fun AppFilterScreen( } } } - } - } -} - -@Immutable -private data class AppInfo( - val name: String, - val packageName: String -) -private fun getAllApps(context: Context): List { - val packageManager = context.packageManager - return packageManager.getInstalledPackages(PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES) - .filter { it.applicationInfo != null } - .map { packageInfo -> - AppInfo( - name = packageInfo.applicationInfo?.loadLabel(packageManager).toString(), - packageName = packageInfo.packageName - ) + AppLetterPopup(activeLetter, showLetterPopup) } - .sortedBy { it.name } + } } @Composable @@ -438,7 +513,6 @@ private fun AppIcon(bitmap: Bitmap?, contentDescription: String) { ) } - @Composable private fun ClearAppsAlertDialog( size: Int, @@ -470,6 +544,144 @@ private fun ClearAppsAlertDialog( ) } +@Composable +private fun BoxScope.AppLetters( + activeLetter: Char?, + onActiveLetterChange: (Char?, Boolean) -> Unit, +) { + val density = LocalDensity.current + val minTotalHeight = with(density) { LETTERS.size * 24.dp.roundToPx() } + + var containerHeight by remember { mutableIntStateOf(0) } + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .onSizeChanged { containerHeight = it.height } + ) { + if (containerHeight >= minTotalHeight) { + + Column( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 8.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f), + shape = RoundedCornerShape(16.dp) + ) + .padding(vertical = 8.dp, horizontal = 4.dp) + .pointerInput(Unit) { + fun Offset.findLetter() = + LETTERS[(this.y / (size.height / LETTERS.size.toFloat())).toInt() + .coerceIn(0, LETTERS.size - 1)] + detectVerticalDragGestures( + onDragStart = { offset -> + onActiveLetterChange(offset.findLetter(), true) + }, + onVerticalDrag = { change, dragAmount -> + onActiveLetterChange(change.position.findLetter(), true) + change.consume() + }, + onDragEnd = { onActiveLetterChange(null, false) }, + onDragCancel = { onActiveLetterChange(null, false) } + ) + } + ) { + LETTERS.forEach { letter -> + Text( + text = letter.toString(), + style = MaterialTheme.typography.labelMedium, + color = if (letter == activeLetter) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier + .padding(vertical = 1.dp) + .clickable { onActiveLetterChange(letter, false) } + ) + } + } + } + } +} + +@Composable +private fun BoxScope.AppLetterPopup( + activeLetter: Char?, + showLetterPopup: Boolean +) { + var popupScale by remember { mutableFloatStateOf(1f) } + AnimatedVisibility( + visible = showLetterPopup && activeLetter != null, + enter = fadeIn() + scaleIn(initialScale = 0.8f), + exit = scaleOut() + fadeOut(), + modifier = Modifier.align(Alignment.Center) + ) { + Surface( + modifier = Modifier + .size(80.dp) + .shadow( + elevation = 16.dp, + shape = RoundedCornerShape(20.dp), + spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(20.dp), + color = Color.Transparent + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.secondary + ), + start = Offset(0f, 0f), + end = Offset(100f, 100f) + ), + shape = RoundedCornerShape(20.dp) + ) + ) { + if (activeLetter != null) { + Text( + text = activeLetter.toString(), + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.scale(popupScale) + ) + } + } + } + } + LaunchedEffect(activeLetter) { + if (activeLetter != null) { + popupScale = 1.2f + delay(100) + popupScale = 1f + } + } +} + +private data class AppListState( + val allApps: List, + val selectedPackages: Set +) + +private class AppListComparator( + private val selectedPackages: Set +) : Comparator { + override fun compare(a: AppInfo, b: AppInfo): Int { + val aSelected = a.packageName in selectedPackages + val bSelected = b.packageName in selectedPackages + + return when { + aSelected && !bSelected -> -1 + !aSelected && bSelected -> 1 + else -> a.name.compareTo(b.name, ignoreCase = true) + } + } +} + @Preview(showBackground = true) @Composable fun PreviewAppFilterScreen() { @@ -480,4 +692,4 @@ fun PreviewAppFilterScreen() { Context.MODE_PRIVATE, ), ) -} +} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/viewmodel/AppFilterViewModel.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/viewmodel/AppFilterViewModel.kt new file mode 100644 index 0000000..8fce3be --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/viewmodel/AppFilterViewModel.kt @@ -0,0 +1,46 @@ +package ru.hepolise.volumekeytrackcontrol.ui.viewmodel + +import android.content.Context +import android.content.pm.PackageManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.hepolise.volumekeytrackcontrol.ui.model.AppInfo + +class AppFilterViewModel : ViewModel() { + private val _apps = MutableStateFlow>(emptyList()) + val apps: StateFlow> = _apps.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + fun loadApps(context: Context, refresh: Boolean = false) { + if (_apps.value.isNotEmpty() && !refresh) return + + _apps.value = emptyList() + viewModelScope.launch { + _isRefreshing.value = true + _apps.value = withContext(Dispatchers.IO) { + getAllApps(context) + } + _isRefreshing.value = false + } + } + + private fun getAllApps(context: Context): List { + val packageManager = context.packageManager + return packageManager.getInstalledPackages(PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES) + .filter { it.applicationInfo != null } + .map { packageInfo -> + AppInfo( + name = packageInfo.applicationInfo?.loadLabel(packageManager).toString(), + packageName = packageInfo.packageName + ) + } + } +} \ No newline at end of file