diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f319d2cd..19d8b8d0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,6 +19,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.hilt) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.license.report) alias(libs.plugins.detekt) @@ -261,9 +262,13 @@ dependencies { implementation(libs.androidx.activity.ktx) - implementation(libs.androidx.navigation.runtime.ktx) implementation(libs.androidx.legacy.support.v4) + // Navigation + implementation(libs.androidx.navigation.runtime.ktx) + implementation(libs.androidx.navigation.compose) + implementation(libs.kotlinx.serialization.json) + // Compose // Animation implementation(libs.androidx.compose.animation) @@ -354,4 +359,5 @@ dependencies { androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.google.truth) androidTestImplementation(libs.android.arch.persistence.room.testing) + androidTestImplementation(libs.androidx.test.navigation) } diff --git a/app/src/androidTest/java/app/musikus/activesession/di/TestActiveSessionUseCasesModule.kt b/app/src/androidTest/java/app/musikus/activesession/di/TestActiveSessionUseCasesModule.kt index a0498bb8..82d75ce9 100644 --- a/app/src/androidTest/java/app/musikus/activesession/di/TestActiveSessionUseCasesModule.kt +++ b/app/src/androidTest/java/app/musikus/activesession/di/TestActiveSessionUseCasesModule.kt @@ -14,12 +14,12 @@ import app.musikus.activesession.domain.usecase.DeleteSectionUseCase import app.musikus.activesession.domain.usecase.GetCompletedSectionsUseCase import app.musikus.activesession.domain.usecase.GetFinalizedSessionUseCase import app.musikus.activesession.domain.usecase.GetOngoingPauseDurationUseCase -import app.musikus.activesession.domain.usecase.GetPausedStateUseCase import app.musikus.activesession.domain.usecase.GetRunningItemDurationUseCase import app.musikus.activesession.domain.usecase.GetRunningItemUseCase import app.musikus.activesession.domain.usecase.GetSessionStatusUseCase import app.musikus.activesession.domain.usecase.GetStartTimeUseCase import app.musikus.activesession.domain.usecase.GetTotalPracticeDurationUseCase +import app.musikus.activesession.domain.usecase.IsSessionPausedUseCase import app.musikus.activesession.domain.usecase.IsSessionRunningUseCase import app.musikus.activesession.domain.usecase.PauseActiveSessionUseCase import app.musikus.activesession.domain.usecase.ResetSessionUseCase @@ -82,7 +82,7 @@ object TestActiveSessionUseCasesModule { getRunningItemDuration = getRunningItemDurationUseCase, getCompletedSections = GetCompletedSectionsUseCase(activeSessionRepository), getOngoingPauseDuration = GetOngoingPauseDurationUseCase(activeSessionRepository, timeProvider), - getPausedState = GetPausedStateUseCase(activeSessionRepository), + isSessionPaused = IsSessionPausedUseCase(activeSessionRepository), getFinalizedSession = GetFinalizedSessionUseCase( activeSessionRepository = activeSessionRepository, getRunningItemDurationUseCase = getRunningItemDurationUseCase, diff --git a/app/src/androidTest/java/app/musikus/core/presentation/MusikusBottomBarTest.kt b/app/src/androidTest/java/app/musikus/core/presentation/MusikusBottomBarTest.kt new file mode 100644 index 00000000..40facabb --- /dev/null +++ b/app/src/androidTest/java/app/musikus/core/presentation/MusikusBottomBarTest.kt @@ -0,0 +1,102 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2024 Matthias Emde + */ + +package app.musikus.core.presentation + +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import app.musikus.core.domain.FakeTimeProvider +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class MusikusBottomBarTest { + + @Inject lateinit var fakeTimeProvider: FakeTimeProvider + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + lateinit var navController: NavHostController + + @Before + fun setUp() { + hiltRule.inject() + composeRule.activity.setContent { + + val mainViewModel: MainViewModel = hiltViewModel() + + val mainUiState by mainViewModel.uiState.collectAsStateWithLifecycle() + val eventHandler = mainViewModel::onUiEvent + + navController = mockk(relaxed = true) + + MusikusBottomBar( + mainUiState = mainUiState, + mainEventHandler = eventHandler, + currentTab = HomeTab.Sessions, + onTabSelected = { selectedTab -> + navController.navigate(Screen.Home(selectedTab)) + }, + ) + } + } + + @Test + fun navigateToSessions() = runTest { + composeRule.onNodeWithText("Sessions").performClick() + + // Since we are already on the sessions tab, we should not navigate + verify(exactly = 0) { + navController.navigate(any()) + } + } + + @Test + fun navigateToGoals() = runTest { + composeRule.onNodeWithText("Goals").performClick() + + verify(exactly = 1) { + navController.navigate(Screen.Home(HomeTab.Goals)) + } + } + + @Test + fun navigateToStatistics() = runTest { + composeRule.onNodeWithText("Statistics").performClick() + + verify(exactly = 1) { + navController.navigate(Screen.Home(HomeTab.Statistics)) + } + } + + @Test + fun navigateToLibrary() = runTest { + composeRule.onNodeWithText("Library").performClick() + + verify(exactly = 1) { + navController.navigate(Screen.Home(HomeTab.Library)) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/app/musikus/core/presentation/MusikusNavHostTest.kt b/app/src/androidTest/java/app/musikus/core/presentation/MusikusNavHostTest.kt new file mode 100644 index 00000000..bb7e8ebd --- /dev/null +++ b/app/src/androidTest/java/app/musikus/core/presentation/MusikusNavHostTest.kt @@ -0,0 +1,291 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2024 Matthias Emde + */ + +package app.musikus.core.presentation + +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.testing.TestNavHostController +import app.musikus.core.domain.FakeTimeProvider +import app.musikus.core.presentation.theme.MusikusTheme +import app.musikus.settings.domain.ColorSchemeSelections +import app.musikus.settings.domain.ThemeSelections +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class MusikusNavHostTest { + + @Inject lateinit var fakeTimeProvider: FakeTimeProvider + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + lateinit var navController: TestNavHostController + + @Before + fun setUp() { + hiltRule.inject() + composeRule.activity.setContent { + val mainViewModel: MainViewModel = hiltViewModel() + + val mainUiState by mainViewModel.uiState.collectAsStateWithLifecycle() + val eventHandler = mainViewModel::onUiEvent + + navController = TestNavHostController(LocalContext.current) + navController.navigatorProvider.addNavigator(ComposeNavigator()) + + MusikusTheme( + theme = ThemeSelections.DAY, + colorScheme = ColorSchemeSelections.MUSIKUS, + ) { + MusikusNavHost( + navController = navController, + mainUiState = mainUiState, + mainEventHandler = eventHandler, + bottomBarHeight = 0.dp, + timeProvider = fakeTimeProvider, + ) + } + } + } + + @Test + fun testStartDestination() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.Home::class.java) + require(screen is Screen.Home) + + assertThat(screen.tab).isEqualTo(HomeTab.Sessions) + composeRule.onNodeWithText("Sessions").assertIsDisplayed() + } + + @Test + fun testNavigationToGoals() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.Home(HomeTab.Goals)) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.Home::class.java) + require(screen is Screen.Home) + + assertThat(screen.tab).isEqualTo(HomeTab.Goals) + composeRule.onNodeWithText("Goals").assertIsDisplayed() + } + + @Test + fun testNavigationToStatistics() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.Home(HomeTab.Statistics)) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.Home::class.java) + require(screen is Screen.Home) + assertThat(screen.tab).isEqualTo(HomeTab.Statistics) + composeRule.onNodeWithText("Statistics").assertIsDisplayed() + } + + @Test + fun testNavigationToLibrary() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.Home(HomeTab.Library)) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.Home::class.java) + require(screen is Screen.Home) + + assertThat(screen.tab).isEqualTo(HomeTab.Library) + composeRule.onNodeWithText("Library").assertIsDisplayed() + } + + @Test + fun testNavigationToActiveSession() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.ActiveSession()) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.ActiveSession::class.java) + composeRule.onNodeWithText("Practice Time").assertIsDisplayed() + } + + @Test + fun testNavigationToSessionStatistics() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.SessionStatistics) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.SessionStatistics::class.java) + composeRule.onNodeWithText("Session History").assertIsDisplayed() + } + + @Test + fun testNavigationToGoalStatistics() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.GoalStatistics) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.GoalStatistics::class.java) + composeRule.onNodeWithText("Goal History").assertIsDisplayed() + } + + @Test + fun testNavigationToSettings() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.Settings) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.Settings::class.java) + composeRule.onNodeWithText("Settings").assertIsDisplayed() + } + + @Test + fun testNavigationToAbout() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.SettingsOption.About) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.SettingsOption.About::class.java) + composeRule.onNodeWithText("About").assertIsDisplayed() + } + + @Test + fun testNavigationToHelp() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.SettingsOption.Help) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.SettingsOption.Help::class.java) + composeRule.onNodeWithText("Help").assertIsDisplayed() + } + + @Test + fun testNavigationToBackup() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.SettingsOption.Backup) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.SettingsOption.Backup::class.java) + composeRule.onNodeWithText("Backup and restore").assertIsDisplayed() + } + + @Test + fun testNavigationToExport() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.SettingsOption.Export) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.SettingsOption.Export::class.java) + composeRule.onNodeWithText("Export session data").assertIsDisplayed() + } + + @Test + fun testNavigationToDonate() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.SettingsOption.Donate) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.SettingsOption.Donate::class.java) + composeRule.onNodeWithText("Support us!").assertIsDisplayed() + } + + @Test + fun testNavigationToAppearance() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.SettingsOption.Appearance) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.SettingsOption.Appearance::class.java) + composeRule.onNodeWithText("Appearance").assertIsDisplayed() + } + + @Test + fun testNavigationToLicense() = runTest { + composeRule.awaitIdle() // ensures that navController is initialized + + composeRule.runOnUiThread { + navController.navigate(Screen.License) + } + + val screen = navController.currentBackStackEntry?.toScreen() + + assertThat(screen).isInstanceOf(Screen.License::class.java) + composeRule.onNodeWithText("Licenses").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt index 3475d42d..5c185e64 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt @@ -26,22 +26,16 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController import androidx.test.core.app.ApplicationProvider import app.musikus.R import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.HomeViewModel import app.musikus.core.presentation.MainActivity import app.musikus.core.presentation.MainViewModel -import app.musikus.core.presentation.Screen -import app.musikus.core.presentation.theme.MusikusTheme import app.musikus.core.presentation.utils.TestTags -import app.musikus.settings.domain.ColorSchemeSelections -import app.musikus.settings.domain.ThemeSelections import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Before @@ -67,28 +61,22 @@ class LibraryScreenTest { fun setUp() { hiltRule.inject() composeRule.activity.setContent { - val viewModel: MainViewModel = hiltViewModel() - - val navController = rememberNavController() - MusikusTheme(theme = ThemeSelections.DAY, colorScheme = ColorSchemeSelections.DEFAULT) { - - val homeViewModel: HomeViewModel = hiltViewModel() - val homeUiState by homeViewModel.uiState.collectAsStateWithLifecycle() - - NavHost( - navController = navController, - startDestination = Screen.HomeTab.Library.route - ) { - composable(Screen.HomeTab.Library.route) { - Library( - homeUiState = homeUiState, - mainEventHandler = viewModel::onUiEvent, - homeEventHandler = homeViewModel::onUiEvent, - navigateTo = { } - ) - } - } - } + val mainViewModel: MainViewModel = hiltViewModel() + val mainUiState by mainViewModel.uiState.collectAsStateWithLifecycle() + val mainEventHandler = mainViewModel::onUiEvent + + val homeViewModel: HomeViewModel = hiltViewModel() + val homeUiState by homeViewModel.uiState.collectAsStateWithLifecycle() + val homeEventHandler = homeViewModel::onUiEvent + + Library( + mainUiState = mainUiState, + mainEventHandler = mainEventHandler, + homeUiState = homeUiState, + homeEventHandler = homeEventHandler, + navigateTo = { }, + bottomBarHeight = 0.dp + ) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8aaf9814..21102635 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -75,16 +75,24 @@ + + - + + + + - - + + + + + diff --git a/app/src/main/java/app/musikus/activesession/di/ActiveSessionUseCasesModule.kt b/app/src/main/java/app/musikus/activesession/di/ActiveSessionUseCasesModule.kt index 6ff72e4f..22dd03b8 100644 --- a/app/src/main/java/app/musikus/activesession/di/ActiveSessionUseCasesModule.kt +++ b/app/src/main/java/app/musikus/activesession/di/ActiveSessionUseCasesModule.kt @@ -14,12 +14,12 @@ import app.musikus.activesession.domain.usecase.DeleteSectionUseCase import app.musikus.activesession.domain.usecase.GetCompletedSectionsUseCase import app.musikus.activesession.domain.usecase.GetFinalizedSessionUseCase import app.musikus.activesession.domain.usecase.GetOngoingPauseDurationUseCase -import app.musikus.activesession.domain.usecase.GetPausedStateUseCase import app.musikus.activesession.domain.usecase.GetRunningItemDurationUseCase import app.musikus.activesession.domain.usecase.GetRunningItemUseCase import app.musikus.activesession.domain.usecase.GetSessionStatusUseCase import app.musikus.activesession.domain.usecase.GetStartTimeUseCase import app.musikus.activesession.domain.usecase.GetTotalPracticeDurationUseCase +import app.musikus.activesession.domain.usecase.IsSessionPausedUseCase import app.musikus.activesession.domain.usecase.IsSessionRunningUseCase import app.musikus.activesession.domain.usecase.PauseActiveSessionUseCase import app.musikus.activesession.domain.usecase.ResetSessionUseCase @@ -80,7 +80,7 @@ object ActiveSessionUseCasesModule { getRunningItemDuration = getRunningItemDurationUseCase, getCompletedSections = GetCompletedSectionsUseCase(activeSessionRepository), getOngoingPauseDuration = GetOngoingPauseDurationUseCase(activeSessionRepository, timeProvider), - getPausedState = GetPausedStateUseCase(activeSessionRepository), + isSessionPaused = IsSessionPausedUseCase(activeSessionRepository), getFinalizedSession = GetFinalizedSessionUseCase( activeSessionRepository = activeSessionRepository, getRunningItemDurationUseCase = getRunningItemDurationUseCase, diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/ActiveSessionUseCases.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/ActiveSessionUseCases.kt index 749407fb..3d3142db 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/ActiveSessionUseCases.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/ActiveSessionUseCases.kt @@ -18,10 +18,10 @@ data class ActiveSessionUseCases( val getRunningItem: GetRunningItemUseCase, val getCompletedSections: GetCompletedSectionsUseCase, val getOngoingPauseDuration: GetOngoingPauseDurationUseCase, - val getPausedState: GetPausedStateUseCase, val getStartTime: GetStartTimeUseCase, val getFinalizedSession: GetFinalizedSessionUseCase, val reset: ResetSessionUseCase, + val isSessionPaused: IsSessionPausedUseCase, val isSessionRunning: IsSessionRunningUseCase, val getSessionStatus: GetSessionStatusUseCase, ) diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/GetPausedStateUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/IsSessionPausedUseCase.kt similarity index 96% rename from app/src/main/java/app/musikus/activesession/domain/usecase/GetPausedStateUseCase.kt rename to app/src/main/java/app/musikus/activesession/domain/usecase/IsSessionPausedUseCase.kt index 723da0f2..4b2c49dc 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/GetPausedStateUseCase.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/IsSessionPausedUseCase.kt @@ -11,7 +11,7 @@ package app.musikus.activesession.domain.usecase import app.musikus.activesession.domain.ActiveSessionRepository import kotlinx.coroutines.flow.first -class GetPausedStateUseCase( +class IsSessionPausedUseCase( private val activeSessionRepository: ActiveSessionRepository ) { suspend operator fun invoke(): Boolean { diff --git a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionScreen.kt b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionScreen.kt index 7e867bf7..0ec4dcee 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionScreen.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionScreen.kt @@ -151,7 +151,6 @@ import app.musikus.R import app.musikus.core.data.LibraryFolderWithItems import app.musikus.core.data.UUIDConverter import app.musikus.core.domain.TimeProvider -import app.musikus.core.presentation.Screen import app.musikus.core.presentation.components.DeleteConfirmationBottomSheet import app.musikus.core.presentation.components.DialogActions import app.musikus.core.presentation.components.DialogHeader @@ -194,6 +193,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable import java.time.ZonedDateTime import java.util.UUID import kotlin.random.Random @@ -202,8 +202,9 @@ import kotlin.time.Duration.Companion.seconds /** * Actions that can be triggered by the Notification */ +@Serializable enum class ActiveSessionActions { - OPEN, PAUSE, FINISH, METRONOME, RECORDER + PAUSE, FINISH, METRONOME, RECORDER } data class ToolsTab( @@ -226,9 +227,8 @@ data class ScreenSizeClass( @Composable fun ActiveSession( viewModel: ActiveSessionViewModel = hiltViewModel(), - deepLinkArgument: String?, + deepLinkAction: ActiveSessionActions? = null, navigateUp: () -> Unit, - navigateTo: (Screen) -> Unit, ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle() val eventHandler = viewModel::onUiEvent @@ -300,9 +300,9 @@ fun ActiveSession( ) /** Handle deep link Arguments */ - LaunchedEffect(deepLinkArgument) { - when (deepLinkArgument) { - ActiveSessionActions.METRONOME.name -> { + LaunchedEffect(deepLinkAction) { + when (deepLinkAction) { + ActiveSessionActions.METRONOME -> { // switch to metronome tab scope.launch { bottomSheetPagerState.animateScrollToPage( @@ -314,7 +314,7 @@ fun ActiveSession( bottomSheetScaffoldState.bottomSheetState.expand() } } - ActiveSessionActions.RECORDER.name -> { + ActiveSessionActions.RECORDER -> { // switch to metronome tab scope.launch { bottomSheetPagerState.animateScrollToPage( @@ -327,9 +327,13 @@ fun ActiveSession( } } - ActiveSessionActions.FINISH.name -> { + ActiveSessionActions.FINISH -> { eventHandler(ActiveSessionUiEvent.ToggleFinishDialog) } + + else -> { + // do nothing + } } } } diff --git a/app/src/main/java/app/musikus/activesession/presentation/SessionService.kt b/app/src/main/java/app/musikus/activesession/presentation/SessionService.kt index f76e46c7..84959eec 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/SessionService.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/SessionService.kt @@ -22,7 +22,6 @@ import android.os.Binder import android.os.Build import android.os.IBinder import android.util.Log -import androidx.annotation.DrawableRes import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat @@ -31,6 +30,7 @@ import app.musikus.R import app.musikus.activesession.domain.usecase.ActiveSessionUseCases import app.musikus.core.di.ApplicationScope import app.musikus.core.domain.TimeProvider +import app.musikus.core.presentation.MainActivity import app.musikus.core.presentation.SESSION_NOTIFICATION_CHANNEL_ID import app.musikus.core.presentation.utils.DurationFormat import app.musikus.core.presentation.utils.getDurationString @@ -53,15 +53,6 @@ enum class ActiveSessionServiceActions { START, STOP } -/** - * Data Structure for a Button inside the Notification - */ -data class NotificationActionButtonConfig( - @DrawableRes val icon: Int, - val text: String, - val tapIntent: PendingIntent? -) - const val LOG_TAG = "SessionService" @AndroidEntryPoint @@ -84,22 +75,19 @@ class SessionService : Service() { private val myReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { Log.d(LOG_TAG, "Received Broadcast") - when (intent.getStringExtra("action")) { - ActiveSessionActions.PAUSE.toString() -> togglePause() + val action = intent.getStringExtra("action")?.let { + ActiveSessionActions.valueOf(it) + } ?: throw IllegalArgumentException("No action provided in broadcast") + + when (action) { + ActiveSessionActions.PAUSE -> togglePause() + else -> throw IllegalArgumentException( + "Broadcast receiver can't handle action: $action" + ) } } } - /** Intents */ - private var pendingIntentTapAction: PendingIntent? = null - private var pendingIntentActionPause: PendingIntent? = null - private var pendingIntentActionFinish: PendingIntent? = null - - /** Notification Button Configs */ - private var pauseActionButton: NotificationActionButtonConfig? = null - private var resumeActionButton: NotificationActionButtonConfig? = null - private var finishActionButton: NotificationActionButtonConfig? = null - /** * ---------------------- Interface for Activity / ViewModel ---------------------- */ @@ -107,13 +95,25 @@ class SessionService : Service() { override fun onCreate() { super.onCreate() Log.d(LOG_TAG, "onCreate") - createPendingIntents() - ContextCompat.registerReceiver( - this, - myReceiver, - IntentFilter(BROADCAST_INTENT_FILTER), - ContextCompat.RECEIVER_NOT_EXPORTED - ) + val filter = IntentFilter(BROADCAST_INTENT_FILTER) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + registerReceiver( + myReceiver, + filter, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + RECEIVER_NOT_EXPORTED + } else { + ContextCompat.RECEIVER_NOT_EXPORTED + } + ) + } else { + ContextCompat.registerReceiver( + this, + myReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -172,7 +172,7 @@ class SessionService : Service() { private fun togglePause() { applicationScope.launch { - if (useCases.getPausedState()) { + if (useCases.isSessionPaused()) { useCases.resume() } else { useCases.pause() @@ -205,7 +205,7 @@ class SessionService : Service() { val currentSectionName = useCases.getRunningItem().first()!!.name - if (useCases.getPausedState()) { + if (useCases.isSessionPaused()) { title = getString(R.string.active_session_service_notification_title_paused) description = getString( R.string.active_session_service_notification_description_paused, @@ -224,105 +224,60 @@ class SessionService : Service() { description = "Could not get session info" } - val actionButton1Intent = - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - try { - if (useCases.getPausedState()) { - resumeActionButton - } else { - pauseActionButton - } - } catch (e: IllegalStateException) { - pauseActionButton - } - } else { - null // TODO: broadcast receiver is broken on Android 14 - } - - val actionButton2Intent = finishActionButton - return getNotification( title = title, description = description, - actionButton1 = actionButton1Intent, // TODO: Fix action button (pause/resume) - actionButton2 = actionButton2Intent ) } - private fun getNotification( + private suspend fun getNotification( title: String, description: String, - actionButton1: NotificationActionButtonConfig?, - actionButton2: NotificationActionButtonConfig? ): Notification { val icon = R.drawable.ic_launcher_foreground - val builder = NotificationCompat.Builder(this, SESSION_NOTIFICATION_CHANNEL_ID) - .setSmallIcon(icon) // without icon, setOngoing does not work - .setOngoing( - true - ) // does not work on Android 14: https://developer.android.com/about/versions/14/behavior-changes-all#non-dismissable-notifications - .setOnlyAlertOnce(true) - .setContentTitle(title) - .setContentText(description) - .setPriority(NotificationCompat.PRIORITY_HIGH) // only relevant below Oreo, else channel priority is used - .setContentIntent(pendingIntentTapAction) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - - if (actionButton1 != null) { - builder.addAction(actionButton1.icon, actionButton1.text, actionButton1.tapIntent) + val pauseButtonIntent = Intent(BROADCAST_INTENT_FILTER).apply { + putExtra("action", ActiveSessionActions.PAUSE.toString()) } - if (actionButton2 != null) { - builder.addAction(actionButton2.icon, actionButton2.text, actionButton2.tapIntent) - } - return builder.build() - } - /** - * Creates all pending intents for the notification. Has to be done when Context is available. - */ - private fun createPendingIntents() { - // trigger deep link to open ActiveSession https://stackoverflow.com/a/72769863 - pendingIntentTapAction = TaskStackBuilder.create(this).run { - addNextIntentWithParentStack( - Intent(Intent.ACTION_VIEW, "musikus://activeSession/${ActiveSessionActions.OPEN}".toUri()) - ) - getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - } - - pendingIntentActionPause = PendingIntent.getBroadcast( + val pauseButtonPendingIntent = PendingIntent.getBroadcast( this, SESSION_NOTIFICATION_ID, - Intent(BROADCAST_INTENT_FILTER).apply { - putExtra("action", ActiveSessionActions.PAUSE.toString()) - }, + pauseButtonIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - pendingIntentActionFinish = TaskStackBuilder.create(this).run { - addNextIntentWithParentStack( - Intent(Intent.ACTION_VIEW, "musikus://activeSession/${ActiveSessionActions.FINISH}".toUri()) + return NotificationCompat.Builder(this, SESSION_NOTIFICATION_CHANNEL_ID).run { + setSmallIcon(icon) // without icon, setOngoing does not work + setOngoing( + true + ) // does not work on Android 14: https://developer.android.com/about/versions/14/behavior-changes-all#non-dismissable-notifications + setOnlyAlertOnce(true) + setContentTitle(title) + setContentText(description) + setPriority(NotificationCompat.PRIORITY_HIGH) // only relevant below Oreo, else channel priority is used + setContentIntent(activeSessionIntent(null)) + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + if (!useCases.isSessionPaused()) { + addAction( + R.drawable.ic_pause, + getString(R.string.active_session_service_notification_action_pause), + pauseButtonPendingIntent + ) + } else { + addAction( + R.drawable.ic_play, + getString(R.string.active_session_service_notification_action_resume), + pauseButtonPendingIntent + ) + } + addAction( + R.drawable.ic_stop, + getString(R.string.active_session_service_notification_action_finish), + activeSessionIntent(ActiveSessionActions.FINISH) ) - getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + build() } - - pauseActionButton = NotificationActionButtonConfig( - icon = R.drawable.ic_pause, - text = getString(R.string.active_session_service_notification_action_pause), - tapIntent = pendingIntentActionPause - ) - - resumeActionButton = NotificationActionButtonConfig( - icon = R.drawable.ic_play, - text = getString(R.string.active_session_service_notification_action_resume), - tapIntent = pendingIntentActionPause - ) - - finishActionButton = NotificationActionButtonConfig( - icon = R.drawable.ic_stop, - text = getString(R.string.active_session_service_notification_action_finish), - tapIntent = pendingIntentActionFinish - ) } override fun onDestroy() { @@ -341,4 +296,20 @@ class SessionService : Service() { stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } + + private fun activeSessionIntent(activeSessionAction: ActiveSessionActions?): PendingIntent { + val intent = Intent(this, MainActivity::class.java).apply { + data = ( + "https://musikus.app" + + (activeSessionAction?.let { "?action=$activeSessionAction" } ?: "") + ).toUri() + } + + return TaskStackBuilder.create(this).run { + addNextIntentWithParentStack(intent) + // Get a PendingIntent and make it immutable + val requestCode = 0 + getPendingIntent(requestCode, PendingIntent.FLAG_IMMUTABLE) + } + } } diff --git a/app/src/main/java/app/musikus/core/data/MusikusDatabase.kt b/app/src/main/java/app/musikus/core/data/MusikusDatabase.kt index 347685d0..9cb15525 100644 --- a/app/src/main/java/app/musikus/core/data/MusikusDatabase.kt +++ b/app/src/main/java/app/musikus/core/data/MusikusDatabase.kt @@ -245,13 +245,15 @@ abstract class MusikusDatabase : RoomDatabase() { val id = cursor.getLong(cursor.getColumnIndexOrThrow("id")) val now = ZonedDateTime.now().toEpochSecond() db.update( - tableName, SQLiteDatabase.CONFLICT_IGNORE, + tableName, + SQLiteDatabase.CONFLICT_IGNORE, ContentValues().let { it.put("created_at", now + id) it.put("modified_at", now + id) it }, - "id=?", arrayOf(id) + "id=?", + arrayOf(id) ) } Log.d("POST_MIGRATION", "Added timestamps for $tableName") @@ -267,12 +269,14 @@ abstract class MusikusDatabase : RoomDatabase() { val id = cursor.getLong(cursor.getColumnIndexOrThrow("id")) val oneTime = cursor.getInt(cursor.getColumnIndexOrThrow("repeat")) db.update( - "goal_description", SQLiteDatabase.CONFLICT_IGNORE, + "goal_description", + SQLiteDatabase.CONFLICT_IGNORE, ContentValues().let { it.put("repeat", if (oneTime == 0) "1" else "0") it }, - "id=?", arrayOf(id) + "id=?", + arrayOf(id) ) } Log.d("POST_MIGRATION", "Migrated 'oneTime' to 'repeat' for goal descriptions") @@ -534,21 +538,25 @@ abstract class MusikusDatabase : RoomDatabase() { "(3 -> 4): Updating $tableName with id ${UUIDConverter().fromByte(id)} to deleted $deleted}" ) db.update( - tableName, SQLiteDatabase.CONFLICT_ROLLBACK, + tableName, + SQLiteDatabase.CONFLICT_ROLLBACK, ContentValues().apply { put("deleted", deleted == "1") }, - "id=?", arrayOf(id) + "id=?", + arrayOf(id) ) if (tableName == "goal_description") { val paused = cursor.getString(cursor.getColumnIndexOrThrow("paused")) db.update( - tableName, SQLiteDatabase.CONFLICT_ROLLBACK, + tableName, + SQLiteDatabase.CONFLICT_ROLLBACK, ContentValues().apply { put("paused", paused == "1") }, - "id=?", arrayOf(id) + "id=?", + arrayOf(id) ) } } diff --git a/app/src/main/java/app/musikus/core/data/daos/GenericDaos.kt b/app/src/main/java/app/musikus/core/data/daos/GenericDaos.kt index 17eb08bb..07f22f5d 100644 --- a/app/src/main/java/app/musikus/core/data/daos/GenericDaos.kt +++ b/app/src/main/java/app/musikus/core/data/daos/GenericDaos.kt @@ -230,7 +230,7 @@ abstract class BaseDao< ioThread { runBlocking { notify.emit("invalidated") - } + } } } }.also { diff --git a/app/src/main/java/app/musikus/core/presentation/HomeBottomBar.kt b/app/src/main/java/app/musikus/core/presentation/HomeBottomBar.kt new file mode 100644 index 00000000..eeab7f00 --- /dev/null +++ b/app/src/main/java/app/musikus/core/presentation/HomeBottomBar.kt @@ -0,0 +1,132 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2024 Matthias Emde + */ + +package app.musikus.core.presentation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.zIndex +import app.musikus.core.presentation.components.MultiFabState + +@OptIn(ExperimentalAnimationGraphicsApi::class) +@Composable +fun MusikusBottomBar( + mainUiState: MainUiState, + mainEventHandler: MainUiEventHandler, + currentTab: HomeTab?, + onTabSelected: (HomeTab) -> Unit, +) { + AnimatedVisibility( + visible = currentTab != null, // only show bottom bar when in home screen + enter = slideInVertically( + initialOffsetY = { bottomBarHeight -> bottomBarHeight }, + animationSpec = tween(durationMillis = ANIMATION_BASE_DURATION) + ), + exit = slideOutVertically( + targetOffsetY = { bottomBarHeight -> bottomBarHeight }, + animationSpec = tween(durationMillis = ANIMATION_BASE_DURATION) + ) + ) { + Box { + NavigationBar { + HomeTab.all.forEach { tab -> + val selected = tab == currentTab + val painterCount = 5 + var activePainter by remember { mutableIntStateOf(0) } + val painter = rememberVectorPainter( + image = tab.getDisplayData().icon.asIcon() + ) + val animatedPainters = (0..painterCount).map { + rememberAnimatedVectorPainter( + animatedImageVector = AnimatedImageVector.animatedVectorResource( + tab.getDisplayData().animatedIcon!! + ), + atEnd = selected && activePainter == it + ) + } + NavigationBarItem( + icon = { + BadgedBox(badge = { + if (tab == HomeTab.Sessions && mainUiState.isSessionRunning) { + Badge() + } + }) { + Image( + painter = if (selected) animatedPainters[activePainter] else painter, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + contentDescription = null + ) + } + }, + label = { + Text( + text = tab.getDisplayData().title.asAnnotatedString(), + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + ) + }, + selected = selected, + onClick = { + if (!selected) { + activePainter = (activePainter + 1) % painterCount + onTabSelected(tab) + } + } + ) + } + } + + /** Navbar Scrim */ + AnimatedVisibility( + modifier = Modifier + .matchParentSize() + .zIndex(1f), + visible = mainUiState.multiFabState == MultiFabState.EXPANDED, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { mainEventHandler(MainUiEvent.CollapseMultiFab) } + ) + ) + } + } + } +} diff --git a/app/src/main/java/app/musikus/core/presentation/HomeScreen.kt b/app/src/main/java/app/musikus/core/presentation/HomeScreen.kt index 3017fd3a..55f2f57f 100644 --- a/app/src/main/java/app/musikus/core/presentation/HomeScreen.kt +++ b/app/src/main/java/app/musikus/core/presentation/HomeScreen.kt @@ -8,49 +8,12 @@ package app.musikus.core.presentation -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi -import androidx.compose.animation.graphics.res.animatedVectorResource -import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter -import androidx.compose.animation.graphics.vector.AnimatedImageVector -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.zIndex +import androidx.compose.ui.unit.Dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController import app.musikus.core.domain.TimeProvider -import app.musikus.core.presentation.components.MultiFabState import app.musikus.goals.presentation.GoalsScreen import app.musikus.library.presentation.Library import app.musikus.sessions.presentation.SessionsScreen @@ -60,73 +23,25 @@ import app.musikus.statistics.presentation.Statistics fun HomeScreen( mainUiState: MainUiState, mainEventHandler: MainUiEventHandler, - initialTab: Screen.HomeTab?, + bottomBarHeight: Dp, + currentTab: HomeTab, viewModel: HomeViewModel = hiltViewModel(), - navController: NavController, + navigateTo: (Screen) -> Unit, timeProvider: TimeProvider, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val eventHandler: HomeUiEventHandler = viewModel::onUiEvent - SideEffect { - if (initialTab != null && initialTab != uiState.currentTab) { - eventHandler(HomeUiEvent.TabSelected(initialTab)) - } - } - - BackHandler( - enabled = - navController.previousBackStackEntry == null && - uiState.currentTab != Screen.HomeTab.defaultTab, - onBack = { eventHandler(HomeUiEvent.TabSelected(Screen.HomeTab.defaultTab)) } - ) - - Scaffold( - snackbarHost = { - SnackbarHost(hostState = mainUiState.snackbarHost) - }, - bottomBar = { - MusikusBottomBar( + when (currentTab) { + is HomeTab.Sessions -> { + SessionsScreen( mainUiState = mainUiState, + mainEventHandler = mainEventHandler, homeUiState = uiState, homeEventHandler = eventHandler, - currentTab = uiState.currentTab, - onTabSelected = { - eventHandler(HomeUiEvent.TabSelected(it)) - }, - ) - } - ) { innerPadding -> - - AnimatedContent( - modifier = Modifier.padding(bottom = innerPadding.calculateBottomPadding()), - targetState = uiState.currentTab, - transitionSpec = { - slideInVertically( - animationSpec = tween(ANIMATION_BASE_DURATION), - initialOffsetY = { fullHeight -> -(fullHeight / 10) } - ) + fadeIn( - animationSpec = tween( - ANIMATION_BASE_DURATION / 2, - ANIMATION_BASE_DURATION / 2 - ) - ) togetherWith - slideOutVertically( - animationSpec = tween(ANIMATION_BASE_DURATION), - targetOffsetY = { fullHeight -> (fullHeight / 10) } - ) + fadeOut(animationSpec = tween(ANIMATION_BASE_DURATION / 2)) - }, - label = "homeTabContent" - ) { currentTab -> - when (currentTab) { - is Screen.HomeTab.Sessions -> { - SessionsScreen( - mainUiState = mainUiState, - mainEventHandler = mainEventHandler, - homeUiState = uiState, - homeEventHandler = eventHandler, - navigateTo = navController::navigateTo, - onSessionEdit = {} + navigateTo = navigateTo, + onSessionEdit = {}, + bottomBarHeight = bottomBarHeight, // onSessionEdit = { sessionId: UUID -> // navController.navigate( // Screen.EditSession.route.replace( @@ -135,112 +50,36 @@ fun HomeScreen( // ) // ) // }, - ) - } - is Screen.HomeTab.Goals -> { - GoalsScreen( - mainEventHandler = mainEventHandler, - homeUiState = uiState, - homeEventHandler = eventHandler, - navigateTo = navController::navigateTo, - timeProvider = timeProvider - ) - } - is Screen.HomeTab.Library -> { - Library( - mainEventHandler = mainEventHandler, - homeUiState = uiState, - homeEventHandler = eventHandler, - navigateTo = navController::navigateTo - ) - } - is Screen.HomeTab.Statistics -> { - Statistics( - homeUiState = uiState, - homeEventHandler = eventHandler, - navigateTo = navController::navigateTo, - timeProvider = timeProvider - ) - } - } + ) } - } -} - -@OptIn(ExperimentalAnimationGraphicsApi::class) -@Composable -fun MusikusBottomBar( - mainUiState: MainUiState, - homeUiState: HomeUiState, - homeEventHandler: HomeUiEventHandler, - currentTab: Screen.HomeTab, - onTabSelected: (Screen.HomeTab) -> Unit, -) { - Box { - NavigationBar { - Screen.HomeTab.allTabs.forEach { tab -> - val selected = tab == currentTab - val painterCount = 5 - var activePainter by remember { mutableIntStateOf(0) } - val painter = rememberVectorPainter( - image = tab.displayData.icon.asIcon() - ) - val animatedPainters = (0..painterCount).map { - rememberAnimatedVectorPainter( - animatedImageVector = AnimatedImageVector.animatedVectorResource( - tab.displayData.animatedIcon!! - ), - atEnd = selected && activePainter == it - ) - } - NavigationBarItem( - icon = { - BadgedBox(badge = { - if (tab == Screen.HomeTab.Sessions && mainUiState.isSessionRunning) { - Badge() - } - }) { - Image( - painter = if (selected) animatedPainters[activePainter] else painter, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), - contentDescription = null - ) - } - }, - label = { - Text( - text = tab.displayData.title.asAnnotatedString(), - fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, - ) - }, - selected = selected, - onClick = { - if (!selected) { - activePainter = (activePainter + 1) % painterCount - onTabSelected(tab) - } - } - ) - } + is HomeTab.Goals -> { + GoalsScreen( + mainUiState = mainUiState, + mainEventHandler = mainEventHandler, + homeUiState = uiState, + homeEventHandler = eventHandler, + navigateTo = navigateTo, + timeProvider = timeProvider, + bottomBarHeight = bottomBarHeight, + ) } - - /** Navbar Scrim */ - AnimatedVisibility( - modifier = Modifier - .matchParentSize() - .zIndex(1f), - visible = homeUiState.multiFabState == MultiFabState.EXPANDED, - enter = fadeIn(), - exit = fadeOut() - ) { - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { homeEventHandler(HomeUiEvent.CollapseMultiFab) } - ) + is HomeTab.Library -> { + Library( + mainUiState = mainUiState, + mainEventHandler = mainEventHandler, + homeUiState = uiState, + homeEventHandler = eventHandler, + navigateTo = navigateTo, + bottomBarHeight = bottomBarHeight, + ) + } + is HomeTab.Statistics -> { + Statistics( + homeUiState = uiState, + homeEventHandler = eventHandler, + navigateTo = navigateTo, + timeProvider = timeProvider, + bottomBarHeight = bottomBarHeight, ) } } diff --git a/app/src/main/java/app/musikus/core/presentation/HomeViewModel.kt b/app/src/main/java/app/musikus/core/presentation/HomeViewModel.kt index 8361183f..580e73fe 100644 --- a/app/src/main/java/app/musikus/core/presentation/HomeViewModel.kt +++ b/app/src/main/java/app/musikus/core/presentation/HomeViewModel.kt @@ -10,29 +10,23 @@ package app.musikus.core.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.musikus.core.presentation.components.MultiFabState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import javax.inject.Inject data class HomeUiState( - val currentTab: Screen.HomeTab, - val multiFabState: MultiFabState, val showMainMenu: Boolean, ) typealias HomeUiEventHandler = (HomeUiEvent) -> Unit sealed class HomeUiEvent { - data class TabSelected(val tab: Screen.HomeTab) : HomeUiEvent() data object ShowMainMenu : HomeUiEvent() data object HideMainMenu : HomeUiEvent() - data object ExpandMultiFab : HomeUiEvent() - data object CollapseMultiFab : HomeUiEvent() } @HiltViewModel @@ -42,12 +36,6 @@ class HomeViewModel @Inject constructor() : ViewModel() { * Own state flows */ - // Current Tab - private val _currentTab = MutableStateFlow(Screen.HomeTab.defaultTab) - - // Content Scrim over NavBar for Multi FAB etc - private val _multiFabState = MutableStateFlow(MultiFabState.COLLAPSED) - // Menu private val _showMainMenu = MutableStateFlow(false) @@ -55,43 +43,26 @@ class HomeViewModel @Inject constructor() : ViewModel() { * Composing the ui state */ - val uiState = combine( - _currentTab, - _showMainMenu, - _multiFabState, - ) { currentTab, showMainMenu, multiFabState -> + val uiState = _showMainMenu.map { showMainMenu -> HomeUiState( - currentTab = currentTab, - multiFabState = multiFabState, showMainMenu = showMainMenu, ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = HomeUiState( - currentTab = _currentTab.value, - multiFabState = _multiFabState.value, showMainMenu = _showMainMenu.value, ) ) fun onUiEvent(event: HomeUiEvent) { when (event) { - is HomeUiEvent.TabSelected -> { - _currentTab.update { event.tab } - } is HomeUiEvent.ShowMainMenu -> { _showMainMenu.update { true } } is HomeUiEvent.HideMainMenu -> { _showMainMenu.update { false } } - is HomeUiEvent.ExpandMultiFab -> { - _multiFabState.update { MultiFabState.EXPANDED } - } - is HomeUiEvent.CollapseMultiFab -> { - _multiFabState.update { MultiFabState.COLLAPSED } - } } } } diff --git a/app/src/main/java/app/musikus/core/presentation/MainActivity.kt b/app/src/main/java/app/musikus/core/presentation/MainActivity.kt index 5841534d..81fa33a9 100644 --- a/app/src/main/java/app/musikus/core/presentation/MainActivity.kt +++ b/app/src/main/java/app/musikus/core/presentation/MainActivity.kt @@ -59,7 +59,7 @@ class MainActivity : PermissionCheckerActivity() { initializeExportImportLaunchers() setContent { - MusikusApp(timeProvider) + MainScreen(timeProvider) } } diff --git a/app/src/main/java/app/musikus/core/presentation/MusikusScreen.kt b/app/src/main/java/app/musikus/core/presentation/MainScreen.kt similarity index 64% rename from app/src/main/java/app/musikus/core/presentation/MusikusScreen.kt rename to app/src/main/java/app/musikus/core/presentation/MainScreen.kt index 5ec8e29e..95224205 100644 --- a/app/src/main/java/app/musikus/core/presentation/MusikusScreen.kt +++ b/app/src/main/java/app/musikus/core/presentation/MainScreen.kt @@ -22,35 +22,32 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.Dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavController import androidx.navigation.NavHostController -import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument import androidx.navigation.navDeepLink -import androidx.navigation.navigation +import androidx.navigation.toRoute import app.musikus.activesession.presentation.ActiveSession import app.musikus.core.domain.TimeProvider import app.musikus.core.presentation.theme.MusikusTheme -import app.musikus.sessions.presentation.EditSession import app.musikus.settings.presentation.addSettingsNavigationGraph import app.musikus.statistics.presentation.addStatisticsNavigationGraph -import java.util.UUID - -const val DEEP_LINK_KEY = "argument" +import kotlin.reflect.typeOf @Composable -fun MusikusApp( +fun MainScreen( timeProvider: TimeProvider, mainViewModel: MainViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), @@ -63,97 +60,127 @@ fun MusikusApp( val theme = uiState.activeTheme ?: return val colorScheme = uiState.activeColorScheme ?: return + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute by remember { + derivedStateOf { + navBackStackEntry?.toScreen() + } + } + val currentTab by remember { + derivedStateOf { + currentRoute?.let { if (it is Screen.Home) it.tab else null } + } + } + MusikusTheme( theme = theme, colorScheme = colorScheme ) { - NavHost( - modifier = Modifier.background(MaterialTheme.colorScheme.background), - navController = navController, - startDestination = Screen.Home.route, - enterTransition = { - getEnterTransition() + // This is the main scaffold of the app which contains the bottom navigation, + // the snackbar host and the nav host + Scaffold( + snackbarHost = { + SnackbarHost(hostState = uiState.snackbarHost) }, - exitTransition = { - getExitTransition() - } - ) { - navigation( - route = Screen.Home.route, - startDestination = Screen.HomeTab.defaultTab.route - ) { - composable( - route = "home/{tab}", - arguments = listOf( - navArgument("tab") { - nullable = true + bottomBar = { + MusikusBottomBar( + mainUiState = uiState, + mainEventHandler = eventHandler, + currentTab = currentTab, + onTabSelected = { selectedTab -> + navController.navigate(Screen.Home(selectedTab)) { + popUpTo(Screen.Home(HomeTab.default)) { + inclusive = selectedTab == HomeTab.default + } } - ) - ) { backStackEntry -> - val tabRoute = backStackEntry.arguments?.getString("tab") - val tab = Screen.HomeTab.allTabs.firstOrNull { it.subRoute == tabRoute } - - HomeScreen( - mainUiState = uiState, - mainEventHandler = eventHandler, - initialTab = tab, - navController = navController, - timeProvider = timeProvider - ) - } - } - - composable( - route = Screen.EditSession.route, - arguments = listOf(navArgument("sessionId") { type = NavType.StringType }) - ) { backStackEntry -> - val sessionId = backStackEntry.arguments?.getString("sessionId") - ?: return@composable navController.navigate(Screen.HomeTab.Sessions.route) - - EditSession( - sessionToEditId = UUID.fromString(sessionId), - navigateUp = navController::navigateUp - ) - } - composable( - route = Screen.ActiveSession.route, - deepLinks = listOf( - navDeepLink { - uriPattern = "musikus://activeSession/{$DEEP_LINK_KEY}" - } - ) - ) { backStackEntry -> - ActiveSession( - navigateUp = navController::navigateUp, - deepLinkArgument = backStackEntry.arguments?.getString(DEEP_LINK_KEY), - navigateTo = navController::navigateTo + }, ) } + ) { innerPadding -> - // Statistics - addStatisticsNavigationGraph( + // Calculate the height of the bottom bar so we can add it as padding in the home tabs + val bottomBarHeight = innerPadding.calculateBottomPadding() + + MusikusNavHost( navController = navController, + mainUiState = uiState, + mainEventHandler = eventHandler, + bottomBarHeight = bottomBarHeight, + timeProvider = timeProvider ) - - // Settings - addSettingsNavigationGraph(navController) } } } -fun NavController.navigateTo(screen: Screen) { - navigate(screen.route) +@Composable +fun MusikusNavHost( + navController: NavHostController, + mainUiState: MainUiState, + mainEventHandler: MainUiEventHandler, + bottomBarHeight: Dp, + timeProvider: TimeProvider +) { + NavHost( + navController = navController, + startDestination = Screen.Home(tab = HomeTab.default), + enterTransition = { + getEnterTransition() + }, + exitTransition = { + getExitTransition() + } + ) { + // Home + composable( + typeMap = mapOf(typeOf() to HomeTabNavType), + ) { backStackEntry -> + val tab = backStackEntry.toRoute().tab + + HomeScreen( + mainUiState = mainUiState, + mainEventHandler = mainEventHandler, + bottomBarHeight = bottomBarHeight, + currentTab = tab, + navigateTo = { navController.navigate(it) }, + timeProvider = timeProvider + ) + } + + // Active Session + composable( + deepLinks = listOf( + navDeepLink( + basePath = "https://musikus.app" + ) + ) + ) { backStackEntry -> + val deepLinkAction = backStackEntry.toRoute().action + + ActiveSession( + deepLinkAction = deepLinkAction, + navigateUp = navController::navigateUp, + ) + } + + // Statistics + addStatisticsNavigationGraph( + navController = navController, + ) + + // Settings + addSettingsNavigationGraph(navController) + } } const val ANIMATION_BASE_DURATION = 400 fun AnimatedContentTransitionScope.getEnterTransition(): EnterTransition { - val initialRoute = initialState.destination.route ?: return fadeIn() - val targetRoute = targetState.destination.route ?: return fadeIn() + val initialScreen = initialState.toScreen() + val targetScreen = targetState.toScreen() return when { // when changing to active session, slide in from the bottom - targetRoute == Screen.ActiveSession.route -> { + targetScreen is Screen.ActiveSession -> { slideInVertically( animationSpec = tween(ANIMATION_BASE_DURATION, easing = EaseOut), initialOffsetY = { fullHeight -> fullHeight } @@ -161,7 +188,7 @@ fun AnimatedContentTransitionScope.getEnterTransition(): Ente } // when changing from active session, stay invisible until active session has slid in from the bottom - initialRoute == Screen.ActiveSession.route -> { + initialScreen is Screen.ActiveSession -> { fadeIn( initialAlpha = 1f, animationSpec = tween(durationMillis = ANIMATION_BASE_DURATION) @@ -170,8 +197,8 @@ fun AnimatedContentTransitionScope.getEnterTransition(): Ente // when changing to settings, zoom in when coming from a sub menu // and slide in from the right when coming from the home screen - targetRoute == Screen.Settings.route -> { - if (initialRoute in (Screen.SettingsOption.allSettings.map { it.route })) { + targetScreen is Screen.Settings -> { + if (initialScreen is Screen.SettingsOption) { scaleIn( animationSpec = tween(ANIMATION_BASE_DURATION / 2), initialScale = 1.2f, @@ -186,8 +213,8 @@ fun AnimatedContentTransitionScope.getEnterTransition(): Ente // when changing from settings screen, if going to setting sub menu, zoom out // otherwise slide in from the right - initialRoute == Screen.Settings.route -> { - if (targetRoute in (Screen.SettingsOption.allSettings.map { it.route })) { + initialScreen is Screen.Settings -> { + if (targetScreen is Screen.SettingsOption) { scaleIn( animationSpec = tween(ANIMATION_BASE_DURATION / 2), initialScale = 0.7f, @@ -201,8 +228,8 @@ fun AnimatedContentTransitionScope.getEnterTransition(): Ente } // when changing to session or goal statistics, slide in from the right - targetRoute == Screen.SessionStatistics.route || - targetRoute == Screen.GoalStatistics.route -> { + targetScreen is Screen.SessionStatistics || + targetScreen is Screen.GoalStatistics -> { slideInHorizontally( animationSpec = tween(ANIMATION_BASE_DURATION), initialOffsetX = { fullWidth -> (fullWidth / 10) } @@ -210,8 +237,8 @@ fun AnimatedContentTransitionScope.getEnterTransition(): Ente } // when changing from session or goal statistics, slide in from the left - initialRoute == Screen.SessionStatistics.route || - initialRoute == Screen.GoalStatistics.route -> { + initialScreen is Screen.SessionStatistics || + initialScreen is Screen.GoalStatistics -> { slideInHorizontally( animationSpec = tween(ANIMATION_BASE_DURATION), initialOffsetX = { fullWidth -> -(fullWidth / 10) } @@ -234,17 +261,17 @@ fun AnimatedContentTransitionScope.getEnterTransition(): Ente } fun AnimatedContentTransitionScope.getExitTransition(): ExitTransition { - val initialRoute = initialState.destination.route ?: return fadeOut() - val targetRoute = targetState.destination.route ?: return fadeOut() + val initialScreen = initialState.toScreen() + val targetScreen = targetState.toScreen() return when { // when changing to active session, show immediately - targetRoute == Screen.ActiveSession.route -> { + targetScreen is Screen.ActiveSession -> { fadeOut(tween(durationMillis = 1, delayMillis = ANIMATION_BASE_DURATION)) } // when changing from active session, slide out to the bottom - initialRoute == Screen.ActiveSession.route -> { + initialScreen is Screen.ActiveSession -> { slideOutVertically( animationSpec = tween(ANIMATION_BASE_DURATION, easing = EaseIn), targetOffsetY = { fullHeight -> fullHeight } @@ -253,8 +280,8 @@ fun AnimatedContentTransitionScope.getExitTransition(): ExitT // when changing to settings, zoom in when coming from a sub menu // and slide out to the left when coming from the home screen - targetRoute == Screen.Settings.route -> { - if (initialRoute in (Screen.SettingsOption.allSettings.map { it.route })) { + targetScreen is Screen.Settings -> { + if (initialScreen is Screen.SettingsOption) { scaleOut( animationSpec = tween(ANIMATION_BASE_DURATION / 2), targetScale = 0.7f, @@ -269,8 +296,8 @@ fun AnimatedContentTransitionScope.getExitTransition(): ExitT // when changing from settings screen, if going to setting sub menu, zoom out // otherwise slide out to the right - initialRoute == Screen.Settings.route -> { - if (targetRoute in (Screen.SettingsOption.allSettings.map { it.route })) { + initialScreen is Screen.Settings -> { + if (targetScreen is Screen.SettingsOption) { scaleOut( animationSpec = tween(ANIMATION_BASE_DURATION / 2), targetScale = 1.2f, @@ -284,8 +311,8 @@ fun AnimatedContentTransitionScope.getExitTransition(): ExitT } // when changing to session or goal statistics, slide in from the right - targetRoute == Screen.SessionStatistics.route || - targetRoute == Screen.GoalStatistics.route -> { + targetScreen is Screen.SessionStatistics || + targetScreen is Screen.GoalStatistics -> { slideOutHorizontally( animationSpec = tween(ANIMATION_BASE_DURATION), targetOffsetX = { fullWidth -> (fullWidth / 10) } @@ -293,8 +320,8 @@ fun AnimatedContentTransitionScope.getExitTransition(): ExitT } // when changing from session or goal statistics, slide in from the left - initialRoute == Screen.SessionStatistics.route || - initialRoute == Screen.GoalStatistics.route -> { + initialScreen is Screen.SessionStatistics || + initialScreen is Screen.GoalStatistics -> { slideOutHorizontally( animationSpec = tween(ANIMATION_BASE_DURATION), targetOffsetX = { fullWidth -> -(fullWidth / 10) } diff --git a/app/src/main/java/app/musikus/core/presentation/MainViewModel.kt b/app/src/main/java/app/musikus/core/presentation/MainViewModel.kt index e10e6c71..37af5be8 100644 --- a/app/src/main/java/app/musikus/core/presentation/MainViewModel.kt +++ b/app/src/main/java/app/musikus/core/presentation/MainViewModel.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import app.musikus.activesession.domain.usecase.ActiveSessionUseCases +import app.musikus.core.presentation.components.MultiFabState import app.musikus.core.presentation.components.showSnackbar import app.musikus.settings.domain.ColorSchemeSelections import app.musikus.settings.domain.ThemeSelections @@ -22,19 +23,23 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import javax.inject.Inject typealias MainUiEventHandler = (MainUiEvent) -> Unit sealed class MainUiEvent { data class ShowSnackbar(val message: String, val onUndo: (() -> Unit)? = null) : MainUiEvent() + data object ExpandMultiFab : MainUiEvent() + data object CollapseMultiFab : MainUiEvent() } data class MainUiState( val activeTheme: ThemeSelections?, val activeColorScheme: ColorSchemeSelections?, - var snackbarHost: SnackbarHostState, - var isSessionRunning: Boolean + val snackbarHost: SnackbarHostState, + val isSessionRunning: Boolean, + val multiFabState: MultiFabState, ) @HiltViewModel @@ -48,22 +53,29 @@ class MainViewModel @Inject constructor( * Private state variables */ - /** Snackbar */ + // Snackbar private val _snackbarHost = MutableStateFlow(SnackbarHostState()) - /** Theme */ + // Theme private val _activeTheme = userPreferencesUseCases.getTheme().stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = null ) + // Color Scheme private val _activeColorScheme = userPreferencesUseCases.getColorScheme().stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = null ) + // Content Scrim over NavBar for Multi FAB etc. + private val _multiFabState = MutableStateFlow(MultiFabState.COLLAPSED) + + /** + * Imported flows + */ private val runningItem = activeSessionUseCases.getRunningItem().stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), @@ -78,13 +90,15 @@ class MainViewModel @Inject constructor( _activeTheme, _activeColorScheme, _snackbarHost, - runningItem - ) { activeTheme, activeColorScheme, snackbarHost, runningItem -> + runningItem, + _multiFabState + ) { activeTheme, activeColorScheme, snackbarHost, runningItem, multiFabState -> MainUiState( activeTheme = activeTheme, activeColorScheme = activeColorScheme, snackbarHost = snackbarHost, - isSessionRunning = runningItem != null + isSessionRunning = runningItem != null, + multiFabState = multiFabState ) }.stateIn( scope = viewModelScope, @@ -93,7 +107,8 @@ class MainViewModel @Inject constructor( activeTheme = _activeTheme.value, activeColorScheme = _activeColorScheme.value, snackbarHost = _snackbarHost.value, - isSessionRunning = runningItem.value != null + isSessionRunning = runningItem.value != null, + multiFabState = _multiFabState.value ) ) @@ -108,6 +123,12 @@ class MainViewModel @Inject constructor( onUndo = event.onUndo ) } + is MainUiEvent.ExpandMultiFab -> { + _multiFabState.update { MultiFabState.EXPANDED } + } + is MainUiEvent.CollapseMultiFab -> { + _multiFabState.update { MultiFabState.COLLAPSED } + } } } } diff --git a/app/src/main/java/app/musikus/core/presentation/Screen.kt b/app/src/main/java/app/musikus/core/presentation/Screen.kt index afc9bda9..f9e8e59e 100644 --- a/app/src/main/java/app/musikus/core/presentation/Screen.kt +++ b/app/src/main/java/app/musikus/core/presentation/Screen.kt @@ -8,147 +8,64 @@ package app.musikus.core.presentation +import android.net.Uri +import android.os.Bundle import androidx.annotation.DrawableRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Help import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.Info +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.toRoute import app.musikus.R +import app.musikus.activesession.presentation.ActiveSessionActions import app.musikus.core.presentation.utils.UiIcon import app.musikus.core.presentation.utils.UiText +import kotlinx.serialization.Serializable -sealed class Screen( - val route: String, - open val displayData: DisplayData? = null, -) { - - data object Home : Screen( - route = "home" - ) - - sealed class HomeTab( - val subRoute: String, - override val displayData: DisplayData - ) : Screen("home/$subRoute") { - data object Sessions : HomeTab( - subRoute = "sessions", - displayData = DisplayData( - title = UiText.StringResource(R.string.components_bottom_bar_items_sessions), - icon = UiIcon.IconResource(R.drawable.ic_sessions), - animatedIcon = R.drawable.avd_sessions, - ) - ) +/** + * Sealed class representing different screens in the application. + */ +@Serializable +sealed class Screen { - data object Goals : HomeTab( - subRoute = "goals", - displayData = DisplayData( - title = UiText.StringResource(R.string.components_bottom_bar_items_goals), - icon = UiIcon.IconResource(R.drawable.ic_goals), - animatedIcon = R.drawable.avd_goals - ) - ) + @Serializable + data class ActiveSession( + val action: ActiveSessionActions? = null + ) : Screen() - data object Statistics : HomeTab( - subRoute = "statistics", - displayData = DisplayData( - title = UiText.StringResource(R.string.components_bottom_bar_items_statistics), - icon = UiIcon.IconResource(R.drawable.ic_bar_chart), - animatedIcon = R.drawable.avd_bar_chart - ) - ) + @Serializable + data class Home( + val tab: HomeTab = HomeTab.Sessions + ) : Screen() - data object Library : HomeTab( - subRoute = "library", - displayData = DisplayData( - title = UiText.StringResource(R.string.components_bottom_bar_items_library), - icon = UiIcon.IconResource(R.drawable.ic_library), - animatedIcon = R.drawable.avd_library - ) - ) - companion object { - val allTabs by lazy { listOf(Sessions, Goals, Statistics, Library) } - val defaultTab = Sessions - } - } + @Serializable + data object Settings : Screen() - data object ActiveSession : Screen( - route = "activeSession", - ) - - data object EditSession : Screen( - route = "editSession/{sessionId}", - ) - - data object SessionStatistics : Screen( - route = "sessionStatistics", - ) - data object GoalStatistics : Screen( - route = "goalStatistics", - ) - - data object Settings : Screen( - route = "settings", - ) - - data object License : Screen( - route = "settings/about/license" - ) - - sealed class SettingsOption( - subRoute: String, - override val displayData: DisplayData - ) : Screen("settings/$subRoute") { - data object About : SettingsOption( - subRoute = "about", - displayData = DisplayData( - title = UiText.StringResource(R.string.settings_items_about), - icon = UiIcon.DynamicIcon(Icons.Outlined.Info), - ) - ) + @Serializable + sealed class SettingsOption : Screen() { + @Serializable + data object About : SettingsOption() - data object Help : SettingsOption( - subRoute = "help", - displayData = DisplayData( - title = UiText.StringResource(R.string.settings_items_help), - icon = UiIcon.DynamicIcon(Icons.AutoMirrored.Outlined.Help), - ) - ) + @Serializable + data object Help : SettingsOption() - data object Backup : SettingsOption( - subRoute = "backup", - displayData = DisplayData( - title = UiText.StringResource(R.string.settings_items_backup), - icon = UiIcon.DynamicIcon(Icons.Outlined.CloudUpload), - ) - ) + @Serializable + data object Backup : SettingsOption() - data object Export : SettingsOption( - subRoute = "export", - displayData = DisplayData( - title = UiText.StringResource(R.string.settings_items_export), - icon = UiIcon.IconResource(R.drawable.ic_export), - ) - ) + @Serializable + data object Export : SettingsOption() - data object Donate : SettingsOption( - subRoute = "donate", - displayData = DisplayData( - title = UiText.StringResource(R.string.settings_items_donate), - icon = UiIcon.DynamicIcon(Icons.Outlined.Favorite), - ) - ) + @Serializable + data object Donate : SettingsOption() - data object Appearance : SettingsOption( - subRoute = "appearance", - displayData = DisplayData( - title = UiText.StringResource(R.string.settings_items_appearance), - icon = UiIcon.IconResource(R.drawable.ic_appearance), - ) - ) + @Serializable + data object Appearance : SettingsOption() companion object { - val allSettings by lazy { + val all by lazy { listOf( About, Help, @@ -161,9 +78,170 @@ sealed class Screen( } } - data class DisplayData( - val title: UiText, - val icon: UiIcon, - @DrawableRes val animatedIcon: Int? = null - ) + @Serializable + data object EditSession : Screen() + + @Serializable + data object SessionStatistics : Screen() + + @Serializable + data object GoalStatistics : Screen() + + @Serializable + data object License : Screen() +} + +val Screen.route: String? + get() = this.javaClass.canonicalName + +/** + * Converts a [NavBackStackEntry] to a [Screen]. + * This function is necessary because there is no direct way of getting a typed [Screen] object from + * the [NavBackStackEntry] outside the NavHost. It does so by parsing the route of the destination + * and comparing it to the canonical class name of the [Screen] object. + */ +fun NavBackStackEntry.toScreen(): Screen { + val route = destination.route?.split(Regex("[^a-zA-Z0-9.]"))?.first() + ?: throw IllegalArgumentException("Route argument missing from $destination") + + return when (route) { + Screen.ActiveSession().route -> toRoute() + Screen.Home().route -> toRoute() + Screen.Settings.route -> toRoute() + Screen.SettingsOption.About.route -> toRoute() + Screen.SettingsOption.Help.route -> toRoute() + Screen.SettingsOption.Backup.route -> toRoute() + Screen.SettingsOption.Export.route -> toRoute() + Screen.SettingsOption.Donate.route -> toRoute() + Screen.SettingsOption.Appearance.route -> toRoute() + Screen.EditSession.route -> toRoute() + Screen.SessionStatistics.route -> toRoute() + Screen.GoalStatistics.route -> toRoute() + Screen.License.route -> toRoute() + else -> throw IllegalArgumentException("Unknown route: $route") + } +} + +@Serializable +sealed class HomeTab { + data object Sessions : HomeTab() + data object Goals : HomeTab() + data object Statistics : HomeTab() + data object Library : HomeTab() + + companion object { + val all by lazy { listOf(Sessions, Goals, Statistics, Library) } + val default by lazy { Sessions } // has to be lazy due to some testing peculiarities + } +} + +/** + * Custom [NavType] for [HomeTab]. + * This is necessary for type-safe navigation because [HomeTab] is a sealed class + * and cannot be directly serialized or deserialized. + */ +val HomeTabNavType = object : NavType(isNullableAllowed = false) { + override fun get( + bundle: Bundle, + key: String + ): HomeTab? { + return bundle.getString(key)?.let { + parseValue(it) + } + } + + override fun put( + bundle: Bundle, + key: String, + value: HomeTab + ) { + bundle.putString(key, serializeAsValue(value)) + } + + override fun parseValue(value: String): HomeTab { + return Uri.decode(value).let { + HomeTab.all.first { tab -> tab.toString() == it } + } + } + + override fun serializeAsValue(value: HomeTab): String { + return Uri.encode(value.toString()) + } +} + +/** + * Data class representing display data for a tab or screen. + * @property title The title of the tab or screen. + * @property icon The icon of the tab or screen. + * @property animatedIcon The animated icon of the tab or screen. + */ +data class DisplayData( + val title: UiText, + val icon: UiIcon, + @DrawableRes val animatedIcon: Int? = null +) + +/** + * Extension function to get the display data for a [HomeTab]. + * @return The display data for the tab. + */ +fun HomeTab.getDisplayData(): DisplayData { + return when (this) { + is HomeTab.Sessions -> DisplayData( + title = UiText.StringResource(R.string.components_bottom_bar_items_sessions), + icon = UiIcon.IconResource(R.drawable.ic_sessions), + animatedIcon = R.drawable.avd_sessions, + ) + + is HomeTab.Goals -> DisplayData( + title = UiText.StringResource(R.string.components_bottom_bar_items_goals), + icon = UiIcon.IconResource(R.drawable.ic_goals), + animatedIcon = R.drawable.avd_goals + ) + + is HomeTab.Statistics -> DisplayData( + title = UiText.StringResource(R.string.components_bottom_bar_items_statistics), + icon = UiIcon.IconResource(R.drawable.ic_bar_chart), + animatedIcon = R.drawable.avd_bar_chart + ) + + is HomeTab.Library -> DisplayData( + title = UiText.StringResource(R.string.components_bottom_bar_items_library), + icon = UiIcon.IconResource(R.drawable.ic_library), + animatedIcon = R.drawable.avd_library + ) + } +} + +/** + * Extension function to get the display data for a [Screen.SettingsOption]. + * @return The display data for the settings option. + */ +fun Screen.SettingsOption.getDisplayData(): DisplayData { + return when (this) { + is Screen.SettingsOption.About -> DisplayData( + title = UiText.StringResource(R.string.settings_items_about), + icon = UiIcon.DynamicIcon(Icons.Outlined.Info), + ) + is Screen.SettingsOption.Help -> DisplayData( + title = UiText.StringResource(R.string.settings_items_help), + icon = UiIcon.DynamicIcon(Icons.AutoMirrored.Outlined.Help), + ) + is Screen.SettingsOption.Backup -> DisplayData( + title = UiText.StringResource(R.string.settings_items_backup), + icon = UiIcon.DynamicIcon(Icons.Outlined.CloudUpload), + ) + is Screen.SettingsOption.Export -> DisplayData( + title = UiText.StringResource(R.string.settings_items_export), + icon = UiIcon.IconResource(R.drawable.ic_export), + ) + is Screen.SettingsOption.Donate -> DisplayData( + title = UiText.StringResource(R.string.settings_items_donate), + icon = UiIcon.DynamicIcon(Icons.Outlined.Favorite), + ) + is Screen.SettingsOption.Appearance -> DisplayData( + title = UiText.StringResource(R.string.settings_items_appearance), + icon = UiIcon.IconResource(R.drawable.ic_appearance), + ) + } } diff --git a/app/src/main/java/app/musikus/core/presentation/components/TwoLiner.kt b/app/src/main/java/app/musikus/core/presentation/components/TwoLiner.kt index 30cdfcc7..e541aaab 100644 --- a/app/src/main/java/app/musikus/core/presentation/components/TwoLiner.kt +++ b/app/src/main/java/app/musikus/core/presentation/components/TwoLiner.kt @@ -63,16 +63,12 @@ fun TwoLiner( // Content Column { data.firstLine?.let { - Text( - text = it.asString(), - style = LocalTextStyle.current, - color = LocalContentColor.current - ) + Text(text = it.asString()) } + data.secondLine?.let { Text( text = it.asString(), - style = LocalTextStyle.current, fontSize = LocalTextStyle.current.fontSize * 0.9f, color = LocalContentColor.current.copy(alpha = 0.6f), lineHeight = 1.2.em diff --git a/app/src/main/java/app/musikus/goals/presentation/GoalsScreen.kt b/app/src/main/java/app/musikus/goals/presentation/GoalsScreen.kt index 822a5f4f..459454f2 100644 --- a/app/src/main/java/app/musikus/goals/presentation/GoalsScreen.kt +++ b/app/src/main/java/app/musikus/goals/presentation/GoalsScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel @@ -58,6 +59,7 @@ import app.musikus.core.presentation.HomeUiEventHandler import app.musikus.core.presentation.HomeUiState import app.musikus.core.presentation.MainUiEvent import app.musikus.core.presentation.MainUiEventHandler +import app.musikus.core.presentation.MainUiState import app.musikus.core.presentation.Screen import app.musikus.core.presentation.components.ActionBar import app.musikus.core.presentation.components.CommonMenuSelections @@ -76,12 +78,14 @@ import app.musikus.goals.data.GoalsSortMode @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun GoalsScreen( + mainUiState: MainUiState, mainEventHandler: MainUiEventHandler, homeUiState: HomeUiState, homeEventHandler: HomeUiEventHandler, navigateTo: (Screen) -> Unit, viewModel: GoalsViewModel = hiltViewModel(), timeProvider: TimeProvider, + bottomBarHeight: Dp, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val eventHandler: GoalsUiEventHandler = viewModel::onUiEvent @@ -94,22 +98,22 @@ fun GoalsScreen( ) BackHandler( - enabled = homeUiState.multiFabState == MultiFabState.EXPANDED, - onBack = { homeEventHandler(HomeUiEvent.CollapseMultiFab) } + enabled = mainUiState.multiFabState == MultiFabState.EXPANDED, + onBack = { mainEventHandler(MainUiEvent.CollapseMultiFab) } ) Scaffold( - contentWindowInsets = WindowInsets(bottom = 0.dp), // makes sure FAB is not shifted up + contentWindowInsets = WindowInsets(bottom = bottomBarHeight), // makes sure FAB is above the bottom Bar modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), floatingActionButton = { MultiFAB( - state = homeUiState.multiFabState, + state = mainUiState.multiFabState, onStateChange = { newState -> if (newState == MultiFabState.EXPANDED) { - homeEventHandler(HomeUiEvent.ExpandMultiFab) + mainEventHandler(MainUiEvent.ExpandMultiFab) eventHandler(GoalsUiEvent.ClearActionMode) } else { - homeEventHandler(HomeUiEvent.CollapseMultiFab) + mainEventHandler(MainUiEvent.CollapseMultiFab) } }, contentDescription = stringResource(id = R.string.goals_screen_multi_fab_description), @@ -117,7 +121,7 @@ fun GoalsScreen( MiniFABData( onClick = { eventHandler(GoalsUiEvent.AddGoalButtonPressed(oneShot = true)) - homeEventHandler(HomeUiEvent.CollapseMultiFab) + mainEventHandler(MainUiEvent.CollapseMultiFab) }, label = stringResource(id = R.string.goals_non_repeating), icon = Icons.Filled.LocalFireDepartment, @@ -125,7 +129,7 @@ fun GoalsScreen( MiniFABData( onClick = { eventHandler(GoalsUiEvent.AddGoalButtonPressed(oneShot = false)) - homeEventHandler(HomeUiEvent.CollapseMultiFab) + mainEventHandler(MainUiEvent.CollapseMultiFab) }, label = stringResource(id = R.string.goals_repeating), icon = Icons.Rounded.Repeat, @@ -334,7 +338,7 @@ fun GoalsScreen( // Content Scrim for multiFAB AnimatedVisibility( modifier = Modifier.zIndex(1f), - visible = homeUiState.multiFabState == MultiFabState.EXPANDED, + visible = mainUiState.multiFabState == MultiFabState.EXPANDED, enter = fadeIn(), exit = fadeOut() ) { @@ -345,7 +349,7 @@ fun GoalsScreen( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - onClick = { homeEventHandler(HomeUiEvent.CollapseMultiFab) } + onClick = { mainEventHandler(MainUiEvent.CollapseMultiFab) } ) ) } diff --git a/app/src/main/java/app/musikus/library/presentation/LibraryDialogs.kt b/app/src/main/java/app/musikus/library/presentation/LibraryDialogs.kt index 1e35106a..3c5c864e 100644 --- a/app/src/main/java/app/musikus/library/presentation/LibraryDialogs.kt +++ b/app/src/main/java/app/musikus/library/presentation/LibraryDialogs.kt @@ -155,9 +155,11 @@ fun LibraryItemDialog( .padding(top = 16.dp) .padding(horizontal = 24.dp), expanded = folderSelectorExpanded, - label = { Text( - text = stringResource(id = R.string.library_item_dialog_folder_selector_label) - ) }, + label = { + Text( + text = stringResource(id = R.string.library_item_dialog_folder_selector_label) + ) + }, leadingIcon = { Icon( imageVector = Icons.Default.Folder, diff --git a/app/src/main/java/app/musikus/library/presentation/LibraryScreen.kt b/app/src/main/java/app/musikus/library/presentation/LibraryScreen.kt index eed94cf8..830fcff1 100644 --- a/app/src/main/java/app/musikus/library/presentation/LibraryScreen.kt +++ b/app/src/main/java/app/musikus/library/presentation/LibraryScreen.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel @@ -79,6 +80,7 @@ import app.musikus.core.presentation.HomeUiEventHandler import app.musikus.core.presentation.HomeUiState import app.musikus.core.presentation.MainUiEvent import app.musikus.core.presentation.MainUiEventHandler +import app.musikus.core.presentation.MainUiState import app.musikus.core.presentation.Screen import app.musikus.core.presentation.components.ActionBar import app.musikus.core.presentation.components.CommonMenuSelections @@ -105,11 +107,13 @@ import java.time.ZonedDateTime @OptIn(ExperimentalMaterial3Api::class) @Composable fun Library( - homeUiState: HomeUiState, - viewModel: LibraryViewModel = hiltViewModel(), + mainUiState: MainUiState, mainEventHandler: MainUiEventHandler, + homeUiState: HomeUiState, homeEventHandler: HomeUiEventHandler, + viewModel: LibraryViewModel = hiltViewModel(), navigateTo: (Screen) -> Unit, + bottomBarHeight: Dp, ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() @@ -127,12 +131,12 @@ fun Library( ) BackHandler( - enabled = homeUiState.multiFabState == MultiFabState.EXPANDED, - onBack = { homeEventHandler(HomeUiEvent.CollapseMultiFab) } + enabled = mainUiState.multiFabState == MultiFabState.EXPANDED, + onBack = { mainEventHandler(MainUiEvent.CollapseMultiFab) } ) Scaffold( - contentWindowInsets = WindowInsets(bottom = 0.dp), // makes sure FAB is not shifted up + contentWindowInsets = WindowInsets(bottom = bottomBarHeight), // makes sure FAB is above the bottom Bar modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), floatingActionButton = { val fabUiState = uiState.fabUiState @@ -140,7 +144,7 @@ fun Library( FloatingActionButton( onClick = { eventHandler(LibraryUiEvent.AddItemButtonPressed) - homeEventHandler(HomeUiEvent.CollapseMultiFab) + mainEventHandler(MainUiEvent.CollapseMultiFab) }, ) { Icon( @@ -150,13 +154,13 @@ fun Library( } } else { MultiFAB( - state = homeUiState.multiFabState, + state = mainUiState.multiFabState, onStateChange = { newState -> if (newState == MultiFabState.EXPANDED) { - homeEventHandler(HomeUiEvent.ExpandMultiFab) + mainEventHandler(MainUiEvent.ExpandMultiFab) eventHandler(LibraryUiEvent.ClearActionMode) } else { - homeEventHandler(HomeUiEvent.CollapseMultiFab) + mainEventHandler(MainUiEvent.CollapseMultiFab) } }, contentDescription = stringResource(id = R.string.library_screen_multi_fab_description), @@ -164,7 +168,7 @@ fun Library( MiniFABData( onClick = { eventHandler(LibraryUiEvent.AddItemButtonPressed) - homeEventHandler(HomeUiEvent.CollapseMultiFab) + mainEventHandler(MainUiEvent.CollapseMultiFab) }, label = stringResource(id = R.string.library_screen_multi_fab_item_description), icon = Icons.Rounded.MusicNote @@ -172,7 +176,7 @@ fun Library( MiniFABData( onClick = { eventHandler(LibraryUiEvent.AddFolderButtonPressed) - homeEventHandler(HomeUiEvent.CollapseMultiFab) + mainEventHandler(MainUiEvent.CollapseMultiFab) }, label = stringResource(id = R.string.library_screen_multi_fab_folder_description), icon = Icons.Rounded.Folder @@ -345,7 +349,7 @@ fun Library( AnimatedVisibility( modifier = Modifier .zIndex(1f), - visible = homeUiState.multiFabState == MultiFabState.EXPANDED, + visible = mainUiState.multiFabState == MultiFabState.EXPANDED, enter = fadeIn(), exit = fadeOut() ) { @@ -356,7 +360,7 @@ fun Library( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - onClick = { homeEventHandler(HomeUiEvent.CollapseMultiFab) } + onClick = { mainEventHandler(MainUiEvent.CollapseMultiFab) } ) ) } diff --git a/app/src/main/java/app/musikus/metronome/presentation/MetronomeService.kt b/app/src/main/java/app/musikus/metronome/presentation/MetronomeService.kt index 06b52a0b..0f4b30c4 100644 --- a/app/src/main/java/app/musikus/metronome/presentation/MetronomeService.kt +++ b/app/src/main/java/app/musikus/metronome/presentation/MetronomeService.kt @@ -25,6 +25,7 @@ import app.musikus.activesession.presentation.ActiveSessionActions import app.musikus.core.di.ApplicationScope import app.musikus.core.di.IoScope import app.musikus.core.presentation.METRONOME_NOTIFICATION_CHANNEL_ID +import app.musikus.core.presentation.MainActivity import app.musikus.settings.domain.usecase.UserPreferencesUseCases import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope @@ -183,7 +184,12 @@ class MetronomeService : Service() { // trigger deep link to open ActiveSession https://stackoverflow.com/a/72769863 pendingIntentTapAction = TaskStackBuilder.create(this).run { addNextIntentWithParentStack( - Intent(Intent.ACTION_VIEW, "musikus://activeSession/${ActiveSessionActions.METRONOME}".toUri()) + Intent(this@MetronomeService, MainActivity::class.java).apply { + data = ( + "https://musikus.app" + + "?action=${ActiveSessionActions.METRONOME}" + ).toUri() + } ) getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } diff --git a/app/src/main/java/app/musikus/metronome/presentation/MetronomeUi.kt b/app/src/main/java/app/musikus/metronome/presentation/MetronomeUi.kt index c0a08416..91f23867 100644 --- a/app/src/main/java/app/musikus/metronome/presentation/MetronomeUi.kt +++ b/app/src/main/java/app/musikus/metronome/presentation/MetronomeUi.kt @@ -56,12 +56,12 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.musikus.R import app.musikus.core.presentation.components.ExceptionHandler -import app.musikus.settings.domain.ColorSchemeSelections import app.musikus.core.presentation.theme.MusikusColorSchemeProvider import app.musikus.core.presentation.theme.MusikusThemedPreview import app.musikus.core.presentation.theme.dimensions import app.musikus.core.presentation.theme.spacing import app.musikus.core.presentation.utils.UiText +import app.musikus.settings.domain.ColorSchemeSelections @Composable fun MetronomeUi( diff --git a/app/src/main/java/app/musikus/recorder/presentation/RecorderService.kt b/app/src/main/java/app/musikus/recorder/presentation/RecorderService.kt index 83e60734..e06d6e2e 100644 --- a/app/src/main/java/app/musikus/recorder/presentation/RecorderService.kt +++ b/app/src/main/java/app/musikus/recorder/presentation/RecorderService.kt @@ -25,6 +25,7 @@ import app.musikus.R import app.musikus.activesession.presentation.ActiveSessionActions import app.musikus.core.di.ApplicationScope import app.musikus.core.domain.TimeProvider +import app.musikus.core.presentation.MainActivity import app.musikus.core.presentation.RECORDER_NOTIFICATION_CHANNEL_ID import app.musikus.core.presentation.utils.DurationFormat import app.musikus.core.presentation.utils.getDurationString @@ -279,7 +280,12 @@ class RecorderService : Service() { // trigger deep link to open ActiveSession https://stackoverflow.com/a/72769863 pendingIntentTapAction = TaskStackBuilder.create(this).run { addNextIntentWithParentStack( - Intent(Intent.ACTION_VIEW, "musikus://activeSession/${ActiveSessionActions.RECORDER}".toUri()) + Intent(this@RecorderService, MainActivity::class.java).apply { + data = ( + "https://musikus.app" + + "?action=${ActiveSessionActions.RECORDER}" + ).toUri() + } ) getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } diff --git a/app/src/main/java/app/musikus/sessions/presentation/SessionsScreen.kt b/app/src/main/java/app/musikus/sessions/presentation/SessionsScreen.kt index f249e726..fb8160ec 100644 --- a/app/src/main/java/app/musikus/sessions/presentation/SessionsScreen.kt +++ b/app/src/main/java/app/musikus/sessions/presentation/SessionsScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -76,6 +77,7 @@ import java.util.UUID fun SessionsScreen( mainUiState: MainUiState, homeUiState: HomeUiState, + bottomBarHeight: Dp, viewModel: SessionsViewModel = hiltViewModel(), navigateTo: (Screen) -> Unit, onSessionEdit: (sessionId: UUID) -> Unit, @@ -100,7 +102,7 @@ fun SessionsScreen( val context = LocalContext.current Scaffold( - contentWindowInsets = WindowInsets(bottom = 0.dp), + contentWindowInsets = WindowInsets(bottom = bottomBarHeight), modifier = Modifier .nestedScroll(scrollBehavior.nestedScrollConnection), floatingActionButton = { @@ -129,7 +131,7 @@ fun SessionsScreen( ) ) }, - onClick = { navigateTo(Screen.ActiveSession) }, + onClick = { navigateTo(Screen.ActiveSession()) }, expanded = fabExpanded, containerColor = if (mainUiState.isSessionRunning) { diff --git a/app/src/main/java/app/musikus/settings/presentation/SettingsScreen.kt b/app/src/main/java/app/musikus/settings/presentation/SettingsScreen.kt index b0da22a7..82d07992 100644 --- a/app/src/main/java/app/musikus/settings/presentation/SettingsScreen.kt +++ b/app/src/main/java/app/musikus/settings/presentation/SettingsScreen.kt @@ -37,7 +37,7 @@ import app.musikus.R import app.musikus.core.presentation.Screen import app.musikus.core.presentation.components.TwoLiner import app.musikus.core.presentation.components.TwoLinerData -import app.musikus.core.presentation.navigateTo +import app.musikus.core.presentation.getDisplayData import app.musikus.core.presentation.theme.spacing import app.musikus.settings.presentation.about.AboutScreen import app.musikus.settings.presentation.about.LicensesScreen @@ -48,34 +48,34 @@ import app.musikus.settings.presentation.export.ExportScreen import app.musikus.settings.presentation.help.HelpScreen fun NavGraphBuilder.addSettingsNavigationGraph(navController: NavController) { - composable(Screen.Settings.route) { + composable { SettingsScreen( navigateUp = { navController.navigateUp() }, - navigateTo = navController::navigateTo + navigateTo = { navController.navigate(it) } ) } - composable(Screen.SettingsOption.Donate.route) { + composable { DonateScreen(navigateUp = { navController.navigateUp() }) } - composable(Screen.SettingsOption.Appearance.route) { + composable { AppearanceScreen(navigateUp = { navController.navigateUp() }) } - composable(Screen.SettingsOption.Backup.route) { + composable { BackupScreen(navigateUp = { navController.navigateUp() }) } - composable(Screen.SettingsOption.Export.route) { + composable { ExportScreen(navigateUp = { navController.navigateUp() }) } - composable(Screen.SettingsOption.Help.route) { + composable { HelpScreen(navigateUp = { navController.navigateUp() }) } - composable(Screen.SettingsOption.About.route) { + composable { AboutScreen( navigateUp = { navController.navigateUp() }, - navigateTo = navController::navigateTo + navigateTo = { navController.navigate(it) } ) } - composable(Screen.License.route) { + composable { LicensesScreen(navigateUp = { navController.navigateUp() }) } } @@ -89,37 +89,37 @@ fun SettingsScreen( val settingsItems = listOf( listOf( TwoLinerData( - icon = Screen.SettingsOption.Donate.displayData.icon, - firstLine = Screen.SettingsOption.Donate.displayData.title, + icon = Screen.SettingsOption.Donate.getDisplayData().icon, + firstLine = Screen.SettingsOption.Donate.getDisplayData().title, onClick = { navigateTo(Screen.SettingsOption.Donate) } ) ), listOf( TwoLinerData( - icon = Screen.SettingsOption.Appearance.displayData.icon, - firstLine = Screen.SettingsOption.Appearance.displayData.title, + icon = Screen.SettingsOption.Appearance.getDisplayData().icon, + firstLine = Screen.SettingsOption.Appearance.getDisplayData().title, onClick = { navigateTo(Screen.SettingsOption.Appearance) } ), TwoLinerData( - icon = Screen.SettingsOption.Backup.displayData.icon, - firstLine = Screen.SettingsOption.Backup.displayData.title, + icon = Screen.SettingsOption.Backup.getDisplayData().icon, + firstLine = Screen.SettingsOption.Backup.getDisplayData().title, onClick = { navigateTo(Screen.SettingsOption.Backup) } ), TwoLinerData( - icon = Screen.SettingsOption.Export.displayData.icon, - firstLine = Screen.SettingsOption.Export.displayData.title, + icon = Screen.SettingsOption.Export.getDisplayData().icon, + firstLine = Screen.SettingsOption.Export.getDisplayData().title, onClick = { navigateTo(Screen.SettingsOption.Export) } ) ), listOf( TwoLinerData( - icon = Screen.SettingsOption.Help.displayData.icon, - firstLine = Screen.SettingsOption.Help.displayData.title, + icon = Screen.SettingsOption.Help.getDisplayData().icon, + firstLine = Screen.SettingsOption.Help.getDisplayData().title, onClick = { navigateTo(Screen.SettingsOption.Help) } ), TwoLinerData( - icon = Screen.SettingsOption.About.displayData.icon, - firstLine = Screen.SettingsOption.About.displayData.title, + icon = Screen.SettingsOption.About.getDisplayData().icon, + firstLine = Screen.SettingsOption.About.getDisplayData().title, onClick = { navigateTo(Screen.SettingsOption.About) } ), ), diff --git a/app/src/main/java/app/musikus/statistics/presentation/StatisticsScreen.kt b/app/src/main/java/app/musikus/statistics/presentation/StatisticsScreen.kt index 9c642912..7c064ebc 100644 --- a/app/src/main/java/app/musikus/statistics/presentation/StatisticsScreen.kt +++ b/app/src/main/java/app/musikus/statistics/presentation/StatisticsScreen.kt @@ -21,6 +21,7 @@ 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.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -61,6 +62,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel @@ -89,12 +91,12 @@ import app.musikus.statistics.presentation.sessionstatistics.SessionStatistics fun NavGraphBuilder.addStatisticsNavigationGraph( navController: NavController, ) { - composable( - route = Screen.SessionStatistics.route, - ) { SessionStatistics(navigateUp = navController::navigateUp) } - composable( - route = Screen.GoalStatistics.route, - ) { GoalStatistics(navigateUp = navController::navigateUp) } + composable { + SessionStatistics(navigateUp = navController::navigateUp) + } + composable { + GoalStatistics(navigateUp = navController::navigateUp) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -105,12 +107,14 @@ fun Statistics( homeEventHandler: HomeUiEventHandler, navigateTo: (Screen) -> Unit, timeProvider: TimeProvider, + bottomBarHeight: Dp, ) { val statisticsUiState by statisticsViewModel.uiState.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() Scaffold( + contentWindowInsets = WindowInsets(bottom = bottomBarHeight), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( diff --git a/app/src/test/java/app/musikus/sessions/domain/usecase/AddSessionUseCaseTest.kt b/app/src/test/java/app/musikus/sessions/domain/usecase/AddSessionUseCaseTest.kt index d35abf77..d25daa69 100644 --- a/app/src/test/java/app/musikus/sessions/domain/usecase/AddSessionUseCaseTest.kt +++ b/app/src/test/java/app/musikus/sessions/domain/usecase/AddSessionUseCaseTest.kt @@ -57,7 +57,7 @@ class AddSessionUseCaseTest { startTimestamp = fakeTimeProvider.now(), duration = 10.minutes ) - } + } } @BeforeEach diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af0fca41..3ce61d69 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ androidx-core-ktx = "1.13.1" androidx-core-testing = "2.2.0" androidx-legacy-support-v4 = "1.0.0" androidx-lifecycle = "2.8.5" -androidx-navigation = "2.8.0" +androidx-navigation = "2.8.2" androidx-test = "1.6.1" androidx-test-runner = "1.6.2" androidx-test-espresso = "3.6.1" @@ -25,6 +25,7 @@ junit = "4.13.2" junit-jupiter = "5.11.0" kotlin = "2.0.20" kotlinx-collections-immutable = "0.3.8" +kotlinx-serialization = "1.6.3" ksp = "2.0.20-1.0.25" license-report = "0.9.8" media3 = "1.4.1" @@ -62,6 +63,7 @@ androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "androidx-core-testing" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore-preferences" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt-navigation-compose" } androidx-legacy-support-v4 = { module = "androidx.legacy:legacy-support-v4", version.ref = "androidx-legacy-support-v4" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } @@ -79,6 +81,7 @@ androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "and androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } +androidx-test-navigation = { module = "androidx.navigation:navigation-testing", version.ref = "androidx-navigation" } core-jdk-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "jdk-desugar" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt-formatting" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } @@ -91,6 +94,7 @@ junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", vers kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } @@ -104,6 +108,7 @@ androidx-room = { id = "androidx.room", version.ref = "room" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } license-report = { id = "com.jaredsburrows.license", version.ref = "license-report" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }