diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1a4e8ef..e288cd2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,6 +48,12 @@ dependencies { implementation("androidx.compose.material3:material3:1.3.2") implementation("androidx.core:core-splashscreen:1.0.1") + // Compose navigation + implementation("androidx.navigation:navigation-compose:2.8.9") + + // Coil + implementation("io.coil-kt:coil-compose:2.6.0") + // Required for preview support debugImplementation("androidx.compose.ui:ui-tooling") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 80c129d..cd0262f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,10 @@ xmlns:android="http://schemas.android.com/apk/res/android"> + + AudioManager.ADJUST_RAISE @@ -206,27 +208,47 @@ object VolumeKeyControlModuleHandlers { } } - private fun hasActiveMediaController() = getActiveMediaController() != null + private fun hasActiveMediaController(): Boolean { + val first = getFirstMediaController() + val active = getMediaController() + if (first != active && first != null) { + return !first.isMusicActive() + } + return active != null + } - private fun getActiveMediaController(): MediaController? { - return mediaControllers?.firstOrNull()?.also { log("chosen media controller: ${it.packageName}") } + private fun getFirstMediaController(): MediaController? { + return mediaControllers?.firstOrNull() + ?.also { log("first media controller: ${it.packageName}") } } - private fun isMusicActive() = getActiveMediaController()?.let { - when (it.playbackState?.state) { - PlaybackState.STATE_PLAYING, - PlaybackState.STATE_FAST_FORWARDING, - PlaybackState.STATE_REWINDING, - PlaybackState.STATE_BUFFERING -> true - else -> false - } - } ?: false + private fun getMediaController(): MediaController? { + return mediaControllers?.find { + val filterType = SharedPreferencesUtil.prefs().getAppFilterType() + val apps = SharedPreferencesUtil.prefs().getApps(filterType) + when (filterType) { + SharedPreferencesUtil.AppFilterType.Disabled -> true + SharedPreferencesUtil.AppFilterType.WhiteList -> it.packageName in apps + SharedPreferencesUtil.AppFilterType.BlackList -> it.packageName !in apps + } + }?.also { log("chosen media controller: ${it.packageName}") } + } + + private fun MediaController.isMusicActive() = when (playbackState?.state) { + PlaybackState.STATE_PLAYING, + PlaybackState.STATE_FAST_FORWARDING, + PlaybackState.STATE_REWINDING, + PlaybackState.STATE_BUFFERING -> true + + else -> false + } private fun sendMediaButtonEventAndTriggerVibration(keyCode: Int) { - getActiveMediaController()?.transportControls?.also { controls -> + getMediaController()?.also { controller -> + val controls = controller.transportControls when (keyCode) { KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { - if (isMusicActive()) controls.pause() else controls.play() + if (controller.isMusicActive()) controls.pause() else controls.play() } KeyEvent.KEYCODE_MEDIA_NEXT -> controls.skipToNext() diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/SettingsActivity.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/SettingsActivity.kt index 0055edf..7635135 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/SettingsActivity.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/SettingsActivity.kt @@ -4,80 +4,29 @@ import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.content.Context -import android.content.Intent -import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.Vibrator import android.view.View import android.view.animation.AccelerateInterpolator import android.view.animation.AnticipateInterpolator -import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.ColorScheme -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.animation.doOnEnd import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import ru.hepolise.volumekeytrackcontrol.ui.component.LongPressSetting import ru.hepolise.volumekeytrackcontrol.ui.component.ModuleIsNotEnabled -import ru.hepolise.volumekeytrackcontrol.ui.component.SwapButtonsSetting -import ru.hepolise.volumekeytrackcontrol.ui.component.VibrationEffectSetting -import ru.hepolise.volumekeytrackcontrol.ui.component.VibrationSettingData -import ru.hepolise.volumekeytrackcontrol.util.Constants -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.IS_SWAP_BUTTONS_DEFAULT_VALUE -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.LONG_PRESS_DURATION_DEFAULT_VALUE -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.SELECTED_EFFECT_DEFAULT_VALUE +import ru.hepolise.volumekeytrackcontrol.ui.navigation.AppNavigation import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.SETTINGS_PREFS_NAME -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.VIBRATION_AMPLITUDE_DEFAULT_VALUE -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.VIBRATION_LENGTH_DEFAULT_VALUE -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getLongPressDuration -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getVibrationAmplitude -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getVibrationLength -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getVibrationType -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isSwapButtons -import ru.hepolise.volumekeytrackcontrol.util.VibrationType import ru.hepolise.volumekeytrackcontrol.util.VibratorUtil.getVibrator -import ru.hepolise.volumekeytrackcontrolmodule.R class SettingsActivity : ComponentActivity() { @@ -115,141 +64,16 @@ class SettingsActivity : ComponentActivity() { MaterialTheme( colorScheme = dynamicColorScheme(context = this) ) { - VibrationSettingsScreen(vibrator = getVibrator()) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun VibrationSettingsScreen(vibrator: Vibrator?) { - val context = LocalContext.current - - @SuppressLint("WorldReadableFiles") @Suppress("DEPRECATION") - val sharedPreferences = try { - context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_WORLD_READABLE) - } catch (e: SecurityException) { - ModuleIsNotEnabled() - return - } - - var longPressDuration by remember { mutableIntStateOf(sharedPreferences.getLongPressDuration()) } - var vibrationType by remember { mutableStateOf(sharedPreferences.getVibrationType()) } - var vibrationLength by remember { mutableIntStateOf(sharedPreferences.getVibrationLength()) } - var vibrationAmplitude by remember { mutableIntStateOf(sharedPreferences.getVibrationAmplitude()) } - var isSwapButtons by remember { mutableStateOf(sharedPreferences.isSwapButtons()) } - - Scaffold( - topBar = { - TopAppBar(title = { Text(stringResource(R.string.app_name)) }) - } - ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(12.dp) - .verticalScroll(rememberScrollState()) - .padding(bottom = 48.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - - LongPressSetting(longPressDuration, sharedPreferences) { - longPressDuration = it + val context = LocalContext.current + + @SuppressLint("WorldReadableFiles") @Suppress("DEPRECATION") + val sharedPreferences = try { + context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_WORLD_READABLE) + } catch (_: SecurityException) { + ModuleIsNotEnabled() + return@MaterialTheme } - - HorizontalDivider(modifier = Modifier.widthIn(max = 300.dp)) - - VibrationEffectSetting( - value = VibrationSettingData( - vibrationType, vibrationLength, vibrationAmplitude - ), - vibrator = vibrator, - sharedPreferences = sharedPreferences - ) { - vibrationType = it.vibrationType - vibrationLength = it.vibrationLength - vibrationAmplitude = it.vibrationAmplitude - } - - - - HorizontalDivider(modifier = Modifier.widthIn(max = 300.dp)) - - Text(text = stringResource(R.string.other_settings), fontSize = 20.sp) - - SwapButtonsSetting( - isSwapButtons = isSwapButtons, - sharedPreferences = sharedPreferences - ) { - isSwapButtons = it - } - - } - - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(end = 16.dp, bottom = 8.dp) - ) { - Spacer(modifier = Modifier.weight(1f)) - - var showResetSettingsDialog by remember { mutableStateOf(false) } - Button(onClick = { - showResetSettingsDialog = true - }) { - Text(stringResource(R.string.settings_reset)) - } - - if (showResetSettingsDialog) { - AlertDialog( - onDismissRequest = { showResetSettingsDialog = false }, - title = { Text(stringResource(R.string.settings_reset)) }, - text = { Text(stringResource(R.string.settings_reset_message)) }, - confirmButton = { - Button(onClick = { - showResetSettingsDialog = false - sharedPreferences.edit().clear().apply() - vibrationType = VibrationType.fromKey(SELECTED_EFFECT_DEFAULT_VALUE) - vibrationLength = VIBRATION_LENGTH_DEFAULT_VALUE - vibrationAmplitude = VIBRATION_AMPLITUDE_DEFAULT_VALUE - longPressDuration = LONG_PRESS_DURATION_DEFAULT_VALUE - isSwapButtons = IS_SWAP_BUTTONS_DEFAULT_VALUE - Toast.makeText( - context, - context.getString(R.string.settings_reset_toast), - Toast.LENGTH_SHORT - ).show() - }) { - Text(stringResource(R.string.yes)) - } - }, - dismissButton = { - TextButton(onClick = { showResetSettingsDialog = false }) { - Text(stringResource(R.string.no)) - } - } - ) - } - - Spacer(modifier = Modifier.width(8.dp)) - - Button(onClick = { - val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse(Constants.GITHUB_URL) - context.startActivity(intent) - }) { - Text(stringResource(R.string.about)) - } - - Spacer(modifier = Modifier.width(8.dp)) + AppNavigation(sharedPreferences = sharedPreferences, vibrator = getVibrator()) } } } @@ -265,9 +89,3 @@ fun dynamicColorScheme(context: Context): ColorScheme { if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() } } - -@Preview(showBackground = true) -@Composable -fun PreviewVibrationSettingsScreen() { - VibrationSettingsScreen(null) -} diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/Util.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/Util.kt new file mode 100644 index 0000000..e6c3221 --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/Util.kt @@ -0,0 +1,23 @@ +package ru.hepolise.volumekeytrackcontrol.ui + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch + +@OptIn(FlowPreview::class) +fun State.debounce( + durationMillis: Long, + coroutineScope: CoroutineScope +): State { + val debouncedState = mutableStateOf(this.value) + coroutineScope.launch { + snapshotFlow { this@debounce.value } + .debounce(durationMillis) + .collect { debouncedState.value = it } + } + return debouncedState +} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/AppFilterSetting.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/AppFilterSetting.kt new file mode 100644 index 0000000..6e5853d --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/AppFilterSetting.kt @@ -0,0 +1,77 @@ +package ru.hepolise.volumekeytrackcontrol.ui.component + +import android.content.SharedPreferences +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.sp +import androidx.core.content.edit +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.APP_FILTER_TYPE +import ru.hepolise.volumekeytrackcontrolmodule.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppFilterSetting( + value: SharedPreferencesUtil.AppFilterType, + sharedPreferences: SharedPreferences, + onValueChange: (SharedPreferencesUtil.AppFilterType) -> Unit, + onNavigateToAppFilter: () -> Unit +) { + Text(text = stringResource(R.string.app_filter), fontSize = 20.sp) + + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + TextField( + value = stringResource(value.resourceId), + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }) { + SharedPreferencesUtil.AppFilterType.values.forEach { type -> + DropdownMenuItem( + text = { Text(stringResource(type.resourceId)) }, + onClick = { + onValueChange(type) + sharedPreferences.edit { + putString(APP_FILTER_TYPE, type.key) + } + expanded = false + } + ) + } + } + } + + if (value == SharedPreferencesUtil.AppFilterType.WhiteList || value == SharedPreferencesUtil.AppFilterType.BlackList) { + Button( + onClick = onNavigateToAppFilter, + ) { + Text(text = stringResource(R.string.manage_apps, stringResource(value.resourceId))) + } + } +} + diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/LongPressSetting.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/LongPressSetting.kt index 871626e..a9a8fba 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/LongPressSetting.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/LongPressSetting.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.edit import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.LONG_PRESS_DURATION import ru.hepolise.volumekeytrackcontrolmodule.R @@ -37,8 +38,9 @@ fun LongPressSetting( onValueChange = { onValueChange(it.toInt()) }, valueRange = 100f..1000f, onValueChangeFinished = { - sharedPreferences.edit().putInt(LONG_PRESS_DURATION, longPressDuration) - .apply() + sharedPreferences.edit { + putInt(LONG_PRESS_DURATION, longPressDuration) + } }, modifier = Modifier.widthIn(max = 300.dp) ) @@ -70,7 +72,7 @@ fun LongPressSetting( onDismissRequest = { showLongPressTimeoutDialog = false }, onConfirm = { onValueChange(it) - sharedPreferences.edit().putInt(LONG_PRESS_DURATION, it).apply() + sharedPreferences.edit { putInt(LONG_PRESS_DURATION, it) } showLongPressTimeoutDialog = false } ) diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/NumberAlertDialog.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/NumberAlertDialog.kt index 4588ac5..1f641e0 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/NumberAlertDialog.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/NumberAlertDialog.kt @@ -63,7 +63,7 @@ fun NumberAlertDialog( } }, ) - LaunchedEffect(true) { + LaunchedEffect(Unit) { focusRequester.requestFocus() } } \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/SwapButtonsSetting.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/SwapButtonsSetting.kt index 3763408..4ba39fb 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/SwapButtonsSetting.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/SwapButtonsSetting.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.content.edit import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.IS_SWAP_BUTTONS import ru.hepolise.volumekeytrackcontrolmodule.R @@ -26,7 +27,7 @@ fun SwapButtonsSetting( checked = isSwapButtons, onCheckedChange = { onValueChange(it) - sharedPreferences.edit().putBoolean(IS_SWAP_BUTTONS, it).apply() + sharedPreferences.edit { putBoolean(IS_SWAP_BUTTONS, it) } } ) Spacer(modifier = Modifier.width(4.dp)) @@ -34,7 +35,7 @@ fun SwapButtonsSetting( text = stringResource(R.string.swap_buttons), modifier = Modifier.clickable { onValueChange(!isSwapButtons) - sharedPreferences.edit().putBoolean(IS_SWAP_BUTTONS, !isSwapButtons).apply() + sharedPreferences.edit { putBoolean(IS_SWAP_BUTTONS, !isSwapButtons) } } ) } diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/VibrationEffectSetting.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/VibrationEffectSetting.kt index 275c908..0e04144 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/VibrationEffectSetting.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/VibrationEffectSetting.kt @@ -29,7 +29,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.SELECTED_EFFECT +import androidx.core.content.edit +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.EFFECT import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.VIBRATION_AMPLITUDE import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.VIBRATION_LENGTH import ru.hepolise.volumekeytrackcontrol.util.VibrationType @@ -96,8 +97,9 @@ fun VibrationEffectSetting( text = { Text(stringResource(VibrationEffectTitles[effect]!!)) }, onClick = { onValueChange(value.copy(vibrationType = effect)) - sharedPreferences.edit().putString(SELECTED_EFFECT, effect.key) - .apply() + sharedPreferences.edit { + putString(EFFECT, effect.key) + } effectExpanded = false } ) @@ -113,8 +115,9 @@ fun VibrationEffectSetting( }, valueRange = 10f..500f, onValueChangeFinished = { - sharedPreferences.edit().putInt(VIBRATION_LENGTH, vibrationLength) - .apply() + sharedPreferences.edit { + putInt(VIBRATION_LENGTH, vibrationLength) + } }, modifier = Modifier.widthIn(max = 300.dp) ) @@ -146,7 +149,7 @@ fun VibrationEffectSetting( onDismissRequest = { showManualVibrationLengthDialog = false }, onConfirm = { onValueChange(value.copy(vibrationLength = it)) - sharedPreferences.edit().putInt(VIBRATION_LENGTH, it).apply() + sharedPreferences.edit { putInt(VIBRATION_LENGTH, it) } showManualVibrationLengthDialog = false } ) @@ -159,8 +162,9 @@ fun VibrationEffectSetting( }, valueRange = 1f..255f, onValueChangeFinished = { - sharedPreferences.edit().putInt(VIBRATION_AMPLITUDE, vibrationAmplitude) - .apply() + sharedPreferences.edit { + putInt(VIBRATION_AMPLITUDE, vibrationAmplitude) + } }, modifier = Modifier.widthIn(max = 300.dp) ) @@ -192,8 +196,9 @@ fun VibrationEffectSetting( onDismissRequest = { showVibrationAmplitudeDialog = false }, onConfirm = { onValueChange(value.copy(vibrationAmplitude = it)) - sharedPreferences.edit().putInt(VIBRATION_AMPLITUDE, it) - .apply() + sharedPreferences.edit { + putInt(VIBRATION_AMPLITUDE, it) + } showVibrationAmplitudeDialog = false } ) diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/navigation/AppNavigation.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/navigation/AppNavigation.kt new file mode 100644 index 0000000..56cdfb8 --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/navigation/AppNavigation.kt @@ -0,0 +1,67 @@ +package ru.hepolise.volumekeytrackcontrol.ui.navigation + +import android.content.SharedPreferences +import android.os.Vibrator +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import ru.hepolise.volumekeytrackcontrol.ui.screen.AppFilterScreen +import ru.hepolise.volumekeytrackcontrol.ui.screen.SettingsScreen +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil + +@Composable +fun AppNavigation(sharedPreferences: SharedPreferences, vibrator: Vibrator) { + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = "main" + ) { + composable( + route = "main", + ) { + SettingsScreen( + sharedPreferences = sharedPreferences, + navController = navController, + vibrator = vibrator + ) + } + + composable( + route = "appFilter/{filterType}", + enterTransition = { + slideInHorizontally( + initialOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing) + ) + fadeIn( + animationSpec = tween(durationMillis = 300) + ) + }, + exitTransition = { + slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing) + ) + fadeOut( + animationSpec = tween(durationMillis = 300) + ) + } + ) { backStackEntry -> + val filterType = SharedPreferencesUtil.AppFilterType.fromKey( + backStackEntry.arguments?.getString("filterType") + ?: SharedPreferencesUtil.AppFilterType.Disabled.key + ) + AppFilterScreen( + filterType = filterType, + sharedPreferences = sharedPreferences, + navController = navController + ) + } + } +} \ 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 new file mode 100644 index 0000000..f5d8796 --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/AppFilterScreen.kt @@ -0,0 +1,483 @@ +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 +import androidx.compose.animation.AnimatedVisibility +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.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +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.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.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.launch +import kotlinx.coroutines.withContext +import ru.hepolise.volumekeytrackcontrol.ui.debounce +import ru.hepolise.volumekeytrackcontrol.ui.viewmodel.AppIconViewModel +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.BLACK_LIST_APPS +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.SETTINGS_PREFS_NAME +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.WHITE_LIST_APPS +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getApps +import ru.hepolise.volumekeytrackcontrolmodule.R + +private const val MAX_APPS = 100 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppFilterScreen( + filterType: SharedPreferencesUtil.AppFilterType, + sharedPreferences: SharedPreferences, + navController: NavController? = null, + viewModel: AppIconViewModel = viewModel(), +) { + val context = LocalContext.current + val iconMap by viewModel.iconMap.collectAsState() + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + + var isRefreshing by remember { mutableStateOf(false) } + + var searchQuery by remember { mutableStateOf("") } + val debouncedQuery by remember(searchQuery) { + derivedStateOf { + searchQuery.trim().replace(Regex("\\s+"), " ") + }.debounce(300, scope) + } + + var apps by remember { mutableStateOf>(emptyList()) } + val selectedApps = remember { mutableStateListOf() } + + val snackbarHostState = remember { SnackbarHostState() } + var isSnackbarVisible by remember { mutableStateOf(false) } + + var showClearDialog by remember { mutableStateOf(false) } + + val lazyListState = rememberLazyListState() + val isScrolling by remember { + derivedStateOf { + lazyListState.isScrollInProgress + } + } + LaunchedEffect(isScrolling) { + if (isScrolling) { + focusManager.clearFocus() + } + } + + val isAtBottom by remember { + derivedStateOf { + val layoutInfo = lazyListState.layoutInfo + layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1 + } + } + val bottomPadding by animateDpAsState( + targetValue = if (selectedApps.isNotEmpty()) 56.dp else 0.dp, + animationSpec = tween(durationMillis = 300) + ) + + 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 + } + } + + LaunchedEffect(Unit) { + selectedApps.addAll(sharedPreferences.getApps(filterType)) + onRefresh() + } + + fun saveApps() { + sharedPreferences.edit { + putStringSet( + when (filterType) { + SharedPreferencesUtil.AppFilterType.BlackList -> BLACK_LIST_APPS + SharedPreferencesUtil.AppFilterType.WhiteList -> WHITE_LIST_APPS + else -> throw IllegalStateException("Invalid filter type: $filterType") + }, + selectedApps.toSet() + ) + } + } + + fun handleAppSelection(packageName: String) { + if (selectedApps.contains(packageName)) { + selectedApps.remove(packageName) + } else { + if (selectedApps.size >= MAX_APPS) { + if (!isSnackbarVisible) { + scope.launch { + isSnackbarVisible = true + snackbarHostState.showSnackbar( + context.getString( + R.string.max_apps_limit, + MAX_APPS + ) + ) + isSnackbarVisible = false + } + } + return + } else { + selectedApps.add(packageName) + } + } + + saveApps() + } + + val filteredApps by remember(apps, debouncedQuery) { + derivedStateOf { + apps.filter { app -> + debouncedQuery.isEmpty() || listOf(app.name, app.packageName).any { + it.contains(debouncedQuery, ignoreCase = true) + } + } + } + } + + if (showClearDialog) { + ClearAppsAlertDialog( + size = selectedApps.size, + onConfirm = { + selectedApps.clear() + saveApps() + showClearDialog = false + }, + onDismissRequest = { showClearDialog = false } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + stringResource( + R.string.select_apps_for, + stringResource(filterType.resourceId) + ) + ) + }, + navigationIcon = { + IconButton(onClick = { navController?.popBackStack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + label = { Text(stringResource(R.string.search_apps)) }, + enabled = !isRefreshing, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { + searchQuery = "" + focusManager.clearFocus() + }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.clear_search) + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .focusable(true) + ) + + Box(modifier = Modifier.weight(1f)) { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = Modifier + .fillMaxWidth() + ) { + LazyColumn( + contentPadding = PaddingValues(bottom = bottomPadding), + state = lazyListState, + modifier = Modifier + .fillMaxSize() + ) { + items( + items = filteredApps, + key = { it.packageName }, + contentType = { it.packageName } + ) { app -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + focusManager.clearFocus() + handleAppSelection(app.packageName) + } + .padding(8.dp) + ) { + LaunchedEffect(app.packageName) { + viewModel.loadIcon(app.packageName) + } + val icon = iconMap[app.packageName] + AppIcon(icon, app.name) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = app.name, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Checkbox( + checked = selectedApps.contains(app.packageName), + onCheckedChange = { + focusManager.clearFocus() + handleAppSelection(app.packageName) + } + ) + } + } + } + } + } + } + AnimatedVisibility( + visible = selectedApps.isNotEmpty() && !isRefreshing, + enter = slideInVertically { height -> height } + fadeIn(), + exit = slideOutVertically { height -> height } + fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) { + AnimatedContent( + targetState = isAtBottom, + transitionSpec = { + val slideDirection = if (targetState) + AnimatedContentTransitionScope.SlideDirection.Up else + AnimatedContentTransitionScope.SlideDirection.Down + + slideIntoContainer(slideDirection) + fadeIn() togetherWith + slideOutOfContainer(slideDirection) + fadeOut() + } + ) { atBottom -> + if (atBottom) { + Button( + onClick = { showClearDialog = true }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(48.dp), + shape = MaterialTheme.shapes.medium, + colors = ButtonDefaults.buttonColors( + containerColor = buttonContainerColor, + contentColor = buttonContentColor + ), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp) + ) { + Icon( + Icons.Default.Clear, + contentDescription = stringResource(R.string.clear) + ) + Spacer(Modifier.width(8.dp)) + Text( + pluralStringResource( + id = R.plurals.app_filter_clear_apps, + count = selectedApps.size, + selectedApps.size + ) + ) + } + } else { + FloatingActionButton( + onClick = { showClearDialog = true }, + modifier = Modifier.size(48.dp), + shape = MaterialTheme.shapes.medium, + containerColor = buttonContainerColor, + contentColor = buttonContentColor, + elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 0.dp) + ) { + Icon( + Icons.Default.Clear, + contentDescription = stringResource(R.string.clear), + modifier = Modifier.size(24.dp) + ) + } + } + } + } + } + } +} + +@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 + ) + } + .sortedBy { it.name } +} + +@Composable +private fun AppIcon(bitmap: Bitmap?, contentDescription: String) { + AsyncImage( + model = bitmap, + contentDescription = contentDescription, + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) +} + + +@Composable +private fun ClearAppsAlertDialog( + size: Int, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(R.string.clear)) }, + text = { + Text( + pluralStringResource( + id = R.plurals.app_filter_clear_message, + count = size, + size + ) + ) + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.no)) + } + } + ) +} + +@Preview(showBackground = true) +@Composable +fun PreviewAppFilterScreen() { + AppFilterScreen( + filterType = SharedPreferencesUtil.AppFilterType.WhiteList, + sharedPreferences = LocalContext.current.getSharedPreferences( + SETTINGS_PREFS_NAME, + Context.MODE_PRIVATE, + ), + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/SettingsScreen.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/SettingsScreen.kt new file mode 100644 index 0000000..61a7979 --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/SettingsScreen.kt @@ -0,0 +1,220 @@ +package ru.hepolise.volumekeytrackcontrol.ui.screen + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Vibrator +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.navigation.NavController +import ru.hepolise.volumekeytrackcontrol.ui.component.AppFilterSetting +import ru.hepolise.volumekeytrackcontrol.ui.component.LongPressSetting +import ru.hepolise.volumekeytrackcontrol.ui.component.SwapButtonsSetting +import ru.hepolise.volumekeytrackcontrol.ui.component.VibrationEffectSetting +import ru.hepolise.volumekeytrackcontrol.ui.component.VibrationSettingData +import ru.hepolise.volumekeytrackcontrol.util.Constants +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.APP_FILTER_TYPE_DEFAULT_VALUE +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.EFFECT_DEFAULT_VALUE +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.IS_SWAP_BUTTONS_DEFAULT_VALUE +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.LONG_PRESS_DURATION_DEFAULT_VALUE +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.SETTINGS_PREFS_NAME +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.VIBRATION_AMPLITUDE_DEFAULT_VALUE +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.VIBRATION_LENGTH_DEFAULT_VALUE +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getAppFilterType +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getLongPressDuration +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getVibrationAmplitude +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getVibrationLength +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getVibrationType +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isSwapButtons +import ru.hepolise.volumekeytrackcontrol.util.VibrationType +import ru.hepolise.volumekeytrackcontrolmodule.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + navController: NavController?, + sharedPreferences: SharedPreferences, + vibrator: Vibrator? +) { + val context = LocalContext.current + + var longPressDuration by remember { mutableIntStateOf(sharedPreferences.getLongPressDuration()) } + var vibrationType by remember { mutableStateOf(sharedPreferences.getVibrationType()) } + var vibrationLength by remember { mutableIntStateOf(sharedPreferences.getVibrationLength()) } + var vibrationAmplitude by remember { mutableIntStateOf(sharedPreferences.getVibrationAmplitude()) } + var isSwapButtons by remember { mutableStateOf(sharedPreferences.isSwapButtons()) } + var appFilterType by remember { mutableStateOf(sharedPreferences.getAppFilterType()) } + + Scaffold( + topBar = { + TopAppBar(title = { Text(stringResource(R.string.app_name)) }) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(12.dp) + .verticalScroll(rememberScrollState()) + .padding(bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + LongPressSetting(longPressDuration, sharedPreferences) { + longPressDuration = it + } + + HorizontalDivider(modifier = Modifier.widthIn(max = 300.dp)) + + VibrationEffectSetting( + value = VibrationSettingData( + vibrationType, vibrationLength, vibrationAmplitude + ), + vibrator = vibrator, + sharedPreferences = sharedPreferences + ) { + vibrationType = it.vibrationType + vibrationLength = it.vibrationLength + vibrationAmplitude = it.vibrationAmplitude + } + + HorizontalDivider(modifier = Modifier.widthIn(max = 300.dp)) + + AppFilterSetting( + value = appFilterType, + sharedPreferences = sharedPreferences, + onValueChange = { newAppFilterType -> appFilterType = newAppFilterType }, + ) { + navController?.navigate("appFilter/${appFilterType.key}") + } + + HorizontalDivider(modifier = Modifier.widthIn(max = 300.dp)) + + Text(text = stringResource(R.string.other_settings), fontSize = 20.sp) + + SwapButtonsSetting( + isSwapButtons = isSwapButtons, + sharedPreferences = sharedPreferences + ) { + isSwapButtons = it + } + + } + + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(end = 16.dp, bottom = 8.dp) + ) { + Spacer(modifier = Modifier.weight(1f)) + + var showResetSettingsDialog by remember { mutableStateOf(false) } + Button(onClick = { + showResetSettingsDialog = true + }) { + Text(stringResource(R.string.settings_reset)) + } + + if (showResetSettingsDialog) { + AlertDialog( + onDismissRequest = { showResetSettingsDialog = false }, + title = { Text(stringResource(R.string.settings_reset)) }, + text = { Text(stringResource(R.string.settings_reset_message)) }, + confirmButton = { + Button(onClick = { + showResetSettingsDialog = false + sharedPreferences.edit { clear() } + vibrationType = VibrationType.fromKey(EFFECT_DEFAULT_VALUE) + vibrationLength = VIBRATION_LENGTH_DEFAULT_VALUE + vibrationAmplitude = VIBRATION_AMPLITUDE_DEFAULT_VALUE + longPressDuration = LONG_PRESS_DURATION_DEFAULT_VALUE + isSwapButtons = IS_SWAP_BUTTONS_DEFAULT_VALUE + appFilterType = SharedPreferencesUtil.AppFilterType.fromKey( + APP_FILTER_TYPE_DEFAULT_VALUE + ) + Toast.makeText( + context, + context.getString(R.string.settings_reset_toast), + Toast.LENGTH_SHORT + ).show() + }) { + Text(stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = { showResetSettingsDialog = false }) { + Text(stringResource(R.string.no)) + } + } + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button(onClick = { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Constants.GITHUB_URL.toUri() + context.startActivity(intent) + }) { + Text(stringResource(R.string.about)) + } + + Spacer(modifier = Modifier.width(8.dp)) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewSettingsScreen() { + SettingsScreen( + navController = null, + sharedPreferences = LocalContext.current.getSharedPreferences( + SETTINGS_PREFS_NAME, + Context.MODE_PRIVATE + ), + vibrator = null + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/viewmodel/AppIconViewModel.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/viewmodel/AppIconViewModel.kt new file mode 100644 index 0000000..8ca9a03 --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/viewmodel/AppIconViewModel.kt @@ -0,0 +1,39 @@ +package ru.hepolise.volumekeytrackcontrol.ui.viewmodel + +import android.app.Application +import android.graphics.Bitmap +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class AppIconViewModel(application: Application) : AndroidViewModel(application) { + val iconMap = MutableStateFlow>(emptyMap()) + + private val cacheMutex = Mutex() + + fun loadIcon(packageName: String) { + viewModelScope.launch(Dispatchers.IO) { + cacheMutex.withLock { + if (iconMap.value.containsKey(packageName)) return@withLock + + val drawable = try { + getApplication().packageManager + .getApplicationInfo(packageName, 0) + .loadIcon(getApplication().packageManager) + } catch (_: Exception) { + null + } + + val bitmap = drawable?.toBitmap(48, 48) + + iconMap.value = iconMap.value + (packageName to bitmap) + } + } + } + +} diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/SharedPreferencesUtil.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/SharedPreferencesUtil.kt index 33026e6..9d2235f 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/SharedPreferencesUtil.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/SharedPreferencesUtil.kt @@ -5,26 +5,31 @@ import android.os.Build import android.view.ViewConfiguration import de.robv.android.xposed.XSharedPreferences import ru.hepolise.volumekeytrackcontrolmodule.BuildConfig +import ru.hepolise.volumekeytrackcontrolmodule.R object SharedPreferencesUtil { const val SETTINGS_PREFS_NAME = "settings_prefs" - const val SELECTED_EFFECT = "selectedEffect" + const val EFFECT = "selectedEffect" const val VIBRATION_LENGTH = "vibrationLength" const val VIBRATION_AMPLITUDE = "vibrationAmplitude" const val LONG_PRESS_DURATION = "longPressDuration" const val IS_SWAP_BUTTONS = "isSwapButtons" + const val APP_FILTER_TYPE = "appFilterType" + const val WHITE_LIST_APPS = "whiteListApps" + const val BLACK_LIST_APPS = "blackListApps" - val SELECTED_EFFECT_DEFAULT_VALUE = + val EFFECT_DEFAULT_VALUE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) VibrationType.Click.key else VibrationType.Manual.key const val VIBRATION_LENGTH_DEFAULT_VALUE = 50 const val VIBRATION_AMPLITUDE_DEFAULT_VALUE = 128 val LONG_PRESS_DURATION_DEFAULT_VALUE = ViewConfiguration.getLongPressTimeout() const val IS_SWAP_BUTTONS_DEFAULT_VALUE = false + val APP_FILTER_TYPE_DEFAULT_VALUE = AppFilterType.Disabled.key fun SharedPreferences?.getVibrationType(): VibrationType { - val defaultValue = SELECTED_EFFECT_DEFAULT_VALUE - return VibrationType.fromKey(this?.getString(SELECTED_EFFECT, defaultValue) ?: defaultValue) + val defaultValue = EFFECT_DEFAULT_VALUE + return VibrationType.fromKey(this?.getString(EFFECT, defaultValue) ?: defaultValue) } fun SharedPreferences?.getVibrationLength(): Int { @@ -47,9 +52,48 @@ object SharedPreferencesUtil { return this?.getBoolean(IS_SWAP_BUTTONS, defaultValue) ?: defaultValue } + fun SharedPreferences?.getAppFilterType(): AppFilterType { + val defaultValue = APP_FILTER_TYPE_DEFAULT_VALUE + return AppFilterType.fromKey(this?.getString(APP_FILTER_TYPE, defaultValue) ?: defaultValue) + } + + fun SharedPreferences?.getApps(appFilterType: AppFilterType = getAppFilterType()): Set { + return when (appFilterType) { + AppFilterType.Disabled -> emptySet() + AppFilterType.WhiteList -> this?.getStringSet(WHITE_LIST_APPS, emptySet()) ?: emptySet() + AppFilterType.BlackList -> this?.getStringSet(BLACK_LIST_APPS, emptySet()) ?: emptySet() + } + } + + fun prefs(): SharedPreferences? { val pref = XSharedPreferences(BuildConfig.APPLICATION_ID, SETTINGS_PREFS_NAME) return if (pref.file.canRead()) pref else null } + sealed class AppFilterType(val value: Int, val key: String, val resourceId: Int) { + data object Disabled : AppFilterType(0, "disabled", R.string.app_filter_disabled) + data object WhiteList : AppFilterType(1, "whitelist", R.string.app_filter_white_list) + data object BlackList : AppFilterType(2, "blacklist", R.string.app_filter_black_list) + + companion object { + val values: List by lazy { + mutableListOf().apply { + add(Disabled) + add(WhiteList) + add(BlackList) + } + } + + fun fromKey(key: String): AppFilterType { + return when (key) { + Disabled.key -> Disabled + WhiteList.key -> WhiteList + BlackList.key -> BlackList + else -> Disabled + } + } + } + } + } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0b1579..20b0e36 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,27 @@ Vibration Amplitude Test Vibration + App Filter + Manage %1$s Apps + Disabled + White List + Black List + + Search apps + Clear Search + App Icon + Select apps for %1$s + You can only select up to %1$d apps. + Clear + + Clear 1 app + Clear %d apps + + + Are you sure you want to clear 1 app? + Are you sure you want to clear %d apps? + + Long Press Duration: %dms Long Press Duration @@ -32,6 +53,8 @@ Module is not enabled in LSPosed.\nRestart the app after enabling the module.\nIf you think this is a bug, please open an issue + Back + Yes No