From 349fa1d6e11990b004eb735c57b564a1eaa8a889 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sat, 30 Nov 2024 23:12:59 +0100 Subject: [PATCH 01/19] remove redundant use case modules --- .../di/TestActiveSessionUseCasesModule.kt | 98 ------------------- .../library/di/TestLibraryUseCasesModule.kt | 70 ------------- .../di/TestUserPreferencesUseCasesModule.kt | 40 -------- .../di/TestRecordingsUseCasesModule.kt | 36 ------- .../di/TestSessionsUseCasesModule.kt | 51 ---------- 5 files changed, 295 deletions(-) delete mode 100644 app/src/androidTest/java/app/musikus/activesession/di/TestActiveSessionUseCasesModule.kt delete mode 100644 app/src/androidTest/java/app/musikus/library/di/TestLibraryUseCasesModule.kt delete mode 100644 app/src/androidTest/java/app/musikus/menu/di/TestUserPreferencesUseCasesModule.kt delete mode 100644 app/src/androidTest/java/app/musikus/recorder/di/TestRecordingsUseCasesModule.kt delete mode 100644 app/src/androidTest/java/app/musikus/sessionslist/di/TestSessionsUseCasesModule.kt 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/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/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), - ) - } -} From eaa20c19e1105a8cef5ebeb1b11cf3b6e05fa941 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Wed, 4 Dec 2024 20:08:07 +0100 Subject: [PATCH 02/19] add test for session service --- app/build.gradle.kts | 1 + .../presentation/SessionServiceTest.kt | 75 +++++++++++++++++++ .../data/TestPermissionRepository.kt | 17 +++++ .../permissions/di/TestPermissionsModule.kt | 59 +++++++++++++++ gradle/libs.versions.toml | 4 +- 5 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/app/musikus/activesession/presentation/SessionServiceTest.kt create mode 100644 app/src/androidTest/java/app/musikus/permissions/data/TestPermissionRepository.kt create mode 100644 app/src/androidTest/java/app/musikus/permissions/di/TestPermissionsModule.kt 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/presentation/SessionServiceTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/SessionServiceTest.kt new file mode 100644 index 00000000..7088a25d --- /dev/null +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/SessionServiceTest.kt @@ -0,0 +1,75 @@ +/* + * 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() + + // Create the stop intent + val stopIntent = Intent( + context, + SessionService::class.java + ).apply { + action = ActiveSessionServiceActions.STOP.name + } + + serviceRule.startService(stopIntent) + + // Verify that the service has stopped correctly + assertThat(binder.pingBinder()).isFalse() + } +} 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/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" } From 8c36d2777381713968189e0d5b41d18e9f020ae7 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Wed, 4 Dec 2024 20:09:14 +0100 Subject: [PATCH 03/19] add test for active session screen --- .../presentation/ActiveSessionScreenTest.kt | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt 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..667bd88f --- /dev/null +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -0,0 +1,81 @@ +/* + * 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.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.musikus.core.data.Nullable +import app.musikus.core.data.UUIDConverter +import app.musikus.core.domain.FakeTimeProvider +import app.musikus.core.presentation.MainActivity +import app.musikus.library.data.entities.LibraryFolderCreationAttributes +import app.musikus.library.data.entities.LibraryItemCreationAttributes +import app.musikus.library.domain.usecase.LibraryUseCases +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class ActiveSessionScreenTest { + @Inject lateinit var libraryUseCases: LibraryUseCases + + @Inject lateinit var fakeTimeProvider: FakeTimeProvider + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @Before + fun setUp() { + hiltRule.inject() + + composeRule.activity.setContent { + runBlocking { + libraryUseCases.addFolder( + LibraryFolderCreationAttributes("TestFolder1") + ) + libraryUseCases.addItem( + LibraryItemCreationAttributes( + name = "TestItem1", + colorIndex = 1, + ) + ) + libraryUseCases.addItem( + LibraryItemCreationAttributes( + name = "TestItem2", + colorIndex = 1, + libraryFolderId = Nullable(UUIDConverter.fromInt(1)) + ) + ) + } + + ActiveSession( + navigateUp = {} + ) + } + } + + @Test + fun startSession_fabChanges() { + composeRule.onNodeWithContentDescription("Start practicing").performClick() + + composeRule.onNodeWithText("TestItem1").performClick() + composeRule.onNodeWithContentDescription("Next item").assertIsDisplayed() + } +} From 277c72a3954c68d572c21a2acde39f320898e1aa Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 8 Dec 2024 22:02:45 +0100 Subject: [PATCH 04/19] fix active session test by adding a dedicated NotificationManager This NotificationManager is dependency injected by dagger hilt which at the same time creates the notification channels. Thereby the whole mechanism works out of the box when used in tests. --- .../presentation/ActiveSessionScreenTest.kt | 32 ++++++ .../presentation/ActiveSessionScreen.kt | 14 ++- .../presentation/SessionService.kt | 6 +- .../app/musikus/core/di/NotificationModule.kt | 33 +++++++ .../musikus/core/presentation/Application.kt | 98 ------------------- .../app/musikus/core/presentation/Musikus.kt | 44 +++++++++ .../musikus/core/presentation/Notification.kt | 64 ++++++++++++ .../recorder/presentation/RecorderService.kt | 9 +- .../main/res/values/activesession_strings.xml | 3 + 9 files changed, 196 insertions(+), 107 deletions(-) create mode 100644 app/src/main/java/app/musikus/core/di/NotificationModule.kt delete mode 100644 app/src/main/java/app/musikus/core/presentation/Application.kt create mode 100644 app/src/main/java/app/musikus/core/presentation/Musikus.kt create mode 100644 app/src/main/java/app/musikus/core/presentation/Notification.kt diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index 667bd88f..24cb9341 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -10,6 +10,7 @@ package app.musikus.activesession.presentation import androidx.activity.compose.setContent import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText @@ -23,11 +24,13 @@ import app.musikus.library.data.entities.LibraryItemCreationAttributes import app.musikus.library.domain.usecase.LibraryUseCases import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Rule import org.junit.Test import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltAndroidTest class ActiveSessionScreenTest { @@ -78,4 +81,33 @@ class ActiveSessionScreenTest { 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 after advancing time by 1 second + // (a session with 0 seconds can not be paused) + fakeTimeProvider.advanceTimeBy(1.seconds) + composeRule.onNodeWithContentDescription("Pause").performClick() + + // Pause timer is displayed + composeRule.onNodeWithText("Paused 00:00").assertIsDisplayed() + + fakeTimeProvider.advanceTimeBy(90.seconds) + + runBlocking { + delay(100) + } + + // Pause timer shows correct time + composeRule.onNodeWithText("Paused 01:30") + .assertIsDisplayed() + .performClick() // Resume session + + // Pause timer is hidden + composeRule.onNodeWithText("Paused", substring = true).assertIsNotDisplayed() + } } 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..500ede66 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionScreen.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionScreen.kt @@ -806,7 +806,10 @@ 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( @@ -829,7 +832,7 @@ private fun PauseButton( ) { Icon( imageVector = Icons.Filled.Pause, - contentDescription = null + contentDescription = stringResource(id = R.string.active_session_top_bar_pause) ) } } @@ -985,7 +988,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()) } 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..5aaf8b68 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) { @@ -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()) } } 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/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/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/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/res/values/activesession_strings.xml b/app/src/main/res/values/activesession_strings.xml index db5d985f..b0922575 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 + Delete Finish Practice Time Paused %1$s + Resume Already practiced From d53826b2ccc24c5553922f30c07e08ccb3e06d34 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Mon, 9 Dec 2024 22:20:26 +0100 Subject: [PATCH 05/19] move clock from ActiveSessionViewModel to TimeProvider --- .../presentation/ActiveSessionScreenTest.kt | 5 ---- .../musikus/core/domain/FakeTimeProvider.kt | 15 ++++++---- .../presentation/ActiveSessionViewModel.kt | 28 ++++--------------- .../java/app/musikus/core/di/MainModule.kt | 6 ++-- .../app/musikus/core/domain/TimeProvider.kt | 21 +++++++++++++- 5 files changed, 39 insertions(+), 36 deletions(-) diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index 24cb9341..5da27118 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -24,7 +24,6 @@ import app.musikus.library.data.entities.LibraryItemCreationAttributes import app.musikus.library.domain.usecase.LibraryUseCases import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Rule @@ -98,10 +97,6 @@ class ActiveSessionScreenTest { fakeTimeProvider.advanceTimeBy(90.seconds) - runBlocking { - delay(100) - } - // Pause timer shows correct time composeRule.onNodeWithText("Paused 01:30") .assertIsDisplayed() 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/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt index c654df59..d2587074 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,12 +59,10 @@ 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, + timeProvider: TimeProvider ) : AndroidViewModel(application) { - private var _clock = MutableStateFlow(false) - private var _timer: Timer? = null - /** ---------- Proxies for Flows from UseCases, turned into StateFlows -------------------- */ private val completedSections = activeSessionUseCases.getCompletedSections().stateIn( @@ -135,7 +131,7 @@ class ActiveSessionViewModel @Inject constructor( private val timerUiState = combine( sessionState, - _clock // should update with clock + timeProvider.clock // should update with clock ) { timerState, _ -> val pause = timerState == ActiveSessionState.PAUSED @@ -174,7 +170,7 @@ class ActiveSessionViewModel @Inject constructor( private val currentItemUiState: StateFlow = combine( sessionState, runningLibraryItem, - _clock // should update with clock + timeProvider.clock // should update with clock ) { sessionState, item, _ -> if (sessionState == ActiveSessionState.NOT_STARTED || item == null) return@combine null @@ -292,7 +288,6 @@ class ActiveSessionViewModel @Inject constructor( val navigationEventsChannelFlow = navigationChannel.receiveAsFlow() init { - startTimer() /** Hide the Tools Bottom Sheet on Startup */ runBlocking(context = Dispatchers.IO) { viewModelScope.launch { @@ -392,19 +387,6 @@ class ActiveSessionViewModel @Inject constructor( } } - private fun startTimer() { - if (_timer != null) { - return - } - _timer = timer( - name = "Timer", - initialDelay = 0, - period = 100.milliseconds.inWholeMilliseconds - ) { - _clock.update { !it } - } - } - private suspend fun stopSession() { // complete running section val savableState = activeSessionUseCases.getFinalizedSession() 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/domain/TimeProvider.kt b/app/src/main/java/app/musikus/core/domain/TimeProvider.kt index 2dbdcc0a..b7fe9cca 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,13 @@ 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.stateIn import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime @@ -21,6 +28,8 @@ import kotlin.time.Duration.Companion.milliseconds interface TimeProvider { fun now(): ZonedDateTime + val clock: Flow + fun localZoneId(): ZoneId = now().zone /** @@ -100,7 +109,17 @@ interface TimeProvider { } } -class TimeProviderImpl : TimeProvider { +class TimeProviderImpl(scope: CoroutineScope) : TimeProvider { + override val clock: StateFlow = flow { + while (true) { + emit(now()) // Emit the current time + delay(100.milliseconds) // Wait 100 milliseconds + } + }.stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(), + initialValue = now() + ) override fun now(): ZonedDateTime { return ZonedDateTime.now() From 3aee1568b33fe1f7058a9a9485c750545b950cb6 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Tue, 10 Dec 2024 22:08:15 +0100 Subject: [PATCH 06/19] experimental (could remove later): remove time provider as dependency for active session use cases and pass timestamps through inputs instead This means that we are now actually using the timestamp emitted by the "clock" flow and basing calculations on it. --- .../di/ActiveSessionUseCasesModule.kt | 26 +++++---------- .../usecase/GetFinalizedSessionUseCase.kt | 11 ++++--- .../usecase/GetOngoingPauseDurationUseCase.kt | 7 ++-- .../usecase/GetRunningItemDurationUseCase.kt | 6 +--- .../GetTotalPracticeDurationUseCase.kt | 7 ++-- .../usecase/PauseActiveSessionUseCase.kt | 11 +++---- .../usecase/ResumeActiveSessionUseCase.kt | 5 +-- .../domain/usecase/SelectItemUseCase.kt | 24 +++++++------- .../presentation/ActiveSessionViewModel.kt | 32 +++++++++++-------- .../presentation/SessionService.kt | 10 +++--- .../app/musikus/core/domain/TimeProvider.kt | 23 +++++++++++-- 11 files changed, 87 insertions(+), 75 deletions(-) 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..74eac7b0 100644 --- a/app/src/main/java/app/musikus/activesession/di/ActiveSessionUseCasesModule.kt +++ b/app/src/main/java/app/musikus/activesession/di/ActiveSessionUseCasesModule.kt @@ -26,7 +26,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 +40,42 @@ object ActiveSessionUseCasesModule { @Singleton fun provideActiveSessionUseCases( activeSessionRepository: ActiveSessionRepository, - timeProvider: TimeProvider, idProvider: IdProvider ): ActiveSessionUseCases { - val getOngoingPauseDurationUseCase = GetOngoingPauseDurationUseCase( - activeSessionRepository, - timeProvider - ) + val getOngoingPauseDurationUseCase = GetOngoingPauseDurationUseCase(activeSessionRepository) val resumeUseCase = ResumeActiveSessionUseCase( activeSessionRepository, getOngoingPauseDurationUseCase ) - val getRunningItemDurationUseCase = GetRunningItemDurationUseCase( - activeSessionRepository, - timeProvider - ) + val getRunningItemDurationUseCase = GetRunningItemDurationUseCase(activeSessionRepository) return ActiveSessionUseCases( selectItem = SelectItemUseCase( activeSessionRepository = activeSessionRepository, - getRunningItemDurationUseCase = getRunningItemDurationUseCase, - timeProvider = timeProvider, + getRunningItemDuration = getRunningItemDurationUseCase, idProvider = idProvider ), getPracticeDuration = GetTotalPracticeDurationUseCase( activeSessionRepository = activeSessionRepository, - runningItemDurationUseCase = getRunningItemDurationUseCase + getRunningItemDuration = getRunningItemDurationUseCase ), deleteSection = DeleteSectionUseCase(activeSessionRepository), pause = PauseActiveSessionUseCase( activeSessionRepository = activeSessionRepository, - getRunningItemDurationUseCase = getRunningItemDurationUseCase, - timeProvider = timeProvider + getRunningItemDuration = getRunningItemDurationUseCase, ), resume = resumeUseCase, getRunningItemDuration = getRunningItemDurationUseCase, getCompletedSections = GetCompletedSectionsUseCase(activeSessionRepository), - getOngoingPauseDuration = GetOngoingPauseDurationUseCase(activeSessionRepository, timeProvider), + getOngoingPauseDuration = getOngoingPauseDurationUseCase, isSessionPaused = IsSessionPausedUseCase(activeSessionRepository), getFinalizedSession = GetFinalizedSessionUseCase( activeSessionRepository = activeSessionRepository, - getRunningItemDurationUseCase = getRunningItemDurationUseCase, + getRunningItemDuration = getRunningItemDurationUseCase, idProvider = idProvider, - getOngoingPauseDurationUseCase = getOngoingPauseDurationUseCase + getOngoingPauseDuration = getOngoingPauseDurationUseCase ), getStartTime = GetStartTimeUseCase(activeSessionRepository), reset = ResetSessionUseCase(activeSessionRepository), 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..69f8caee 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,21 +15,22 @@ 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 getRunningItemDuration: GetRunningItemDurationUseCase, + private val getOngoingPauseDuration: GetOngoingPauseDurationUseCase, 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 = getRunningItemDuration(at).inWholeSeconds.seconds + val ongoingPauseDuration = getOngoingPauseDuration(at) // append finished section to completed sections val updatedSections = state.completedSections + PracticeSection( diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/GetOngoingPauseDurationUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/GetOngoingPauseDurationUseCase.kt index 59633733..31c4c28c 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/GetOngoingPauseDurationUseCase.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/GetOngoingPauseDurationUseCase.kt @@ -9,20 +9,19 @@ package app.musikus.activesession.domain.usecase import app.musikus.activesession.domain.ActiveSessionRepository -import app.musikus.core.domain.TimeProvider 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 { + suspend operator fun invoke(at: ZonedDateTime): Duration { val state = activeSessionRepository.getSessionState().first() ?: return 0.seconds 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/GetRunningItemDurationUseCase.kt index ef6ad0a9..a8205f5e 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/GetRunningItemDurationUseCase.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/GetRunningItemDurationUseCase.kt @@ -9,7 +9,6 @@ package app.musikus.activesession.domain.usecase import app.musikus.activesession.domain.ActiveSessionRepository -import app.musikus.core.domain.TimeProvider import app.musikus.core.domain.minus import kotlinx.coroutines.flow.first import java.time.ZonedDateTime @@ -18,11 +17,8 @@ import kotlin.time.Duration.Companion.seconds class GetRunningItemDurationUseCase( private val activeSessionRepository: ActiveSessionRepository, - private val timeProvider: TimeProvider ) { - suspend operator fun invoke( - at: ZonedDateTime = timeProvider.now() - ): Duration { + suspend operator fun invoke(at: ZonedDateTime): Duration { val state = activeSessionRepository.getSessionState().first() ?: throw IllegalStateException("State is null. Cannot get running section!") diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/GetTotalPracticeDurationUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/GetTotalPracticeDurationUseCase.kt index f119b211..6d9f20c5 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/GetTotalPracticeDurationUseCase.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/GetTotalPracticeDurationUseCase.kt @@ -10,15 +10,16 @@ package app.musikus.activesession.domain.usecase import app.musikus.activesession.domain.ActiveSessionRepository import kotlinx.coroutines.flow.first +import java.time.ZonedDateTime import kotlin.time.Duration class GetTotalPracticeDurationUseCase( private val activeSessionRepository: ActiveSessionRepository, - private val runningItemDurationUseCase: GetRunningItemDurationUseCase + private val getRunningItemDuration: GetRunningItemDurationUseCase ) { - suspend operator fun invoke(): Duration { + suspend operator fun invoke(at: ZonedDateTime): Duration { val state = activeSessionRepository.getSessionState().first() - val runningItemDuration = runningItemDurationUseCase() + val runningItemDuration = getRunningItemDuration(at) if (state == null) { throw IllegalStateException("State is null. Cannot get total practice time!") 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..1c0f8e32 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,15 @@ package app.musikus.activesession.domain.usecase import app.musikus.activesession.domain.ActiveSessionRepository -import app.musikus.core.domain.TimeProvider import kotlinx.coroutines.flow.first +import java.time.ZonedDateTime import kotlin.time.Duration.Companion.seconds class PauseActiveSessionUseCase( private val activeSessionRepository: ActiveSessionRepository, - private val getRunningItemDurationUseCase: GetRunningItemDurationUseCase, - private val timeProvider: TimeProvider + private val getRunningItemDuration: GetRunningItemDurationUseCase, ) { - suspend operator fun invoke() { + suspend operator fun invoke(at: ZonedDateTime) { val state = activeSessionRepository.getSessionState().first() ?: throw IllegalStateException("Cannot pause when state is null") @@ -28,11 +27,11 @@ class PauseActiveSessionUseCase( // 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 + if (state.completedSections.isEmpty() && getRunningItemDuration(at) < 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..c465e58a 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, ) { - 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 = getOngoingPauseDurationUseCase(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..36260bcb 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,31 +12,33 @@ 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 getRunningItemDuration: GetRunningItemDurationUseCase, 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) { + if (getRunningItemDuration(at) < 1.seconds) { throw IllegalStateException("Must wait for at least one second before starting a new section.") } @@ -44,10 +46,10 @@ class SelectItemUseCase( if (state.isPaused) throw IllegalStateException("You must resume before selecting a new item.") // take time - val runningSectionTrueDuration = getRunningItemDurationUseCase() + val runningSectionTrueDuration = getRunningItemDuration(at) val changeSectionTimestamp = state.startTimestampSectionPauseCompensated + runningSectionTrueDuration.inWholeSeconds.seconds // running section duration calculated until changeSectionTimestamp - val runningSectionRoundedDuration = getRunningItemDurationUseCase(at = changeSectionTimestamp) + val runningSectionRoundedDuration = getRunningItemDuration(at = changeSectionTimestamp) // append finished section to completed sections val updatedSections = state.completedSections + PracticeSection( @@ -61,7 +63,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 +72,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/ActiveSessionViewModel.kt b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt index d2587074..ac485ea5 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.runBlocking import java.util.UUID import javax.inject.Inject import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @HiltViewModel @@ -60,7 +61,7 @@ class ActiveSessionViewModel @Inject constructor( private val sessionUseCases: SessionsUseCases, private val permissionsUseCases: PermissionsUseCases, @ApplicationScope private val applicationScope: CoroutineScope, - timeProvider: TimeProvider + private val timeProvider: TimeProvider ) : AndroidViewModel(application) { /** ---------- Proxies for Flows from UseCases, turned into StateFlows -------------------- */ @@ -132,16 +133,16 @@ class ActiveSessionViewModel @Inject constructor( private val timerUiState = combine( sessionState, timeProvider.clock // should update with clock - ) { timerState, _ -> + ) { timerState, now -> val pause = timerState == ActiveSessionState.PAUSED val practiceDuration = try { - activeSessionUseCases.getPracticeDuration() + activeSessionUseCases.getPracticeDuration(now) } catch (e: IllegalStateException) { Duration.ZERO // Session not yet started } val pauseDurStr = getDurationString( - activeSessionUseCases.getOngoingPauseDuration(), + activeSessionUseCases.getOngoingPauseDuration(now), DurationFormat.MS_DIGITAL ) ActiveSessionTimerUiState( @@ -171,11 +172,11 @@ class ActiveSessionViewModel @Inject constructor( sessionState, runningLibraryItem, timeProvider.clock // should update with clock - ) { sessionState, item, _ -> + ) { sessionState, item, now -> if (sessionState == ActiveSessionState.NOT_STARTED || item == null) return@combine null val currentItemDuration = try { - activeSessionUseCases.getRunningItemDuration() + activeSessionUseCases.getRunningItemDuration(now) } catch (e: IllegalStateException) { Duration.ZERO // Session not yet started } @@ -354,20 +355,23 @@ 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 + activeSessionUseCases.getRunningItemDuration(timeProvider.now()) < 1.seconds ) { - delay(1000) + delay(1000.milliseconds) } - activeSessionUseCases.selectItem(item) + activeSessionUseCases.selectItem( + item = item, + at = timeProvider.now() + ) } private suspend fun deleteSection(sectionId: UUID) { if (sessionState.value == ActiveSessionState.PAUSED) { - activeSessionUseCases.resume() + activeSessionUseCases.resume(timeProvider.now()) } activeSessionUseCases.deleteSection(sectionId) } @@ -381,15 +385,15 @@ class ActiveSessionViewModel @Inject constructor( 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 suspend fun stopSession() { // complete running section - val savableState = activeSessionUseCases.getFinalizedSession() + val savableState = activeSessionUseCases.getFinalizedSession(timeProvider.now()) // ignore empty sections (e.g. when paused and then stopped immediately)) val sections = savableState.completedSections.filter { it.duration > 0.seconds } // store in database 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 5aaf8b68..55f1e9b9 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/SessionService.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/SessionService.kt @@ -176,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() @@ -202,8 +202,10 @@ class SessionService : Service() { // TODO: move this logic to use case try { - val totalPracticeDurationStr = - getDurationString(useCases.getPracticeDuration(), DurationFormat.HMS_DIGITAL) + val totalPracticeDurationStr = getDurationString( + useCases.getPracticeDuration(timeProvider.now()), + DurationFormat.HMS_DIGITAL + ) val currentSectionName = useCases.getRunningItem().first()!!.name 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 b7fe9cca..70a7a3d3 100644 --- a/app/src/main/java/app/musikus/core/domain/TimeProvider.kt +++ b/app/src/main/java/app/musikus/core/domain/TimeProvider.kt @@ -14,6 +14,8 @@ 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 @@ -110,19 +112,34 @@ interface TimeProvider { } class TimeProviderImpl(scope: CoroutineScope) : TimeProvider { + + private var isClockRunning = false + override val clock: StateFlow = flow { while (true) { - emit(now()) // Emit the current time + emit(ZonedDateTime.now()) // Emit the current time delay(100.milliseconds) // Wait 100 milliseconds } + }.onStart { + println("Clock started") + isClockRunning = true + }.onCompletion { + println("Clock stopped") + isClockRunning = false }.stateIn( scope = scope, started = SharingStarted.WhileSubscribed(), - initialValue = now() + initialValue = ZonedDateTime.now() ) override fun now(): ZonedDateTime { - return ZonedDateTime.now() + return if (isClockRunning) { + println("get running clock value") + clock.value + } else { + println("get zoned date time now") + ZonedDateTime.now() + } } } From 3e083c2f58799d27f39f279b12bc610a9d17fff0 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Fri, 13 Dec 2024 21:51:37 +0100 Subject: [PATCH 07/19] remove active icon duration check from pause use case Some of the checks were moved around the use cases in order to ensure the user can not finish an empty session. --- .../presentation/ActiveSessionScreenTest.kt | 4 +- .../di/ActiveSessionUseCasesModule.kt | 3 +- .../domain/usecase/ActiveSessionUseCases.kt | 2 +- .../usecase/GetFinalizedSessionUseCase.kt | 24 +++-- .../GetTotalPracticeDurationUseCase.kt | 6 +- .../usecase/PauseActiveSessionUseCase.kt | 6 -- .../presentation/ActiveSessionScreen.kt | 8 +- .../presentation/ActiveSessionUiState.kt | 1 + .../presentation/ActiveSessionViewModel.kt | 89 ++++++++++++++----- .../presentation/SessionService.kt | 2 +- .../musikus/core/data/PrepopulateDatabase.kt | 15 ++-- 11 files changed, 104 insertions(+), 56 deletions(-) diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index 5da27118..bbde5f7a 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -87,9 +87,7 @@ class ActiveSessionScreenTest { composeRule.onNodeWithContentDescription("Start practicing").performClick() composeRule.onNodeWithText("TestItem1").performClick() - // Pause session after advancing time by 1 second - // (a session with 0 seconds can not be paused) - fakeTimeProvider.advanceTimeBy(1.seconds) + // Pause session composeRule.onNodeWithContentDescription("Pause").performClick() // Pause timer is displayed 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 74eac7b0..6982a988 100644 --- a/app/src/main/java/app/musikus/activesession/di/ActiveSessionUseCasesModule.kt +++ b/app/src/main/java/app/musikus/activesession/di/ActiveSessionUseCasesModule.kt @@ -57,14 +57,13 @@ object ActiveSessionUseCasesModule { getRunningItemDuration = getRunningItemDurationUseCase, idProvider = idProvider ), - getPracticeDuration = GetTotalPracticeDurationUseCase( + getTotalPracticeDuration = GetTotalPracticeDurationUseCase( activeSessionRepository = activeSessionRepository, getRunningItemDuration = getRunningItemDurationUseCase ), deleteSection = DeleteSectionUseCase(activeSessionRepository), pause = PauseActiveSessionUseCase( activeSessionRepository = activeSessionRepository, - getRunningItemDuration = getRunningItemDurationUseCase, ), resume = resumeUseCase, getRunningItemDuration = getRunningItemDurationUseCase, diff --git a/app/src/main/java/app/musikus/activesession/domain/usecase/ActiveSessionUseCases.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/ActiveSessionUseCases.kt index 3d3142db..5627f6c5 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 @@ -13,7 +13,7 @@ data class ActiveSessionUseCases( val deleteSection: DeleteSectionUseCase, val pause: PauseActiveSessionUseCase, val resume: ResumeActiveSessionUseCase, - val getPracticeDuration: GetTotalPracticeDurationUseCase, + val getTotalPracticeDuration: GetTotalPracticeDurationUseCase, val getRunningItemDuration: GetRunningItemDurationUseCase, val getRunningItem: GetRunningItemUseCase, val getCompletedSections: GetCompletedSectionsUseCase, 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 69f8caee..0006d2b5 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 @@ -33,13 +33,23 @@ class GetFinalizedSessionUseCase( val ongoingPauseDuration = getOngoingPauseDuration(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/GetTotalPracticeDurationUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/GetTotalPracticeDurationUseCase.kt index 6d9f20c5..524f831d 100644 --- a/app/src/main/java/app/musikus/activesession/domain/usecase/GetTotalPracticeDurationUseCase.kt +++ b/app/src/main/java/app/musikus/activesession/domain/usecase/GetTotalPracticeDurationUseCase.kt @@ -19,11 +19,9 @@ class GetTotalPracticeDurationUseCase( ) { suspend operator fun invoke(at: ZonedDateTime): Duration { val state = activeSessionRepository.getSessionState().first() - val runningItemDuration = getRunningItemDuration(at) + ?: throw IllegalStateException("State is null. Cannot get total practice time!") - if (state == null) { - throw IllegalStateException("State is null. Cannot get total practice time!") - } + val runningItemDuration = getRunningItemDuration(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/PauseActiveSessionUseCase.kt b/app/src/main/java/app/musikus/activesession/domain/usecase/PauseActiveSessionUseCase.kt index 1c0f8e32..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 @@ -11,11 +11,9 @@ package app.musikus.activesession.domain.usecase import app.musikus.activesession.domain.ActiveSessionRepository import kotlinx.coroutines.flow.first import java.time.ZonedDateTime -import kotlin.time.Duration.Companion.seconds class PauseActiveSessionUseCase( private val activeSessionRepository: ActiveSessionRepository, - private val getRunningItemDuration: GetRunningItemDurationUseCase, ) { suspend operator fun invoke(at: ZonedDateTime) { val state = activeSessionRepository.getSessionState().first() @@ -25,10 +23,6 @@ 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() && getRunningItemDuration(at) < 1.seconds) return - activeSessionRepository.setSessionState( state.copy( currentPauseStartTimestamp = at, 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 500ede66..56bd91ae 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionScreen.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionScreen.kt @@ -370,6 +370,7 @@ private fun ActiveSessionScreen( 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) } }, @@ -776,6 +777,7 @@ private fun ActiveSessionToolsLayout( @Composable private fun ActiveSessionTopBar( sessionState: State, + isFinishedButtonEnabled: State, onDiscard: () -> Unit, onNavigateUp: () -> Unit, onTogglePause: () -> Unit, @@ -813,7 +815,8 @@ private fun ActiveSessionTopBar( } TextButton( - onClick = onSave + onClick = onSave, + enabled = isFinishedButtonEnabled.value ) { Text(text = stringResource(id = R.string.active_session_top_bar_save)) } @@ -1574,7 +1577,8 @@ private fun PreviewActiveSessionScreen( sessionState = MutableStateFlow(ActiveSessionState.RUNNING), mainContentUiState = MutableStateFlow(mainContent), newItemSelectorUiState = MutableStateFlow(null), - dialogUiState = MutableStateFlow(dialogs) + dialogUiState = MutableStateFlow(dialogs), + isFinishButtonEnabled = MutableStateFlow(true) ) ) }, 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 ac485ea5..888efa31 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt @@ -128,25 +128,71 @@ class ActiveSessionViewModel @Inject constructor( private val _exceptionChannel = Channel() val exceptionChannel = _exceptionChannel.receiveAsFlow() + /** ------------------- Combined flows ---------------------------- */ + + private val totalPracticeDuration = combine( + sessionState, + timeProvider.clock + ) { sessionState, now -> + if (sessionState == ActiveSessionState.NOT_STARTED) return@combine Duration.ZERO + activeSessionUseCases.getTotalPracticeDuration(now) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = Duration.ZERO + ) + + private val ongoingPauseDuration = combine( + sessionState, + timeProvider.clock + ) { sessionState, now -> + if (sessionState == ActiveSessionState.NOT_STARTED) return@combine Duration.ZERO + activeSessionUseCases.getOngoingPauseDuration(now) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = Duration.ZERO + ) + + private val runningItemDuration = combine( + sessionState, + timeProvider.clock + ) { sessionState, now -> + if (sessionState == ActiveSessionState.NOT_STARTED) return@combine Duration.ZERO + activeSessionUseCases.getRunningItemDuration(now) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = Duration.ZERO + ) + + val isFinishButtonEnabled = combine( + sessionState, + timeProvider.clock + ) { sessionState, now -> + if (sessionState == ActiveSessionState.NOT_STARTED) return@combine false + activeSessionUseCases.getTotalPracticeDuration(now) > 1.seconds + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false + ) + /** ------------------- Sub UI states ------------------------------------------- */ private val timerUiState = combine( sessionState, - timeProvider.clock // should update with clock - ) { timerState, now -> - val pause = timerState == ActiveSessionState.PAUSED - - val practiceDuration = try { - activeSessionUseCases.getPracticeDuration(now) - } catch (e: IllegalStateException) { - Duration.ZERO // Session not yet started - } + totalPracticeDuration, + ongoingPauseDuration, + ) { sessionState, totalPracticeDuration, ongoingPauseDuration -> + val pause = sessionState == ActiveSessionState.PAUSED + val pauseDurStr = getDurationString( - activeSessionUseCases.getOngoingPauseDuration(now), + ongoingPauseDuration, DurationFormat.MS_DIGITAL ) ActiveSessionTimerUiState( - timerText = getFormattedTimerText(practiceDuration), + timerText = getFormattedTimerText(totalPracticeDuration), subHeadingText = if (pause) { UiText.StringResource(R.string.active_session_timer_subheading_paused, pauseDurStr) @@ -171,19 +217,14 @@ class ActiveSessionViewModel @Inject constructor( private val currentItemUiState: StateFlow = combine( sessionState, runningLibraryItem, - timeProvider.clock // should update with clock - ) { sessionState, item, now -> + runningItemDuration + ) { sessionState, item, runningItemDuration -> if (sessionState == ActiveSessionState.NOT_STARTED || item == null) return@combine null - val currentItemDuration = try { - activeSessionUseCases.getRunningItemDuration(now) - } 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] @@ -281,7 +322,8 @@ class ActiveSessionViewModel @Inject constructor( ) ), newItemSelectorUiState = newItemSelectorUiState, - dialogUiState = dialogsUiStates + dialogUiState = dialogsUiStates, + isFinishButtonEnabled = isFinishButtonEnabled ) ).asStateFlow() @@ -392,21 +434,20 @@ class ActiveSessionViewModel @Inject constructor( } private suspend fun stopSession() { + // TODO this logic should be moved to the use case // complete running section val savableState = activeSessionUseCases.getFinalizedSession(timeProvider.now()) - // ignore empty sections (e.g. when paused and then stopped immediately)) - val sections = savableState.completedSections.filter { it.duration > 0.seconds } // 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, 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 55f1e9b9..9396d2d8 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/SessionService.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/SessionService.kt @@ -203,7 +203,7 @@ class SessionService : Service() { // TODO: move this logic to use case try { val totalPracticeDurationStr = getDurationString( - useCases.getPracticeDuration(timeProvider.now()), + useCases.getTotalPracticeDuration(timeProvider.now()), DurationFormat.HMS_DIGITAL ) 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) From f3f43179130d981acbd9f91bd24211b4fbb6831a Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sat, 14 Dec 2024 11:57:21 +0100 Subject: [PATCH 08/19] fix race condition by passing session state into use cases instead of querying it from the repository --- .../di/ActiveSessionUseCasesModule.kt | 29 +++++----- .../domain/usecase/ActiveSessionUseCases.kt | 7 ++- ... => ComputeOngoingPauseDurationUseCase.kt} | 13 ++--- ...t => ComputeRunningItemDurationUseCase.kt} | 15 ++--- ...=> ComputeTotalPracticeDurationUseCase.kt} | 18 +++--- .../usecase/GetActiveSessionStateUseCase.kt | 17 ++++++ .../usecase/GetFinalizedSessionUseCase.kt | 8 +-- .../usecase/ResumeActiveSessionUseCase.kt | 4 +- .../domain/usecase/SelectItemUseCase.kt | 8 +-- .../presentation/ActiveSessionViewModel.kt | 58 ++++++++----------- .../presentation/SessionService.kt | 5 +- 11 files changed, 94 insertions(+), 88 deletions(-) rename app/src/main/java/app/musikus/activesession/domain/usecase/{GetOngoingPauseDurationUseCase.kt => ComputeOngoingPauseDurationUseCase.kt} (67%) rename app/src/main/java/app/musikus/activesession/domain/usecase/{GetRunningItemDurationUseCase.kt => ComputeRunningItemDurationUseCase.kt} (70%) rename app/src/main/java/app/musikus/activesession/domain/usecase/{GetTotalPracticeDurationUseCase.kt => ComputeTotalPracticeDurationUseCase.kt} (53%) create mode 100644 app/src/main/java/app/musikus/activesession/domain/usecase/GetActiveSessionStateUseCase.kt 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 6982a988..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 @@ -42,39 +43,39 @@ object ActiveSessionUseCasesModule { activeSessionRepository: ActiveSessionRepository, idProvider: IdProvider ): ActiveSessionUseCases { - val getOngoingPauseDurationUseCase = GetOngoingPauseDurationUseCase(activeSessionRepository) + val computeOngoingPauseDurationUseCase = ComputeOngoingPauseDurationUseCase() val resumeUseCase = ResumeActiveSessionUseCase( activeSessionRepository, - getOngoingPauseDurationUseCase + computeOngoingPauseDurationUseCase ) - val getRunningItemDurationUseCase = GetRunningItemDurationUseCase(activeSessionRepository) + val computeRunningItemDurationUseCase = ComputeRunningItemDurationUseCase() return ActiveSessionUseCases( + getState = GetActiveSessionStateUseCase(activeSessionRepository), selectItem = SelectItemUseCase( activeSessionRepository = activeSessionRepository, - getRunningItemDuration = getRunningItemDurationUseCase, + computeRunningItemDuration = computeRunningItemDurationUseCase, idProvider = idProvider ), - getTotalPracticeDuration = GetTotalPracticeDurationUseCase( - activeSessionRepository = activeSessionRepository, - getRunningItemDuration = getRunningItemDurationUseCase + computeTotalPracticeDuration = ComputeTotalPracticeDurationUseCase( + computeRunningItemDuration = computeRunningItemDurationUseCase ), deleteSection = DeleteSectionUseCase(activeSessionRepository), pause = PauseActiveSessionUseCase( activeSessionRepository = activeSessionRepository, ), resume = resumeUseCase, - getRunningItemDuration = getRunningItemDurationUseCase, + computeRunningItemDuration = computeRunningItemDurationUseCase, getCompletedSections = GetCompletedSectionsUseCase(activeSessionRepository), - getOngoingPauseDuration = getOngoingPauseDurationUseCase, + computeOngoingPauseDuration = computeOngoingPauseDurationUseCase, isSessionPaused = IsSessionPausedUseCase(activeSessionRepository), getFinalizedSession = GetFinalizedSessionUseCase( activeSessionRepository = activeSessionRepository, - getRunningItemDuration = getRunningItemDurationUseCase, + computeRunningItemDuration = computeRunningItemDurationUseCase, idProvider = idProvider, - getOngoingPauseDuration = 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 5627f6c5..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 getTotalPracticeDuration: 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 67% 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 31c4c28c..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,18 +8,17 @@ package app.musikus.activesession.domain.usecase -import app.musikus.activesession.domain.ActiveSessionRepository +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, -) { - suspend operator fun invoke(at: ZonedDateTime): 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 = at - state.currentPauseStartTimestamp if (duration < 0.seconds) { 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 70% 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 a8205f5e..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,20 +8,17 @@ package app.musikus.activesession.domain.usecase -import app.musikus.activesession.domain.ActiveSessionRepository +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, -) { - suspend operator fun invoke(at: ZonedDateTime): Duration { - val state = activeSessionRepository.getSessionState().first() - ?: throw IllegalStateException("State is null. Cannot get running section!") - +class ComputeRunningItemDurationUseCase { + operator fun invoke( + state: SessionState, + at: ZonedDateTime + ): Duration { 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 53% 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 524f831d..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,20 +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 getRunningItemDuration: GetRunningItemDurationUseCase +class ComputeTotalPracticeDurationUseCase( + private val computeRunningItemDuration: ComputeRunningItemDurationUseCase ) { - suspend operator fun invoke(at: ZonedDateTime): Duration { - val state = activeSessionRepository.getSessionState().first() - ?: throw IllegalStateException("State is null. Cannot get total practice time!") - - val runningItemDuration = getRunningItemDuration(at) + 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 0006d2b5..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 @@ -20,8 +20,8 @@ import kotlin.time.Duration.Companion.seconds class GetFinalizedSessionUseCase( private val activeSessionRepository: ActiveSessionRepository, - private val getRunningItemDuration: GetRunningItemDurationUseCase, - private val getOngoingPauseDuration: GetOngoingPauseDurationUseCase, + private val computeRunningItemDuration: ComputeRunningItemDurationUseCase, + private val computeOngoingPauseDuration: ComputeOngoingPauseDurationUseCase, private val idProvider: IdProvider ) { suspend operator fun invoke(at: ZonedDateTime): SessionState { @@ -29,8 +29,8 @@ class GetFinalizedSessionUseCase( ?: throw IllegalStateException("State is null. Cannot finish session!") // take time - val runningSectionRoundedDuration = getRunningItemDuration(at).inWholeSeconds.seconds - val ongoingPauseDuration = getOngoingPauseDuration(at) + val runningSectionRoundedDuration = computeRunningItemDuration(state, at).inWholeSeconds.seconds + val ongoingPauseDuration = computeOngoingPauseDuration(state, at) // append finished section to completed sections val updatedSections = state.completedSections 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 c465e58a..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 @@ -15,7 +15,7 @@ import java.time.ZonedDateTime class ResumeActiveSessionUseCase( private val activeSessionRepository: ActiveSessionRepository, - private val getOngoingPauseDurationUseCase: GetOngoingPauseDurationUseCase, + private val computeOngoingPauseDurationUseCase: ComputeOngoingPauseDurationUseCase, ) { suspend operator fun invoke(at: ZonedDateTime) { val state = activeSessionRepository.getSessionState().first() @@ -25,7 +25,7 @@ class ResumeActiveSessionUseCase( throw IllegalStateException("Cannot resume when not paused") } - val currentPauseDuration = getOngoingPauseDurationUseCase(at) + 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 36260bcb..86ac2f3e 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 @@ -21,7 +21,7 @@ import kotlin.time.Duration.Companion.seconds class SelectItemUseCase( private val activeSessionRepository: ActiveSessionRepository, - private val getRunningItemDuration: GetRunningItemDurationUseCase, + private val computeRunningItemDuration: ComputeRunningItemDurationUseCase, private val idProvider: IdProvider ) { suspend operator fun invoke( @@ -38,7 +38,7 @@ class SelectItemUseCase( } // check too fast - if (getRunningItemDuration(at) < 1.seconds) { + if (computeRunningItemDuration(state, at) < 1.seconds) { throw IllegalStateException("Must wait for at least one second before starting a new section.") } @@ -46,10 +46,10 @@ class SelectItemUseCase( if (state.isPaused) throw IllegalStateException("You must resume before selecting a new item.") // take time - val runningSectionTrueDuration = getRunningItemDuration(at) + val runningSectionTrueDuration = computeRunningItemDuration(state, at) val changeSectionTimestamp = state.startTimestampSectionPauseCompensated + runningSectionTrueDuration.inWholeSeconds.seconds // running section duration calculated until changeSectionTimestamp - val runningSectionRoundedDuration = getRunningItemDuration(at = changeSectionTimestamp) + val runningSectionRoundedDuration = computeRunningItemDuration(state, at = changeSectionTimestamp) // append finished section to completed sections val updatedSections = state.completedSections + PracticeSection( 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 888efa31..38e2d2f1 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt @@ -50,7 +50,6 @@ import kotlinx.coroutines.runBlocking import java.util.UUID import javax.inject.Inject import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @HiltViewModel @@ -66,6 +65,12 @@ class ActiveSessionViewModel @Inject constructor( /** ---------- 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), @@ -130,48 +135,41 @@ class ActiveSessionViewModel @Inject constructor( /** ------------------- Combined flows ---------------------------- */ - private val totalPracticeDuration = combine( - sessionState, - timeProvider.clock - ) { sessionState, now -> - if (sessionState == ActiveSessionState.NOT_STARTED) return@combine Duration.ZERO - activeSessionUseCases.getTotalPracticeDuration(now) + 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 = combine( - sessionState, - timeProvider.clock - ) { sessionState, now -> - if (sessionState == ActiveSessionState.NOT_STARTED) return@combine Duration.ZERO - activeSessionUseCases.getOngoingPauseDuration(now) + 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 = combine( - sessionState, - timeProvider.clock - ) { sessionState, now -> - if (sessionState == ActiveSessionState.NOT_STARTED) return@combine Duration.ZERO - activeSessionUseCases.getRunningItemDuration(now) + 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 = combine( - sessionState, - timeProvider.clock - ) { sessionState, now -> - if (sessionState == ActiveSessionState.NOT_STARTED) return@combine false - activeSessionUseCases.getTotalPracticeDuration(now) > 1.seconds + val isFinishButtonEnabled = totalPracticeDuration.map { + it > 1.seconds }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), @@ -399,12 +397,7 @@ class ActiveSessionViewModel @Inject constructor( if (sessionState.value == ActiveSessionState.PAUSED) { 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(timeProvider.now()) < 1.seconds - ) { - delay(1000.milliseconds) - } + activeSessionUseCases.selectItem( item = item, at = timeProvider.now() @@ -412,9 +405,6 @@ class ActiveSessionViewModel @Inject constructor( } private suspend fun deleteSection(sectionId: UUID) { - if (sessionState.value == ActiveSessionState.PAUSED) { - activeSessionUseCases.resume(timeProvider.now()) - } activeSessionUseCases.deleteSection(sectionId) } 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 9396d2d8..3d0583a8 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/SessionService.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/SessionService.kt @@ -202,8 +202,11 @@ class SessionService : Service() { // TODO: move this logic to use case try { + val state = useCases.getState().first() + check(state != null) { "State is null. Cannot create notification." } + val totalPracticeDurationStr = getDurationString( - useCases.getTotalPracticeDuration(timeProvider.now()), + useCases.computeTotalPracticeDuration(state, timeProvider.now()), DurationFormat.HMS_DIGITAL ) From 78557034bc05b1c83fee64fb69eb896cc55576b3 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sat, 14 Dec 2024 15:55:40 +0100 Subject: [PATCH 09/19] add more integration tests to active session --- .../presentation/ActiveSessionScreenTest.kt | 68 ++++++++++++++++++- .../domain/usecase/SelectItemUseCase.kt | 5 -- .../main/res/values/activesession_strings.xml | 2 +- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index bbde5f7a..1f3fed6a 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -22,6 +22,7 @@ import app.musikus.core.presentation.MainActivity import app.musikus.library.data.entities.LibraryFolderCreationAttributes import app.musikus.library.data.entities.LibraryItemCreationAttributes import app.musikus.library.domain.usecase.LibraryUseCases +import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.runBlocking @@ -43,8 +44,12 @@ class ActiveSessionScreenTest { @get:Rule(order = 1) val composeRule = createAndroidComposeRule() + var wasNavigateUpCalled = false + @Before fun setUp() { + wasNavigateUpCalled = false + hiltRule.inject() composeRule.activity.setContent { @@ -61,6 +66,12 @@ class ActiveSessionScreenTest { libraryUseCases.addItem( LibraryItemCreationAttributes( name = "TestItem2", + colorIndex = 2, + ) + ) + libraryUseCases.addItem( + LibraryItemCreationAttributes( + name = "TestItem3", colorIndex = 1, libraryFolderId = Nullable(UUIDConverter.fromInt(1)) ) @@ -68,7 +79,7 @@ class ActiveSessionScreenTest { } ActiveSession( - navigateUp = {} + navigateUp = { wasNavigateUpCalled = true } ) } } @@ -103,4 +114,59 @@ class ActiveSessionScreenTest { // 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.onNodeWithText("TestItem3").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() { + // 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() + + // Session is discarded and screen is reset + composeRule.onNodeWithContentDescription("Start practicing").assertIsDisplayed() + + // Navigate up is called + assertThat(wasNavigateUpCalled).isTrue() + } } 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 86ac2f3e..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 @@ -37,11 +37,6 @@ class SelectItemUseCase( throw IllegalStateException("Must not select the same library item which is already running.") } - // check too fast - if (computeRunningItemDuration(state, at) < 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.") diff --git a/app/src/main/res/values/activesession_strings.xml b/app/src/main/res/values/activesession_strings.xml index b0922575..7e85461e 100644 --- a/app/src/main/res/values/activesession_strings.xml +++ b/app/src/main/res/values/activesession_strings.xml @@ -14,7 +14,7 @@ Pause - Delete + Discard Finish From 473f9c10214334be363792e191052ce0f6ca74f2 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sat, 14 Dec 2024 17:35:23 +0100 Subject: [PATCH 10/19] add tests for finishing session --- .../presentation/ActiveSessionScreenTest.kt | 104 +++++++++++++++++- .../presentation/ActiveSessionViewModel.kt | 2 +- .../sessions/presentation/SessionCard.kt | 20 +++- app/src/main/res/values/strings.xml | 4 + 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index 1f3fed6a..1f217c3b 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -10,31 +10,45 @@ 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.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 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.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 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 @@ -152,7 +166,7 @@ class ActiveSessionScreenTest { } @Test - fun discardSession() { + fun discardSession() = runTest { // Start session composeRule.onNodeWithContentDescription("Start practicing").performClick() composeRule.onNodeWithText("TestItem1").performClick() @@ -163,10 +177,94 @@ class ActiveSessionScreenTest { // Confirm discard composeRule.onNodeWithText("Discard session?", substring = true).performClick() - // Session is discarded and screen is reset - composeRule.onNodeWithContentDescription("Start practicing").assertIsDisplayed() + // Navigate up is called + assertThat(wasNavigateUpCalled).isTrue() + + // Sessions are still empty + val sessions = sessionsUseCases.getAll().first() + assertThat(sessions).isEmpty() + } + + @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() + } + + @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 assertThat(wasNavigateUpCalled).isTrue() + + // 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/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt index 38e2d2f1..ce8d120f 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt @@ -169,7 +169,7 @@ class ActiveSessionViewModel @Inject constructor( ) val isFinishButtonEnabled = totalPracticeDuration.map { - it > 1.seconds + it >= 1.seconds }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), 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/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 From c1d383b0937e0bdb9527b2c9c6c434f44f0e792b Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sat, 14 Dec 2024 23:28:33 +0100 Subject: [PATCH 11/19] add tests for deleting sections Removed snackbar host from active session scaffold and used the main ui event "ShowSnackbar" instead. --- .../presentation/ActiveSessionScreenTest.kt | 106 +++++++++++++----- .../presentation/ActiveSessionScreen.kt | 62 ++++------ .../presentation/ActiveSessionViewModel.kt | 26 ++--- .../core/presentation/MusikusNavHost.kt | 1 + .../recorder/presentation/RecorderUi.kt | 18 +-- 5 files changed, 118 insertions(+), 95 deletions(-) diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index 1f217c3b..99b98482 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -18,6 +18,9 @@ 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.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.navigation.NavHostController import app.musikus.core.data.Nullable import app.musikus.core.data.SectionWithLibraryItem import app.musikus.core.data.SessionWithSectionsWithLibraryItems @@ -25,6 +28,8 @@ 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.MainUiEvent +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 @@ -35,6 +40,9 @@ 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.slot +import io.mockk.verify import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -48,6 +56,7 @@ import kotlin.time.Duration.Companion.seconds @HiltAndroidTest class ActiveSessionScreenTest { @Inject lateinit var libraryUseCases: LibraryUseCases + @Inject lateinit var sessionsUseCases: SessionsUseCases @Inject lateinit var fakeTimeProvider: FakeTimeProvider @@ -58,42 +67,45 @@ class ActiveSessionScreenTest { @get:Rule(order = 1) val composeRule = createAndroidComposeRule() - var wasNavigateUpCalled = false + lateinit var navController: NavHostController + lateinit var mainViewModel: MainViewModel @Before fun setUp() { - wasNavigateUpCalled = false - hiltRule.inject() - composeRule.activity.setContent { - runBlocking { - libraryUseCases.addFolder( - LibraryFolderCreationAttributes("TestFolder1") - ) - libraryUseCases.addItem( - LibraryItemCreationAttributes( - name = "TestItem1", - colorIndex = 1, - ) + 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 = "TestItem2", + colorIndex = 2, ) - libraryUseCases.addItem( - LibraryItemCreationAttributes( - name = "TestItem3", - colorIndex = 1, - libraryFolderId = Nullable(UUIDConverter.fromInt(1)) - ) + ) + libraryUseCases.addItem( + LibraryItemCreationAttributes( + name = "TestItem3", + colorIndex = 1, + libraryFolderId = Nullable(UUIDConverter.fromInt(1)) ) - } + ) + } + composeRule.activity.setContent { ActiveSession( - navigateUp = { wasNavigateUpCalled = true } + navigateUp = navController::navigateUp, + mainEventHandler = mainViewModel::onUiEvent, ) } } @@ -178,13 +190,49 @@ class ActiveSessionScreenTest { composeRule.onNodeWithText("Discard session?", substring = true).performClick() // Navigate up is called - assertThat(wasNavigateUpCalled).isTrue() + verify(exactly = 1) { + navController.navigateUp() + } // Sessions are still empty val sessions = sessionsUseCases.getAll().first() assertThat(sessions).isEmpty() } + @Test + fun deleteSection() = 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") + assertThat(uiEvent.onUndo).isNotNull() + + // Assert section is deleted + composeRule.onNodeWithContentDescription("TestItem1").assertIsNotDisplayed() + } + @Test fun finishButtonDisabledForEmptySession() { // Start session @@ -230,7 +278,9 @@ class ActiveSessionScreenTest { composeRule.awaitIdle() // Navigate up is called - assertThat(wasNavigateUpCalled).isTrue() + verify(exactly = 1) { + navController.navigateUp() + } // Sessions are still empty val sessions = sessionsUseCases.getAll().first() 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 56bd91ae..e1ec1e8e 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,12 +355,11 @@ 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( @@ -390,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 ) @@ -448,9 +442,8 @@ private fun ActiveSessionScreen( ) }, onConfirm = { - eventHandler( - dialogEvent(ActiveSessionEndDialogUiEvent.Confirmed) - ) + eventHandler(dialogEvent(ActiveSessionEndDialogUiEvent.Confirmed)) + navigateUp() } ) } @@ -486,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. */ @@ -495,7 +487,6 @@ private fun ActiveSessionAdaptiveScaffold( // Scaffold needed for topBar modifier = modifier, topBar = topBar, - snackbarHost = { SnackbarHost(snackbarHostState) } ) { Row( modifier = Modifier @@ -544,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( @@ -614,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 @@ -692,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 -> @@ -1088,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 @@ -1127,9 +1115,8 @@ private fun SectionList( ) { item -> SectionListElement( modifier = Modifier.animateItemPlacement(), - scope = scope, item = item, - snackbarHostState = snackbarHostState, + showSnackbar = showSnackbar, onSectionDeleted = onSectionDeleted, ) } @@ -1149,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 @@ -1174,13 +1160,10 @@ private fun SectionListElement( 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") - } + MainUiEvent.ShowSnackbar( + message = context.getString(R.string.active_session_sections_list_element_deleted), + onUndo = { } + ) ) } ) { @@ -1600,7 +1583,7 @@ private fun PreviewActiveSessionScreen( navigateUp = {}, bottomSheetScaffoldState = rememberBottomSheetScaffoldState(), bottomSheetPagerState = rememberPagerState(pageCount = { 2 }), - snackbarHostState = remember { SnackbarHostState() } + showSnackbar = {} ) } } @@ -1626,8 +1609,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/ActiveSessionViewModel.kt b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt index ce8d120f..24630b83 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionViewModel.kt @@ -130,9 +130,13 @@ 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 -> @@ -325,9 +329,6 @@ class ActiveSessionViewModel @Inject constructor( ) ).asStateFlow() - private val navigationChannel = Channel() - val navigationEventsChannelFlow = navigationChannel.receiveAsFlow() - init { /** Hide the Tools Bottom Sheet on Startup */ runBlocking(context = Dispatchers.IO) { @@ -340,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) // } } } @@ -353,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 { @@ -408,13 +409,6 @@ class ActiveSessionViewModel @Inject constructor( 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(timeProvider.now()) @@ -446,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/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/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 = {} ) } } From ff8d2a02c30f070cf7dbc0fdb9a41d042ea4d24f Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 15 Dec 2024 11:50:41 +0100 Subject: [PATCH 12/19] remove println statements --- app/src/main/java/app/musikus/core/domain/TimeProvider.kt | 8 ++------ .../java/app/musikus/core/presentation/utils/UiText.kt | 1 - 2 files changed, 2 insertions(+), 7 deletions(-) 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 70a7a3d3..b54e9579 100644 --- a/app/src/main/java/app/musikus/core/domain/TimeProvider.kt +++ b/app/src/main/java/app/musikus/core/domain/TimeProvider.kt @@ -117,14 +117,12 @@ class TimeProviderImpl(scope: CoroutineScope) : TimeProvider { override val clock: StateFlow = flow { while (true) { - emit(ZonedDateTime.now()) // Emit the current time - delay(100.milliseconds) // Wait 100 milliseconds + emit(ZonedDateTime.now()) + delay(100.milliseconds) } }.onStart { - println("Clock started") isClockRunning = true }.onCompletion { - println("Clock stopped") isClockRunning = false }.stateIn( scope = scope, @@ -134,10 +132,8 @@ class TimeProviderImpl(scope: CoroutineScope) : TimeProvider { override fun now(): ZonedDateTime { return if (isClockRunning) { - println("get running clock value") clock.value } else { - println("get zoned date time now") ZonedDateTime.now() } } 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) From 8486d3be01ec63f10b13f54c564e683ebb46a249 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 15 Dec 2024 12:05:38 +0100 Subject: [PATCH 13/19] call onUndo after deleting section --- .../presentation/ActiveSessionScreenTest.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index 99b98482..7f9a8fae 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -200,7 +200,7 @@ class ActiveSessionScreenTest { } @Test - fun deleteSection() = runTest { + fun deleteAndRedoSection() = runTest { // Start session composeRule.onNodeWithContentDescription("Start practicing").performClick() composeRule.onNodeWithText("TestItem1").performClick() @@ -227,10 +227,16 @@ class ActiveSessionScreenTest { val uiEvent = uiEventSlot.captured check(uiEvent is MainUiEvent.ShowSnackbar) assertThat(uiEvent.message).isEqualTo("Section deleted") - assertThat(uiEvent.onUndo).isNotNull() + 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 From ccb0fa8c95293e6a920a8ae45550a238017242a3 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 15 Dec 2024 15:43:41 +0100 Subject: [PATCH 14/19] fix session service test --- .../presentation/SessionServiceTest.kt | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/SessionServiceTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/SessionServiceTest.kt index 7088a25d..7a4c3cb1 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/SessionServiceTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/SessionServiceTest.kt @@ -55,21 +55,7 @@ class SessionServiceTest { val binder = serviceRule.bindService(startIntent) - // Verify that the service has started correctly assertThat(binder.pingBinder()).isTrue() - - // Create the stop intent - val stopIntent = Intent( - context, - SessionService::class.java - ).apply { - action = ActiveSessionServiceActions.STOP.name - } - - serviceRule.startService(stopIntent) - - // Verify that the service has stopped correctly - assertThat(binder.pingBinder()).isFalse() } } From 37b2fadcff4caa7770b17693ed76a8def6429901 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 15 Dec 2024 17:20:02 +0100 Subject: [PATCH 15/19] fix unit tests after changing TimeProvider interface --- .../app/musikus/core/domain/FakeTimeProvider.kt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) 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 { From a7a18815bfb4ee80738acd9313ba3d5e33019ab5 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Fri, 3 Jan 2025 21:07:29 +0100 Subject: [PATCH 16/19] disable snackbar while undo is not working --- .../presentation/ActiveSessionScreenTest.kt | 78 +++++++++---------- .../presentation/ActiveSessionScreen.kt | 13 ++-- 2 files changed, 46 insertions(+), 45 deletions(-) diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index 7f9a8fae..0c003e79 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -199,45 +199,45 @@ class ActiveSessionScreenTest { 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 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() { 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 e1ec1e8e..7662b81a 100644 --- a/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionScreen.kt +++ b/app/src/main/java/app/musikus/activesession/presentation/ActiveSessionScreen.kt @@ -1159,12 +1159,13 @@ private fun SectionListElement( deleted = deleted, onDeleted = { onSectionDeleted(item) - showSnackbar( - MainUiEvent.ShowSnackbar( - message = context.getString(R.string.active_session_sections_list_element_deleted), - onUndo = { } - ) - ) + // 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( From fe2907a18fc748255eeea895f83317bc48584071 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 5 Jan 2025 11:24:01 +0100 Subject: [PATCH 17/19] add comments explaining the origin of IDs --- .../presentation/ActiveSessionScreenTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index 0c003e79..722d4ec0 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -70,6 +70,16 @@ class ActiveSessionScreenTest { 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() @@ -255,6 +265,13 @@ class ActiveSessionScreenTest { 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 From 0a941555c4fa33dead5436285d1fdf9b04a425b0 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 5 Jan 2025 11:37:49 +0100 Subject: [PATCH 18/19] improve specificity of test --- .../presentation/ActiveSessionScreenTest.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index 722d4ec0..2be58afb 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -13,14 +13,19 @@ 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.onParent import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.printToString import androidx.compose.ui.test.swipeLeft import androidx.navigation.NavHostController +import androidx.test.espresso.matcher.ViewMatchers.hasSibling import app.musikus.core.data.Nullable import app.musikus.core.data.SectionWithLibraryItem import app.musikus.core.data.SessionWithSectionsWithLibraryItems @@ -163,7 +168,11 @@ class ActiveSessionScreenTest { composeRule.onNodeWithText("TestItem3").performClick() // Item is selected - composeRule.onNodeWithText("TestItem3").assertIsDisplayed() + composeRule.onNode( + matcher = hasText("TestItem3") + and + hasAnySibling(hasText("00:00")) + ).assertIsDisplayed() } @Test From c0ef613e242f1e1a49cad3a059fa415ac02b63f1 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 5 Jan 2025 11:43:02 +0100 Subject: [PATCH 19/19] auto-fix linting issues --- .../presentation/ActiveSessionScreenTest.kt | 7 ------- .../presentation/utils/DurationFormatter.kt | 14 ++++++++------ .../app/musikus/goals/presentation/GoalCard.kt | 12 ++++++++---- .../musikus/sessions/data/daos/SessionDao.kt | 3 +-- .../domain/usecase/GetAllSessionsUseCase.kt | 2 +- .../presentation/StatisticsViewModel.kt | 17 ++++++++++------- 6 files changed, 28 insertions(+), 27 deletions(-) diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index 2be58afb..1d71f7aa 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -18,14 +18,9 @@ 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.onParent import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.printToString -import androidx.compose.ui.test.swipeLeft import androidx.navigation.NavHostController -import androidx.test.espresso.matcher.ViewMatchers.hasSibling import app.musikus.core.data.Nullable import app.musikus.core.data.SectionWithLibraryItem import app.musikus.core.data.SessionWithSectionsWithLibraryItems @@ -33,7 +28,6 @@ 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.MainUiEvent import app.musikus.core.presentation.MainViewModel import app.musikus.library.data.daos.LibraryItem import app.musikus.library.data.entities.LibraryFolderCreationAttributes @@ -46,7 +40,6 @@ 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.slot import io.mockk.verify import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking 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/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/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/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,