From e6ec1075a19b1e5089b083bacfb7b2f0698e8b55 Mon Sep 17 00:00:00 2001 From: Jules Date: Thu, 30 May 2024 19:52:06 +0200 Subject: [PATCH] 1.0.10 + Customize controls Took 2 hours 35 minutes --- app/build.gradle.kts | 4 +- .../java/fr/angel/soundtap/MainActivity.kt | 33 +- .../java/fr/angel/soundtap/MainViewModel.kt | 77 ++- .../soundtap/data/models/BottomSheetState.kt | 64 +- .../customization/CustomizationSettings.kt | 106 ++- .../fr/angel/soundtap/navigation/NavGraph.kt | 26 +- .../fr/angel/soundtap/navigation/Screens.kt | 36 +- .../service/SoundTapAccessibilityService.kt | 50 +- .../soundtap/service/media/MediaCallback.kt | 40 +- .../fr/angel/soundtap/ui/app/AppScreen.kt | 35 +- .../soundtap/ui/app/CustomizationScreen.kt | 649 ------------------ .../fr/angel/soundtap/ui/app/HistoryScreen.kt | 30 +- .../angel/soundtap/ui/app/SettingsScreen.kt | 5 +- .../fr/angel/soundtap/ui/app/SupportScreen.kt | 27 +- .../customization/CustomizationControls.kt | 138 ++++ .../ui/app/customization/CustomizationHome.kt | 603 ++++++++++++++++ .../app/customization/CustomizationScreen.kt | 177 +++++ .../settings/SettingsItemCustomBottom.kt | 26 +- .../ui/components/settings/SettingsSwitch.kt | 26 +- 19 files changed, 1340 insertions(+), 812 deletions(-) delete mode 100644 app/src/main/java/fr/angel/soundtap/ui/app/CustomizationScreen.kt create mode 100644 app/src/main/java/fr/angel/soundtap/ui/app/customization/CustomizationControls.kt create mode 100644 app/src/main/java/fr/angel/soundtap/ui/app/customization/CustomizationHome.kt create mode 100644 app/src/main/java/fr/angel/soundtap/ui/app/customization/CustomizationScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 84aa6ee..0f11c3c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ android { applicationId = "fr.angel.soundtap" minSdk = 30 targetSdk = 34 - versionCode = 29 - versionName = "1.0.9" + versionCode = 32 + versionName = "1.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/fr/angel/soundtap/MainActivity.kt b/app/src/main/java/fr/angel/soundtap/MainActivity.kt index 6dbf8b5..a5176a7 100644 --- a/app/src/main/java/fr/angel/soundtap/MainActivity.kt +++ b/app/src/main/java/fr/angel/soundtap/MainActivity.kt @@ -1,17 +1,19 @@ /* - * Copyright 2024 Angel Studio * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package fr.angel.soundtap @@ -108,6 +110,7 @@ class MainActivity : ComponentActivity() { } LaunchedEffect(key1 = Unit) { + mainViewModel.setDefaultNavController(navController) mainViewModel.updatePermissionStates(this@MainActivity) } @@ -144,7 +147,9 @@ class MainActivity : ComponentActivity() { enter = scaleIn(), exit = scaleOut(), ) { - IconButton(onClick = { navController.popBackStack() }) { + IconButton(onClick = { + uiState.currentNavController.navigateUp() + }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", @@ -211,7 +216,9 @@ class MainActivity : ComponentActivity() { ) } Spacer(modifier = Modifier.height(24.dp)) - sheetState.content(sheetState) + sheetState.content(sheetState) { + scope.launch { mainViewModel.hideBottomSheet() } + } Spacer(modifier = Modifier.navigationBarsPadding()) } } diff --git a/app/src/main/java/fr/angel/soundtap/MainViewModel.kt b/app/src/main/java/fr/angel/soundtap/MainViewModel.kt index a92f1f4..a65a748 100644 --- a/app/src/main/java/fr/angel/soundtap/MainViewModel.kt +++ b/app/src/main/java/fr/angel/soundtap/MainViewModel.kt @@ -1,17 +1,19 @@ /* - * Copyright 2024 Angel Studio * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package fr.angel.soundtap @@ -23,12 +25,14 @@ import androidx.compose.material3.SheetState import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import fr.angel.soundtap.data.enums.AutoPlayMode import fr.angel.soundtap.data.enums.HapticFeedbackLevel import fr.angel.soundtap.data.enums.WorkingMode import fr.angel.soundtap.data.models.BottomSheetState +import fr.angel.soundtap.data.settings.customization.ControlMediaAction import fr.angel.soundtap.data.settings.customization.CustomizationSettings import fr.angel.soundtap.data.settings.settings.AppSettings import fr.angel.soundtap.data.settings.stats.StatsSettings @@ -52,7 +56,11 @@ data class MainUiState( val customizationSettings: CustomizationSettings = CustomizationSettings(), val appSettings: AppSettings = AppSettings(), val statsSettings: StatsSettings = StatsSettings(), + val defaultNavController: NavHostController? = null, + val focusedNavController: NavHostController? = null, ) { + val currentNavController: NavHostController + get() = focusedNavController ?: defaultNavController!! val defaultScreen: Screens get() = if (appSettings.onboardingPageCompleted) Screens.App else Screens.Onboarding } @@ -208,4 +216,51 @@ class MainViewModel fun setBottomSheetState(sheetState: SheetState) { this.sheetState = sheetState } + + fun setDefaultNavController(navController: NavHostController) { + _uiState.value = _uiState.value.copy(defaultNavController = navController) + } + + fun setFocusedNavController(navController: NavHostController) { + _uiState.value = _uiState.value.copy(focusedNavController = navController) + } + + fun resetFocusedNavController() { + _uiState.value = _uiState.value.copy(focusedNavController = null) + } + + fun toggleControlMediaAction(action: ControlMediaAction) { + viewModelScope.launch { + customizationSettingsDataStore.updateData { settings -> + val newAction = action.copy(enabled = !action.enabled) + when (action.id) { + 0 -> settings.copy(longVolumeUpPressControlMediaAction = newAction) + 1 -> settings.copy(longVolumeDownPressControlMediaAction = newAction) + 2 -> settings.copy(doubleVolumeLongPressControlMediaAction = newAction) + else -> settings + } + } + } + } + + fun changeControlMediaAction(controlMediaAction: ControlMediaAction) { + showBottomSheet( + BottomSheetState.EditControlMediaAction( + displayName = "Edit ${controlMediaAction.title} action", + onSetAction = { newAction -> + viewModelScope.launch { + customizationSettingsDataStore.updateData { settings -> + val newControlMediaAction = controlMediaAction.copy(action = newAction) + when (controlMediaAction.id) { + 0 -> settings.copy(longVolumeUpPressControlMediaAction = newControlMediaAction) + 1 -> settings.copy(longVolumeDownPressControlMediaAction = newControlMediaAction) + 2 -> settings.copy(doubleVolumeLongPressControlMediaAction = newControlMediaAction) + else -> settings + } + } + } + }, + ), + ) + } } diff --git a/app/src/main/java/fr/angel/soundtap/data/models/BottomSheetState.kt b/app/src/main/java/fr/angel/soundtap/data/models/BottomSheetState.kt index 9ddbe33..a55b888 100644 --- a/app/src/main/java/fr/angel/soundtap/data/models/BottomSheetState.kt +++ b/app/src/main/java/fr/angel/soundtap/data/models/BottomSheetState.kt @@ -18,26 +18,33 @@ package fr.angel.soundtap.data.models +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import fr.angel.soundtap.data.settings.customization.MediaAction sealed class BottomSheetState( open val displayName: String?, open val onDismiss: (() -> Unit)? = null, - val content: @Composable (BottomSheetState) -> Unit, + val content: @Composable (BottomSheetState, () -> Unit) -> Unit, ) { data object None : BottomSheetState( displayName = "Oops, something went wrong", - content = { }, + content = { _, _ -> + Text("An error occurred while trying to display the bottom sheet.") + }, ) data class SetTimer( @@ -46,7 +53,7 @@ sealed class BottomSheetState( val onTimerSet: (Long) -> Unit, ) : BottomSheetState( displayName = displayName, - content = { state -> + content = { state, hide -> val timerChoices: Map = mapOf( "5 minutes" to 5 * 60 * 1000, @@ -58,7 +65,7 @@ sealed class BottomSheetState( ) Column( - verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { timerChoices.forEach { (label, duration) -> Button( @@ -71,17 +78,64 @@ sealed class BottomSheetState( ), onClick = { onTimerSet(duration) + hide() + state.onDismiss?.invoke() + }, + ) { + Text( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + text = label, + textAlign = TextAlign.Start, + ) + } + } + } + }, + ) + + data class EditControlMediaAction( + override val displayName: String? = "Control Media Action", + override val onDismiss: (() -> Unit)? = null, + val onSetAction: (MediaAction) -> Unit, + ) : BottomSheetState( + displayName = displayName, + content = { state, hide -> + val options = MediaAction.entries + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + options.forEach { action -> + Button( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + onClick = { + onSetAction(action) + + hide() // Dismiss the bottom sheet state.onDismiss?.invoke() }, ) { + Icon( + imageVector = action.icon, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(8.dp)) Text( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), - text = label, + text = action.title, textAlign = TextAlign.Start, ) } diff --git a/app/src/main/java/fr/angel/soundtap/data/settings/customization/CustomizationSettings.kt b/app/src/main/java/fr/angel/soundtap/data/settings/customization/CustomizationSettings.kt index 9d2fcc6..c649941 100644 --- a/app/src/main/java/fr/angel/soundtap/data/settings/customization/CustomizationSettings.kt +++ b/app/src/main/java/fr/angel/soundtap/data/settings/customization/CustomizationSettings.kt @@ -1,24 +1,48 @@ /* - * Copyright 2024 Angel Studio * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package fr.angel.soundtap.data.settings.customization +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.FastForward +import androidx.compose.material.icons.rounded.FastRewind +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.SkipNext +import androidx.compose.material.icons.rounded.SkipPrevious +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material.icons.rounded.ToggleOn +import androidx.compose.material.icons.rounded.UnfoldMoreDouble +import androidx.compose.ui.graphics.vector.ImageVector import fr.angel.soundtap.data.enums.AutoPlayMode import fr.angel.soundtap.data.enums.HapticFeedbackLevel import fr.angel.soundtap.data.enums.WorkingMode +import fr.angel.soundtap.data.settings.customization.CustomizationSettings.Companion.ACTION_FAST_FORWARD +import fr.angel.soundtap.data.settings.customization.CustomizationSettings.Companion.ACTION_NEXT +import fr.angel.soundtap.data.settings.customization.CustomizationSettings.Companion.ACTION_PAUSE +import fr.angel.soundtap.data.settings.customization.CustomizationSettings.Companion.ACTION_PLAY +import fr.angel.soundtap.data.settings.customization.CustomizationSettings.Companion.ACTION_PLAY_PAUSE +import fr.angel.soundtap.data.settings.customization.CustomizationSettings.Companion.ACTION_PREVIOUS +import fr.angel.soundtap.data.settings.customization.CustomizationSettings.Companion.ACTION_REWIND +import fr.angel.soundtap.data.settings.customization.CustomizationSettings.Companion.ACTION_STOP import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient const val DEFAULT_LONG_PRESS_THRESHOLD = 400L const val DEFAULT_DOUBLE_PRESS_THRESHOLD = 400L @@ -33,4 +57,64 @@ data class CustomizationSettings( val autoPlayMode: AutoPlayMode = AutoPlayMode.ON_HEADSET_CONNECTED, val preferredMediaPlayer: String? = null, val autoPlay: Boolean = false, + // Control customization + val longVolumeUpPressControlMediaAction: ControlMediaAction = + ControlMediaAction( + id = 0, + title = "Long volume up press", + icon = Icons.Rounded.KeyboardArrowUp, + enabled = true, + action = MediaAction.NEXT, + ), + val longVolumeDownPressControlMediaAction: ControlMediaAction = + ControlMediaAction( + id = 1, + title = "Long volume down press", + icon = Icons.Rounded.KeyboardArrowDown, + enabled = true, + action = MediaAction.PREVIOUS, + ), + val doubleVolumeLongPressControlMediaAction: ControlMediaAction = + ControlMediaAction( + id = 2, + title = "Double volume long press", + icon = Icons.Rounded.UnfoldMoreDouble, + enabled = true, + action = MediaAction.PLAY_PAUSE, + ), +) { + companion object { + const val ACTION_PLAY_PAUSE = "play_pause" + const val ACTION_NEXT = "next" + const val ACTION_PREVIOUS = "previous" + const val ACTION_STOP = "stop" + const val ACTION_FAST_FORWARD = "fast_forward" + const val ACTION_REWIND = "rewind" + const val ACTION_PLAY = "play" + const val ACTION_PAUSE = "pause" + } +} + +enum class MediaAction( + val action: String, + val icon: ImageVector, + val title: String, +) { + PLAY_PAUSE(ACTION_PLAY_PAUSE, Icons.Rounded.ToggleOn, "Play/Pause"), + NEXT(ACTION_NEXT, Icons.Rounded.SkipNext, "Next"), + PREVIOUS(ACTION_PREVIOUS, Icons.Rounded.SkipPrevious, "Previous"), + STOP(ACTION_STOP, Icons.Rounded.Stop, "Stop"), + FAST_FORWARD(ACTION_FAST_FORWARD, Icons.Rounded.FastForward, "Fast forward"), + REWIND(ACTION_REWIND, Icons.Rounded.FastRewind, "Rewind"), + PLAY(ACTION_PLAY, Icons.Rounded.PlayArrow, "Play"), + PAUSE(ACTION_PAUSE, Icons.Rounded.Pause, "Pause"), +} + +@Serializable +data class ControlMediaAction( + val id: Int, + val title: String, + val enabled: Boolean, + val action: MediaAction, + @Transient val icon: ImageVector? = null, ) diff --git a/app/src/main/java/fr/angel/soundtap/navigation/NavGraph.kt b/app/src/main/java/fr/angel/soundtap/navigation/NavGraph.kt index 765c2dd..7cedf8d 100644 --- a/app/src/main/java/fr/angel/soundtap/navigation/NavGraph.kt +++ b/app/src/main/java/fr/angel/soundtap/navigation/NavGraph.kt @@ -1,17 +1,19 @@ /* - * Copyright 2024 Angel Studio * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package fr.angel.soundtap.navigation @@ -32,10 +34,10 @@ import androidx.navigation.compose.navigation import fr.angel.soundtap.MainViewModel import fr.angel.soundtap.ui.OnboardingScreen import fr.angel.soundtap.ui.app.App -import fr.angel.soundtap.ui.app.CustomizationScreen import fr.angel.soundtap.ui.app.HistoryScreen import fr.angel.soundtap.ui.app.SettingsScreen import fr.angel.soundtap.ui.app.SupportScreen +import fr.angel.soundtap.ui.app.customization.CustomizationScreen @OptIn(ExperimentalSharedTransitionApi::class) @Composable diff --git a/app/src/main/java/fr/angel/soundtap/navigation/Screens.kt b/app/src/main/java/fr/angel/soundtap/navigation/Screens.kt index be7d7ee..2ab6ab9 100644 --- a/app/src/main/java/fr/angel/soundtap/navigation/Screens.kt +++ b/app/src/main/java/fr/angel/soundtap/navigation/Screens.kt @@ -1,17 +1,19 @@ /* - * Copyright 2024 Angel Studio * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package fr.angel.soundtap.navigation @@ -22,7 +24,11 @@ sealed class Screens( data object App : Screens(route = "app") { data object Home : Screens(route = "home") - data object Customization : Screens(route = "customization", showBackArrow = true) + data object Customization : Screens(route = "customization", showBackArrow = true) { + data object Home : Screens(route = "customization_default", showBackArrow = true) + + data object Controls : Screens(route = "customization_controls", showBackArrow = true) + } data object History : Screens(route = "history", showBackArrow = true) @@ -39,11 +45,15 @@ sealed class Screens( // App App.route -> App App.Home.route -> App.Home - App.Customization.route -> App.Customization App.History.route -> App.History App.Settings.route -> App.Settings App.Support.route -> App.Support + // Customization + App.Customization.route -> App.Customization + App.Customization.Home.route -> App.Customization.Home + App.Customization.Controls.route -> App.Customization.Controls + // Onboarding Onboarding.route -> Onboarding diff --git a/app/src/main/java/fr/angel/soundtap/service/SoundTapAccessibilityService.kt b/app/src/main/java/fr/angel/soundtap/service/SoundTapAccessibilityService.kt index 088bccb..1040d18 100644 --- a/app/src/main/java/fr/angel/soundtap/service/SoundTapAccessibilityService.kt +++ b/app/src/main/java/fr/angel/soundtap/service/SoundTapAccessibilityService.kt @@ -1,17 +1,19 @@ /* - * Copyright 2024 Angel Studio * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package fr.angel.soundtap.service @@ -29,9 +31,11 @@ import fr.angel.soundtap.data.enums.AutoPlayMode import fr.angel.soundtap.data.enums.HapticFeedbackLevel import fr.angel.soundtap.data.enums.WorkingMode import fr.angel.soundtap.data.enums.isOnHeadsetConnectedActive +import fr.angel.soundtap.data.settings.customization.CustomizationSettings import fr.angel.soundtap.data.settings.customization.DEFAULT_DELAY_BETWEEN_EVENTS import fr.angel.soundtap.data.settings.customization.DEFAULT_DOUBLE_PRESS_THRESHOLD import fr.angel.soundtap.data.settings.customization.DEFAULT_LONG_PRESS_THRESHOLD +import fr.angel.soundtap.data.settings.customization.MediaAction import fr.angel.soundtap.data.settings.customization.customizationSettingsDataStore import fr.angel.soundtap.service.media.MediaReceiver import kotlinx.coroutines.CoroutineScope @@ -88,6 +92,7 @@ class SoundTapAccessibilityService : AccessibilityService() { private var workingMode = WorkingMode.SCREEN_ON_OFF private var autoPlayMode = AutoPlayMode.ON_HEADSET_CONNECTED private var preferredMediaPlayer: String? = null + private var customizationSettings = CustomizationSettings() companion object { private const val TAG = "SoundTapAccessibilityService" @@ -210,6 +215,7 @@ class SoundTapAccessibilityService : AccessibilityService() { scope.launch(Dispatchers.IO) { customizationSettingsDataStore.data.collect { + customizationSettings = it hapticFeedbackLevel = it.hapticFeedbackLevel longPressThreshold = it.longPressThreshold doublePressThreshold = it.doublePressThreshold @@ -307,13 +313,15 @@ class SoundTapAccessibilityService : AccessibilityService() { **/ private fun volumeUpLongPressed() { + if (customizationSettings.longVolumeUpPressControlMediaAction.enabled.not()) return vibratorHelper.createHapticFeedback(hapticFeedbackLevel) - MediaReceiver.firstCallback?.skipToNext() + executeAction(customizationSettings.longVolumeUpPressControlMediaAction.action) } private fun volumeDownLongPressed() { + if (customizationSettings.longVolumeDownPressControlMediaAction.enabled.not()) return vibratorHelper.createHapticFeedback(hapticFeedbackLevel) - MediaReceiver.firstCallback?.skipToPrevious() + executeAction(customizationSettings.longVolumeDownPressControlMediaAction.action) } private fun bothVolumePressed() { @@ -323,8 +331,22 @@ class SoundTapAccessibilityService : AccessibilityService() { GlobalHelper.startMediaPlayer(context = this.application, packageName = it) } } else { + if (customizationSettings.doubleVolumeLongPressControlMediaAction.enabled) return vibratorHelper.createHapticFeedback(hapticFeedbackLevel) - MediaReceiver.firstCallback?.togglePlayPause() + executeAction(customizationSettings.doubleVolumeLongPressControlMediaAction.action) + } + } + + private fun executeAction(action: MediaAction) { + when (action) { + MediaAction.PLAY_PAUSE -> MediaReceiver.firstCallback?.togglePlayPause() + MediaAction.NEXT -> MediaReceiver.firstCallback?.skipToNext() + MediaAction.PREVIOUS -> MediaReceiver.firstCallback?.skipToPrevious() + MediaAction.STOP -> MediaReceiver.firstCallback?.stop() + MediaAction.FAST_FORWARD -> MediaReceiver.firstCallback?.fastForward() + MediaAction.REWIND -> MediaReceiver.firstCallback?.rewind() + MediaAction.PLAY -> MediaReceiver.firstCallback?.play() + MediaAction.PAUSE -> MediaReceiver.firstCallback?.pause() } } } diff --git a/app/src/main/java/fr/angel/soundtap/service/media/MediaCallback.kt b/app/src/main/java/fr/angel/soundtap/service/media/MediaCallback.kt index 57bea7e..1a751b6 100644 --- a/app/src/main/java/fr/angel/soundtap/service/media/MediaCallback.kt +++ b/app/src/main/java/fr/angel/soundtap/service/media/MediaCallback.kt @@ -1,17 +1,19 @@ /* - * Copyright 2024 Angel Studio * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package fr.angel.soundtap.service.media @@ -161,4 +163,20 @@ class MediaCallback onToggleSupportedPlayer(true) } } + + fun fastForward() { + mediaController.transportControls.seekTo(mediaController.playbackState?.position?.plus(10000) ?: 0) + } + + fun rewind() { + mediaController.transportControls.seekTo(mediaController.playbackState?.position?.minus(10000) ?: 0) + } + + fun play() { + mediaController.transportControls.play() + } + + fun pause() { + mediaController.transportControls.pause() + } } diff --git a/app/src/main/java/fr/angel/soundtap/ui/app/AppScreen.kt b/app/src/main/java/fr/angel/soundtap/ui/app/AppScreen.kt index f1e0305..da4a2db 100644 --- a/app/src/main/java/fr/angel/soundtap/ui/app/AppScreen.kt +++ b/app/src/main/java/fr/angel/soundtap/ui/app/AppScreen.kt @@ -1,17 +1,19 @@ /* - * Copyright 2024 Angel Studio * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package fr.angel.soundtap.ui.app @@ -41,7 +43,6 @@ import androidx.compose.material.icons.outlined.NotificationImportant import androidx.compose.material.icons.outlined.SettingsAccessibility import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -49,7 +50,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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 @@ -69,9 +69,8 @@ import fr.angel.soundtap.ui.components.GridCard import fr.angel.soundtap.ui.components.InfoCard import fr.angel.soundtap.ui.components.InfoCardType import fr.angel.soundtap.ui.components.MediaCards -import kotlinx.coroutines.launch -@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun SharedTransitionScope.App( modifier: Modifier = Modifier, @@ -84,7 +83,6 @@ fun SharedTransitionScope.App( innerPadding: PaddingValues, ) { val context = LocalContext.current - val scope = rememberCoroutineScope() val uiState by mainViewModel.uiState.collectAsStateWithLifecycle() val accessibilityServiceState by SoundTapAccessibilityService.uiState.collectAsStateWithLifecycle() val mediaCallback = MediaReceiver.firstCallback @@ -250,10 +248,7 @@ fun SharedTransitionScope.App( } else { mainViewModel.showBottomSheet( bottomSheetState = - BottomSheetState.SetTimer( - onTimerSet = { duration -> mainViewModel.setSleepTimer(duration) }, - onDismiss = { scope.launch { mainViewModel.hideBottomSheet() } }, - ), + BottomSheetState.SetTimer(onTimerSet = { duration -> mainViewModel.setSleepTimer(duration) }), ) } }, diff --git a/app/src/main/java/fr/angel/soundtap/ui/app/CustomizationScreen.kt b/app/src/main/java/fr/angel/soundtap/ui/app/CustomizationScreen.kt deleted file mode 100644 index 46bc086..0000000 --- a/app/src/main/java/fr/angel/soundtap/ui/app/CustomizationScreen.kt +++ /dev/null @@ -1,649 +0,0 @@ -/* - * Copyright 2024 Angel Studio - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package fr.angel.soundtap.ui.app - -import android.graphics.RenderEffect -import android.graphics.RuntimeShader -import android.os.Build -import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.withInfiniteAnimationFrameMillis -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsDraggedAsState -import androidx.compose.foundation.layout.Arrangement -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.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -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.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.PlayCircleOutline -import androidx.compose.material.icons.filled.Remove -import androidx.compose.material.icons.filled.ToggleOn -import androidx.compose.material.icons.filled.TouchApp -import androidx.compose.material.icons.filled.Tune -import androidx.compose.material.icons.filled.Vibration -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.produceState -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.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.asComposeRenderEffect -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import fr.angel.soundtap.MainViewModel -import fr.angel.soundtap.VibratorHelper -import fr.angel.soundtap.animations.PERLIN_NOISE -import fr.angel.soundtap.data.enums.AutoPlayMode -import fr.angel.soundtap.data.enums.HapticFeedbackLevel -import fr.angel.soundtap.data.enums.WorkingMode -import fr.angel.soundtap.ui.components.settings.SettingsItemCustomBottom -import kotlin.math.roundToInt -import kotlinx.coroutines.launch - -@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class) -@Composable -fun SharedTransitionScope.CustomizationScreen( - modifier: Modifier = Modifier, - mainViewModel: MainViewModel, - animatedVisibilityScope: AnimatedVisibilityScope, - navigateToSettings: () -> Unit, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - - val uiState by mainViewModel.uiState.collectAsStateWithLifecycle() - - var longPressDurationTempValue by remember { mutableFloatStateOf(uiState.customizationSettings.longPressThreshold.toFloat()) } - - LaunchedEffect(uiState.customizationSettings.longPressThreshold) { - if (longPressDurationTempValue == 0f) { - longPressDurationTempValue = uiState.customizationSettings.longPressThreshold.toFloat() - } - } - - Card( - modifier = - modifier - .padding(8.dp) - .fillMaxSize() - .sharedElement( - state = - rememberSharedContentState( - key = "Customize-card", - ), - animatedVisibilityScope = animatedVisibilityScope, - ), - ) { - Column(modifier = Modifier.fillMaxSize()) { - Row( - modifier = - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Icon( - modifier = - Modifier - .size(48.dp) - .sharedElement( - state = - rememberSharedContentState( - key = "Customize-icon", - ), - animatedVisibilityScope = animatedVisibilityScope, - ), - imageVector = Icons.Default.Tune, - contentDescription = null, - ) - - Text( - text = "Customization", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = - Modifier - .sharedBounds( - rememberSharedContentState( - key = "Customize", - ), - animatedVisibilityScope = animatedVisibilityScope, - ), - ) - } - HorizontalDivider() - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(8.dp), - ) { - item { - SettingsItemCustomBottom( - title = "Working mode", - subtitle = "Select when the skipping action should be available.", - icon = Icons.Default.ToggleOn, - content = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - val settingsList = WorkingMode.entries - - settingsList.forEachIndexed { index, item -> - - val backgroundColor by animateColorAsState( - targetValue = - if (uiState.customizationSettings.workingMode.ordinal == index) { - MaterialTheme.colorScheme.primary.copy(0.3f) - } else { - MaterialTheme.colorScheme.onSurface.copy(0.05f) - }, - label = "backgroundColor", - ) - - val borderColor by animateColorAsState( - targetValue = - if (uiState.customizationSettings.workingMode.ordinal == index) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(0.1f) - }, - label = "borderColor", - ) - - val cornerShape by animateFloatAsState( - targetValue = - if (uiState.customizationSettings.workingMode.ordinal == index) { - 16f - } else { - 12f - }, - label = "cornerShape", - ) - - Box( - modifier = - Modifier - .weight(1f) - .clip(RoundedCornerShape(cornerShape.dp)) - .aspectRatio(1f) - .background(backgroundColor) - .border( - width = 1.dp, - color = borderColor, - shape = RoundedCornerShape(cornerShape.dp), - ) - .clickable { - VibratorHelper(context = context).click() - scope.launch { - mainViewModel.setWorkingMode(item) - } - } - .padding(8.dp), - ) { - item.selectedComposable( - this, - uiState.customizationSettings.workingMode == item, - ) - Text( - text = item.title, - style = MaterialTheme.typography.bodyMedium, - modifier = - Modifier - .padding(8.dp) - .align(Alignment.BottomCenter), - ) - } - } - } - }, - ) - } - item { - SettingsItemCustomBottom( - title = "Haptic feedback", - subtitle = "Vibrate when an action is performed.", - icon = Icons.Default.Vibration, - content = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - val settingsList = HapticFeedbackLevel.entries - - settingsList.forEachIndexed { index, item -> - - val backgroundColor by animateColorAsState( - targetValue = - if (uiState.customizationSettings.hapticFeedbackLevel.ordinal == index) { - MaterialTheme.colorScheme.primary.copy(0.3f) - } else { - MaterialTheme.colorScheme.onSurface.copy(0.05f) - }, - label = "backgroundColor", - ) - - val borderColor by animateColorAsState( - targetValue = - if (uiState.customizationSettings.hapticFeedbackLevel.ordinal == index) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(0.1f) - }, - label = "borderColor", - ) - - val cornerShape by animateFloatAsState( - targetValue = - if (uiState.customizationSettings.hapticFeedbackLevel.ordinal == index) { - 16f - } else { - 12f - }, - label = "cornerShape", - ) - - Box( - modifier = - Modifier - .weight(1f) - .clip(RoundedCornerShape(cornerShape.dp)) - .aspectRatio(1f) - .background(backgroundColor) - .border( - width = 1.dp, - color = borderColor, - shape = RoundedCornerShape(cornerShape.dp), - ) - .clickable { - scope.launch { - mainViewModel.setHapticFeedback(item) - VibratorHelper(context = context).createHapticFeedback( - item, - ) - } - }, - ) { - val selected = - uiState.customizationSettings.hapticFeedbackLevel.ordinal == index - val time by produceState(0f) { - while (true) { - withInfiniteAnimationFrameMillis { - value = it / 100f - } - } - } - - val shaderModifier = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val shader = - remember { RuntimeShader(PERLIN_NOISE) } - - if (selected && item != HapticFeedbackLevel.NONE) { - Modifier - .onSizeChanged { size -> - shader.setFloatUniform( - "resolution", - size.width.toFloat() * (0.4f + 0.2f * index), - size.height.toFloat() * (0.4f + 0.2f * index), - ) - } - .graphicsLayer { - shader.setFloatUniform("time", time) - renderEffect = - RenderEffect - .createRuntimeShaderEffect( - shader, - "contents", - ) - .asComposeRenderEffect() - } - } else { - Modifier - } - } else { - Modifier - } - - Icon( - imageVector = ImageVector.vectorResource(id = item.icon), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = - Modifier - .fillMaxSize() - .alpha( - animateFloatAsState( - targetValue = if (selected) 1f else 0.2f, - label = "iconAlpha", - ).value, - ) - .then(shaderModifier), - ) - } - } - } - }, - ) - } - item { - SettingsItemCustomBottom( - title = "Long press duration - ${longPressDurationTempValue.roundToInt()} ms", - subtitle = "Set the duration for a long press action.", - icon = Icons.Default.TouchApp, - content = { - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsDraggedAsState() - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - IconButton( - onClick = { - longPressDurationTempValue -= 100 - VibratorHelper(context = context).tick() - scope.launch { - mainViewModel.setLongPressDuration( - longPressDurationTempValue.toLong(), - ) - } - }, - enabled = longPressDurationTempValue > 300, - ) { - Icon( - imageVector = Icons.Default.Remove, - contentDescription = null, - ) - } - Slider( - modifier = Modifier.weight(1f), - value = longPressDurationTempValue, - onValueChange = { - VibratorHelper(context = context).tick() - longPressDurationTempValue = it - }, - valueRange = 300f..2000f, - steps = 16, - interactionSource = interactionSource, - onValueChangeFinished = { - scope.launch { - mainViewModel.setLongPressDuration( - longPressDurationTempValue.toLong(), - ) - } - }, - thumb = { - Box( - modifier = - Modifier - .width(12.dp) - .height(24.dp) - .offset(x = 6.dp), - contentAlignment = Alignment.Center, - ) { - Surface( - modifier = - Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.small), - tonalElevation = 12.dp, - ) { } - - Box( - modifier = - Modifier - .fillMaxWidth( - animateFloatAsState( - targetValue = if (isPressed) 0.1f else 0.3f, - label = "thumbScale", - ).value, - ) - .fillMaxHeight() - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), - ) - } - }, - track = { - val rangeSpan = 2000f - 300f - val adjustedTempValue = longPressDurationTempValue - 300f - val selectedFraction = adjustedTempValue / rangeSpan - - Row( - modifier = - Modifier - .fillMaxWidth() - .height(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Box( - modifier = - Modifier - .fillMaxWidth(selectedFraction) - .fillMaxHeight() - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), - ) - Box( - modifier = - Modifier - .weight(1f) - .fillMaxHeight() - .clip(CircleShape) - .background( - MaterialTheme.colorScheme.primary.copy( - 0.3f, - ), - ), - ) - } - }, - ) - IconButton( - onClick = { - longPressDurationTempValue += 100 - VibratorHelper(context = context).tick() - scope.launch { - mainViewModel.setLongPressDuration( - longPressDurationTempValue.toLong(), - ) - } - }, - enabled = longPressDurationTempValue < 2000, - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - ) - } - } - }, - ) - } - item { - SettingsItemCustomBottom( - title = "Auto play", - subtitle = "Automatically resume your selected favorite media player music when you connect your headphones.", - icon = Icons.Default.PlayCircleOutline, - trailing = { - Switch( - checked = uiState.customizationSettings.autoPlay, - onCheckedChange = { - scope.launch { - mainViewModel.setAutoPlay(it) - } - }, - ) - }, - onClick = { - scope.launch { - mainViewModel.setAutoPlay(!uiState.customizationSettings.autoPlay) - } - }, - content = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - val settingsList = AutoPlayMode.entries - - settingsList.forEachIndexed { index, item -> - - val backgroundColor by animateColorAsState( - targetValue = - if (uiState.customizationSettings.autoPlayMode.ordinal == index) { - MaterialTheme.colorScheme.primary.copy(0.3f) - } else { - MaterialTheme.colorScheme.onSurface.copy(0.05f) - }, - label = "backgroundColor", - ) - - val borderColor by animateColorAsState( - targetValue = - if (uiState.customizationSettings.autoPlayMode.ordinal == index) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(0.1f) - }, - label = "borderColor", - ) - - val cornerShape by animateFloatAsState( - targetValue = - if (uiState.customizationSettings.autoPlayMode.ordinal == index) { - 16f - } else { - 12f - }, - label = "cornerShape", - ) - - Box( - modifier = - Modifier - .weight(1f) - .clip(RoundedCornerShape(cornerShape.dp)) - .aspectRatio(1f) - .background(backgroundColor) - .border( - width = 1.dp, - color = borderColor, - shape = RoundedCornerShape(cornerShape.dp), - ) - .clickable { - VibratorHelper(context = context).click() - scope.launch { - mainViewModel.setAutoPlayMode(item) - } - } - .padding(8.dp), - ) { - item.selectedComposable( - this, - uiState.customizationSettings.autoPlayMode == item, - ) - Text( - text = item.title, - style = MaterialTheme.typography.bodyMedium, - modifier = - Modifier - .padding(8.dp) - .align(Alignment.BottomCenter), - ) - } - } - } - Button( - modifier = Modifier.fillMaxWidth(), - onClick = navigateToSettings, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Default.Tune, - contentDescription = null, - ) - Text( - text = "Set preferred media player", - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - }, - ) - } - } - } - } -} diff --git a/app/src/main/java/fr/angel/soundtap/ui/app/HistoryScreen.kt b/app/src/main/java/fr/angel/soundtap/ui/app/HistoryScreen.kt index 13ed091..5bfba01 100644 --- a/app/src/main/java/fr/angel/soundtap/ui/app/HistoryScreen.kt +++ b/app/src/main/java/fr/angel/soundtap/ui/app/HistoryScreen.kt @@ -1,17 +1,19 @@ /* - * Copyright 2024 Angel Studio * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package fr.angel.soundtap.ui.app @@ -169,7 +171,10 @@ fun SharedTransitionScope.HistoryScreen( } Column( - modifier = Modifier.fillMaxSize(), + modifier = + Modifier + .fillMaxSize() + .skipToLookaheadSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -201,6 +206,7 @@ fun SharedTransitionScope.HistoryScreen( } } else { LazyColumn( + modifier = Modifier.skipToLookaheadSize(), verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = PaddingValues(4.dp), ) { diff --git a/app/src/main/java/fr/angel/soundtap/ui/app/SettingsScreen.kt b/app/src/main/java/fr/angel/soundtap/ui/app/SettingsScreen.kt index 03f50be..8f8b86e 100644 --- a/app/src/main/java/fr/angel/soundtap/ui/app/SettingsScreen.kt +++ b/app/src/main/java/fr/angel/soundtap/ui/app/SettingsScreen.kt @@ -159,7 +159,10 @@ fun SharedTransitionScope.SettingsScreen( } HorizontalDivider() LazyColumn( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .skipToLookaheadSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { diff --git a/app/src/main/java/fr/angel/soundtap/ui/app/SupportScreen.kt b/app/src/main/java/fr/angel/soundtap/ui/app/SupportScreen.kt index 117a5b6..54883a7 100644 --- a/app/src/main/java/fr/angel/soundtap/ui/app/SupportScreen.kt +++ b/app/src/main/java/fr/angel/soundtap/ui/app/SupportScreen.kt @@ -1,17 +1,19 @@ /* - * Copyright 2024 Angel Studio * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package fr.angel.soundtap.ui.app @@ -117,7 +119,8 @@ fun SharedTransitionScope.SupportScreen( Modifier .fillMaxSize() .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState()) + .skipToLookaheadSize(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/fr/angel/soundtap/ui/app/customization/CustomizationControls.kt b/app/src/main/java/fr/angel/soundtap/ui/app/customization/CustomizationControls.kt new file mode 100644 index 0000000..0a6cfeb --- /dev/null +++ b/app/src/main/java/fr/angel/soundtap/ui/app/customization/CustomizationControls.kt @@ -0,0 +1,138 @@ +/* + * + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package fr.angel.soundtap.ui.app.customization + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import fr.angel.soundtap.MainViewModel +import fr.angel.soundtap.data.settings.customization.ControlMediaAction +import fr.angel.soundtap.ui.components.settings.SettingsItemCustomBottom + +@Composable +fun CustomizationControls( + modifier: Modifier = Modifier, + mainViewModel: MainViewModel, +) { + val uiState by mainViewModel.uiState.collectAsStateWithLifecycle() + + var longPressDurationTempValue by remember { mutableFloatStateOf(uiState.customizationSettings.longPressThreshold.toFloat()) } + + LaunchedEffect(uiState.customizationSettings.longPressThreshold) { + if (longPressDurationTempValue == 0f) { + longPressDurationTempValue = uiState.customizationSettings.longPressThreshold.toFloat() + } + } + + val defaultControls = + remember(uiState.customizationSettings) { + listOf( + uiState.customizationSettings.longVolumeUpPressControlMediaAction, + uiState.customizationSettings.longVolumeDownPressControlMediaAction, + uiState.customizationSettings.doubleVolumeLongPressControlMediaAction, + ) + } + + LazyColumn( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(8.dp), + ) { + items(defaultControls, key = { it.id }) { controlMediaAction -> + ControlCard( + modifier = Modifier.fillMaxWidth(), + controlMediaAction = controlMediaAction, + onToggle = { mainViewModel.toggleControlMediaAction(controlMediaAction) }, + onActionChange = { mainViewModel.changeControlMediaAction(controlMediaAction) }, + ) + } + } +} + +@Composable +private fun ControlCard( + modifier: Modifier = Modifier, + controlMediaAction: ControlMediaAction, + onToggle: (Boolean) -> Unit, + onActionChange: () -> Unit, +) { + SettingsItemCustomBottom( + modifier = modifier, + title = controlMediaAction.title, + subtitle = controlMediaAction.action.title, + icon = controlMediaAction.icon, + trailing = { + Switch( + checked = controlMediaAction.enabled, + onCheckedChange = onToggle, + ) + }, + content = { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(color = MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = controlMediaAction.action.icon, + contentDescription = null, + ) + Text( + text = controlMediaAction.action.title, + modifier = Modifier.weight(1f), + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = onActionChange, + ) { + Text(text = "Change") + } + } + }, + ) +} diff --git a/app/src/main/java/fr/angel/soundtap/ui/app/customization/CustomizationHome.kt b/app/src/main/java/fr/angel/soundtap/ui/app/customization/CustomizationHome.kt new file mode 100644 index 0000000..37907d6 --- /dev/null +++ b/app/src/main/java/fr/angel/soundtap/ui/app/customization/CustomizationHome.kt @@ -0,0 +1,603 @@ +/* + * + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package fr.angel.soundtap.ui.app.customization + +import android.graphics.RenderEffect +import android.graphics.RuntimeShader +import android.os.Build +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.withInfiniteAnimationFrameMillis +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.layout.Arrangement +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.PlayCircleOutline +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material.icons.filled.ToggleOn +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Vibration +import androidx.compose.material.icons.rounded.ControlCamera +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.produceState +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asComposeRenderEffect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import fr.angel.soundtap.MainViewModel +import fr.angel.soundtap.VibratorHelper +import fr.angel.soundtap.animations.PERLIN_NOISE +import fr.angel.soundtap.data.enums.AutoPlayMode +import fr.angel.soundtap.data.enums.HapticFeedbackLevel +import fr.angel.soundtap.data.enums.WorkingMode +import fr.angel.soundtap.ui.components.settings.SettingsItem +import fr.angel.soundtap.ui.components.settings.SettingsItemCustomBottom +import kotlin.math.roundToInt +import kotlinx.coroutines.launch + +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class) +@Composable +fun CustomizationHome( + modifier: Modifier = Modifier, + mainViewModel: MainViewModel, + navigateToControls: () -> Unit, + navigateToSettings: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val uiState by mainViewModel.uiState.collectAsStateWithLifecycle() + + var longPressDurationTempValue by remember { mutableFloatStateOf(uiState.customizationSettings.longPressThreshold.toFloat()) } + + LaunchedEffect(uiState.customizationSettings.longPressThreshold) { + if (longPressDurationTempValue == 0f) { + longPressDurationTempValue = uiState.customizationSettings.longPressThreshold.toFloat() + } + } + + LazyColumn( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(8.dp), + ) { + item { + SettingsItem( + title = "Customize controls", + subtitle = "Change the behavior of the volume buttons as you like.", + icon = Icons.Rounded.ControlCamera, + onClick = navigateToControls, + ) + } + + item { + SettingsItemCustomBottom( + title = "Working mode", + subtitle = "Select when the skipping action should be available.", + icon = Icons.Default.ToggleOn, + content = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + val settingsList = WorkingMode.entries + + settingsList.forEachIndexed { index, item -> + + val backgroundColor by animateColorAsState( + targetValue = + if (uiState.customizationSettings.workingMode.ordinal == index) { + MaterialTheme.colorScheme.primary.copy(0.3f) + } else { + MaterialTheme.colorScheme.onSurface.copy(0.05f) + }, + label = "backgroundColor", + ) + + val borderColor by animateColorAsState( + targetValue = + if (uiState.customizationSettings.workingMode.ordinal == index) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(0.1f) + }, + label = "borderColor", + ) + + val cornerShape by animateFloatAsState( + targetValue = + if (uiState.customizationSettings.workingMode.ordinal == index) { + 16f + } else { + 12f + }, + label = "cornerShape", + ) + + Box( + modifier = + Modifier + .weight(1f) + .clip(RoundedCornerShape(cornerShape.dp)) + .aspectRatio(1f) + .background(backgroundColor) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape(cornerShape.dp), + ) + .clickable { + VibratorHelper(context = context).click() + scope.launch { + mainViewModel.setWorkingMode(item) + } + } + .padding(8.dp), + ) { + item.selectedComposable( + this, + uiState.customizationSettings.workingMode == item, + ) + Text( + text = item.title, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .padding(8.dp) + .align(Alignment.BottomCenter), + ) + } + } + } + }, + ) + } + item { + SettingsItemCustomBottom( + title = "Haptic feedback", + subtitle = "Vibrate when an action is performed.", + icon = Icons.Default.Vibration, + content = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + val settingsList = HapticFeedbackLevel.entries + + settingsList.forEachIndexed { index, item -> + + val backgroundColor by animateColorAsState( + targetValue = + if (uiState.customizationSettings.hapticFeedbackLevel.ordinal == index) { + MaterialTheme.colorScheme.primary.copy(0.3f) + } else { + MaterialTheme.colorScheme.onSurface.copy(0.05f) + }, + label = "backgroundColor", + ) + + val borderColor by animateColorAsState( + targetValue = + if (uiState.customizationSettings.hapticFeedbackLevel.ordinal == index) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(0.1f) + }, + label = "borderColor", + ) + + val cornerShape by animateFloatAsState( + targetValue = + if (uiState.customizationSettings.hapticFeedbackLevel.ordinal == index) { + 16f + } else { + 12f + }, + label = "cornerShape", + ) + + Box( + modifier = + Modifier + .weight(1f) + .clip(RoundedCornerShape(cornerShape.dp)) + .aspectRatio(1f) + .background(backgroundColor) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape(cornerShape.dp), + ) + .clickable { + scope.launch { + mainViewModel.setHapticFeedback(item) + VibratorHelper(context = context).createHapticFeedback( + item, + ) + } + }, + ) { + val selected = + uiState.customizationSettings.hapticFeedbackLevel.ordinal == index + val time by produceState(0f) { + while (true) { + withInfiniteAnimationFrameMillis { + value = it / 100f + } + } + } + + val shaderModifier = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val shader = + remember { RuntimeShader(PERLIN_NOISE) } + + if (selected && item != HapticFeedbackLevel.NONE) { + Modifier + .onSizeChanged { size -> + shader.setFloatUniform( + "resolution", + size.width.toFloat() * (0.4f + 0.2f * index), + size.height.toFloat() * (0.4f + 0.2f * index), + ) + } + .graphicsLayer { + shader.setFloatUniform("time", time) + renderEffect = + RenderEffect + .createRuntimeShaderEffect( + shader, + "contents", + ) + .asComposeRenderEffect() + } + } else { + Modifier + } + } else { + Modifier + } + + Icon( + imageVector = ImageVector.vectorResource(id = item.icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .fillMaxSize() + .alpha( + animateFloatAsState( + targetValue = if (selected) 1f else 0.2f, + label = "iconAlpha", + ).value, + ) + .then(shaderModifier), + ) + } + } + } + }, + ) + } + item { + SettingsItemCustomBottom( + title = "Long press duration - ${longPressDurationTempValue.roundToInt()} ms", + subtitle = "Set the duration for a long press action.", + icon = Icons.Default.TouchApp, + content = { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsDraggedAsState() + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton( + onClick = { + longPressDurationTempValue -= 100 + VibratorHelper(context = context).tick() + scope.launch { + mainViewModel.setLongPressDuration( + longPressDurationTempValue.toLong(), + ) + } + }, + enabled = longPressDurationTempValue > 300, + ) { + Icon( + imageVector = Icons.Default.Remove, + contentDescription = null, + ) + } + Slider( + modifier = Modifier.weight(1f), + value = longPressDurationTempValue, + onValueChange = { + VibratorHelper(context = context).tick() + longPressDurationTempValue = it + }, + valueRange = 300f..2000f, + steps = 16, + interactionSource = interactionSource, + onValueChangeFinished = { + scope.launch { + mainViewModel.setLongPressDuration( + longPressDurationTempValue.toLong(), + ) + } + }, + thumb = { + Box( + modifier = + Modifier + .width(12.dp) + .height(24.dp) + .offset(x = 6.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = + Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.small), + tonalElevation = 12.dp, + ) { } + + Box( + modifier = + Modifier + .fillMaxWidth( + animateFloatAsState( + targetValue = if (isPressed) 0.1f else 0.3f, + label = "thumbScale", + ).value, + ) + .fillMaxHeight() + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + } + }, + track = { + val rangeSpan = 2000f - 300f + val adjustedTempValue = longPressDurationTempValue - 300f + val selectedFraction = adjustedTempValue / rangeSpan + + Row( + modifier = + Modifier + .fillMaxWidth() + .height(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(selectedFraction) + .fillMaxHeight() + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.primary.copy( + 0.3f, + ), + ), + ) + } + }, + ) + IconButton( + onClick = { + longPressDurationTempValue += 100 + VibratorHelper(context = context).tick() + scope.launch { + mainViewModel.setLongPressDuration( + longPressDurationTempValue.toLong(), + ) + } + }, + enabled = longPressDurationTempValue < 2000, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + ) + } + } + }, + ) + } + item { + SettingsItemCustomBottom( + title = "Auto play", + subtitle = "Automatically resume your selected favorite media player music when you connect your headphones.", + icon = Icons.Default.PlayCircleOutline, + trailing = { + Switch( + checked = uiState.customizationSettings.autoPlay, + onCheckedChange = { + scope.launch { + mainViewModel.setAutoPlay(it) + } + }, + ) + }, + onClick = { + scope.launch { + mainViewModel.setAutoPlay(!uiState.customizationSettings.autoPlay) + } + }, + content = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + val settingsList = AutoPlayMode.entries + + settingsList.forEachIndexed { index, item -> + + val backgroundColor by animateColorAsState( + targetValue = + if (uiState.customizationSettings.autoPlayMode.ordinal == index) { + MaterialTheme.colorScheme.primary.copy(0.3f) + } else { + MaterialTheme.colorScheme.onSurface.copy(0.05f) + }, + label = "backgroundColor", + ) + + val borderColor by animateColorAsState( + targetValue = + if (uiState.customizationSettings.autoPlayMode.ordinal == index) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(0.1f) + }, + label = "borderColor", + ) + + val cornerShape by animateFloatAsState( + targetValue = + if (uiState.customizationSettings.autoPlayMode.ordinal == index) { + 16f + } else { + 12f + }, + label = "cornerShape", + ) + + Box( + modifier = + Modifier + .weight(1f) + .clip(RoundedCornerShape(cornerShape.dp)) + .aspectRatio(1f) + .background(backgroundColor) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape(cornerShape.dp), + ) + .clickable { + VibratorHelper(context = context).click() + scope.launch { + mainViewModel.setAutoPlayMode(item) + } + } + .padding(8.dp), + ) { + item.selectedComposable( + this, + uiState.customizationSettings.autoPlayMode == item, + ) + Text( + text = item.title, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .padding(8.dp) + .align(Alignment.BottomCenter), + ) + } + } + } + Button( + modifier = Modifier.fillMaxWidth(), + onClick = navigateToSettings, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = null, + ) + Text( + text = "Set preferred media player", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + }, + ) + } + } +} diff --git a/app/src/main/java/fr/angel/soundtap/ui/app/customization/CustomizationScreen.kt b/app/src/main/java/fr/angel/soundtap/ui/app/customization/CustomizationScreen.kt new file mode 100644 index 0000000..aea2964 --- /dev/null +++ b/app/src/main/java/fr/angel/soundtap/ui/app/customization/CustomizationScreen.kt @@ -0,0 +1,177 @@ +/* + * + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ +package fr.angel.soundtap.ui.app.customization + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import fr.angel.soundtap.MainViewModel +import fr.angel.soundtap.navigation.Screens + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun SharedTransitionScope.CustomizationScreen( + modifier: Modifier = Modifier, + mainViewModel: MainViewModel, + animatedVisibilityScope: AnimatedVisibilityScope, + navigateToSettings: () -> Unit, +) { + val uiState by mainViewModel.uiState.collectAsStateWithLifecycle() + + val navController = rememberNavController() + val currentBackStack by navController.currentBackStackEntryAsState() + val currentDestination = currentBackStack?.destination + val currentScreen: Screens = + Screens.fromRoute(currentDestination?.route ?: uiState.defaultScreen.route) + + LaunchedEffect(currentScreen) { + if (currentScreen != Screens.App.Customization.Home) { + mainViewModel.setFocusedNavController(navController) + } else { + mainViewModel.resetFocusedNavController() + } + } + + DisposableEffect(Unit) { + onDispose { + mainViewModel.resetFocusedNavController() + } + } + + Card( + modifier = + modifier + .padding(8.dp) + .fillMaxSize() + .sharedElement( + state = + rememberSharedContentState( + key = "Customize-card", + ), + animatedVisibilityScope = animatedVisibilityScope, + ), + ) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + modifier = + Modifier + .size(48.dp) + .sharedElement( + state = + rememberSharedContentState( + key = "Customize-icon", + ), + animatedVisibilityScope = animatedVisibilityScope, + ), + imageVector = Icons.Default.Tune, + contentDescription = null, + ) + + Text( + text = "Customization", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .sharedBounds( + rememberSharedContentState( + key = "Customize", + ), + animatedVisibilityScope = animatedVisibilityScope, + ), + ) + } + HorizontalDivider() + NavHost( + navController = navController, + startDestination = Screens.App.Customization.Home.route, + modifier = + Modifier + .fillMaxSize() + .skipToLookaheadSize(), + ) { + composable( + route = Screens.App.Customization.Home.route, + popEnterTransition = { slideInHorizontally(tween(250)) }, + exitTransition = { fadeOut(tween(250)) }, + ) { + CustomizationHome( + modifier = Modifier.fillMaxSize(), + mainViewModel = mainViewModel, + navigateToSettings = navigateToSettings, + navigateToControls = { navController.navigate(Screens.App.Customization.Controls.route) }, + ) + } + + composable( + route = Screens.App.Customization.Controls.route, + enterTransition = { slideInHorizontally(tween(250)) { it } }, + popExitTransition = { + fadeOut(tween(250)) + slideOutHorizontally(tween(250)) { it } + }, + ) { + CustomizationControls( + modifier = Modifier.fillMaxSize(), + mainViewModel = mainViewModel, + ) + } + } + } + } +} diff --git a/app/src/main/java/fr/angel/soundtap/ui/components/settings/SettingsItemCustomBottom.kt b/app/src/main/java/fr/angel/soundtap/ui/components/settings/SettingsItemCustomBottom.kt index 7c8b396..27c3f2f 100644 --- a/app/src/main/java/fr/angel/soundtap/ui/components/settings/SettingsItemCustomBottom.kt +++ b/app/src/main/java/fr/angel/soundtap/ui/components/settings/SettingsItemCustomBottom.kt @@ -1,17 +1,19 @@ /* - * Copyright 2024 Angel Studio * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package fr.angel.soundtap.ui.components.settings @@ -145,8 +147,6 @@ fun SettingsItemCustomBottom( text = subtitle, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, ) } } diff --git a/app/src/main/java/fr/angel/soundtap/ui/components/settings/SettingsSwitch.kt b/app/src/main/java/fr/angel/soundtap/ui/components/settings/SettingsSwitch.kt index 94f8a2b..e5e6499 100644 --- a/app/src/main/java/fr/angel/soundtap/ui/components/settings/SettingsSwitch.kt +++ b/app/src/main/java/fr/angel/soundtap/ui/components/settings/SettingsSwitch.kt @@ -1,17 +1,19 @@ /* - * Copyright 2024 Angel Studio * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * Copyright (c) 2024 Angel Studio + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package fr.angel.soundtap.ui.components.settings @@ -147,8 +149,6 @@ fun SettingsItem( text = subtitle, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, ) } }