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