diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1703d795..09214ab3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -366,6 +366,7 @@ dependencies { androidTestImplementation(libs.androidx.test.espresso.core) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) 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 deleted file mode 100644 index df0fa6a8..00000000 --- a/app/src/androidTest/java/app/musikus/activesession/di/TestActiveSessionUseCasesModule.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * 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, Michael Prommersberger - */ - -package app.musikus.activesession.di - -import app.musikus.activesession.domain.ActiveSessionRepository -import app.musikus.activesession.domain.usecase.ActiveSessionUseCases -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.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 -import app.musikus.activesession.domain.usecase.ResumeActiveSessionUseCase -import app.musikus.activesession.domain.usecase.SelectItemUseCase -import app.musikus.core.domain.IdProvider -import app.musikus.core.domain.TimeProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.components.SingletonComponent -import dagger.hilt.testing.TestInstallIn - -@Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [ActiveSessionUseCasesModule::class] -) -object TestActiveSessionUseCasesModule { - - @Provides - fun provideActiveSessionUseCases( - activeSessionRepository: ActiveSessionRepository, - timeProvider: TimeProvider, - idProvider: IdProvider - ): ActiveSessionUseCases { - val getOngoingPauseDurationUseCase = GetOngoingPauseDurationUseCase( - activeSessionRepository, - timeProvider - ) - - val resumeUseCase = ResumeActiveSessionUseCase( - activeSessionRepository, - getOngoingPauseDurationUseCase - ) - - val getRunningItemDurationUseCase = GetRunningItemDurationUseCase( - activeSessionRepository, - timeProvider - ) - - return ActiveSessionUseCases( - selectItem = SelectItemUseCase( - activeSessionRepository = activeSessionRepository, - getRunningItemDurationUseCase = getRunningItemDurationUseCase, - timeProvider = timeProvider, - idProvider = idProvider - ), - getPracticeDuration = GetTotalPracticeDurationUseCase( - activeSessionRepository = activeSessionRepository, - runningItemDurationUseCase = getRunningItemDurationUseCase - ), - deleteSection = DeleteSectionUseCase(activeSessionRepository), - pause = PauseActiveSessionUseCase( - activeSessionRepository = activeSessionRepository, - getRunningItemDurationUseCase = getRunningItemDurationUseCase, - timeProvider = timeProvider - ), - resume = resumeUseCase, - getRunningItemDuration = getRunningItemDurationUseCase, - getCompletedSections = GetCompletedSectionsUseCase(activeSessionRepository), - getOngoingPauseDuration = GetOngoingPauseDurationUseCase(activeSessionRepository, timeProvider), - isSessionPaused = IsSessionPausedUseCase(activeSessionRepository), - getFinalizedSession = GetFinalizedSessionUseCase( - activeSessionRepository = activeSessionRepository, - getRunningItemDurationUseCase = getRunningItemDurationUseCase, - idProvider = idProvider, - getOngoingPauseDurationUseCase = getOngoingPauseDurationUseCase - ), - getStartTime = GetStartTimeUseCase(activeSessionRepository), - reset = ResetSessionUseCase(activeSessionRepository), - getRunningItem = GetRunningItemUseCase(activeSessionRepository), - isSessionRunning = IsSessionRunningUseCase(activeSessionRepository), - getSessionStatus = GetSessionStatusUseCase(activeSessionRepository) - ) - } -} diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt new file mode 100644 index 00000000..1d71f7aa --- /dev/null +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -0,0 +1,345 @@ +/* + * 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.activesession.presentation + +import androidx.activity.compose.setContent +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasAnySibling +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.navigation.NavHostController +import app.musikus.core.data.Nullable +import app.musikus.core.data.SectionWithLibraryItem +import app.musikus.core.data.SessionWithSectionsWithLibraryItems +import app.musikus.core.data.UUIDConverter +import app.musikus.core.domain.FakeTimeProvider +import app.musikus.core.domain.plus +import app.musikus.core.presentation.MainActivity +import app.musikus.core.presentation.MainViewModel +import app.musikus.library.data.daos.LibraryItem +import app.musikus.library.data.entities.LibraryFolderCreationAttributes +import app.musikus.library.data.entities.LibraryItemCreationAttributes +import app.musikus.library.domain.usecase.LibraryUseCases +import app.musikus.sessions.data.daos.Section +import app.musikus.sessions.data.daos.Session +import app.musikus.sessions.domain.usecase.SessionsUseCases +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@HiltAndroidTest +class ActiveSessionScreenTest { + @Inject lateinit var libraryUseCases: LibraryUseCases + + @Inject lateinit var sessionsUseCases: SessionsUseCases + + @Inject lateinit var fakeTimeProvider: FakeTimeProvider + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + lateinit var navController: NavHostController + lateinit var mainViewModel: MainViewModel + + /** + * Set up the test environment. + * + * This creates a folder ('TestFolder1') and three Items ('TestItem1', 'TestItem2', 'TestItem3'). + * 'TestItem3' is in the 'TestFolder1'. The IDs of the folder and items are as follows: + * 'TestFolder1': 00000000-0000-0000-0000-000000000001 + * 'TestItem1': 00000000-0000-0000-0000-000000000002 + * 'TestItem2': 00000000-0000-0000-0000-000000000003 + * 'TestItem3': 00000000-0000-0000-0000-000000000004 + */ + @Before + fun setUp() { + hiltRule.inject() + + navController = mockk(relaxed = true) + mainViewModel = mockk(relaxed = true) + + runBlocking { + libraryUseCases.addFolder( + LibraryFolderCreationAttributes("TestFolder1") + ) + libraryUseCases.addItem( + LibraryItemCreationAttributes( + name = "TestItem1", + colorIndex = 1, + ) + ) + libraryUseCases.addItem( + LibraryItemCreationAttributes( + name = "TestItem2", + colorIndex = 2, + ) + ) + libraryUseCases.addItem( + LibraryItemCreationAttributes( + name = "TestItem3", + colorIndex = 1, + libraryFolderId = Nullable(UUIDConverter.fromInt(1)) + ) + ) + } + + composeRule.activity.setContent { + ActiveSession( + navigateUp = navController::navigateUp, + mainEventHandler = mainViewModel::onUiEvent, + ) + } + } + + @Test + fun startSession_fabChanges() { + composeRule.onNodeWithContentDescription("Start practicing").performClick() + + composeRule.onNodeWithText("TestItem1").performClick() + composeRule.onNodeWithContentDescription("Next item").assertIsDisplayed() + } + + @Test + fun pauseAndResumeSession() { + // Start session + composeRule.onNodeWithContentDescription("Start practicing").performClick() + composeRule.onNodeWithText("TestItem1").performClick() + + // Pause session + composeRule.onNodeWithContentDescription("Pause").performClick() + + // Pause timer is displayed + composeRule.onNodeWithText("Paused 00:00").assertIsDisplayed() + + fakeTimeProvider.advanceTimeBy(90.seconds) + + // Pause timer shows correct time + composeRule.onNodeWithText("Paused 01:30") + .assertIsDisplayed() + .performClick() // Resume session + + // Pause timer is hidden + composeRule.onNodeWithText("Paused", substring = true).assertIsNotDisplayed() + } + + @Test + fun selectItemFromFolder() { + // Open item selector + composeRule.onNodeWithContentDescription("Start practicing").performClick() + + // Select folder + composeRule.onNodeWithText("TestFolder1").performClick() + + // Select item + composeRule.onNodeWithText("TestItem3").performClick() + + // Item is selected + composeRule.onNode( + matcher = hasText("TestItem3") + and + hasAnySibling(hasText("00:00")) + ).assertIsDisplayed() + } + + @Test + fun practiceMultipleItemsInARow() { + // Open item selector + composeRule.onNodeWithContentDescription("Start practicing").performClick() + + // Select item + composeRule.onNodeWithText("TestItem1").performClick() + + // Item is selected + composeRule.onNodeWithText("TestItem1").assertIsDisplayed() + + // Open item selector again + composeRule.onNodeWithContentDescription("Next item").performClick() + + // Select next item + composeRule.onNodeWithText("TestItem2").performClick() + + // Item is selected + composeRule.onNodeWithText("TestItem2").assertIsDisplayed() + } + + @Test + fun discardSession() = runTest { + // Start session + composeRule.onNodeWithContentDescription("Start practicing").performClick() + composeRule.onNodeWithText("TestItem1").performClick() + + // Discard session + composeRule.onNodeWithContentDescription("Discard").performClick() + + // Confirm discard + composeRule.onNodeWithText("Discard session?", substring = true).performClick() + + // Navigate up is called + verify(exactly = 1) { + navController.navigateUp() + } + + // Sessions are still empty + val sessions = sessionsUseCases.getAll().first() + assertThat(sessions).isEmpty() + } + +// @Test +// fun deleteAndRedoSection() = runTest { +// // Start session +// composeRule.onNodeWithContentDescription("Start practicing").performClick() +// composeRule.onNodeWithText("TestItem1").performClick() +// +// // Advance time +// fakeTimeProvider.advanceTimeBy(3.minutes) +// +// // Start next section +// composeRule.onNodeWithContentDescription("Next item").performClick() +// composeRule.onNodeWithText("TestItem2").performClick() +// +// // Delete previous section +// composeRule.onNodeWithText("TestItem1").performTouchInput { swipeLeft() } +// +// composeRule.awaitIdle() +// +// // Assert showSnackbar is called +// val uiEventSlot = slot() +// +// verify(exactly = 1) { +// mainViewModel.onUiEvent(capture(uiEventSlot)) +// } +// +// val uiEvent = uiEventSlot.captured +// check(uiEvent is MainUiEvent.ShowSnackbar) +// assertThat(uiEvent.message).isEqualTo("Section deleted") +// check(uiEvent.onUndo != null) +// +// // Assert section is deleted +// composeRule.onNodeWithContentDescription("TestItem1").assertIsNotDisplayed() +// +// // Undo delete +// uiEvent.onUndo.invoke() +// +// // Assert section is restored (TODO: this is not implemented yet) +// composeRule.onNodeWithContentDescription("TestItem1").assertIsNotDisplayed() +// } + + @Test + fun finishButtonDisabledForEmptySession() { + // Start session + composeRule.onNodeWithContentDescription("Start practicing").performClick() + composeRule.onNodeWithText("TestItem1").performClick() + + // Assert finish session disabled + composeRule.onNodeWithText("Finish").assertIsNotEnabled() + + // Advance time + fakeTimeProvider.advanceTimeBy(1.seconds) + + // Assert finish session enabled + composeRule.onNodeWithText("Finish").assertIsEnabled() + } + + /** + * This test simulates a full session, from start to finish. + * While doing so, the following elements (and IDs) are created: + * Intermediate section (during GetFinalizedSessionUseCase): 00000000-0000-0000-0000-000000000005 + * Session: 00000000-0000-0000-0000-000000000006 + * Section: 00000000-0000-0000-0000-000000000007 + */ + @Test + fun finishSession() = runTest { + // Start session + composeRule.onNodeWithContentDescription("Start practicing").performClick() + composeRule.onNodeWithText("TestItem1").performClick() + + // Advance time + fakeTimeProvider.advanceTimeBy(3.minutes) + + // Pause session + composeRule.onNodeWithContentDescription("Pause").performClick() + + // Advance time + fakeTimeProvider.advanceTimeBy(1.minutes) + + // Finish session + composeRule.onNodeWithText("Finish").performClick() + + // Add rating and comment + composeRule.onNodeWithContentDescription("Rate 5 out of 5 stars").performClick() + composeRule.onNodeWithText("Comment (optional)").performTextInput("Perfect 5 out of 7") + + // Confirm finish + composeRule.onNodeWithText("Save").performClick() + + // Wait for the navigation to finish + composeRule.awaitIdle() + + // Navigate up is called + verify(exactly = 1) { + navController.navigateUp() + } + + // Sessions are still empty + val sessions = sessionsUseCases.getAll().first() + assertThat(sessions).containsExactly( + SessionWithSectionsWithLibraryItems( + session = Session( + id = UUIDConverter.fromInt(6), + breakDurationSeconds = 60, + rating = 5, + comment = "Perfect 5 out of 7", + createdAt = FakeTimeProvider.START_TIME + 4.minutes, + modifiedAt = FakeTimeProvider.START_TIME + 4.minutes, + ), + sections = listOf( + SectionWithLibraryItem( + section = Section( + id = UUIDConverter.fromInt(7), + sessionId = UUIDConverter.fromInt(6), + libraryItemId = UUIDConverter.fromInt(2), + durationSeconds = 180, + startTimestamp = FakeTimeProvider.START_TIME, + ), + libraryItem = LibraryItem( + id = UUIDConverter.fromInt(2), + name = "TestItem1", + colorIndex = 1, + libraryFolderId = null, + customOrder = null, + createdAt = FakeTimeProvider.START_TIME, + modifiedAt = FakeTimeProvider.START_TIME + ), + ) + ) + ) + ) + } +} diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/SessionServiceTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/SessionServiceTest.kt new file mode 100644 index 00000000..7a4c3cb1 --- /dev/null +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/SessionServiceTest.kt @@ -0,0 +1,61 @@ +/* + * 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.activesession.presentation + +import android.app.NotificationManager +import android.content.Context +import android.content.Context.NOTIFICATION_SERVICE +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import androidx.test.rule.ServiceTestRule +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.TimeoutException + +@HiltAndroidTest +class SessionServiceTest { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val serviceRule = ServiceTestRule() + + lateinit var context: Context + lateinit var notificationManager: NotificationManager + + @Before + fun setUp() { + hiltRule.inject() + + context = ApplicationProvider.getApplicationContext() + notificationManager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + } + + @Test + @Throws(TimeoutException::class) + fun testWithBoundService() { + // Create the start intent + val startIntent = Intent( + context, + SessionService::class.java + ).apply { + action = ActiveSessionServiceActions.START.name + } + + val binder = serviceRule.bindService(startIntent) + + // Verify that the service has started correctly + assertThat(binder.pingBinder()).isTrue() + } +} diff --git a/app/src/androidTest/java/app/musikus/core/domain/FakeTimeProvider.kt b/app/src/androidTest/java/app/musikus/core/domain/FakeTimeProvider.kt index 744830ec..5692d85b 100644 --- a/app/src/androidTest/java/app/musikus/core/domain/FakeTimeProvider.kt +++ b/app/src/androidTest/java/app/musikus/core/domain/FakeTimeProvider.kt @@ -8,27 +8,32 @@ package app.musikus.core.domain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import java.time.ZonedDateTime import kotlin.time.Duration import kotlin.time.toJavaDuration class FakeTimeProvider : TimeProvider { - private var _currentDateTime = START_TIME + private val _clock = MutableStateFlow(START_TIME) + override val clock: Flow get() = _clock.asStateFlow() override fun now(): ZonedDateTime { - return _currentDateTime + return _clock.value } fun setCurrentDateTime(dateTime: ZonedDateTime) { - _currentDateTime = dateTime + _clock.update { dateTime } } fun advanceTimeBy(duration: Duration) { - _currentDateTime = _currentDateTime.plus(duration.toJavaDuration()) + _clock.update { it.plus(duration.toJavaDuration()) } } fun revertTimeBy(duration: Duration) { - _currentDateTime = _currentDateTime.minus(duration.toJavaDuration()) + _clock.update { it.minus(duration.toJavaDuration()) } } companion object { diff --git a/app/src/androidTest/java/app/musikus/library/di/TestLibraryUseCasesModule.kt b/app/src/androidTest/java/app/musikus/library/di/TestLibraryUseCasesModule.kt deleted file mode 100644 index a94cc17e..00000000 --- a/app/src/androidTest/java/app/musikus/library/di/TestLibraryUseCasesModule.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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, Michael Prommersberger - */ - -package app.musikus.library.di - -import app.musikus.core.domain.UserPreferencesRepository -import app.musikus.library.domain.LibraryRepository -import app.musikus.library.domain.usecase.AddFolderUseCase -import app.musikus.library.domain.usecase.AddItemUseCase -import app.musikus.library.domain.usecase.DeleteFoldersUseCase -import app.musikus.library.domain.usecase.DeleteItemsUseCase -import app.musikus.library.domain.usecase.EditFolderUseCase -import app.musikus.library.domain.usecase.EditItemUseCase -import app.musikus.library.domain.usecase.GetAllLibraryItemsUseCase -import app.musikus.library.domain.usecase.GetFolderSortInfoUseCase -import app.musikus.library.domain.usecase.GetItemSortInfoUseCase -import app.musikus.library.domain.usecase.GetLastPracticedDateUseCase -import app.musikus.library.domain.usecase.GetSortedLibraryFoldersUseCase -import app.musikus.library.domain.usecase.GetSortedLibraryItemsUseCase -import app.musikus.library.domain.usecase.LibraryUseCases -import app.musikus.library.domain.usecase.RestoreFoldersUseCase -import app.musikus.library.domain.usecase.RestoreItemsUseCase -import app.musikus.library.domain.usecase.SelectFolderSortModeUseCase -import app.musikus.library.domain.usecase.SelectItemSortModeUseCase -import app.musikus.sessions.domain.SessionRepository -import dagger.Module -import dagger.Provides -import dagger.hilt.components.SingletonComponent -import dagger.hilt.testing.TestInstallIn - -@Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [LibraryUseCasesModule::class] -) -object TestLibraryUseCasesModule { - @Provides - fun provideLibraryUseCases( - libraryRepository: LibraryRepository, - sessionRepository: SessionRepository, - userPreferencesRepository: UserPreferencesRepository - ): LibraryUseCases { - val getItemSortInfo = GetItemSortInfoUseCase(userPreferencesRepository) - val getFolderSortInfo = GetFolderSortInfoUseCase(userPreferencesRepository) - - return LibraryUseCases( - getAllItems = GetAllLibraryItemsUseCase(libraryRepository), - getSortedItems = GetSortedLibraryItemsUseCase(libraryRepository, getItemSortInfo), - getSortedFolders = GetSortedLibraryFoldersUseCase(libraryRepository, getFolderSortInfo), - getLastPracticedDate = GetLastPracticedDateUseCase(sessionRepository), - addItem = AddItemUseCase(libraryRepository), - addFolder = AddFolderUseCase(libraryRepository), - editItem = EditItemUseCase(libraryRepository), - editFolder = EditFolderUseCase(libraryRepository), - deleteItems = DeleteItemsUseCase(libraryRepository), - deleteFolders = DeleteFoldersUseCase(libraryRepository), - restoreItems = RestoreItemsUseCase(libraryRepository), - restoreFolders = RestoreFoldersUseCase(libraryRepository), - getItemSortInfo = getItemSortInfo, - getFolderSortInfo = getFolderSortInfo, - selectItemSortMode = SelectItemSortModeUseCase(userPreferencesRepository), - selectFolderSortMode = SelectFolderSortModeUseCase(userPreferencesRepository) - ) - } -} diff --git a/app/src/androidTest/java/app/musikus/menu/di/TestUserPreferencesUseCasesModule.kt b/app/src/androidTest/java/app/musikus/menu/di/TestUserPreferencesUseCasesModule.kt deleted file mode 100644 index 2180c2cf..00000000 --- a/app/src/androidTest/java/app/musikus/menu/di/TestUserPreferencesUseCasesModule.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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, Michael Prommersberger - */ - -package app.musikus.menu.di - -import app.musikus.core.domain.UserPreferencesRepository -import app.musikus.menu.domain.usecase.GetColorSchemeUseCase -import app.musikus.menu.domain.usecase.GetThemeUseCase -import app.musikus.menu.domain.usecase.SelectColorSchemeUseCase -import app.musikus.menu.domain.usecase.SelectThemeUseCase -import app.musikus.menu.domain.usecase.SettingsUseCases -import dagger.Module -import dagger.Provides -import dagger.hilt.components.SingletonComponent -import dagger.hilt.testing.TestInstallIn - -@Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [SettingsUseCasesModule::class] -) -object TestUserPreferencesUseCasesModule { - - @Provides - fun provideUserPreferencesUseCases( - userPreferencesRepository: UserPreferencesRepository - ): SettingsUseCases { - return SettingsUseCases( - getTheme = GetThemeUseCase(userPreferencesRepository), - getColorScheme = GetColorSchemeUseCase(userPreferencesRepository), - selectTheme = SelectThemeUseCase(userPreferencesRepository), - selectColorScheme = SelectColorSchemeUseCase(userPreferencesRepository), - ) - } -} diff --git a/app/src/androidTest/java/app/musikus/permissions/data/TestPermissionRepository.kt b/app/src/androidTest/java/app/musikus/permissions/data/TestPermissionRepository.kt new file mode 100644 index 00000000..3d68dad8 --- /dev/null +++ b/app/src/androidTest/java/app/musikus/permissions/data/TestPermissionRepository.kt @@ -0,0 +1,17 @@ +/* + * 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.permissions.data + +import app.musikus.permissions.domain.PermissionRepository + +class TestPermissionRepository : PermissionRepository { + override suspend fun requestPermissions(permissions: List): Result { + return Result.success(Unit) + } +} diff --git a/app/src/androidTest/java/app/musikus/permissions/di/TestPermissionsModule.kt b/app/src/androidTest/java/app/musikus/permissions/di/TestPermissionsModule.kt new file mode 100644 index 00000000..01a869db --- /dev/null +++ b/app/src/androidTest/java/app/musikus/permissions/di/TestPermissionsModule.kt @@ -0,0 +1,59 @@ +/* + * 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.permissions.di + +import android.app.Application +import app.musikus.core.di.ApplicationScope +import app.musikus.permissions.data.TestPermissionRepository +import app.musikus.permissions.domain.PermissionChecker +import app.musikus.permissions.domain.PermissionRepository +import app.musikus.permissions.domain.usecase.PermissionsUseCases +import app.musikus.permissions.domain.usecase.RequestPermissionsUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import kotlinx.coroutines.CoroutineScope +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [PermissionsModule::class] +) +object TestPermissionsModule { + + @Provides + @Singleton + fun providePermissionChecker( + application: Application, + @ApplicationScope applicationScope: CoroutineScope + ): PermissionChecker { + return PermissionChecker( + context = application, + applicationScope = applicationScope + ) + } + + @Provides + @Singleton + fun providePermissionRepository(): PermissionRepository { + return TestPermissionRepository() + } + + @Provides + @Singleton + fun providePermissionsUseCases( + permissionRepository: PermissionRepository + ): PermissionsUseCases { + return PermissionsUseCases( + request = RequestPermissionsUseCase(permissionRepository) + ) + } +} diff --git a/app/src/androidTest/java/app/musikus/recorder/di/TestRecordingsUseCasesModule.kt b/app/src/androidTest/java/app/musikus/recorder/di/TestRecordingsUseCasesModule.kt deleted file mode 100644 index 580fbde9..00000000 --- a/app/src/androidTest/java/app/musikus/recorder/di/TestRecordingsUseCasesModule.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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, Michael Prommersberger - */ - -package app.musikus.recorder.di - -import app.musikus.recorder.domain.RecordingsRepository -import app.musikus.recorder.domain.usecase.GetRawRecordingUseCase -import app.musikus.recorder.domain.usecase.GetRecordingsUseCase -import app.musikus.recorder.domain.usecase.RecordingsUseCases -import dagger.Module -import dagger.Provides -import dagger.hilt.components.SingletonComponent -import dagger.hilt.testing.TestInstallIn - -@Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [RecordingsUseCasesModule::class] -) -object TestRecordingsUseCasesModule { - - @Provides - fun provideRecordingsUseCases( - recordingRepository: RecordingsRepository, - ): RecordingsUseCases { - return RecordingsUseCases( - get = GetRecordingsUseCase(recordingRepository), - getRawRecording = GetRawRecordingUseCase(recordingRepository), - ) - } -} diff --git a/app/src/androidTest/java/app/musikus/sessionslist/di/TestSessionsUseCasesModule.kt b/app/src/androidTest/java/app/musikus/sessionslist/di/TestSessionsUseCasesModule.kt deleted file mode 100644 index f1fa654e..00000000 --- a/app/src/androidTest/java/app/musikus/sessionslist/di/TestSessionsUseCasesModule.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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, Michael Prommersberger - */ - -package app.musikus.sessionslist.di - -import app.musikus.library.domain.usecase.LibraryUseCases -import app.musikus.sessions.di.SessionsUseCasesModule -import app.musikus.sessions.domain.SessionRepository -import app.musikus.sessions.domain.usecase.AddSessionUseCase -import app.musikus.sessions.domain.usecase.DeleteSessionsUseCase -import app.musikus.sessions.domain.usecase.EditSessionUseCase -import app.musikus.sessions.domain.usecase.GetAllSessionsUseCase -import app.musikus.sessions.domain.usecase.GetSessionByIdUseCase -import app.musikus.sessions.domain.usecase.GetSessionsForDaysForMonthsUseCase -import app.musikus.sessions.domain.usecase.GetSessionsInTimeframeUseCase -import app.musikus.sessions.domain.usecase.RestoreSessionsUseCase -import app.musikus.sessions.domain.usecase.SessionsUseCases -import dagger.Module -import dagger.Provides -import dagger.hilt.components.SingletonComponent -import dagger.hilt.testing.TestInstallIn - -@Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [SessionsUseCasesModule::class] -) -object TestSessionsUseCasesModule { - - @Provides - fun provideSessionUseCases( - sessionRepository: SessionRepository, - libraryUseCases: LibraryUseCases, - ): SessionsUseCases { - return SessionsUseCases( - getAll = GetAllSessionsUseCase(sessionRepository), - getSessionsForDaysForMonths = GetSessionsForDaysForMonthsUseCase(sessionRepository), - getInTimeframe = GetSessionsInTimeframeUseCase(sessionRepository), - getById = GetSessionByIdUseCase(sessionRepository), - add = AddSessionUseCase(sessionRepository, libraryUseCases.getAllItems), - edit = EditSessionUseCase(sessionRepository), - delete = DeleteSessionsUseCase(sessionRepository), - restore = RestoreSessionsUseCase(sessionRepository), - ) - } -} 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 22dd03b8..46c3e6c7 100644 --- a/app/src/main/java/app/musikus/activesession/di/ActiveSessionUseCasesModule.kt +++ b/app/src/main/java/app/musikus/activesession/di/ActiveSessionUseCasesModule.kt @@ -10,15 +10,16 @@ package app.musikus.activesession.di import app.musikus.activesession.domain.ActiveSessionRepository import app.musikus.activesession.domain.usecase.ActiveSessionUseCases +import app.musikus.activesession.domain.usecase.ComputeOngoingPauseDurationUseCase +import app.musikus.activesession.domain.usecase.ComputeRunningItemDurationUseCase +import app.musikus.activesession.domain.usecase.ComputeTotalPracticeDurationUseCase import app.musikus.activesession.domain.usecase.DeleteSectionUseCase +import app.musikus.activesession.domain.usecase.GetActiveSessionStateUseCase 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.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 @@ -26,7 +27,6 @@ import app.musikus.activesession.domain.usecase.ResetSessionUseCase import app.musikus.activesession.domain.usecase.ResumeActiveSessionUseCase import app.musikus.activesession.domain.usecase.SelectItemUseCase import app.musikus.core.domain.IdProvider -import app.musikus.core.domain.TimeProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -41,51 +41,41 @@ object ActiveSessionUseCasesModule { @Singleton fun provideActiveSessionUseCases( activeSessionRepository: ActiveSessionRepository, - timeProvider: TimeProvider, idProvider: IdProvider ): ActiveSessionUseCases { - val getOngoingPauseDurationUseCase = GetOngoingPauseDurationUseCase( - activeSessionRepository, - timeProvider - ) + val computeOngoingPauseDurationUseCase = ComputeOngoingPauseDurationUseCase() val resumeUseCase = ResumeActiveSessionUseCase( activeSessionRepository, - getOngoingPauseDurationUseCase + computeOngoingPauseDurationUseCase ) - val getRunningItemDurationUseCase = GetRunningItemDurationUseCase( - activeSessionRepository, - timeProvider - ) + val computeRunningItemDurationUseCase = ComputeRunningItemDurationUseCase() return ActiveSessionUseCases( + getState = GetActiveSessionStateUseCase(activeSessionRepository), selectItem = SelectItemUseCase( activeSessionRepository = activeSessionRepository, - getRunningItemDurationUseCase = getRunningItemDurationUseCase, - timeProvider = timeProvider, + computeRunningItemDuration = computeRunningItemDurationUseCase, idProvider = idProvider ), - getPracticeDuration = GetTotalPracticeDurationUseCase( - activeSessionRepository = activeSessionRepository, - runningItemDurationUseCase = getRunningItemDurationUseCase + computeTotalPracticeDuration = ComputeTotalPracticeDurationUseCase( + computeRunningItemDuration = computeRunningItemDurationUseCase ), deleteSection = DeleteSectionUseCase(activeSessionRepository), pause = PauseActiveSessionUseCase( activeSessionRepository = activeSessionRepository, - getRunningItemDurationUseCase = getRunningItemDurationUseCase, - timeProvider = timeProvider ), resume = resumeUseCase, - getRunningItemDuration = getRunningItemDurationUseCase, + computeRunningItemDuration = computeRunningItemDurationUseCase, getCompletedSections = GetCompletedSectionsUseCase(activeSessionRepository), - getOngoingPauseDuration = GetOngoingPauseDurationUseCase(activeSessionRepository, timeProvider), + computeOngoingPauseDuration = computeOngoingPauseDurationUseCase, isSessionPaused = IsSessionPausedUseCase(activeSessionRepository), getFinalizedSession = GetFinalizedSessionUseCase( activeSessionRepository = activeSessionRepository, - getRunningItemDurationUseCase = getRunningItemDurationUseCase, + computeRunningItemDuration = computeRunningItemDurationUseCase, idProvider = idProvider, - getOngoingPauseDurationUseCase = getOngoingPauseDurationUseCase + computeOngoingPauseDuration = computeOngoingPauseDurationUseCase ), getStartTime = GetStartTimeUseCase(activeSessionRepository), reset = ResetSessionUseCase(activeSessionRepository), 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 3d3142db..c7dd5b18 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 @@ -9,15 +9,16 @@ package app.musikus.activesession.domain.usecase data class ActiveSessionUseCases( + val getState: GetActiveSessionStateUseCase, val selectItem: SelectItemUseCase, val deleteSection: DeleteSectionUseCase, val pause: PauseActiveSessionUseCase, val resume: ResumeActiveSessionUseCase, - val getPracticeDuration: GetTotalPracticeDurationUseCase, - val getRunningItemDuration: GetRunningItemDurationUseCase, + val computeTotalPracticeDuration: ComputeTotalPracticeDurationUseCase, + val computeRunningItemDuration: ComputeRunningItemDurationUseCase, val getRunningItem: GetRunningItemUseCase, val getCompletedSections: GetCompletedSectionsUseCase, - val getOngoingPauseDuration: GetOngoingPauseDurationUseCase, + val computeOngoingPauseDuration: ComputeOngoingPauseDurationUseCase, val getStartTime: GetStartTimeUseCase, val getFinalizedSession: GetFinalizedSessionUseCase, val reset: ResetSessionUseCase, diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/GetOngoingPauseDurationUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/ComputeOngoingPauseDurationUseCase.kt similarity index 56% rename from app/src/main/java/app/musikus/activesession/domain/usecase/GetOngoingPauseDurationUseCase.kt rename to app/src/main/java/app/musikus/activesession/domain/usecase/ComputeOngoingPauseDurationUseCase.kt index 59633733..8cedae41 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/GetOngoingPauseDurationUseCase.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/ComputeOngoingPauseDurationUseCase.kt @@ -8,21 +8,19 @@ package app.musikus.activesession.domain.usecase -import app.musikus.activesession.domain.ActiveSessionRepository -import app.musikus.core.domain.TimeProvider +import app.musikus.activesession.domain.SessionState import app.musikus.core.domain.minus -import kotlinx.coroutines.flow.first +import java.time.ZonedDateTime import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -class GetOngoingPauseDurationUseCase( - private val activeSessionRepository: ActiveSessionRepository, - private val timeProvider: TimeProvider -) { - suspend operator fun invoke(): Duration { - val state = activeSessionRepository.getSessionState().first() ?: return 0.seconds +class ComputeOngoingPauseDurationUseCase { + operator fun invoke( + state: SessionState, + at: ZonedDateTime + ): Duration { if (state.currentPauseStartTimestamp == null) return 0.seconds - val duration = timeProvider.now() - state.currentPauseStartTimestamp + val duration = at - state.currentPauseStartTimestamp if (duration < 0.seconds) { throw IllegalStateException("Duration is negative. This should not happen.") } diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/GetRunningItemDurationUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/ComputeRunningItemDurationUseCase.kt similarity index 66% rename from app/src/main/java/app/musikus/activesession/domain/usecase/GetRunningItemDurationUseCase.kt rename to app/src/main/java/app/musikus/activesession/domain/usecase/ComputeRunningItemDurationUseCase.kt index ef6ad0a9..d25245c3 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/GetRunningItemDurationUseCase.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/ComputeRunningItemDurationUseCase.kt @@ -8,24 +8,17 @@ package app.musikus.activesession.domain.usecase -import app.musikus.activesession.domain.ActiveSessionRepository -import app.musikus.core.domain.TimeProvider +import app.musikus.activesession.domain.SessionState import app.musikus.core.domain.minus -import kotlinx.coroutines.flow.first import java.time.ZonedDateTime import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -class GetRunningItemDurationUseCase( - private val activeSessionRepository: ActiveSessionRepository, - private val timeProvider: TimeProvider -) { - suspend operator fun invoke( - at: ZonedDateTime = timeProvider.now() +class ComputeRunningItemDurationUseCase { + operator fun invoke( + state: SessionState, + at: ZonedDateTime ): Duration { - val state = activeSessionRepository.getSessionState().first() - ?: throw IllegalStateException("State is null. Cannot get running section!") - val duration = if (state.isPaused) { if (state.currentPauseStartTimestamp == null) { throw IllegalStateException("CurrentPauseTimestamp is null although isPaused is true.") diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/GetTotalPracticeDurationUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/ComputeTotalPracticeDurationUseCase.kt similarity index 51% rename from app/src/main/java/app/musikus/activesession/domain/usecase/GetTotalPracticeDurationUseCase.kt rename to app/src/main/java/app/musikus/activesession/domain/usecase/ComputeTotalPracticeDurationUseCase.kt index f119b211..41f6f517 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/GetTotalPracticeDurationUseCase.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/ComputeTotalPracticeDurationUseCase.kt @@ -8,21 +8,18 @@ package app.musikus.activesession.domain.usecase -import app.musikus.activesession.domain.ActiveSessionRepository -import kotlinx.coroutines.flow.first +import app.musikus.activesession.domain.SessionState +import java.time.ZonedDateTime import kotlin.time.Duration -class GetTotalPracticeDurationUseCase( - private val activeSessionRepository: ActiveSessionRepository, - private val runningItemDurationUseCase: GetRunningItemDurationUseCase +class ComputeTotalPracticeDurationUseCase( + private val computeRunningItemDuration: ComputeRunningItemDurationUseCase ) { - suspend operator fun invoke(): Duration { - val state = activeSessionRepository.getSessionState().first() - val runningItemDuration = runningItemDurationUseCase() - - if (state == null) { - throw IllegalStateException("State is null. Cannot get total practice time!") - } + operator fun invoke( + state: SessionState, + at: ZonedDateTime + ): Duration { + val runningItemDuration = computeRunningItemDuration(state, at) // add up all completed section durations // add running section duration on top (by using initial value of fold) diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/GetActiveSessionStateUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/GetActiveSessionStateUseCase.kt new file mode 100644 index 00000000..f613f822 --- /dev/null +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/GetActiveSessionStateUseCase.kt @@ -0,0 +1,17 @@ +/* + * 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.activesession.domain.usecase + +import app.musikus.activesession.domain.ActiveSessionRepository + +class GetActiveSessionStateUseCase( + private val activeSessionRepository: ActiveSessionRepository +) { + operator fun invoke() = activeSessionRepository.getSessionState() +} diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/GetFinalizedSessionUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/GetFinalizedSessionUseCase.kt index fb517c07..38a29d81 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/GetFinalizedSessionUseCase.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/GetFinalizedSessionUseCase.kt @@ -15,30 +15,41 @@ import app.musikus.core.domain.IdProvider import app.musikus.core.domain.minus import app.musikus.core.domain.plus import kotlinx.coroutines.flow.first +import java.time.ZonedDateTime import kotlin.time.Duration.Companion.seconds class GetFinalizedSessionUseCase( private val activeSessionRepository: ActiveSessionRepository, - private val getRunningItemDurationUseCase: GetRunningItemDurationUseCase, - private val getOngoingPauseDurationUseCase: GetOngoingPauseDurationUseCase, + private val computeRunningItemDuration: ComputeRunningItemDurationUseCase, + private val computeOngoingPauseDuration: ComputeOngoingPauseDurationUseCase, private val idProvider: IdProvider ) { - suspend operator fun invoke(): SessionState { + suspend operator fun invoke(at: ZonedDateTime): SessionState { val state = activeSessionRepository.getSessionState().first() ?: throw IllegalStateException("State is null. Cannot finish session!") // take time - val runningSectionRoundedDuration = getRunningItemDurationUseCase().inWholeSeconds.seconds - val ongoingPauseDuration = getOngoingPauseDurationUseCase() + val runningSectionRoundedDuration = computeRunningItemDuration(state, at).inWholeSeconds.seconds + val ongoingPauseDuration = computeOngoingPauseDuration(state, at) // append finished section to completed sections - val updatedSections = state.completedSections + PracticeSection( - id = idProvider.generateId(), - libraryItem = state.currentSectionItem, - pauseDuration = (state.startTimestampSectionPauseCompensated + ongoingPauseDuration) - state.startTimestampSection, - duration = runningSectionRoundedDuration, - startTimestamp = state.startTimestampSection - ) + val updatedSections = state.completedSections + .plus( + PracticeSection( + id = idProvider.generateId(), + libraryItem = state.currentSectionItem, + pauseDuration = ( + state.startTimestampSectionPauseCompensated + ongoingPauseDuration + ) - state.startTimestampSection, + duration = runningSectionRoundedDuration, + startTimestamp = state.startTimestampSection + ) + ) + .filter { it.duration > 0.seconds } + + if (updatedSections.isEmpty()) { + throw IllegalStateException("Completed sections are empty.") + } return state.copy( completedSections = updatedSections diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/PauseActiveSessionUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/PauseActiveSessionUseCase.kt index f498e0ec..a3acc94e 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/PauseActiveSessionUseCase.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/PauseActiveSessionUseCase.kt @@ -9,16 +9,13 @@ package app.musikus.activesession.domain.usecase import app.musikus.activesession.domain.ActiveSessionRepository -import app.musikus.core.domain.TimeProvider import kotlinx.coroutines.flow.first -import kotlin.time.Duration.Companion.seconds +import java.time.ZonedDateTime class PauseActiveSessionUseCase( private val activeSessionRepository: ActiveSessionRepository, - private val getRunningItemDurationUseCase: GetRunningItemDurationUseCase, - private val timeProvider: TimeProvider ) { - suspend operator fun invoke() { + suspend operator fun invoke(at: ZonedDateTime) { val state = activeSessionRepository.getSessionState().first() ?: throw IllegalStateException("Cannot pause when state is null") @@ -26,13 +23,9 @@ class PauseActiveSessionUseCase( throw IllegalStateException("Cannot pause when already paused.") } - // ignore pause if first section is running and less than 1 second has passed - // (prevents finishing empty session) - if (state.completedSections.isEmpty() && getRunningItemDurationUseCase() < 1.seconds) return - activeSessionRepository.setSessionState( state.copy( - currentPauseStartTimestamp = timeProvider.now(), + currentPauseStartTimestamp = at, isPaused = true ) ) diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/ResumeActiveSessionUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/ResumeActiveSessionUseCase.kt index 0132dceb..6e22e90e 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/ResumeActiveSessionUseCase.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/ResumeActiveSessionUseCase.kt @@ -11,12 +11,13 @@ package app.musikus.activesession.domain.usecase import app.musikus.activesession.domain.ActiveSessionRepository import app.musikus.core.domain.plus import kotlinx.coroutines.flow.first +import java.time.ZonedDateTime class ResumeActiveSessionUseCase( private val activeSessionRepository: ActiveSessionRepository, - private val getOngoingPauseDurationUseCase: GetOngoingPauseDurationUseCase, + private val computeOngoingPauseDurationUseCase: ComputeOngoingPauseDurationUseCase, ) { - suspend operator fun invoke() { + suspend operator fun invoke(at: ZonedDateTime) { val state = activeSessionRepository.getSessionState().first() ?: throw IllegalStateException("Cannot resume when state is null") @@ -24,7 +25,7 @@ class ResumeActiveSessionUseCase( throw IllegalStateException("Cannot resume when not paused") } - val currentPauseDuration = getOngoingPauseDurationUseCase() + val currentPauseDuration = computeOngoingPauseDurationUseCase(state, at) activeSessionRepository.setSessionState( state.copy( startTimestampSectionPauseCompensated = diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/SelectItemUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/SelectItemUseCase.kt index 244934de..ae4b1b92 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/SelectItemUseCase.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/SelectItemUseCase.kt @@ -12,42 +12,39 @@ import app.musikus.activesession.domain.ActiveSessionRepository import app.musikus.activesession.domain.PracticeSection import app.musikus.activesession.domain.SessionState import app.musikus.core.domain.IdProvider -import app.musikus.core.domain.TimeProvider import app.musikus.core.domain.minus import app.musikus.core.domain.plus import app.musikus.library.data.daos.LibraryItem import kotlinx.coroutines.flow.first +import java.time.ZonedDateTime import kotlin.time.Duration.Companion.seconds class SelectItemUseCase( private val activeSessionRepository: ActiveSessionRepository, - private val getRunningItemDurationUseCase: GetRunningItemDurationUseCase, - private val timeProvider: TimeProvider, + private val computeRunningItemDuration: ComputeRunningItemDurationUseCase, private val idProvider: IdProvider ) { - suspend operator fun invoke(libraryItem: LibraryItem) { + suspend operator fun invoke( + item: LibraryItem, + at: ZonedDateTime, + ) { val state = activeSessionRepository.getSessionState().first() if (state != null) { /** another section is already running */ // check same item - if (state.currentSectionItem == libraryItem) { + if (state.currentSectionItem == item) { throw IllegalStateException("Must not select the same library item which is already running.") } - // check too fast - if (getRunningItemDurationUseCase() < 1.seconds) { - throw IllegalStateException("Must wait for at least one second before starting a new section.") - } - // only start new item when not paused if (state.isPaused) throw IllegalStateException("You must resume before selecting a new item.") // take time - val runningSectionTrueDuration = getRunningItemDurationUseCase() + val runningSectionTrueDuration = computeRunningItemDuration(state, at) val changeSectionTimestamp = state.startTimestampSectionPauseCompensated + runningSectionTrueDuration.inWholeSeconds.seconds // running section duration calculated until changeSectionTimestamp - val runningSectionRoundedDuration = getRunningItemDurationUseCase(at = changeSectionTimestamp) + val runningSectionRoundedDuration = computeRunningItemDuration(state, at = changeSectionTimestamp) // append finished section to completed sections val updatedSections = state.completedSections + PracticeSection( @@ -61,7 +58,7 @@ class SelectItemUseCase( activeSessionRepository.setSessionState( state.copy( completedSections = updatedSections, - currentSectionItem = libraryItem, + currentSectionItem = item, startTimestampSection = changeSectionTimestamp, // new sections starts when the old one ends startTimestampSectionPauseCompensated = changeSectionTimestamp, ) @@ -70,11 +67,11 @@ class SelectItemUseCase( } /** starting the first section */ - val changeOverTime = timeProvider.now() + val changeOverTime = at activeSessionRepository.setSessionState( SessionState( // create new session state completedSections = emptyList(), - currentSectionItem = libraryItem, + currentSectionItem = item, startTimestamp = changeOverTime, startTimestampSection = changeOverTime, startTimestampSectionPauseCompensated = changeOverTime, 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 05c9ed6a..7662b81a 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionScreen.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionScreen.kt @@ -90,8 +90,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Tab @@ -151,6 +149,8 @@ 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.MainUiEvent +import app.musikus.core.presentation.MainUiEventHandler import app.musikus.core.presentation.components.DeleteConfirmationBottomSheet import app.musikus.core.presentation.components.DialogActions import app.musikus.core.presentation.components.DialogHeader @@ -158,7 +158,6 @@ import app.musikus.core.presentation.components.ExceptionHandler import app.musikus.core.presentation.components.SwipeToDeleteContainer import app.musikus.core.presentation.components.conditional import app.musikus.core.presentation.components.fadingEdge -import app.musikus.core.presentation.components.showSnackbar import app.musikus.core.presentation.theme.MusikusColorSchemeProvider import app.musikus.core.presentation.theme.MusikusPreviewElement1 import app.musikus.core.presentation.theme.MusikusPreviewElement2 @@ -187,7 +186,6 @@ import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -227,6 +225,7 @@ data class ScreenSizeClass( @Composable fun ActiveSession( viewModel: ActiveSessionViewModel = hiltViewModel(), + mainEventHandler: MainUiEventHandler, deepLinkAction: ActiveSessionActions? = null, navigateUp: () -> Unit, ) { @@ -235,7 +234,6 @@ fun ActiveSession( val scope = rememberCoroutineScope() val windowsSizeClass = calculateWindowSizeClass(activity = LocalContext.current as Activity) - val snackbarHostState = remember { SnackbarHostState() } val bottomSheetScaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberStandardBottomSheetState( skipHiddenState = false, @@ -254,18 +252,15 @@ fun ActiveSession( type = ActiveSessionTab.RECORDER, title = stringResource(id = R.string.active_session_toolbar_recorder), icon = UiIcon.DynamicIcon(Icons.Default.Mic), - content = { RecorderUi(snackbarHostState = snackbarHostState) } + content = { RecorderUi(showSnackbar = { mainEventHandler(it) }) } ) ).toImmutableList() // state for Tabs val bottomSheetPagerState = rememberPagerState(pageCount = { tabs.size }) - ObserveAsEvents(viewModel.navigationEventsChannelFlow) { event -> + ObserveAsEvents(viewModel.eventChannel) { event -> when (event) { - is NavigationEvent.NavigateUp -> { - navigateUp() - } - is NavigationEvent.HideTools -> { + is ActiveSessionEvent.HideTools -> { scope.launch { bottomSheetScaffoldState.bottomSheetState.hide() } @@ -292,7 +287,7 @@ fun ActiveSession( tabs = tabs, bottomSheetScaffoldState = bottomSheetScaffoldState, bottomSheetPagerState = bottomSheetPagerState, - snackbarHostState = snackbarHostState, + showSnackbar = { mainEventHandler(it) }, sizeClass = ScreenSizeClass( windowsSizeClass.widthSizeClass, windowsSizeClass.heightSizeClass @@ -360,16 +355,16 @@ private fun ActiveSessionScreen( bottomSheetScaffoldState: BottomSheetScaffoldState, bottomSheetPagerState: PagerState, sizeClass: ScreenSizeClass, - snackbarHostState: SnackbarHostState, + showSnackbar: (MainUiEvent.ShowSnackbar) -> Unit, ) { // Custom Scaffold for our elements which adapts to available window sizes ActiveSessionAdaptiveScaffold( screenSizeClass = sizeClass, - snackbarHostState = snackbarHostState, bottomSheetScaffoldState = bottomSheetScaffoldState, topBar = { ActiveSessionTopBar( sessionState = uiState.value.sessionState.collectAsState(), + isFinishedButtonEnabled = uiState.value.isFinishButtonEnabled.collectAsState(), onDiscard = remember { { eventHandler(ActiveSessionUiEvent.ToggleDiscardDialog) } }, onNavigateUp = remember { { navigateUp() } }, onTogglePause = remember { { eventHandler(ActiveSessionUiEvent.TogglePauseState) } }, @@ -389,7 +384,7 @@ private fun ActiveSessionScreen( contentPadding = padding, uiState = uiState.value.mainContentUiState.collectAsState(), sessionState = uiState.value.sessionState.collectAsState(), - snackbarHostState = snackbarHostState, + showSnackbar = showSnackbar, eventHandler = eventHandler, screenSizeClass = sizeClass ) @@ -447,9 +442,8 @@ private fun ActiveSessionScreen( ) }, onConfirm = { - eventHandler( - dialogEvent(ActiveSessionEndDialogUiEvent.Confirmed) - ) + eventHandler(dialogEvent(ActiveSessionEndDialogUiEvent.Confirmed)) + navigateUp() } ) } @@ -485,7 +479,6 @@ private fun ActiveSessionAdaptiveScaffold( mainContent: @Composable (State) -> Unit, toolsContent: @Composable () -> Unit, bottomSheetScaffoldState: BottomSheetScaffoldState, - snackbarHostState: SnackbarHostState, ) { if (screenSizeClass.height == WindowHeightSizeClass.Compact) { /** Landscape / small height. Use two columns with main content left, bottom sheet right. */ @@ -494,7 +487,6 @@ private fun ActiveSessionAdaptiveScaffold( // Scaffold needed for topBar modifier = modifier, topBar = topBar, - snackbarHost = { SnackbarHost(snackbarHostState) } ) { Row( modifier = Modifier @@ -543,7 +535,6 @@ private fun ActiveSessionAdaptiveScaffold( modifier = modifier, topBar = topBar, bottomBar = bottomBar, - snackbarHost = { SnackbarHost(snackbarHostState) }, content = { paddingValues -> Surface(Modifier.padding(paddingValues)) { // don't overlap with bottomBar ToolsBottomSheetScaffold( @@ -613,7 +604,7 @@ private fun ActiveSessionMainContent( contentPadding: State, uiState: State, sessionState: State, - snackbarHostState: SnackbarHostState, + showSnackbar: (MainUiEvent.ShowSnackbar) -> Unit, eventHandler: ActiveSessionUiEventHandler, ) { // condense UI a bit if there is limited space @@ -691,10 +682,9 @@ private fun ActiveSessionMainContent( if (pastItemsState.value != null) { SectionList( uiState = pastItemsState, - scope = rememberCoroutineScope(), nestedScrollConnection = nestedScrollConnection, // for hiding the FAB listState = sectionsListState, - snackbarHostState = snackbarHostState, + showSnackbar = showSnackbar, onSectionDeleted = remember { { section -> @@ -776,6 +766,7 @@ private fun ActiveSessionToolsLayout( @Composable private fun ActiveSessionTopBar( sessionState: State, + isFinishedButtonEnabled: State, onDiscard: () -> Unit, onNavigateUp: () -> Unit, onTogglePause: () -> Unit, @@ -806,11 +797,15 @@ private fun ActiveSessionTopBar( IconButton( onClick = onDiscard, ) { - Icon(imageVector = Icons.Outlined.Delete, contentDescription = null) + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(id = R.string.active_session_top_bar_delete) + ) } TextButton( - onClick = onSave + onClick = onSave, + enabled = isFinishedButtonEnabled.value ) { Text(text = stringResource(id = R.string.active_session_top_bar_save)) } @@ -829,7 +824,7 @@ private fun PauseButton( ) { Icon( imageVector = Icons.Filled.Pause, - contentDescription = null + contentDescription = stringResource(id = R.string.active_session_top_bar_pause) ) } } @@ -985,7 +980,12 @@ private fun PracticeTimer( contentColor = MaterialTheme.colorScheme.onTertiary ) ) { - Icon(imageVector = Icons.Outlined.PlayCircle, contentDescription = null) + Icon( + imageVector = Icons.Outlined.PlayCircle, + contentDescription = stringResource( + id = R.string.active_session_timer_subheading_resume + ) + ) Spacer(Modifier.width(MaterialTheme.spacing.small)) Text(text = uiState.value.subHeadingText.asString()) } @@ -1077,11 +1077,10 @@ private fun CurrentPracticingItem( @Composable private fun SectionList( uiState: State, - scope: CoroutineScope, onSectionDeleted: (CompletedSectionUiState) -> Unit, nestedScrollConnection: NestedScrollConnection, listState: LazyListState, - snackbarHostState: SnackbarHostState, + showSnackbar: (MainUiEvent.ShowSnackbar) -> Unit, additionalBottomContentPadding: Dp = 0.dp, ) { val listUiState = uiState.value ?: return @@ -1116,9 +1115,8 @@ private fun SectionList( ) { item -> SectionListElement( modifier = Modifier.animateItemPlacement(), - scope = scope, item = item, - snackbarHostState = snackbarHostState, + showSnackbar = showSnackbar, onSectionDeleted = onSectionDeleted, ) } @@ -1138,9 +1136,8 @@ private fun SectionList( @Composable private fun SectionListElement( modifier: Modifier = Modifier, - scope: CoroutineScope, item: CompletedSectionUiState, - snackbarHostState: SnackbarHostState, + showSnackbar: (MainUiEvent.ShowSnackbar) -> Unit, onSectionDeleted: (CompletedSectionUiState) -> Unit = {}, ) { val context = LocalContext.current @@ -1162,15 +1159,13 @@ private fun SectionListElement( deleted = deleted, onDeleted = { onSectionDeleted(item) - showSnackbar( - context = context, - scope = scope, - hostState = snackbarHostState, - message = context.getString(R.string.active_session_sections_list_element_deleted), - onUndo = { - TODO("Fix this using soft delete of sections in repository") - } - ) + // as long as we don't have undo, we don't need to show a snackbar +// showSnackbar( +// MainUiEvent.ShowSnackbar( +// message = context.getString(R.string.active_session_sections_list_element_deleted), +// onUndo = { } +// ) +// ) } ) { Surface( @@ -1566,7 +1561,8 @@ private fun PreviewActiveSessionScreen( sessionState = MutableStateFlow(ActiveSessionState.RUNNING), mainContentUiState = MutableStateFlow(mainContent), newItemSelectorUiState = MutableStateFlow(null), - dialogUiState = MutableStateFlow(dialogs) + dialogUiState = MutableStateFlow(dialogs), + isFinishButtonEnabled = MutableStateFlow(true) ) ) }, @@ -1588,7 +1584,7 @@ private fun PreviewActiveSessionScreen( navigateUp = {}, bottomSheetScaffoldState = rememberBottomSheetScaffoldState(), bottomSheetPagerState = rememberPagerState(pageCount = { 2 }), - snackbarHostState = remember { SnackbarHostState() } + showSnackbar = {} ) } } @@ -1614,8 +1610,7 @@ private fun PreviewSectionItem( MusikusThemedPreview(theme) { SectionListElement( item = dummySections.first(), - snackbarHostState = remember { SnackbarHostState() }, - scope = rememberCoroutineScope() + showSnackbar = { }, ) } } diff --git a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionUiState.kt b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionUiState.kt index 10618467..b0bb53ec 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionUiState.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionUiState.kt @@ -32,6 +32,7 @@ data class ActiveSessionUiState( val mainContentUiState: StateFlow, val newItemSelectorUiState: StateFlow, val dialogUiState: StateFlow, + val isFinishButtonEnabled: StateFlow, ) @Stable diff --git a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt index c654df59..24630b83 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt @@ -17,6 +17,7 @@ import app.musikus.activesession.domain.usecase.ActiveSessionUseCases import app.musikus.activesession.domain.usecase.SessionStatus import app.musikus.core.data.Nullable import app.musikus.core.di.ApplicationScope +import app.musikus.core.domain.TimeProvider import app.musikus.core.presentation.theme.libraryItemColors import app.musikus.core.presentation.utils.DurationFormat import app.musikus.core.presentation.utils.UiText @@ -46,12 +47,9 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import java.util.Timer import java.util.UUID import javax.inject.Inject -import kotlin.concurrent.timer import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @HiltViewModel @@ -61,14 +59,18 @@ class ActiveSessionViewModel @Inject constructor( private val activeSessionUseCases: ActiveSessionUseCases, private val sessionUseCases: SessionsUseCases, private val permissionsUseCases: PermissionsUseCases, - @ApplicationScope private val applicationScope: CoroutineScope + @ApplicationScope private val applicationScope: CoroutineScope, + private val timeProvider: TimeProvider ) : AndroidViewModel(application) { - private var _clock = MutableStateFlow(false) - private var _timer: Timer? = null - /** ---------- Proxies for Flows from UseCases, turned into StateFlows -------------------- */ + private val state = activeSessionUseCases.getState().stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = null + ) + private val completedSections = activeSessionUseCases.getCompletedSections().stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), @@ -128,28 +130,71 @@ class ActiveSessionViewModel @Inject constructor( private val _endDialogVisible = MutableStateFlow(false) private val _discardDialogVisible = MutableStateFlow(false) private val _newItemSelectorVisible = MutableStateFlow(false) + private val _exceptionChannel = Channel() val exceptionChannel = _exceptionChannel.receiveAsFlow() + private val _eventChannel = Channel() + val eventChannel = _eventChannel.receiveAsFlow() + + /** ------------------- Combined flows ---------------------------- */ + + private val totalPracticeDuration = timeProvider.clock.map { now -> + // we intentionally do not collect events from the flow here in order to avoid race conditions + state.value?.let { + activeSessionUseCases.computeTotalPracticeDuration(it, now) + } ?: Duration.ZERO + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = Duration.ZERO + ) + + private val ongoingPauseDuration = timeProvider.clock.map { now -> + // we intentionally do not collect events from the flow here in order to avoid race conditions + state.value?.let { + activeSessionUseCases.computeOngoingPauseDuration(it, now) + } ?: Duration.ZERO + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = Duration.ZERO + ) + + private val runningItemDuration = timeProvider.clock.map { now -> + // we intentionally do not collect events from the flow here in order to avoid race conditions + state.value?.let { + activeSessionUseCases.computeRunningItemDuration(it, now) + } ?: Duration.ZERO + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = Duration.ZERO + ) + + val isFinishButtonEnabled = totalPracticeDuration.map { + it >= 1.seconds + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false + ) + /** ------------------- Sub UI states ------------------------------------------- */ private val timerUiState = combine( sessionState, - _clock // should update with clock - ) { timerState, _ -> - val pause = timerState == ActiveSessionState.PAUSED - - val practiceDuration = try { - activeSessionUseCases.getPracticeDuration() - } catch (e: IllegalStateException) { - Duration.ZERO // Session not yet started - } + totalPracticeDuration, + ongoingPauseDuration, + ) { sessionState, totalPracticeDuration, ongoingPauseDuration -> + val pause = sessionState == ActiveSessionState.PAUSED + val pauseDurStr = getDurationString( - activeSessionUseCases.getOngoingPauseDuration(), + ongoingPauseDuration, DurationFormat.MS_DIGITAL ) ActiveSessionTimerUiState( - timerText = getFormattedTimerText(practiceDuration), + timerText = getFormattedTimerText(totalPracticeDuration), subHeadingText = if (pause) { UiText.StringResource(R.string.active_session_timer_subheading_paused, pauseDurStr) @@ -174,19 +219,14 @@ class ActiveSessionViewModel @Inject constructor( private val currentItemUiState: StateFlow = combine( sessionState, runningLibraryItem, - _clock // should update with clock - ) { sessionState, item, _ -> + runningItemDuration + ) { sessionState, item, runningItemDuration -> if (sessionState == ActiveSessionState.NOT_STARTED || item == null) return@combine null - val currentItemDuration = try { - activeSessionUseCases.getRunningItemDuration() - } catch (e: IllegalStateException) { - Duration.ZERO // Session not yet started - } ActiveSessionCurrentItemUiState( name = item.name, durationText = getDurationString( - currentItemDuration, + runningItemDuration, DurationFormat.MS_DIGITAL ).toString(), color = libraryItemColors[item.colorIndex] @@ -284,15 +324,12 @@ class ActiveSessionViewModel @Inject constructor( ) ), newItemSelectorUiState = newItemSelectorUiState, - dialogUiState = dialogsUiStates + dialogUiState = dialogsUiStates, + isFinishButtonEnabled = isFinishButtonEnabled ) ).asStateFlow() - private val navigationChannel = Channel() - val navigationEventsChannelFlow = navigationChannel.receiveAsFlow() - init { - startTimer() /** Hide the Tools Bottom Sheet on Startup */ runBlocking(context = Dispatchers.IO) { viewModelScope.launch { @@ -304,7 +341,7 @@ class ActiveSessionViewModel @Inject constructor( // also take into account whether recording is in progress or metronome is running // and open the respective tab in case // viewModelScope.launch { -// navigationChannel.send(NavigationEvent.HideTools) +// _eventChannel.send(ActiveSessionEvent.HideTools) // } } } @@ -317,7 +354,7 @@ class ActiveSessionViewModel @Inject constructor( is ActiveSessionUiEvent.DeleteSection -> viewModelScope.launch { deleteSection(event.sectionId) } is ActiveSessionUiEvent.EndDialogUiEvent -> onEndDialogUiEvent(event.dialogEvent) is ActiveSessionUiEvent.BackPressed -> { /* TODO */ } - ActiveSessionUiEvent.DiscardSessionDialogConfirmed -> discardSession() + ActiveSessionUiEvent.DiscardSessionDialogConfirmed -> activeSessionUseCases.reset() ActiveSessionUiEvent.ToggleDiscardDialog -> _discardDialogVisible.update { !it } ActiveSessionUiEvent.ToggleFinishDialog -> _endDialogVisible.update { !it } ActiveSessionUiEvent.ToggleNewItemSelector -> viewModelScope.launch { @@ -359,68 +396,42 @@ class ActiveSessionViewModel @Inject constructor( private suspend fun selectItem(item: LibraryItem) { // resume session if paused if (sessionState.value == ActiveSessionState.PAUSED) { - activeSessionUseCases.resume() + activeSessionUseCases.resume(timeProvider.now()) } - // wait until the current item has been running for at least 1 second - if (sessionState.value != ActiveSessionState.NOT_STARTED && - activeSessionUseCases.getRunningItemDuration() < 1.seconds - ) { - delay(1000) - } - activeSessionUseCases.selectItem(item) + + activeSessionUseCases.selectItem( + item = item, + at = timeProvider.now() + ) } private suspend fun deleteSection(sectionId: UUID) { - if (sessionState.value == ActiveSessionState.PAUSED) { - activeSessionUseCases.resume() - } activeSessionUseCases.deleteSection(sectionId) } - private fun discardSession() { - activeSessionUseCases.reset() - viewModelScope.launch { - navigationChannel.send(NavigationEvent.NavigateUp) - } - } - private suspend fun togglePauseState() { when (sessionState.value) { - ActiveSessionState.RUNNING -> activeSessionUseCases.pause() - ActiveSessionState.PAUSED -> activeSessionUseCases.resume() + ActiveSessionState.RUNNING -> activeSessionUseCases.pause(timeProvider.now()) + ActiveSessionState.PAUSED -> activeSessionUseCases.resume(timeProvider.now()) else -> {} } } - private fun startTimer() { - if (_timer != null) { - return - } - _timer = timer( - name = "Timer", - initialDelay = 0, - period = 100.milliseconds.inWholeMilliseconds - ) { - _clock.update { !it } - } - } - private suspend fun stopSession() { + // TODO this logic should be moved to the use case // complete running section - val savableState = activeSessionUseCases.getFinalizedSession() - // ignore empty sections (e.g. when paused and then stopped immediately)) - val sections = savableState.completedSections.filter { it.duration > 0.seconds } + val savableState = activeSessionUseCases.getFinalizedSession(timeProvider.now()) // store in database sessionUseCases.add( sessionCreationAttributes = SessionCreationAttributes( // add up all pause durations - breakDuration = sections.fold(0.seconds) { acc, section -> + breakDuration = savableState.completedSections.fold(0.seconds) { acc, section -> acc + section.pauseDuration }, comment = _endDialogComment.value, rating = _endDialogRating.value ), - sectionCreationAttributes = sections.map { section -> + sectionCreationAttributes = savableState.completedSections.map { section -> SectionCreationAttributes( libraryItemId = section.libraryItem.id, duration = section.duration, @@ -429,13 +440,9 @@ class ActiveSessionViewModel @Inject constructor( } ) activeSessionUseCases.reset() // reset the active session state - viewModelScope.launch { - navigationChannel.send(NavigationEvent.NavigateUp) - } } } -sealed interface NavigationEvent { - object NavigateUp : NavigationEvent - object HideTools : NavigationEvent +sealed interface ActiveSessionEvent { + object HideTools : ActiveSessionEvent } 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 84959eec..3d0583a8 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/SessionService.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/SessionService.kt @@ -71,6 +71,9 @@ class SessionService : Service() { lateinit var useCases: ActiveSessionUseCases private var _timer: Timer? = null + @Inject + lateinit var notificationManager: NotificationManager + /** Broadcast receiver (currently only for pause action) */ private val myReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -173,9 +176,9 @@ class SessionService : Service() { private fun togglePause() { applicationScope.launch { if (useCases.isSessionPaused()) { - useCases.resume() + useCases.resume(timeProvider.now()) } else { - useCases.pause() + useCases.pause(timeProvider.now()) } } updateNotification() @@ -186,8 +189,7 @@ class SessionService : Service() { private fun updateNotification() { Log.d(LOG_TAG, "updateNotification") applicationScope.launch { - val mNotificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - mNotificationManager.notify(SESSION_NOTIFICATION_ID, createNotification()) + notificationManager.notify(SESSION_NOTIFICATION_ID, createNotification()) } } @@ -200,8 +202,13 @@ class SessionService : Service() { // TODO: move this logic to use case try { - val totalPracticeDurationStr = - getDurationString(useCases.getPracticeDuration(), DurationFormat.HMS_DIGITAL) + val state = useCases.getState().first() + check(state != null) { "State is null. Cannot create notification." } + + val totalPracticeDurationStr = getDurationString( + useCases.computeTotalPracticeDuration(state, timeProvider.now()), + DurationFormat.HMS_DIGITAL + ) val currentSectionName = useCases.getRunningItem().first()!!.name diff --git a/app/src/main/java/app/musikus/core/data/PrepopulateDatabase.kt b/app/src/main/java/app/musikus/core/data/PrepopulateDatabase.kt index 1f383bfd..4ca5e850 100644 --- a/app/src/main/java/app/musikus/core/data/PrepopulateDatabase.kt +++ b/app/src/main/java/app/musikus/core/data/PrepopulateDatabase.kt @@ -9,6 +9,7 @@ package app.musikus.core.data import android.util.Log +import app.musikus.core.domain.minus import app.musikus.goals.data.GoalRepositoryImpl import app.musikus.goals.data.entities.GoalDescriptionCreationAttributes import app.musikus.goals.data.entities.GoalInstanceCreationAttributes @@ -23,9 +24,9 @@ import app.musikus.sessions.data.entities.SectionCreationAttributes import app.musikus.sessions.data.entities.SessionCreationAttributes import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first -import java.time.temporal.ChronoUnit import kotlin.math.pow import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds suspend fun prepopulateDatabase( database: MusikusDatabase, @@ -167,22 +168,24 @@ suspend fun prepopulateDatabase( comment = "", ) }.forEach { (sessionNum, session) -> + var partialSessionDuration = 0.seconds database.sessionDao.insert( session, (1..(1..5).random()).map { val duration = (10..20).random().minutes - SectionCreationAttributes( + val newSection = SectionCreationAttributes( libraryItemId = items.random().id, - startTimestamp = database.timeProvider.now().minus( + startTimestamp = database.timeProvider.now() - ( ( (sessionNum / 2) * // two sessions per day initially 24 * 60 * 60 * 1.02.pow(sessionNum.toDouble()) // exponential growth - ).toLong() + duration.inWholeSeconds, - ChronoUnit.SECONDS - ), + ).toLong().seconds + duration + partialSessionDuration + ), duration = duration, ) + partialSessionDuration += duration + return@map newSection } ) delay(10) diff --git a/app/src/main/java/app/musikus/core/di/MainModule.kt b/app/src/main/java/app/musikus/core/di/MainModule.kt index f2df7f51..c0c333fb 100644 --- a/app/src/main/java/app/musikus/core/di/MainModule.kt +++ b/app/src/main/java/app/musikus/core/di/MainModule.kt @@ -37,8 +37,10 @@ object MainModule { @Provides @Singleton - fun provideTimeProvider(): TimeProvider { - return TimeProviderImpl() + fun provideTimeProvider( + @ApplicationScope scope: CoroutineScope + ): TimeProvider { + return TimeProviderImpl(scope) } @Provides diff --git a/app/src/main/java/app/musikus/core/di/NotificationModule.kt b/app/src/main/java/app/musikus/core/di/NotificationModule.kt new file mode 100644 index 00000000..d4bb3903 --- /dev/null +++ b/app/src/main/java/app/musikus/core/di/NotificationModule.kt @@ -0,0 +1,33 @@ +/* + * 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.di + +import android.app.NotificationManager +import android.content.Context +import app.musikus.core.presentation.createNotificationChannels +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NotificationModule { + + @Provides + @Singleton + fun provideNotificationManager( + @ApplicationContext context: Context + ): NotificationManager { + createNotificationChannels(context) + return context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } +} diff --git a/app/src/main/java/app/musikus/core/domain/TimeProvider.kt b/app/src/main/java/app/musikus/core/domain/TimeProvider.kt index 2dbdcc0a..b54e9579 100644 --- a/app/src/main/java/app/musikus/core/domain/TimeProvider.kt +++ b/app/src/main/java/app/musikus/core/domain/TimeProvider.kt @@ -8,6 +8,15 @@ package app.musikus.core.domain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime @@ -21,6 +30,8 @@ import kotlin.time.Duration.Companion.milliseconds interface TimeProvider { fun now(): ZonedDateTime + val clock: Flow + fun localZoneId(): ZoneId = now().zone /** @@ -100,10 +111,31 @@ interface TimeProvider { } } -class TimeProviderImpl : TimeProvider { +class TimeProviderImpl(scope: CoroutineScope) : TimeProvider { + + private var isClockRunning = false + + override val clock: StateFlow = flow { + while (true) { + emit(ZonedDateTime.now()) + delay(100.milliseconds) + } + }.onStart { + isClockRunning = true + }.onCompletion { + isClockRunning = false + }.stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(), + initialValue = ZonedDateTime.now() + ) override fun now(): ZonedDateTime { - return ZonedDateTime.now() + return if (isClockRunning) { + clock.value + } else { + ZonedDateTime.now() + } } } diff --git a/app/src/main/java/app/musikus/core/presentation/Application.kt b/app/src/main/java/app/musikus/core/presentation/Application.kt deleted file mode 100644 index 885205a5..00000000 --- a/app/src/main/java/app/musikus/core/presentation/Application.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * 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, Michael Prommersberger - */ - -package app.musikus.core.presentation - -import android.app.Application -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.os.Build -import app.musikus.R -import dagger.hilt.android.HiltAndroidApp -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors - -const val METRONOME_NOTIFICATION_CHANNEL_ID = "metronome_notification_channel" -const val METRONOME_NOTIFICATION_CHANNEL_NAME = "Metronome notification" - -const val SESSION_NOTIFICATION_CHANNEL_ID = "session_notification_channel" -const val SESSION_NOTIFICATION_CHANNEL_NAME = "Session notification" - -const val RECORDER_NOTIFICATION_CHANNEL_ID = "recorder_notification_channel" -const val RECORDER_NOTIFICATION_CHANNEL_NAME = "Recorder notification" - -@HiltAndroidApp -class Musikus : Application() { - - override fun onCreate() { - super.onCreate() - createNotificationChannels() - } - - private fun createNotificationChannels() { - // Create the NotificationChannel, but only on API 26+ because - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val notificationManager = getSystemService( - Context.NOTIFICATION_SERVICE - ) as NotificationManager - - val sessionNotificationChannel = NotificationChannel( - SESSION_NOTIFICATION_CHANNEL_ID, - SESSION_NOTIFICATION_CHANNEL_NAME, - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = "Notification to keep track of the running session" - } - - // Register the channel with the system - notificationManager.createNotificationChannel(sessionNotificationChannel) - - val metronomeNotificationChannel = NotificationChannel( - METRONOME_NOTIFICATION_CHANNEL_ID, - METRONOME_NOTIFICATION_CHANNEL_NAME, - NotificationManager.IMPORTANCE_DEFAULT - ).apply { - description = "Notification to keep track of the metronome" - } - - // Register the channel with the system - notificationManager.createNotificationChannel(metronomeNotificationChannel) - - val recorderNotificationChannel = NotificationChannel( - RECORDER_NOTIFICATION_CHANNEL_ID, - RECORDER_NOTIFICATION_CHANNEL_NAME, - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = "Notification to keep track of the recorder" - } - // Register the channel with the system - notificationManager.createNotificationChannel(recorderNotificationChannel) - } - } - - companion object { - val executorService: ExecutorService = Executors.newFixedThreadPool(4) - private val IO_EXECUTOR = Executors.newSingleThreadExecutor() - - fun ioThread(f: () -> Unit) { - IO_EXECUTOR.execute(f) - } - - var noSessionsYet = true - var serviceIsRunning = false - - fun getRandomQuote(context: Context): CharSequence { - return context.resources.getTextArray(R.array.quotes).random() - } - - fun dp(context: Context, dp: Int): Float { - return context.resources.displayMetrics.density * dp - } - } -} diff --git a/app/src/main/java/app/musikus/core/presentation/Musikus.kt b/app/src/main/java/app/musikus/core/presentation/Musikus.kt new file mode 100644 index 00000000..96b79217 --- /dev/null +++ b/app/src/main/java/app/musikus/core/presentation/Musikus.kt @@ -0,0 +1,44 @@ +/* + * 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, Michael Prommersberger + */ + +package app.musikus.core.presentation + +import android.app.Application +import android.content.Context +import app.musikus.R +import dagger.hilt.android.HiltAndroidApp +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@HiltAndroidApp +class Musikus : Application() { + + override fun onCreate() { + super.onCreate() + } + + companion object { + val executorService: ExecutorService = Executors.newFixedThreadPool(4) + private val IO_EXECUTOR = Executors.newSingleThreadExecutor() + + fun ioThread(f: () -> Unit) { + IO_EXECUTOR.execute(f) + } + + var noSessionsYet = true + var serviceIsRunning = false + + fun getRandomQuote(context: Context): CharSequence { + return context.resources.getTextArray(R.array.quotes).random() + } + + fun dp(context: Context, dp: Int): Float { + return context.resources.displayMetrics.density * dp + } + } +} diff --git a/app/src/main/java/app/musikus/core/presentation/MusikusNavHost.kt b/app/src/main/java/app/musikus/core/presentation/MusikusNavHost.kt index aade2edf..7018bec9 100644 --- a/app/src/main/java/app/musikus/core/presentation/MusikusNavHost.kt +++ b/app/src/main/java/app/musikus/core/presentation/MusikusNavHost.kt @@ -103,6 +103,7 @@ fun MusikusNavHost( ActiveSession( deepLinkAction = deepLinkAction, + mainEventHandler = mainEventHandler, navigateUp = navController::navigateUp, ) } diff --git a/app/src/main/java/app/musikus/core/presentation/Notification.kt b/app/src/main/java/app/musikus/core/presentation/Notification.kt new file mode 100644 index 00000000..5c3a71b4 --- /dev/null +++ b/app/src/main/java/app/musikus/core/presentation/Notification.kt @@ -0,0 +1,64 @@ +/* + * 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 android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build + +const val METRONOME_NOTIFICATION_CHANNEL_ID = "metronome_notification_channel" +const val METRONOME_NOTIFICATION_CHANNEL_NAME = "Metronome notification" + +const val SESSION_NOTIFICATION_CHANNEL_ID = "session_notification_channel" +const val SESSION_NOTIFICATION_CHANNEL_NAME = "Session notification" + +const val RECORDER_NOTIFICATION_CHANNEL_ID = "recorder_notification_channel" +const val RECORDER_NOTIFICATION_CHANNEL_NAME = "Recorder notification" + +fun createNotificationChannels(context: Context) { + // Create the NotificationChannel, but only on API 26+ because + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + + val sessionNotificationChannel = NotificationChannel( + SESSION_NOTIFICATION_CHANNEL_ID, + SESSION_NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Notification to keep track of the running session" + } + + // Register the channel with the system + notificationManager.createNotificationChannel(sessionNotificationChannel) + + val metronomeNotificationChannel = NotificationChannel( + METRONOME_NOTIFICATION_CHANNEL_ID, + METRONOME_NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Notification to keep track of the metronome" + } + + // Register the channel with the system + notificationManager.createNotificationChannel(metronomeNotificationChannel) + + val recorderNotificationChannel = NotificationChannel( + RECORDER_NOTIFICATION_CHANNEL_ID, + RECORDER_NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Notification to keep track of the recorder" + } + // Register the channel with the system + notificationManager.createNotificationChannel(recorderNotificationChannel) + } +} diff --git a/app/src/main/java/app/musikus/core/presentation/utils/DurationFormatter.kt b/app/src/main/java/app/musikus/core/presentation/utils/DurationFormatter.kt index 91635bda..0bf11f04 100644 --- a/app/src/main/java/app/musikus/core/presentation/utils/DurationFormatter.kt +++ b/app/src/main/java/app/musikus/core/presentation/utils/DurationFormatter.kt @@ -182,9 +182,10 @@ fun getDurationString( hours > 0 -> "${hours + 1} hours" // if the duration is less than one hour but still positive, show the number of begun minutes duration.isPositive() -> "${minutes + 1} minutes" - // if duration is zero or negative, throw an error - else -> throw (IllegalArgumentException("Duration must be positive")) - }) + // if duration is zero or negative, throw an error + else -> throw (IllegalArgumentException("Duration must be positive")) + } + ) } DurationFormat.PRETTY_APPROX_SHORT -> { @@ -196,9 +197,10 @@ fun getDurationString( hours > 0 -> "${hours + 1}h" // if the duration is less than one hour but still positive, show the number of begun minutes duration.isPositive() -> "${minutes + 1}m" - // if the duration is zero or negative, throw an error - else -> throw (IllegalArgumentException("Duration must be positive")) - }) + // if the duration is zero or negative, throw an error + else -> throw (IllegalArgumentException("Duration must be positive")) + } + ) } } } diff --git a/app/src/main/java/app/musikus/core/presentation/utils/UiText.kt b/app/src/main/java/app/musikus/core/presentation/utils/UiText.kt index 30b43bab..5cee68d7 100644 --- a/app/src/main/java/app/musikus/core/presentation/utils/UiText.kt +++ b/app/src/main/java/app/musikus/core/presentation/utils/UiText.kt @@ -76,7 +76,6 @@ fun htmlResource(@StringRes resId: Int, vararg formatArgs: Any): AnnotatedString return AnnotatedString.Builder().apply { append(spanned.toString()) spanned.getSpans(0, spanned.length, Any::class.java).forEach { span -> - println(span) val start = spanned.getSpanStart(span) val end = spanned.getSpanEnd(span) diff --git a/app/src/main/java/app/musikus/goals/presentation/GoalCard.kt b/app/src/main/java/app/musikus/goals/presentation/GoalCard.kt index eaca9c0d..9ee3dc07 100644 --- a/app/src/main/java/app/musikus/goals/presentation/GoalCard.kt +++ b/app/src/main/java/app/musikus/goals/presentation/GoalCard.kt @@ -143,10 +143,14 @@ fun GoalCard( Text( modifier = Modifier.padding(8.dp), maxLines = 1, - text = if (remainingTime.isPositive()) stringResource( - R.string.core_time_left, - getDurationString(remainingTime, DurationFormat.PRETTY_APPROX) - ) else stringResource(R.string.goals_goal_card_expired), + text = if (remainingTime.isPositive()) { + stringResource( + R.string.core_time_left, + getDurationString(remainingTime, DurationFormat.PRETTY_APPROX) + ) + } else { + stringResource(R.string.goals_goal_card_expired) + }, ) } } 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 e06d6e2e..230bfa97 100644 --- a/app/src/main/java/app/musikus/recorder/presentation/RecorderService.kt +++ b/app/src/main/java/app/musikus/recorder/presentation/RecorderService.kt @@ -78,6 +78,9 @@ class RecorderService : Service() { @ApplicationScope lateinit var applicationScope: CoroutineScope + @Inject + lateinit var notificationManager: NotificationManager + /** Interface object for clients that bind */ private val binder = LocalBinder() inner class LocalBinder : Binder() { @@ -293,8 +296,7 @@ class RecorderService : Service() { private fun updateNotification(duration: Duration) { val notification: Notification = getNotification(duration) - val mNotificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - mNotificationManager.notify(RECORDER_NOTIFICATION_ID, notification) + notificationManager.notify(RECORDER_NOTIFICATION_ID, notification) } private fun setFinalNotification() { @@ -303,8 +305,7 @@ class RecorderService : Service() { text = getString(R.string.recorder_service_final_notification_text), persistent = false ) - val mNotificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - mNotificationManager.notify(RECORDER_NOTIFICATION_ID, notification) + notificationManager.notify(RECORDER_NOTIFICATION_ID, notification) } private fun getNotification(duration: Duration): Notification { diff --git a/app/src/main/java/app/musikus/recorder/presentation/RecorderUi.kt b/app/src/main/java/app/musikus/recorder/presentation/RecorderUi.kt index d15da711..905d2932 100644 --- a/app/src/main/java/app/musikus/recorder/presentation/RecorderUi.kt +++ b/app/src/main/java/app/musikus/recorder/presentation/RecorderUi.kt @@ -49,7 +49,6 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedIconButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -76,6 +75,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.session.MediaController import app.musikus.R +import app.musikus.core.presentation.MainUiEvent import app.musikus.core.presentation.components.DialogActions import app.musikus.core.presentation.components.ExceptionHandler import app.musikus.core.presentation.components.Waveform @@ -100,7 +100,7 @@ import kotlin.time.Duration.Companion.seconds fun RecorderUi( modifier: Modifier = Modifier, viewModel: RecorderViewModel = hiltViewModel(), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } + showSnackbar: (MainUiEvent.ShowSnackbar) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val eventHandler = viewModel::onUiEvent @@ -167,7 +167,7 @@ fun RecorderUi( onSetCurrentPosition = { currentPlaybackPosition = it }, - snackbarHostState = snackbarHostState, + showSnackbar = showSnackbar, ) } @@ -180,7 +180,7 @@ fun RecorderLayout( currentPlaybackPosition: Long, onSetCurrentPosition: (Long) -> Unit, playerState: PlayerState?, - snackbarHostState: SnackbarHostState + showSnackbar: (MainUiEvent.ShowSnackbar) -> Unit, ) { Column( modifier = modifier @@ -207,7 +207,7 @@ fun RecorderLayout( onSetCurrentPosition = onSetCurrentPosition, currentPosition = currentPlaybackPosition, currentRawRecording = uiState.currentPlaybackRawMedia, - snackbarHostState = snackbarHostState + showSnackbar = showSnackbar ) } @@ -351,7 +351,7 @@ private fun RecordingsList( onSetCurrentPosition: (Long) -> Unit, playerState: PlayerState?, onNewMediaSelected: (Uri?) -> Unit, - snackbarHostState: SnackbarHostState + showSnackbar: (MainUiEvent.ShowSnackbar) -> Unit ) { if (recordingsList.isEmpty()) { Column(modifier = modifier) { @@ -396,7 +396,7 @@ private fun RecordingsList( onSetCurrentPosition = onSetCurrentPosition, onClearPlayback = { onNewMediaSelected(null) }, currentRawRecording = currentRawRecording, - snackbarHostState = snackbarHostState + showSnackbar = showSnackbar ) } } @@ -411,7 +411,7 @@ private fun RecordingListItem( isPlaying: Boolean, playerState: PlayerState?, // TODO remove? mediaController: MediaController?, // TODO remove? - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + showSnackbar: (MainUiEvent.ShowSnackbar) -> Unit, currentPlaybackPosition: Long, onSetCurrentPosition: (Long) -> Unit, onStartPlayingPressed: () -> Unit, @@ -755,7 +755,7 @@ private fun PreviewRecorderUi( playerState = null, currentPlaybackPosition = 0, onSetCurrentPosition = {}, - snackbarHostState = remember { SnackbarHostState() } + showSnackbar = {} ) } } diff --git a/app/src/main/java/app/musikus/sessions/data/daos/SessionDao.kt b/app/src/main/java/app/musikus/sessions/data/daos/SessionDao.kt index 00dcf37c..3a6ba507 100644 --- a/app/src/main/java/app/musikus/sessions/data/daos/SessionDao.kt +++ b/app/src/main/java/app/musikus/sessions/data/daos/SessionDao.kt @@ -142,8 +142,7 @@ abstract class SessionDao( @Transaction @RewriteQueriesToDropUnusedColumns @Query("SELECT * FROM session WHERE deleted=0") - abstract fun getAllWithSectionsWithLibraryItems() - : Flow> + abstract fun getAllWithSectionsWithLibraryItems(): Flow> @Transaction @RewriteQueriesToDropUnusedColumns diff --git a/app/src/main/java/app/musikus/sessions/domain/usecase/GetAllSessionsUseCase.kt b/app/src/main/java/app/musikus/sessions/domain/usecase/GetAllSessionsUseCase.kt index 49eb621f..d0da0633 100644 --- a/app/src/main/java/app/musikus/sessions/domain/usecase/GetAllSessionsUseCase.kt +++ b/app/src/main/java/app/musikus/sessions/domain/usecase/GetAllSessionsUseCase.kt @@ -19,4 +19,4 @@ class GetAllSessionsUseCase( operator fun invoke(): Flow> { return sessionsRepository.sessionsWithSectionsWithLibraryItems } -} \ No newline at end of file +} diff --git a/app/src/main/java/app/musikus/sessions/presentation/SessionCard.kt b/app/src/main/java/app/musikus/sessions/presentation/SessionCard.kt index 177047ef..4f5260ba 100644 --- a/app/src/main/java/app/musikus/sessions/presentation/SessionCard.kt +++ b/app/src/main/java/app/musikus/sessions/presentation/SessionCard.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -63,7 +65,17 @@ fun RatingBar( size: Dp = 16.dp, onRatingChanged: (Int) -> Unit = {} ) { - Row(modifier) { + val ratingBarContentDescription = stringResource( + id = R.string.components_rating_bar_rating_description, + rating, + total + ) + + Row( + modifier = modifier.semantics { + contentDescription = ratingBarContentDescription + } + ) { for (i in 1..total) { Icon( modifier = Modifier @@ -78,7 +90,11 @@ fun RatingBar( } else { MaterialTheme.colorScheme.onSurfaceVariant }, - contentDescription = null + contentDescription = stringResource( + id = R.string.components_rating_bar_individual_rating_description, + i, + total + ) ) } } diff --git a/app/src/main/java/app/musikus/statistics/presentation/StatisticsViewModel.kt b/app/src/main/java/app/musikus/statistics/presentation/StatisticsViewModel.kt index 30a4f41e..c989e4db 100644 --- a/app/src/main/java/app/musikus/statistics/presentation/StatisticsViewModel.kt +++ b/app/src/main/java/app/musikus/statistics/presentation/StatisticsViewModel.kt @@ -242,8 +242,8 @@ class StatisticsViewModel @Inject constructor( GoalCardGoalDisplayData( label = it.instance.startTimestamp.musikusFormat(DateFormat.DAY_AND_MONTH), progress = ( - it.progress.inWholeSeconds.toFloat() / it.instance.target.inWholeSeconds - ).coerceAtMost(1f), + it.progress.inWholeSeconds.toFloat() / it.instance.target.inWholeSeconds + ).coerceAtMost(1f), color = it.description.libraryItems.firstOrNull()?.let { item -> libraryItemColors[item.colorIndex] } @@ -257,10 +257,12 @@ class StatisticsViewModel @Inject constructor( // the actual data. This triggers the card animation. if (_noGoalsCard) { // Emit the empty placeholder - emit(StatisticsGoalsCardUiState( - successRate = null, - lastGoalsDisplayData = lastFiveGoals.map { GoalCardGoalDisplayData() } - )) + emit( + StatisticsGoalsCardUiState( + successRate = null, + lastGoalsDisplayData = lastFiveGoals.map { GoalCardGoalDisplayData() } + ) + ) delay(350) _noGoalsCard = false } @@ -270,7 +272,8 @@ class StatisticsViewModel @Inject constructor( StatisticsGoalsCardUiState( successRate = successRate, lastGoalsDisplayData = lastGoalsDisplayData - )) + ) + ) } }.stateIn( scope = viewModelScope, diff --git a/app/src/main/res/values/activesession_strings.xml b/app/src/main/res/values/activesession_strings.xml index db5d985f..7e85461e 100644 --- a/app/src/main/res/values/activesession_strings.xml +++ b/app/src/main/res/values/activesession_strings.xml @@ -13,11 +13,14 @@ Discard session? All progress will be lost! + Pause + Discard Finish Practice Time Paused %1$s + Resume Already practiced diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e91fab6c..0fc2dd8e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,10 @@ + + %1$d out of %1$d star rating + Rate %1$d out of %1$d stars + Settings About the app diff --git a/app/src/test/java/app/musikus/core/domain/FakeTimeProvider.kt b/app/src/test/java/app/musikus/core/domain/FakeTimeProvider.kt index c12d1cd6..51cd689a 100644 --- a/app/src/test/java/app/musikus/core/domain/FakeTimeProvider.kt +++ b/app/src/test/java/app/musikus/core/domain/FakeTimeProvider.kt @@ -8,32 +8,37 @@ package app.musikus.core.domain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import java.time.ZoneId import java.time.ZonedDateTime import kotlin.time.Duration import kotlin.time.toJavaDuration class FakeTimeProvider : TimeProvider { - private var _currentDateTime = START_TIME + private val _clock = MutableStateFlow(START_TIME) + override val clock: Flow get() = _clock.asStateFlow() override fun now(): ZonedDateTime { - return _currentDateTime + return _clock.value } fun setCurrentDateTime(dateTime: ZonedDateTime) { - _currentDateTime = dateTime + _clock.update { dateTime } } fun moveToTimezone(newZoneId: ZoneId) { - _currentDateTime = _currentDateTime.withZoneSameInstant(newZoneId) + _clock.update { it.withZoneSameInstant(newZoneId) } } fun advanceTimeBy(duration: Duration) { - _currentDateTime = _currentDateTime.plus(duration.toJavaDuration()) + _clock.update { it.plus(duration.toJavaDuration()) } } fun revertTimeBy(duration: Duration) { - _currentDateTime = _currentDateTime.minus(duration.toJavaDuration()) + _clock.update { it.minus(duration.toJavaDuration()) } } companion object { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76526a51..de42f40e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,8 @@ androidx-legacy-support-v4 = "1.0.0" androidx-lifecycle = "2.8.7" androidx-navigation = "2.8.4" androidx-test = "1.6.1" -androidx-test-runner = "1.6.2" +androidx-test-runner = "1.6.1" +androidx-test-rules = "1.6.1" androidx-test-espresso = "3.6.1" androidx-test-ext-junit = "1.2.1" androidx-hilt-navigation-compose = "1.2.0" @@ -81,6 +82,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-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" } 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" }