From d36860795e79e661fcc711d85e651a8a0402b749 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Tue, 19 Nov 2024 23:42:01 +0100 Subject: [PATCH] fix: navigation not working (#133) Rename MusikusScreen/MusikusApp to MainScreen to ensure consistency with other screens. Fix navigation issue by handling navigation between tabs exclusively through the NavHost. Move navigation bar from the home screen into the main screen. Migrate to type-safe navigation, including type -safe deep links. Fix pause and resume actions from notification. Add navigation tests: - MusikusNavHostTest.kt - MusikusBottomBarTest.kt --- app/build.gradle.kts | 8 +- .../di/TestActiveSessionUseCasesModule.kt | 4 +- .../core/presentation/MusikusBottomBarTest.kt | 102 ++++++ .../core/presentation/MusikusNavHostTest.kt | 291 ++++++++++++++++ .../library/presentation/LibraryScreenTest.kt | 46 +-- app/src/main/AndroidManifest.xml | 14 +- .../di/ActiveSessionUseCasesModule.kt | 4 +- .../domain/usecase/ActiveSessionUseCases.kt | 2 +- ...teUseCase.kt => IsSessionPausedUseCase.kt} | 2 +- .../presentation/ActiveSessionScreen.kt | 22 +- .../presentation/SessionService.kt | 191 +++++----- .../app/musikus/core/data/MusikusDatabase.kt | 24 +- .../app/musikus/core/data/daos/GenericDaos.kt | 2 +- .../core/presentation/HomeBottomBar.kt | 132 +++++++ .../musikus/core/presentation/HomeScreen.kt | 239 +++---------- .../core/presentation/HomeViewModel.kt | 33 +- .../musikus/core/presentation/MainActivity.kt | 2 +- .../{MusikusScreen.kt => MainScreen.kt} | 229 ++++++------ .../core/presentation/MainViewModel.kt | 37 +- .../app/musikus/core/presentation/Screen.kt | 328 +++++++++++------- .../core/presentation/components/TwoLiner.kt | 8 +- .../musikus/goals/presentation/GoalsScreen.kt | 24 +- .../library/presentation/LibraryDialogs.kt | 8 +- .../library/presentation/LibraryScreen.kt | 30 +- .../presentation/MetronomeService.kt | 8 +- .../metronome/presentation/MetronomeUi.kt | 2 +- .../recorder/presentation/RecorderService.kt | 8 +- .../sessions/presentation/SessionsScreen.kt | 6 +- .../settings/presentation/SettingsScreen.kt | 46 +-- .../presentation/StatisticsScreen.kt | 16 +- .../domain/usecase/AddSessionUseCaseTest.kt | 2 +- gradle/libs.versions.toml | 7 +- 32 files changed, 1176 insertions(+), 701 deletions(-) create mode 100644 app/src/androidTest/java/app/musikus/core/presentation/MusikusBottomBarTest.kt create mode 100644 app/src/androidTest/java/app/musikus/core/presentation/MusikusNavHostTest.kt rename app/src/main/java/app/musikus/activesession/domain/usecase/{GetPausedStateUseCase.kt => IsSessionPausedUseCase.kt} (96%) create mode 100644 app/src/main/java/app/musikus/core/presentation/HomeBottomBar.kt rename app/src/main/java/app/musikus/core/presentation/{MusikusScreen.kt => MainScreen.kt} (64%) 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" }