diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 13ee6bd..b81b9cb 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,7 +6,6 @@ - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) -- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] 📝 Documentation update - [ ] ♻️ Code refactoring (no functional changes) - [ ] 🎨 UI/UX improvement @@ -20,7 +19,6 @@ Fixes #(issue number) ## Changes Made - - - - @@ -47,17 +45,5 @@ Fixes #(issue number) - Android Version: - App Version: -## Checklist - - -- [ ] My code follows the project's code style guidelines -- [ ] I have performed a self-review of my code -- [ ] I have commented my code, particularly in hard-to-understand areas -- [ ] I have made corresponding changes to the documentation -- [ ] My changes generate no new warnings or errors -- [ ] I have added tests that prove my fix is effective or that my feature works -- [ ] New and existing unit tests pass locally with my changes -- [ ] Any dependent changes have been merged and published - ## Additional Notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dae4a4..9ad8eb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,11 @@ on: push: branches: - develop - - main + - master pull_request: branches: - develop - - main + - master jobs: build: @@ -80,57 +80,6 @@ jobs: app/build/reports/lint-results*.xml retention-days: 7 - # Uncomment this job when you have instrumented tests ready - # instrumented-tests: - # name: Instrumented Tests - # runs-on: ubuntu-latest - # timeout-minutes: 45 - # strategy: - # matrix: - # api-level: [31, 34] - # target: [google_apis] - # - # steps: - # - name: Checkout code - # uses: actions/checkout@v4 - # - # - name: Set up JDK 17 - # uses: actions/setup-java@v4 - # with: - # distribution: 'temurin' - # java-version: '17' - # - # - name: Setup Gradle - # uses: gradle/actions/setup-gradle@v3 - # - # - name: Grant execute permission for gradlew - # run: chmod +x gradlew - # - # - name: Enable KVM group perms - # run: | - # echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - # sudo udevadm control --reload-rules - # sudo udevadm trigger --name-match=kvm - # - # - name: Run instrumented tests - # uses: reactivecircus/android-emulator-runner@v2 - # with: - # api-level: ${{ matrix.api-level }} - # target: ${{ matrix.target }} - # arch: x86_64 - # profile: Nexus 6 - # script: ./gradlew connectedDebugAndroidTest --stacktrace - # - # - name: Upload instrumented test results - # if: always() - # uses: actions/upload-artifact@v4 - # with: - # name: instrumented-test-results-api-${{ matrix.api-level }} - # path: | - # app/build/reports/androidTests/ - # app/build/outputs/androidTest-results/ - # retention-days: 7 - code-quality: name: Code Quality Checks runs-on: ubuntu-latest diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 4b158c2..fe06dbe 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -13,16 +13,11 @@ - + + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c76e5f1..1bd516a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.detekt) + alias(libs.plugins.ksp) id("org.jetbrains.kotlin.kapt") id("com.google.dagger.hilt.android") } @@ -16,8 +17,8 @@ android { applicationId = "com.serranoie.app.media.sorter" minSdk = 31 targetSdk = 36 - versionCode = 1014 - versionName = "1.0.14" + versionCode = 102 + versionName = "1.0.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -48,10 +49,17 @@ android { buildFeatures { compose = true } + + testOptions { + unitTests { + isReturnDefaultValues = true + } + } } dependencies { implementation(libs.androidx.core.ktx) + implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.activity.compose) @@ -64,7 +72,15 @@ dependencies { implementation(libs.androidx.material3) implementation(libs.material3) implementation(libs.androidx.work.runtime.ktx) + // Unit Testing testImplementation(libs.junit) + testImplementation("io.mockk:mockk:1.13.14") + testImplementation("io.mockk:mockk-android:1.13.14") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + testImplementation("androidx.arch.core:core-testing:2.2.0") + testImplementation("app.cash.turbine:turbine:1.2.0") + + // UI Testing androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) @@ -90,23 +106,24 @@ dependencies { // Wavy Slider for video progress implementation(libs.wavy.slider) - implementation("com.google.dagger:hilt-android:2.57.1") - kapt("com.google.dagger:hilt-android-compiler:2.57.1") + implementation(libs.hilt.android) + kapt(libs.hilt.android.compiler) implementation(libs.androidx.hilt.navigation.compose) // Hilt Work integration - implementation("androidx.hilt:hilt-work:1.2.0") - kapt("androidx.hilt:hilt-compiler:1.2.0") + kapt(libs.androidx.hilt.compiler) implementation(libs.androidx.hilt.work) // Update checking dependencies - implementation("com.google.android.play:app-update:2.1.0") - implementation("com.google.android.play:app-update-ktx:2.1.0") - implementation("androidx.work:work-runtime-ktx:2.10.0") - implementation("com.squareup.retrofit2:retrofit:2.11.0") - implementation("com.squareup.retrofit2:converter-gson:2.11.0") - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation(libs.app.update) + implementation(libs.app.update.ktx) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.retrofit) + implementation(libs.retrofit.gson) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + + implementation("androidx.compose.animation:animation:1.6.7") } // Detekt configuration diff --git a/app/src/androidTest/java/com/serranoie/app/media/sorter/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/serranoie/app/media/sorter/ExampleInstrumentedTest.kt deleted file mode 100644 index 414f016..0000000 --- a/app/src/androidTest/java/com/serranoie/app/media/sorter/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.serranoie.app.media.sorter - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.serranoie.app.media.sorter", appContext.packageName) - } -} diff --git a/app/src/androidTest/java/com/serranoie/app/media/sorter/presentation/review/ReviewScreenTest.kt b/app/src/androidTest/java/com/serranoie/app/media/sorter/presentation/review/ReviewScreenTest.kt new file mode 100644 index 0000000..3a4d6f2 --- /dev/null +++ b/app/src/androidTest/java/com/serranoie/app/media/sorter/presentation/review/ReviewScreenTest.kt @@ -0,0 +1,120 @@ +package com.serranoie.app.media.sorter.presentation.review + +import android.app.PendingIntent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.serranoie.app.media.sorter.data.MediaFile +import com.serranoie.app.media.sorter.domain.Result +import com.serranoie.app.media.sorter.domain.repository.MediaRepository +import com.serranoie.app.media.sorter.presentation.model.MediaFileUi +import com.serranoie.app.media.sorter.presentation.ui.theme.SorterTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.time.LocalDate + +@RunWith(AndroidJUnit4::class) +class ReviewScreenTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val fakeRepository = object : MediaRepository { + override suspend fun fetchMediaFiles(): Result> = Result.Success(emptyList()) + override suspend fun getMediaByFolder(): Result>> = Result.Success(emptyMap()) + override suspend fun getMediaGroupedByDate(): Result>> = Result.Success(emptyMap()) + override suspend fun getMediaGroupedByDateFiltered(): Result>> = Result.Success(emptyMap()) + override suspend fun deleteMedia(uri: Uri): Result = Result.Success(true) + override suspend fun deleteMultipleMedia(uris: List): Result = Result.Success(uris.size) + override fun createDeletionRequest(uris: List, useTrash: Boolean): PendingIntent? = null + override fun clearCache() {} + override suspend fun markAsViewed(mediaId: Long) {} + override suspend fun isViewed(mediaId: Long): Boolean = false + override suspend fun clearViewedHistory() {} + override suspend fun getViewedCount(): Int = 0 + } + + private fun dummyFile(id: String = "1") = MediaFileUi( + id = id, + fileName = "File_$id.jpg", + fileInfo = "1.0 MB • Today", + mediaType = "image", + date = "Today", + fileSize = "1.0 MB", + dimensions = "100x100", + dateCreated = "Today", + lastAccessed = "Today", + modified = "Today", + path = "/" + ) + + @Test + fun emptyState_is_shown_when_no_deleted_files() { + composeRule.setContent { + SorterTheme { + ReviewScreen( + deletedFiles = emptyList(), + repository = fakeRepository, + useTrash = false + ) + } + } + + composeRule.onNodeWithTag("ReviewScreen").assertExists() + composeRule.onNodeWithTag("ReviewEmptyState").assertIsDisplayed() + composeRule.onNodeWithTag("ReviewDeleteAllFab").assertDoesNotExist() + } + + @Test + fun deleteAll_flow_shows_dialog_cancel_dismisses_and_confirm_calls_callback() { + var deleteAllCalls = 0 + + composeRule.setContent { + SorterTheme { + ReviewScreen( + deletedFiles = listOf(dummyFile("1"), dummyFile("2")), + repository = fakeRepository, + useTrash = false, + onDeleteAll = { deleteAllCalls++ } + ) + } + } + + composeRule.onNodeWithTag("ReviewGrid").assertIsDisplayed() + composeRule.onNodeWithTag("ReviewDeleteAllFab").performClick() + composeRule.onNodeWithTag("ReviewDeleteAllDialog").assertExists() + + composeRule.onNodeWithTag("ReviewDeleteAllCancel").performClick() + composeRule.onNodeWithTag("ReviewDeleteAllDialog").assertDoesNotExist() + + composeRule.onNodeWithTag("ReviewDeleteAllFab").performClick() + composeRule.onNodeWithTag("ReviewDeleteAllConfirm").performClick() + + composeRule.waitUntil(timeoutMillis = 5_000) { deleteAllCalls == 1 } + } + + @Test + fun doubleTap_on_grid_item_opens_fullscreen_and_close_dismisses() { + val file = dummyFile("42") + + composeRule.setContent { + SorterTheme { + ReviewScreen( + deletedFiles = listOf(file), + repository = fakeRepository, + useTrash = false + ) + } + } + + composeRule.onNodeWithTag("ReviewGridItem_${file.id}") + .performTouchInput { doubleClick() } + + composeRule.onNodeWithTag("ReviewFullscreenViewer").assertExists() + composeRule.onNodeWithTag("ReviewFullscreenClose").performClick() + composeRule.onNodeWithTag("ReviewFullscreenViewer").assertDoesNotExist() + } +} diff --git a/app/src/androidTest/java/com/serranoie/app/media/sorter/presentation/settings/SettingsScreenTest.kt b/app/src/androidTest/java/com/serranoie/app/media/sorter/presentation/settings/SettingsScreenTest.kt new file mode 100644 index 0000000..eeff989 --- /dev/null +++ b/app/src/androidTest/java/com/serranoie/app/media/sorter/presentation/settings/SettingsScreenTest.kt @@ -0,0 +1,159 @@ +package com.serranoie.app.media.sorter.presentation.settings + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.serranoie.app.media.sorter.R +import com.serranoie.app.media.sorter.presentation.ui.theme.SorterTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SettingsScreenTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun settingsScreen_renders_and_back_calls_callback() { + val context = composeRule.activity + var backClicked = false + + composeRule.setContent { + SorterTheme { + SettingsScreen( + appTheme = "System", + isMaterialYouEnabled = true, + isBlurredBackgroundEnabled = true, + isAutoPlayEnabled = false, + syncFileToTrashBin = false, + onThemeChange = {}, + onMaterialYouToggle = {}, + onBlurredBackgroundToggle = {}, + onAutoPlayToggle = {}, + onSyncFileToTrashBinToggle = {}, + onResetTutorial = {}, + onResetViewedHistory = {}, + onBack = { backClicked = true }, + onCheckForUpdates = {}, + onDismissUpdateMessage = {} + ) + } + } + + composeRule.onNodeWithTag("SettingsScreen").assertExists() + composeRule.onNodeWithText(context.getString(R.string.settings_title)).assertIsDisplayed() + + // Click back icon + composeRule.onNodeWithTag("SettingsBackButton").performClick() + composeRule.runOnIdle { assert(backClicked) } + } + + @Test + fun themeItem_opens_dialog_and_selecting_theme_calls_callback() { + var themeSelected: String? = null + + composeRule.setContent { + SorterTheme { + SettingsScreen( + appTheme = "System", + isMaterialYouEnabled = true, + isBlurredBackgroundEnabled = true, + isAutoPlayEnabled = false, + syncFileToTrashBin = false, + onThemeChange = { themeSelected = it }, + onMaterialYouToggle = {}, + onBlurredBackgroundToggle = {}, + onAutoPlayToggle = {}, + onSyncFileToTrashBinToggle = {}, + onResetTutorial = {}, + onResetViewedHistory = {}, + onBack = {}, + onCheckForUpdates = {}, + onDismissUpdateMessage = {} + ) + } + } + + composeRule.onNodeWithTag("ThemePickerDialog").assertDoesNotExist() + composeRule.onNodeWithTag("SettingsThemeItem").performClick() + composeRule.onNodeWithTag("ThemePickerDialog").assertExists() + + // Dialog uses hardcoded English strings + composeRule.onNodeWithText("Dark").performClick() + composeRule.runOnIdle { + assert(themeSelected == "Dark") + } + } + + @Test + fun updateCheckMessage_shows_snackbar() { + val message = "Update available!" + + composeRule.setContent { + SorterTheme { + SettingsScreen( + appTheme = "System", + isMaterialYouEnabled = true, + isBlurredBackgroundEnabled = true, + isAutoPlayEnabled = false, + syncFileToTrashBin = false, + updateCheckMessage = message, + onThemeChange = {}, + onMaterialYouToggle = {}, + onBlurredBackgroundToggle = {}, + onAutoPlayToggle = {}, + onSyncFileToTrashBinToggle = {}, + onResetTutorial = {}, + onResetViewedHistory = {}, + onBack = {}, + onCheckForUpdates = {}, + onDismissUpdateMessage = {} + ) + } + } + + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithText(message).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText(message).assertIsDisplayed() + } + + @Test + fun toggles_call_callbacks() { + var materialYouToggles = 0 + var autoplayToggles = 0 + + composeRule.setContent { + SorterTheme { + SettingsScreen( + appTheme = "System", + isMaterialYouEnabled = true, + isBlurredBackgroundEnabled = true, + isAutoPlayEnabled = false, + syncFileToTrashBin = false, + onThemeChange = {}, + onMaterialYouToggle = { materialYouToggles++ }, + onBlurredBackgroundToggle = {}, + onAutoPlayToggle = { autoplayToggles++ }, + onSyncFileToTrashBinToggle = {}, + onResetTutorial = {}, + onResetViewedHistory = {}, + onBack = {}, + onCheckForUpdates = {}, + onDismissUpdateMessage = {} + ) + } + } + + composeRule.onNodeWithTag("SettingsMaterialYouSwitch").performClick() + composeRule.onNodeWithTag("SettingsAutoplaySwitch").performClick() + + composeRule.runOnIdle { + assert(materialYouToggles == 1) + assert(autoplayToggles == 1) + } + } +} diff --git a/app/src/androidTest/java/com/serranoie/app/media/sorter/presentation/sorter/SorterMediaScreenTest.kt b/app/src/androidTest/java/com/serranoie/app/media/sorter/presentation/sorter/SorterMediaScreenTest.kt new file mode 100644 index 0000000..b4c9c35 --- /dev/null +++ b/app/src/androidTest/java/com/serranoie/app/media/sorter/presentation/sorter/SorterMediaScreenTest.kt @@ -0,0 +1,98 @@ +package com.serranoie.app.media.sorter.presentation.sorter + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.serranoie.app.media.sorter.presentation.model.MediaFileUi +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SorterMediaScreenTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun launchScreen( + isZoomed: Boolean = false, + isCompleted: Boolean = false, + deletedCount: Int = 0, + file: MediaFileUi? = dummyImageFile(), + ) { + composeRule.setContent { + SorterMediaScreen( + currentFile = file, + isCompleted = isCompleted, + deletedCount = deletedCount, + useBlurredBackground = true, + autoPlayVideos = false, + onKeepCurrent = {}, + onTrashCurrent = { file }, + onUndoTrash = {}, + onBackToOnboarding = {}, + onNavigateToReview = {}, + onNavigateToSettings = {} + ) + } + if (isZoomed) composeRule.runOnIdle { + composeRule.onNodeWithTag("ZoomOverlay", useUnmergedTree = true).assertExists() + } + } + + @Test + fun completionScreen_shows_when_completed() { + launchScreen(isCompleted = true, deletedCount = 2) + composeRule.onNodeWithTag("CompletionScreen").assertIsDisplayed() + // Verify the review button exists (deletedCount > 0) + composeRule.onNodeWithText("2", substring = true).assertExists() + } + + @Test + fun normalScreen_shows_zoomable_media_and_info_overlay() { + launchScreen(isCompleted = false) + composeRule.onNodeWithTag("MediaFileName", useUnmergedTree = true).assertExists() + composeRule.onAllNodesWithText("Beach_Sunset_01.jpg", ignoreCase = true, substring = true) + .assertCountEquals(2) // file name in card + info overlay + composeRule.onNodeWithTag("CompletionScreen").assertDoesNotExist() + } + + @Test + fun zoomOverlay_not_shown_when_not_zoomed() { + launchScreen(isZoomed = false) + composeRule.onNodeWithTag("ZoomOverlay", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun backPress_navigates_when_not_zoomed() { + launchScreen(isZoomed = false) + // Simulate back press + composeRule.activityRule.scenario.onActivity { + it.onBackPressedDispatcher.onBackPressed() + } + // TODO: assert navigation, e.g. callback tracked (use mock in real test) + } + + @After + fun tearDown() { + // Clean up if needed + } + + companion object { + fun dummyImageFile() = MediaFileUi( + id = "1", + fileName = "Beach_Sunset_01.jpg", + fileInfo = "2.5 MB • Yesterday", + mediaType = "image", + date = "Yesterday", + fileSize = "2.5 MB", + dimensions = "4032x3024", + dateCreated = "2025-01-08 10:30 AM", + lastAccessed = "2025-01-09 09:15 AM", + modified = "Yesterday", + path = "/photos/beach/", + ) + } +} diff --git a/app/src/androidTest/java/com/serranoie/app/media/sorter/presentation/tutorial/TutorialScreenTest.kt b/app/src/androidTest/java/com/serranoie/app/media/sorter/presentation/tutorial/TutorialScreenTest.kt new file mode 100644 index 0000000..2902dbb --- /dev/null +++ b/app/src/androidTest/java/com/serranoie/app/media/sorter/presentation/tutorial/TutorialScreenTest.kt @@ -0,0 +1,51 @@ +package com.serranoie.app.media.sorter.presentation.tutorial + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.serranoie.app.media.sorter.R +import com.serranoie.app.media.sorter.presentation.ui.theme.SorterTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TutorialScreenTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun tutorialScreen_renders_core_content() { + val context = composeRule.activity + composeRule.setContent { + SorterTheme { + TutorialScreen() + } + } + + composeRule.onNodeWithTag("TutorialScreen").assertExists() + composeRule.onNodeWithText(context.getString(R.string.tutorial_title)).assertIsDisplayed() + composeRule.onNodeWithTag("TutorialGetStartedButton").assertIsDisplayed() + composeRule.onNodeWithContentDescription(context.getString(R.string.content_desc_demo_media)) + .assertExists() + } + + @Test + fun getStartedButton_calls_callback() { + var clicked = false + composeRule.setContent { + SorterTheme { + TutorialScreen( + onGetStarted = { clicked = true } + ) + } + } + + composeRule.onNodeWithTag("TutorialGetStartedButton").performClick() + composeRule.runOnIdle { + assert(clicked) + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d00dc5e..5484c86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,7 +48,7 @@ + android:theme="@style/Theme.Sorter.Splash"> diff --git a/app/src/main/java/com/serranoie/app/media/sorter/MainActivity.kt b/app/src/main/java/com/serranoie/app/media/sorter/MainActivity.kt index 321f83d..fccbee4 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/MainActivity.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/MainActivity.kt @@ -7,12 +7,14 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.compose.LocalActivity import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.lifecycleScope import androidx.work.ExistingPeriodicWorkPolicy @@ -25,8 +27,8 @@ import com.serranoie.app.media.sorter.presentation.navigation.PermissionHandler import com.serranoie.app.media.sorter.presentation.settings.SettingsViewModel import com.serranoie.app.media.sorter.presentation.sorter.SorterViewModel import com.serranoie.app.media.sorter.presentation.update.UpdateViewModel -import com.serranoie.app.media.sorter.ui.components.UpdateDialog -import com.serranoie.app.media.sorter.ui.theme.SorterTheme +import com.serranoie.app.media.sorter.presentation.ui.components.UpdateDialog +import com.serranoie.app.media.sorter.presentation.ui.theme.SorterTheme import com.serranoie.app.media.sorter.work.UpdateCheckWorker import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @@ -34,8 +36,17 @@ import java.util.concurrent.TimeUnit @AndroidEntryPoint class MainActivity : ComponentActivity() { + + private val navigationViewModel: NavigationViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) + + splashScreen.setKeepOnScreenCondition { + !navigationViewModel.navigationState.value.isReady + } + enableEdgeToEdge() setContent { SorterApp( @@ -99,27 +110,29 @@ fun SorterApp(intent: Intent? = null) { val navigationState by navigationViewModel.navigationState.collectAsState() - val requestPermissions = PermissionHandler( - showPermissionDialog = navigationState.showPermissionDialog, - sorterViewModel = sorterViewModel, - onPermissionsGranted = { - navigationViewModel.updatePermissions(true) - }, - onPermissionsDenied = { - navigationViewModel.showPermissionDialog(true) - }, - onDismissDialog = { - navigationViewModel.showPermissionDialog(false) - }) - - AppNavHost( - currentScreen = navigationState.currentScreen, - appSettings = appSettings, - hasPermissions = navigationState.hasPermissions, - onRequestPermissions = requestPermissions, - onNavigate = { action -> - navigationViewModel.handleAction(action) - }) + if (navigationState.isReady) { + val requestPermissions = PermissionHandler( + showPermissionDialog = navigationState.showPermissionDialog, + sorterViewModel = sorterViewModel, + onPermissionsGranted = { + navigationViewModel.updatePermissions(true) + }, + onPermissionsDenied = { + navigationViewModel.showPermissionDialog(true) + }, + onDismissDialog = { + navigationViewModel.showPermissionDialog(false) + }) + + AppNavHost( + currentScreen = navigationState.currentScreen, + appSettings = appSettings, + hasPermissions = navigationState.hasPermissions, + onRequestPermissions = requestPermissions, + onNavigate = { action -> + navigationViewModel.handleAction(action) + }) + } if (showUpdateDialog && updateCheckResult?.updateInfo != null) { val updateInfo = updateCheckResult!!.updateInfo!! diff --git a/app/src/main/java/com/serranoie/app/media/sorter/data/UpdatePreferences.kt b/app/src/main/java/com/serranoie/app/media/sorter/data/UpdatePreferences.kt index 48df5fa..15689a6 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/data/UpdatePreferences.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/data/UpdatePreferences.kt @@ -1,6 +1,7 @@ package com.serranoie.app.media.sorter.data import android.content.Context +import androidx.annotation.VisibleForTesting import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit @@ -12,7 +13,8 @@ import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton -private val Context.updateDataStore: DataStore by preferencesDataStore(name = "update_store") +@VisibleForTesting +val Context.updateDataStore: DataStore by preferencesDataStore(name = "update_store") @Singleton class UpdatePreferences @Inject constructor( diff --git a/app/src/main/java/com/serranoie/app/media/sorter/data/datasource/AndroidMediaDataSource.kt b/app/src/main/java/com/serranoie/app/media/sorter/data/datasource/AndroidMediaDataSource.kt index b071e23..c356645 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/data/datasource/AndroidMediaDataSource.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/data/datasource/AndroidMediaDataSource.kt @@ -29,6 +29,27 @@ class AndroidMediaDataSource @Inject constructor( private const val TAG = "AndroidMediaDataSource" } + /** + * `Build.VERSION.SDK_INT` is a static final field and is not reliably mockable in JVM unit tests. + * This override exists to keep `testDebugUnitTest` stable without Robolectric. + */ + internal var sdkIntOverride: Int? = null + + /** + * `MediaStore.*.EXTERNAL_CONTENT_URI` is not reliably usable in JVM unit tests (no Android runtime). + * Allow tests to inject a mocked base Uri to keep `testDebugUnitTest` stable without Robolectric. + */ + internal var imagesContentUriOverride: Uri? = null + internal var videosContentUriOverride: Uri? = null + + private fun sdkInt(): Int = sdkIntOverride ?: Build.VERSION.SDK_INT + + private fun imagesContentUri(): Uri = + imagesContentUriOverride ?: MediaStore.Images.Media.EXTERNAL_CONTENT_URI + + private fun videosContentUri(): Uri = + videosContentUriOverride ?: MediaStore.Video.Media.EXTERNAL_CONTENT_URI + override suspend fun fetchImages(): Result> = withContext(Dispatchers.IO) { try { val projection = arrayOf( @@ -47,7 +68,7 @@ class AndroidMediaDataSource @Inject constructor( val sortOrder = "${MediaStore.Images.Media.DATE_TAKEN} DESC" fetchMediaGeneric( - contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentUri = imagesContentUri(), projection = projection, sortOrder = sortOrder, mediaType = "image" @@ -82,7 +103,7 @@ class AndroidMediaDataSource @Inject constructor( val sortOrder = "${MediaStore.Video.Media.DATE_TAKEN} DESC" fetchMediaGeneric( - contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + contentUri = videosContentUri(), projection = projection, sortOrder = sortOrder, mediaType = "video" @@ -128,8 +149,13 @@ class AndroidMediaDataSource @Inject constructor( override suspend fun deleteMultipleMedia(uris: List): Result = withContext(Dispatchers.IO) { try { + if (uris.isEmpty()) { + return@withContext 0.asSuccess() + } + var successCount = 0 var failureCount = 0 + var permissionFailureCount = 0 val pendingUris = mutableListOf() uris.forEach { uri -> @@ -143,9 +169,11 @@ class AndroidMediaDataSource @Inject constructor( } catch (e: RecoverableSecurityException) { Log.e(TAG, "RecoverableSecurityException for $uri, needs user permission, exception: $e") pendingUris.add(uri) + permissionFailureCount++ failureCount++ } catch (e: SecurityException) { Log.e(TAG, "SecurityException for $uri", e) + permissionFailureCount++ failureCount++ } catch (e: Exception) { Log.e(TAG, "Failed to delete $uri", e) @@ -158,11 +186,8 @@ class AndroidMediaDataSource @Inject constructor( "Deleted $successCount of ${uris.size} media files ($failureCount failed, ${pendingUris.size} need permission)" ) - if (successCount == 0 && pendingUris.size == uris.size) { - Log.d( - TAG, - "All files need user permission, returning error to trigger permission request" - ) + return@withContext if (successCount == 0 && permissionFailureCount == uris.size) { + Log.d(TAG, "All files require permission, returning PermissionError") AppError.PermissionError().asError() } else { successCount.asSuccess() @@ -178,10 +203,10 @@ class AndroidMediaDataSource @Inject constructor( } override fun createDeleteRequest(uris: List): android.app.PendingIntent? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + return if (sdkInt() >= Build.VERSION_CODES.R) { Log.d(TAG, "Creating delete request for ${uris.size} files (Android 11+)") MediaStore.createDeleteRequest(context.contentResolver, uris) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + } else if (sdkInt() >= Build.VERSION_CODES.Q) { Log.d(TAG, "Creating delete request for ${uris.size} files (Android 10)") MediaStore.createDeleteRequest(context.contentResolver, uris) } else { @@ -191,7 +216,7 @@ class AndroidMediaDataSource @Inject constructor( } override fun createTrashRequest(uris: List): android.app.PendingIntent? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + return if (sdkInt() >= Build.VERSION_CODES.R) { try { Log.d(TAG, "Creating trash request for ${uris.size} files (moves to trash bin)") MediaStore.createTrashRequest(context.contentResolver, uris, true) @@ -212,7 +237,7 @@ class AndroidMediaDataSource @Inject constructor( Log.d(TAG, "Creating deletion request for ${uris.size} files (useTrash: $useTrash)") // If user wants to use trash and we're on Android 11+, try trash first - if (useTrash && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (useTrash && sdkInt() >= Build.VERSION_CODES.R) { val trashRequest = createTrashRequest(uris) if (trashRequest != null) { Log.d(TAG, "Using trash request (files will be recoverable for 30 days)") @@ -241,16 +266,15 @@ class AndroidMediaDataSource @Inject constructor( ): Result> { val mediaFiles = mutableListOf() - try { - val cursor: Cursor? = context.contentResolver.query( - contentUri, - projection, - null, - null, - sortOrder - ) + val cursor: Cursor? = context.contentResolver.query( + contentUri, + projection, + null, + null, + sortOrder + ) - cursor?.use { + cursor?.use { val idColumn = it.getColumnIndexOrThrow(projection[0]) val nameColumn = it.getColumnIndexOrThrow(projection[1]) val sizeColumn = it.getColumnIndexOrThrow(projection[2]) @@ -286,7 +310,7 @@ class AndroidMediaDataSource @Inject constructor( System.currentTimeMillis() } } - + val fileDate = Instant.ofEpochMilli(timestamp) .atZone(ZoneId.systemDefault()) .toLocalDate() @@ -307,15 +331,10 @@ class AndroidMediaDataSource @Inject constructor( } } - return if (mediaFiles.isEmpty()) { - AppError.NoMediaFoundError().asError() - } else { - mediaFiles.asSuccess() - } - - } catch (e: Exception) { - Log.e(TAG, "Error in fetchMediaGeneric", e) - return AppError.fromThrowable(e).asError() + return if (mediaFiles.isEmpty()) { + AppError.NoMediaFoundError().asError() + } else { + mediaFiles.asSuccess() } } } diff --git a/app/src/main/java/com/serranoie/app/media/sorter/update/model/UpdateInfo.kt b/app/src/main/java/com/serranoie/app/media/sorter/data/update/model/UpdateInfo.kt similarity index 93% rename from app/src/main/java/com/serranoie/app/media/sorter/update/model/UpdateInfo.kt rename to app/src/main/java/com/serranoie/app/media/sorter/data/update/model/UpdateInfo.kt index 236d6e3..4142fa0 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/update/model/UpdateInfo.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/data/update/model/UpdateInfo.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.update.model +package com.serranoie.app.media.sorter.data.update.model data class UpdateInfo( val versionName: String, diff --git a/app/src/main/java/com/serranoie/app/media/sorter/domain/DeleteMediaUseCase.kt b/app/src/main/java/com/serranoie/app/media/sorter/domain/media/DeleteMediaUseCase.kt similarity index 81% rename from app/src/main/java/com/serranoie/app/media/sorter/domain/DeleteMediaUseCase.kt rename to app/src/main/java/com/serranoie/app/media/sorter/domain/media/DeleteMediaUseCase.kt index 95a3ea1..96362df 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/domain/DeleteMediaUseCase.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/domain/media/DeleteMediaUseCase.kt @@ -1,6 +1,7 @@ -package com.serranoie.app.media.sorter.domain +package com.serranoie.app.media.sorter.domain.media import android.net.Uri +import com.serranoie.app.media.sorter.domain.Result import com.serranoie.app.media.sorter.domain.repository.MediaRepository import javax.inject.Inject diff --git a/app/src/main/java/com/serranoie/app/media/sorter/domain/GetMediaRandomBatchesUseCase.kt b/app/src/main/java/com/serranoie/app/media/sorter/domain/media/GetMediaRandomBatchesUseCase.kt similarity index 91% rename from app/src/main/java/com/serranoie/app/media/sorter/domain/GetMediaRandomBatchesUseCase.kt rename to app/src/main/java/com/serranoie/app/media/sorter/domain/media/GetMediaRandomBatchesUseCase.kt index af30963..d5642d1 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/domain/GetMediaRandomBatchesUseCase.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/domain/media/GetMediaRandomBatchesUseCase.kt @@ -1,7 +1,11 @@ -package com.serranoie.app.media.sorter.domain +package com.serranoie.app.media.sorter.domain.media import android.util.Log import com.serranoie.app.media.sorter.data.MediaFile +import com.serranoie.app.media.sorter.domain.AppError +import com.serranoie.app.media.sorter.domain.Result +import com.serranoie.app.media.sorter.domain.asError +import com.serranoie.app.media.sorter.domain.asSuccess import com.serranoie.app.media.sorter.domain.repository.MediaRepository import java.time.LocalDate import javax.inject.Inject diff --git a/app/src/main/java/com/serranoie/app/media/sorter/domain/SorterMediaUseCase.kt b/app/src/main/java/com/serranoie/app/media/sorter/domain/media/SorterMediaUseCase.kt similarity index 87% rename from app/src/main/java/com/serranoie/app/media/sorter/domain/SorterMediaUseCase.kt rename to app/src/main/java/com/serranoie/app/media/sorter/domain/media/SorterMediaUseCase.kt index 225a353..c307f37 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/domain/SorterMediaUseCase.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/domain/media/SorterMediaUseCase.kt @@ -1,7 +1,9 @@ -package com.serranoie.app.media.sorter.domain +package com.serranoie.app.media.sorter.domain.media import android.util.Log import com.serranoie.app.media.sorter.data.MediaFile +import com.serranoie.app.media.sorter.domain.Result +import com.serranoie.app.media.sorter.domain.asSuccess import com.serranoie.app.media.sorter.domain.repository.MediaRepository import javax.inject.Inject @@ -14,15 +16,15 @@ class SorterMediaUseCase @Inject constructor( suspend operator fun invoke(): Result> { Log.d(TAG, "Fetching media files in chronological order") - + return when (val result = repository.fetchMediaFiles()) { is Result.Success -> { val validFiles = result.data.filter { file -> file.fileSize > 0 && file.fileName.isNotBlank() } - + Log.d(TAG, "Filtered ${result.data.size} files to ${validFiles.size} valid files") - + val sorted = validFiles.sortedByDescending { it.dateTaken } sorted.asSuccess() } @@ -34,4 +36,4 @@ class SorterMediaUseCase @Inject constructor( suspend fun getMediaByFolder(): Result>> { return repository.getMediaByFolder() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/serranoie/app/media/sorter/presentation/navigation/NavigationState.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/navigation/NavigationState.kt index 55cf95d..a2e8db1 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/presentation/navigation/NavigationState.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/navigation/NavigationState.kt @@ -3,7 +3,8 @@ package com.serranoie.app.media.sorter.presentation.navigation data class NavigationState( val currentScreen: Screen = Screen.Onboard, val hasPermissions: Boolean = false, - val showPermissionDialog: Boolean = false + val showPermissionDialog: Boolean = false, + val isReady: Boolean = false ) sealed class NavigationAction { diff --git a/app/src/main/java/com/serranoie/app/media/sorter/presentation/navigation/NavigationViewModel.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/navigation/NavigationViewModel.kt index 55f70f4..b4447d1 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/presentation/navigation/NavigationViewModel.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/navigation/NavigationViewModel.kt @@ -25,7 +25,14 @@ class NavigationViewModel @Inject constructor( viewModelScope.launch { getAppSettings().collect { settings -> tutorialCompleted = settings.tutorialCompleted - if (settings.tutorialCompleted && _navigationState.value.currentScreen == Screen.Onboard) { + + if (!_navigationState.value.isReady) { + // First emission: determine the correct start screen + val startScreen = if (settings.tutorialCompleted) Screen.Sorter else Screen.Onboard + _navigationState.update { + it.copy(currentScreen = startScreen, isReady = true) + } + } else if (settings.tutorialCompleted && _navigationState.value.currentScreen == Screen.Onboard) { navigateTo(Screen.Sorter) } } diff --git a/app/src/main/java/com/serranoie/app/media/sorter/presentation/review/ReviewScreen.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/review/ReviewScreen.kt index 5be1391..4a255d0 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/presentation/review/ReviewScreen.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/review/ReviewScreen.kt @@ -79,6 +79,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -88,10 +89,10 @@ import coil.request.ImageRequest import com.serranoie.app.media.sorter.R import com.serranoie.app.media.sorter.domain.repository.MediaRepository import com.serranoie.app.media.sorter.presentation.model.MediaFileUi -import com.serranoie.app.media.sorter.ui.theme.AureaSpacing -import com.serranoie.app.media.sorter.ui.theme.components.GridZoomLevel -import com.serranoie.app.media.sorter.ui.theme.components.PinchToZoomGridContainer -import com.serranoie.app.media.sorter.ui.theme.components.detectPinchGestures +import com.serranoie.app.media.sorter.presentation.ui.theme.AureaSpacing +import com.serranoie.app.media.sorter.presentation.ui.theme.components.GridZoomLevel +import com.serranoie.app.media.sorter.presentation.ui.theme.components.PinchToZoomGridContainer +import com.serranoie.app.media.sorter.presentation.ui.theme.components.detectPinchGestures import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -147,6 +148,7 @@ fun ReviewScreen( ExtendedFloatingActionButton( onClick = { showDeleteConfirmDialog = true }, expanded = expandedFab, + modifier = Modifier.testTag("ReviewDeleteAllFab"), icon = { Icon( imageVector = Icons.Default.Delete, contentDescription = stringResource(R.string.review_btn_delete_all) @@ -167,9 +169,10 @@ fun ReviewScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues) + .testTag("ReviewScreen") ) { if (deletedFiles.isEmpty()) { - EmptyState() + EmptyState(modifier = Modifier.testTag("ReviewEmptyState")) } else { PinchToZoomGridContainer( modifier = Modifier.fillMaxSize(), initialLevel = 1, zoomLevels = zoomLevels @@ -196,12 +199,15 @@ fun ReviewScreen( } }, onDismiss = { showDeleteConfirmDialog = false - }) + }, modifier = Modifier.testTag("ReviewDeleteAllDialog")) } selectedMediaForFullscreen?.let { media -> FullscreenMediaViewer( - media = media, onDismiss = { selectedMediaForFullscreen = null }) + media = media, + onDismiss = { selectedMediaForFullscreen = null }, + modifier = Modifier.testTag("ReviewFullscreenViewer") + ) } } } @@ -209,12 +215,12 @@ fun ReviewScreen( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun EmptyState() { +private fun EmptyState(modifier: Modifier = Modifier) { val colorScheme = MaterialTheme.colorScheme val spacing = AureaSpacing.current Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .padding(spacing.xl), horizontalAlignment = Alignment.CenterHorizontally, @@ -273,6 +279,7 @@ private fun ZoomableStaggeredGrid( verticalItemSpacing = spacing.xs, modifier = Modifier .fillMaxSize() + .testTag("ReviewGrid") .graphicsLayer { scaleX = zoomTransition scaleY = zoomTransition @@ -462,6 +469,7 @@ private fun MediaGridItem( Card( modifier = Modifier + .testTag("ReviewGridItem_${file.id}") .fillMaxWidth() .height(baseHeight * heightMultiplier) .pointerInput(Unit) { @@ -575,9 +583,12 @@ private fun MediaGridItem( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun DeleteAllConfirmationDialog( - itemCount: Int, onConfirm: () -> Unit, onDismiss: () -> Unit + itemCount: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier ) { - AlertDialog(onDismissRequest = onDismiss, icon = { + AlertDialog(modifier = modifier, onDismissRequest = onDismiss, icon = { Icon( imageVector = Icons.Default.Delete, contentDescription = null, @@ -603,7 +614,9 @@ private fun DeleteAllConfirmationDialog( ) }, confirmButton = { Button( - onClick = onConfirm, colors = ButtonDefaults.buttonColors( + onClick = onConfirm, + modifier = Modifier.testTag("ReviewDeleteAllConfirm"), + colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError ) @@ -615,7 +628,7 @@ private fun DeleteAllConfirmationDialog( ) } }, dismissButton = { - TextButton(onClick = onDismiss) { + TextButton(onClick = onDismiss, modifier = Modifier.testTag("ReviewDeleteAllCancel")) { Text( text = stringResource(R.string.review_btn_cancel), style = MaterialTheme.typography.labelLargeEmphasized ) @@ -626,13 +639,15 @@ private fun DeleteAllConfirmationDialog( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun FullscreenMediaViewer( - media: MediaFileUi, onDismiss: () -> Unit + media: MediaFileUi, + onDismiss: () -> Unit, + modifier: Modifier = Modifier ) { val context = LocalContext.current val spacing = AureaSpacing.current Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(Color.Black) ) { @@ -666,7 +681,7 @@ private fun FullscreenMediaViewer( shape = CircleShape, color = Color.Black.copy(alpha = 0.5f) ) { - IconButton(onClick = onDismiss) { + IconButton(onClick = onDismiss, modifier = Modifier.testTag("ReviewFullscreenClose")) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.content_desc_close), diff --git a/app/src/main/java/com/serranoie/app/media/sorter/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/settings/SettingsScreen.kt index 26a1c2a..d382e55 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/settings/SettingsScreen.kt @@ -53,18 +53,19 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.platform.testTag import com.serranoie.app.media.sorter.R import com.serranoie.app.media.sorter.presentation.util.Utils import com.serranoie.app.media.sorter.presentation.util.Utils.strongHapticFeedback import com.serranoie.app.media.sorter.presentation.util.Utils.toggleFeedback import com.serranoie.app.media.sorter.presentation.util.Utils.weakHapticFeedback -import com.serranoie.app.media.sorter.ui.theme.AureaSpacing -import com.serranoie.app.media.sorter.ui.theme.components.CustomPaddedExpandableItem -import com.serranoie.app.media.sorter.ui.theme.components.CustomPaddedListItem -import com.serranoie.app.media.sorter.ui.theme.components.PaddedListGroup -import com.serranoie.app.media.sorter.ui.theme.components.PaddedListItemPosition -import com.serranoie.app.media.sorter.ui.theme.util.DevicePreview -import com.serranoie.app.media.sorter.ui.theme.util.PreviewWrapper +import com.serranoie.app.media.sorter.presentation.ui.theme.AureaSpacing +import com.serranoie.app.media.sorter.presentation.ui.theme.components.CustomPaddedExpandableItem +import com.serranoie.app.media.sorter.presentation.ui.theme.components.CustomPaddedListItem +import com.serranoie.app.media.sorter.presentation.ui.theme.components.PaddedListGroup +import com.serranoie.app.media.sorter.presentation.ui.theme.components.PaddedListItemPosition +import com.serranoie.app.media.sorter.presentation.ui.theme.util.DevicePreview +import com.serranoie.app.media.sorter.presentation.ui.theme.util.PreviewWrapper private const val DEFAULT_VERSION = "1.0" @@ -123,7 +124,7 @@ fun SettingsScreen( style = MaterialTheme.typography.titleLargeEmphasized, ) }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = onBack, modifier = Modifier.testTag("SettingsBackButton")) { Icon( imageVector = Icons.Default.ArrowBack, contentDescription = stringResource(R.string.content_desc_back) ) @@ -137,7 +138,8 @@ fun SettingsScreen( LazyColumn( modifier = Modifier .fillMaxSize() - .padding(paddingValues), + .padding(paddingValues) + .testTag("SettingsScreen"), contentPadding = PaddingValues(bottom = aureaSpacing.m) ) { item { @@ -148,7 +150,9 @@ fun SettingsScreen( onClick = { showThemeDialog = true view.weakHapticFeedback() - }, position = PaddedListItemPosition.First + }, + position = PaddedListItemPosition.First, + modifier = Modifier.testTag("SettingsThemeItem") ) { Icon( imageVector = Icons.Default.Brightness4, @@ -176,7 +180,9 @@ fun SettingsScreen( } CustomPaddedListItem( - onClick = onMaterialYouToggle, position = PaddedListItemPosition.Middle + onClick = onMaterialYouToggle, + position = PaddedListItemPosition.Middle, + modifier = Modifier.testTag("SettingsMaterialYouItem") ) { Icon( imageVector = Icons.Default.Palette, @@ -200,14 +206,18 @@ fun SettingsScreen( checked = isMaterialYouEnabled, onCheckedChange = { onMaterialYouToggle() view.toggleFeedback() - }) + }, + modifier = Modifier.testTag("SettingsMaterialYouSwitch") + ) } CustomPaddedListItem( onClick = { onBlurredBackgroundToggle() view.strongHapticFeedback() - }, position = PaddedListItemPosition.Last + }, + position = PaddedListItemPosition.Last, + modifier = Modifier.testTag("SettingsBlurBackgroundItem") ) { Icon( imageVector = Icons.Default.Brightness4, @@ -241,7 +251,9 @@ fun SettingsScreen( title = stringResource(R.string.settings_behaviour_title) ) { CustomPaddedListItem( - onClick = onAutoPlayToggle, position = PaddedListItemPosition.First + onClick = onAutoPlayToggle, + position = PaddedListItemPosition.First, + modifier = Modifier.testTag("SettingsAutoplayItem") ) { Icon( imageVector = Icons.Default.PlayArrow, @@ -265,14 +277,18 @@ fun SettingsScreen( checked = isAutoPlayEnabled, onCheckedChange = { onAutoPlayToggle() view.toggleFeedback() - }) + }, + modifier = Modifier.testTag("SettingsAutoplaySwitch") + ) } CustomPaddedListItem( onClick = { onResetViewedHistory() view.strongHapticFeedback() - }, position = PaddedListItemPosition.Last + }, + position = PaddedListItemPosition.Last, + modifier = Modifier.testTag("SettingsResetViewedHistoryItem") ) { Icon( imageVector = Icons.Default.Refresh, @@ -307,6 +323,7 @@ fun SettingsScreen( view.weakHapticFeedback() }, position = PaddedListItemPosition.Single, + modifier = Modifier.testTag("SettingsStorageExpandableItem"), defaultContent = { Icon( imageVector = Icons.Rounded.Storage, @@ -331,7 +348,10 @@ fun SettingsScreen( checked = syncFileToTrashBin, onCheckedChange = { onSyncFileToTrashBinToggle() view.toggleFeedback() - }, modifier = Modifier.padding(end = aureaSpacing.xs) + }, + modifier = Modifier + .padding(end = aureaSpacing.xs) + .testTag("SettingsSyncTrashSwitch") ) }, expandedContent = { @@ -430,7 +450,9 @@ fun SettingsScreen( onClick = { Utils.openWebLink(context, "https://www.github.com/isaacsa51/Sorter") view.weakHapticFeedback() - }, position = PaddedListItemPosition.Middle + }, + position = PaddedListItemPosition.Middle, + modifier = Modifier.testTag("SettingsCheckUpdatesItem") ) { Icon( imageVector = Icons.Default.Policy, @@ -541,7 +563,9 @@ fun SettingsScreen( onClick = { onResetTutorial() view.toggleFeedback() - }, position = PaddedListItemPosition.Single + }, + position = PaddedListItemPosition.Single, + modifier = Modifier.testTag("SettingsResetTutorialItem") ) { Icon( imageVector = Icons.Default.Refresh, diff --git a/app/src/main/java/com/serranoie/app/media/sorter/presentation/settings/ThemePickerDialog.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/settings/ThemePickerDialog.kt index 8c11d7d..c658f1c 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/presentation/settings/ThemePickerDialog.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/settings/ThemePickerDialog.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -33,7 +34,8 @@ fun ThemePickerDialog( Surface( shape = RoundedCornerShape(28.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 6.dp + tonalElevation = 6.dp, + modifier = Modifier.testTag("ThemePickerDialog") ) { Column( modifier = Modifier diff --git a/app/src/main/java/com/serranoie/app/media/sorter/presentation/sorter/SorterMediaScreen.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/sorter/SorterMediaScreen.kt index c1d5cd2..8458eec 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/presentation/sorter/SorterMediaScreen.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/sorter/SorterMediaScreen.kt @@ -1,6 +1,5 @@ package com.serranoie.app.media.sorter.presentation.sorter -import android.R.attr.spacing import android.content.Intent import android.util.Log import androidx.compose.foundation.layout.* @@ -13,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -24,26 +24,27 @@ import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.zIndex -import com.serranoie.app.media.sorter.ui.theme.components.ActionFeedbackTint -import com.serranoie.app.media.sorter.ui.theme.components.ActionType -import com.serranoie.app.media.sorter.ui.theme.components.BlurredMediaBackground -import com.serranoie.app.media.sorter.ui.theme.components.CompletionScreen -import com.serranoie.app.media.sorter.ui.theme.components.FileInfo -import com.serranoie.app.media.sorter.ui.theme.components.GestureGradient -import com.serranoie.app.media.sorter.ui.theme.components.GestureIndicator -import com.serranoie.app.media.sorter.ui.theme.components.MediaInfoOverlay -import com.serranoie.app.media.sorter.ui.theme.components.MediaTypeBadge -import com.serranoie.app.media.sorter.ui.theme.components.SorterTopAppBar -import com.serranoie.app.media.sorter.ui.theme.components.VideoPlayer -import com.serranoie.app.media.sorter.ui.theme.components.ZoomOverlay -import com.serranoie.app.media.sorter.ui.theme.components.ZoomableMediaContent import com.serranoie.app.media.sorter.presentation.model.MediaFileUi +import com.serranoie.app.media.sorter.presentation.ui.theme.AureaSpacing +import com.serranoie.app.media.sorter.presentation.ui.theme.components.ActionFeedbackTint +import com.serranoie.app.media.sorter.presentation.ui.theme.components.ActionType +import com.serranoie.app.media.sorter.presentation.ui.theme.components.BlurredMediaBackground +import com.serranoie.app.media.sorter.presentation.ui.theme.components.CompletionScreen +import com.serranoie.app.media.sorter.presentation.ui.theme.components.FileInfo +import com.serranoie.app.media.sorter.presentation.ui.theme.components.GestureGradient +import com.serranoie.app.media.sorter.presentation.ui.theme.components.GestureIndicator +import com.serranoie.app.media.sorter.presentation.ui.theme.components.MediaInfoOverlay +import com.serranoie.app.media.sorter.presentation.ui.theme.components.MediaTypeBadge +import com.serranoie.app.media.sorter.presentation.ui.theme.components.SorterTopAppBar +import com.serranoie.app.media.sorter.presentation.ui.theme.components.VideoPlayer +import com.serranoie.app.media.sorter.presentation.ui.theme.components.ZoomOverlay +import com.serranoie.app.media.sorter.presentation.ui.theme.components.ZoomableMediaContent +import com.serranoie.app.media.sorter.presentation.ui.theme.util.DevicePreview +import com.serranoie.app.media.sorter.presentation.ui.theme.util.PreviewWrapper import ir.mahozad.multiplatform.wavyslider.WaveDirection import ir.mahozad.multiplatform.wavyslider.material3.WavySlider as WavySlider3 -import com.serranoie.app.media.sorter.ui.theme.util.DevicePreview -import com.serranoie.app.media.sorter.ui.theme.util.PreviewWrapper -import com.serranoie.app.media.sorter.ui.theme.AureaSpacing import kotlinx.coroutines.launch +import androidx.activity.compose.BackHandler @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class) @Composable @@ -58,7 +59,8 @@ fun SorterMediaScreen( onUndoTrash: () -> Unit, onBackToOnboarding: (() -> Unit)? = null, onNavigateToReview: () -> Unit = {}, - onNavigateToSettings: () -> Unit = {} + onNavigateToSettings: () -> Unit = {}, + isLoading: Boolean = false ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -85,11 +87,15 @@ fun SorterMediaScreen( // Animated values for gesture indicators val keepIconAlpha = animateFloatAsState(keepProgress, animationSpec = tween(240)).value - val keepIconScale = animateFloatAsState(0.7f + 0.5f * keepProgress, animationSpec = tween(240)).value - val keepIconOffset = animateFloatAsState((-32f + 60f * keepProgress), animationSpec = tween(240)).value + val keepIconScale = + animateFloatAsState(0.7f + 0.5f * keepProgress, animationSpec = tween(240)).value + val keepIconOffset = + animateFloatAsState((-32f + 60f * keepProgress), animationSpec = tween(240)).value val trashIconAlpha = animateFloatAsState(trashProgress, animationSpec = tween(240)).value - val trashIconScale = animateFloatAsState(0.7f + 0.5f * trashProgress, animationSpec = tween(240)).value - val trashIconOffset = animateFloatAsState((32f - 60f * trashProgress), animationSpec = tween(240)).value + val trashIconScale = + animateFloatAsState(0.7f + 0.5f * trashProgress, animationSpec = tween(240)).value + val trashIconOffset = + animateFloatAsState((32f - 60f * trashProgress), animationSpec = tween(240)).value Scaffold( modifier = Modifier.fillMaxSize(), @@ -101,6 +107,13 @@ fun SorterMediaScreen( .fillMaxSize() .padding(innerPadding) ) { + // Show loading indicator if requested (while media is loading) + if (isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Box // Ensure nothing else is composed while loading + } BlurredMediaBackground( uri = currentFile?.uri, mediaType = currentFile?.mediaType ?: "image", @@ -121,7 +134,8 @@ fun SorterMediaScreen( deletedCount = deletedCount, onReviewDeleted = onNavigateToReview, onBackToTutorial = onBackToOnboarding, - onSettings = onNavigateToSettings + onSettings = onNavigateToSettings, + modifier = Modifier.testTag("CompletionScreen") ) } else { AnimatedVisibility( @@ -151,7 +165,8 @@ fun SorterMediaScreen( containerColor = colorScheme.errorContainer, contentColor = colorScheme.error, alpha = trashIconAlpha, - scale = trashIconScale + scale = trashIconScale, + modifier = Modifier.testTag("GestureIndicator") ) } @@ -229,6 +244,12 @@ fun SorterMediaScreen( .padding(spacing.s) ) + Text( + text = currentFile.fileName, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag("MediaFileName") + ) + if (currentFile.mediaType == "video") { VideoPlayer( uri = currentFile.uri, @@ -397,7 +418,8 @@ fun SorterMediaScreen( containerColor = MaterialTheme.colorScheme.surface, contentColor = Color(0xFF4CAF50), alpha = keepIconAlpha, - scale = keepIconScale + scale = keepIconScale, + modifier = Modifier.testTag("KeepButton") ) } @@ -424,11 +446,15 @@ fun SorterMediaScreen( modifier = Modifier .fillMaxSize() .zIndex(100f) + .testTag("ZoomOverlay") ) } } } } + BackHandler(enabled = isZoomed) { + isZoomed = false + } } } } diff --git a/app/src/main/java/com/serranoie/app/media/sorter/presentation/sorter/SorterMediaScreenHelpers.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/sorter/SorterMediaScreenHelpers.kt index e64bc00..6a872c7 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/presentation/sorter/SorterMediaScreenHelpers.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/sorter/SorterMediaScreenHelpers.kt @@ -22,8 +22,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.serranoie.app.media.sorter.presentation.model.MediaFileUi -import com.serranoie.app.media.sorter.ui.theme.AureaSpacing -import com.serranoie.app.media.sorter.ui.theme.components.* +import com.serranoie.app.media.sorter.presentation.ui.theme.AureaSpacing +import com.serranoie.app.media.sorter.presentation.ui.theme.components.FileInfo +import com.serranoie.app.media.sorter.presentation.ui.theme.components.GestureGradient +import com.serranoie.app.media.sorter.presentation.ui.theme.components.GestureIndicator +import com.serranoie.app.media.sorter.presentation.ui.theme.components.MediaContent +import com.serranoie.app.media.sorter.presentation.ui.theme.components.MediaInfoOverlay +import com.serranoie.app.media.sorter.presentation.ui.theme.components.MediaTypeBadge +import com.serranoie.app.media.sorter.presentation.ui.theme.components.VideoPlayer import ir.mahozad.multiplatform.wavyslider.WaveDirection import ir.mahozad.multiplatform.wavyslider.material3.WavySlider as WavySlider3 import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/com/serranoie/app/media/sorter/presentation/sorter/SorterViewModel.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/sorter/SorterViewModel.kt index d996cb5..4852d4b 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/presentation/sorter/SorterViewModel.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/sorter/SorterViewModel.kt @@ -4,10 +4,10 @@ import android.content.ContentUris import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.serranoie.app.media.sorter.domain.DeleteMediaUseCase -import com.serranoie.app.media.sorter.domain.GetMediaRandomBatchesUseCase +import com.serranoie.app.media.sorter.domain.media.DeleteMediaUseCase +import com.serranoie.app.media.sorter.domain.media.GetMediaRandomBatchesUseCase import com.serranoie.app.media.sorter.domain.Result -import com.serranoie.app.media.sorter.domain.SorterMediaUseCase +import com.serranoie.app.media.sorter.domain.media.SorterMediaUseCase import com.serranoie.app.media.sorter.domain.UndoManager import com.serranoie.app.media.sorter.domain.repository.MediaRepository import com.serranoie.app.media.sorter.presentation.mapper.MediaFileMapper diff --git a/app/src/main/java/com/serranoie/app/media/sorter/presentation/tutorial/TutorialScreen.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/tutorial/TutorialScreen.kt index 8e0d73d..a9ce7ea 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/presentation/tutorial/TutorialScreen.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/tutorial/TutorialScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -30,12 +31,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.serranoie.app.media.sorter.R import com.serranoie.app.media.sorter.presentation.sorter.SwipeableCard -import com.serranoie.app.media.sorter.ui.theme.components.FileInfo -import com.serranoie.app.media.sorter.ui.theme.components.GestureIndicator -import com.serranoie.app.media.sorter.ui.theme.components.MediaInfoOverlay -import com.serranoie.app.media.sorter.ui.theme.util.DevicePreview -import com.serranoie.app.media.sorter.ui.theme.util.PreviewWrapper -import com.serranoie.app.media.sorter.ui.theme.AureaSpacing +import com.serranoie.app.media.sorter.presentation.ui.theme.components.FileInfo +import com.serranoie.app.media.sorter.presentation.ui.theme.components.GestureIndicator +import com.serranoie.app.media.sorter.presentation.ui.theme.components.MediaInfoOverlay +import com.serranoie.app.media.sorter.presentation.ui.theme.util.DevicePreview +import com.serranoie.app.media.sorter.presentation.ui.theme.util.PreviewWrapper +import com.serranoie.app.media.sorter.presentation.ui.theme.AureaSpacing @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -113,7 +114,9 @@ fun TutorialScreen( } Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .testTag("TutorialScreen"), ) { innerPadding -> Box( modifier = Modifier @@ -249,7 +252,8 @@ fun TutorialScreen( Button( modifier = Modifier .fillMaxWidth() - .height(56.dp), + .height(56.dp) + .testTag("TutorialGetStartedButton"), colors = ButtonDefaults.buttonColors( containerColor = colorScheme.primary ), diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/components/UpdateDialog.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/components/UpdateDialog.kt similarity index 94% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/components/UpdateDialog.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/components/UpdateDialog.kt index 1ed298a..4af4d92 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/components/UpdateDialog.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/components/UpdateDialog.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.components +package com.serranoie.app.media.sorter.presentation.ui.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download @@ -13,7 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.serranoie.app.media.sorter.R -import com.serranoie.app.media.sorter.update.model.UpdateInfo +import com.serranoie.app.media.sorter.data.update.model.UpdateInfo @Composable fun UpdateDialog( diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/AureaSpacing.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/AureaSpacing.kt similarity index 98% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/AureaSpacing.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/AureaSpacing.kt index a1520b9..50a7965 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/AureaSpacing.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/AureaSpacing.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme +package com.serranoie.app.media.sorter.presentation.ui.theme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/Color.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/Color.kt similarity index 80% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/Color.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/Color.kt index dd17365..5ad8bbf 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/Color.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/Color.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme +package com.serranoie.app.media.sorter.presentation.ui.theme import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/Theme.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/Theme.kt similarity index 96% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/Theme.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/Theme.kt index 35a85a2..992d3c5 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/Theme.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/Theme.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme +package com.serranoie.app.media.sorter.presentation.ui.theme import android.app.Activity import android.os.Build diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/Type.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/Type.kt similarity index 99% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/Type.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/Type.kt index 7cc837b..05c2999 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/Type.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/Type.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme +package com.serranoie.app.media.sorter.presentation.ui.theme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Typography diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/ActionFeedbackTint.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/ActionFeedbackTint.kt similarity index 96% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/ActionFeedbackTint.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/ActionFeedbackTint.kt index 7e790ca..36ec019 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/ActionFeedbackTint.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/ActionFeedbackTint.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/BlurredMediaBackground.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/BlurredMediaBackground.kt similarity index 97% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/BlurredMediaBackground.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/BlurredMediaBackground.kt index 3a96046..e380a65 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/BlurredMediaBackground.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/BlurredMediaBackground.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import android.net.Uri import androidx.compose.animation.core.animateFloatAsState diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/CompletionScreen.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/CompletionScreen.kt similarity index 95% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/CompletionScreen.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/CompletionScreen.kt index 0d66744..b5a8d1a 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/CompletionScreen.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/CompletionScreen.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -16,8 +16,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.serranoie.app.media.sorter.R -import com.serranoie.app.media.sorter.ui.theme.SorterTheme -import com.serranoie.app.media.sorter.ui.theme.util.DevicePreview +import com.serranoie.app.media.sorter.presentation.ui.theme.SorterTheme +import com.serranoie.app.media.sorter.presentation.ui.theme.util.DevicePreview /** * Completion screen shown when all media files have been sorted diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/FileInfo.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/FileInfo.kt similarity index 80% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/FileInfo.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/FileInfo.kt index cc8a925..3bf4548 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/FileInfo.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/FileInfo.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components /** * Data class representing file information to be displayed diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/GestureGradient.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/GestureGradient.kt similarity index 93% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/GestureGradient.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/GestureGradient.kt index 90a9dcc..feba1b0 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/GestureGradient.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/GestureGradient.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -12,8 +12,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.serranoie.app.media.sorter.ui.theme.util.ComponentPreview -import com.serranoie.app.media.sorter.ui.theme.util.PreviewWrapper +import com.serranoie.app.media.sorter.presentation.ui.theme.util.ComponentPreview +import com.serranoie.app.media.sorter.presentation.ui.theme.util.PreviewWrapper /** * Gradient component that appears at the edge of the screen during swipe gestures diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/GestureIndicator.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/GestureIndicator.kt similarity index 98% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/GestureIndicator.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/GestureIndicator.kt index 5bf730c..8ee4774 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/GestureIndicator.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/GestureIndicator.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.EaseOut diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/ImageElementKey.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/ImageElementKey.kt similarity index 74% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/ImageElementKey.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/ImageElementKey.kt index 1813e5d..328fc42 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/ImageElementKey.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/ImageElementKey.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import android.net.Uri diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/MediaContent.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/MediaContent.kt similarity index 98% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/MediaContent.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/MediaContent.kt index f109866..4e996cf 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/MediaContent.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/MediaContent.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import android.net.Uri import androidx.compose.foundation.background diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/MediaInfoOverlay.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/MediaInfoOverlay.kt similarity index 97% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/MediaInfoOverlay.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/MediaInfoOverlay.kt index 5c418ea..cd0dad7 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/MediaInfoOverlay.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/MediaInfoOverlay.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -33,9 +33,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.serranoie.app.media.sorter.R -import com.serranoie.app.media.sorter.ui.theme.util.ComponentPreview -import com.serranoie.app.media.sorter.ui.theme.util.PreviewWrapper -import com.serranoie.app.media.sorter.ui.theme.AureaSpacing +import com.serranoie.app.media.sorter.presentation.ui.theme.util.ComponentPreview +import com.serranoie.app.media.sorter.presentation.ui.theme.util.PreviewWrapper +import com.serranoie.app.media.sorter.presentation.ui.theme.AureaSpacing /** * Overlay component that displays file information and action buttons at the bottom @@ -152,7 +152,7 @@ private fun SharedTransitionScope.ExpandedInfoContent( onExpandToggle: () -> Unit, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope, - spacing: com.serranoie.app.media.sorter.ui.theme.PhiSpacing, + spacing: com.serranoie.app.media.sorter.presentation.ui.theme.PhiSpacing, cornerRadius: androidx.compose.ui.unit.Dp ) { with(sharedTransitionScope) { diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/MediaTypeBadge.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/MediaTypeBadge.kt similarity index 91% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/MediaTypeBadge.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/MediaTypeBadge.kt index 124868d..936f894 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/MediaTypeBadge.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/MediaTypeBadge.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -12,8 +12,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.serranoie.app.media.sorter.ui.theme.util.ComponentPreview -import com.serranoie.app.media.sorter.ui.theme.util.PreviewWrapper +import com.serranoie.app.media.sorter.presentation.ui.theme.util.ComponentPreview +import com.serranoie.app.media.sorter.presentation.ui.theme.util.PreviewWrapper /** * Badge component that displays the media type (e.g., JPG, MP4) diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/PinchToZoomGrid.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/PinchToZoomGrid.kt similarity index 98% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/PinchToZoomGrid.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/PinchToZoomGrid.kt index 3dbeed8..2b2aa82 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/PinchToZoomGrid.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/PinchToZoomGrid.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/SettingsGroup.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/SettingsGroup.kt similarity index 97% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/SettingsGroup.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/SettingsGroup.kt index 3b17c92..f141e75 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/SettingsGroup.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/SettingsGroup.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically @@ -41,8 +41,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.serranoie.app.media.sorter.ui.theme.util.ComponentPreview -import com.serranoie.app.media.sorter.ui.theme.util.PreviewWrapper +import com.serranoie.app.media.sorter.presentation.ui.theme.util.ComponentPreview +import com.serranoie.app.media.sorter.presentation.ui.theme.util.PreviewWrapper /** * A flexible settings group container that can hold any composable content. @@ -267,6 +267,7 @@ fun PaddedListItem( fun CustomPaddedListItem( onClick: () -> Unit, position: PaddedListItemPosition = PaddedListItemPosition.Middle, + modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit ) { val shape = when (position) { @@ -286,7 +287,7 @@ fun CustomPaddedListItem( shape = shape, tonalElevation = 4.dp, color = MaterialTheme.colorScheme.surfaceContainer, - modifier = Modifier + modifier = modifier .fillMaxWidth() .clip(shape) ) { @@ -313,6 +314,7 @@ fun CustomPaddedExpandableItem( isExpanded: Boolean, onToggleExpanded: () -> Unit, position: PaddedListItemPosition = PaddedListItemPosition.Middle, + modifier: Modifier = Modifier, defaultContent: @Composable RowScope.() -> Unit, expandedContent: @Composable ColumnScope.() -> Unit ) { @@ -333,7 +335,7 @@ fun CustomPaddedExpandableItem( shape = shape, tonalElevation = 4.dp, color = MaterialTheme.colorScheme.surfaceContainer, - modifier = Modifier + modifier = modifier .fillMaxWidth() .clip(shape) ) { diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/SharedElementTransition.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/SharedElementTransition.kt similarity index 97% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/SharedElementTransition.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/SharedElementTransition.kt index 3d2342e..5ebbaa4 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/SharedElementTransition.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/SharedElementTransition.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.EnterExitState diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/SorterTopAppBar.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/SorterTopAppBar.kt similarity index 97% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/SorterTopAppBar.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/SorterTopAppBar.kt index bd59a5e..2b42fbf 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/SorterTopAppBar.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/SorterTopAppBar.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -25,7 +25,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.serranoie.app.media.sorter.ui.theme.util.PreviewWrapper +import com.serranoie.app.media.sorter.presentation.ui.theme.util.PreviewWrapper /** * Top app bar for the Sorter screen with date badge, background toggle, and grid view button diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/VideoPlayer.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/VideoPlayer.kt similarity index 99% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/VideoPlayer.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/VideoPlayer.kt index 93f6b89..c517157 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/VideoPlayer.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/VideoPlayer.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import android.net.Uri import android.view.ViewGroup diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/VideoPlayerContent.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/VideoPlayerContent.kt similarity index 98% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/VideoPlayerContent.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/VideoPlayerContent.kt index e364374..817218e 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/VideoPlayerContent.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/VideoPlayerContent.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import android.net.Uri import androidx.annotation.OptIn diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/ZoomOverlay.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/ZoomOverlay.kt similarity index 99% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/ZoomOverlay.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/ZoomOverlay.kt index ad31c3e..832172d 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/ZoomOverlay.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/ZoomOverlay.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import android.net.Uri import androidx.compose.animation.AnimatedVisibility diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/ZoomableMediaContent.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/ZoomableMediaContent.kt similarity index 98% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/ZoomableMediaContent.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/ZoomableMediaContent.kt index b269403..674ba86 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/components/ZoomableMediaContent.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/components/ZoomableMediaContent.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.components +package com.serranoie.app.media.sorter.presentation.ui.theme.components import android.net.Uri import androidx.compose.animation.AnimatedVisibilityScope diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/util/PreviewAnnotation.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/util/PreviewAnnotation.kt similarity index 97% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/util/PreviewAnnotation.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/util/PreviewAnnotation.kt index 81337dc..4d6f98d 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/util/PreviewAnnotation.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/util/PreviewAnnotation.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.ui.theme.util +package com.serranoie.app.media.sorter.presentation.ui.theme.util import android.content.res.Configuration import androidx.compose.ui.tooling.preview.Preview diff --git a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/util/PreviewUtils.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/util/PreviewUtils.kt similarity index 62% rename from app/src/main/java/com/serranoie/app/media/sorter/ui/theme/util/PreviewUtils.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/util/PreviewUtils.kt index d54b890..c974497 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/ui/theme/util/PreviewUtils.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/ui/theme/util/PreviewUtils.kt @@ -1,8 +1,8 @@ -package com.serranoie.app.media.sorter.ui.theme.util +package com.serranoie.app.media.sorter.presentation.ui.theme.util import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import com.serranoie.app.media.sorter.ui.theme.SorterTheme +import com.serranoie.app.media.sorter.presentation.ui.theme.SorterTheme @Composable fun PreviewWrapper(content: @Composable () -> Unit) { diff --git a/app/src/main/java/com/serranoie/app/media/sorter/update/UpdateManager.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/update/UpdateManager.kt similarity index 91% rename from app/src/main/java/com/serranoie/app/media/sorter/update/UpdateManager.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/update/UpdateManager.kt index 1240fed..75a8ba3 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/update/UpdateManager.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/update/UpdateManager.kt @@ -1,14 +1,14 @@ -package com.serranoie.app.media.sorter.update +package com.serranoie.app.media.sorter.presentation.update import android.content.Context import android.content.pm.PackageManager.NameNotFoundException import com.serranoie.app.media.sorter.R import com.serranoie.app.media.sorter.data.UpdatePreferences -import com.serranoie.app.media.sorter.update.model.UpdateInfo -import com.serranoie.app.media.sorter.update.model.UpdateSource -import com.serranoie.app.media.sorter.update.model.Version -import com.serranoie.app.media.sorter.update.service.GitHubUpdateChecker -import com.serranoie.app.media.sorter.update.service.PlayStoreUpdateChecker +import com.serranoie.app.media.sorter.data.update.model.UpdateInfo +import com.serranoie.app.media.sorter.data.update.model.UpdateSource +import com.serranoie.app.media.sorter.data.update.model.Version +import com.serranoie.app.media.sorter.presentation.update.service.GitHubUpdateChecker +import com.serranoie.app.media.sorter.presentation.update.service.PlayStoreUpdateChecker import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.firstOrNull import javax.inject.Inject diff --git a/app/src/main/java/com/serranoie/app/media/sorter/presentation/update/UpdateViewModel.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/update/UpdateViewModel.kt index 02ec659..cab06ab 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/presentation/update/UpdateViewModel.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/update/UpdateViewModel.kt @@ -2,10 +2,7 @@ package com.serranoie.app.media.sorter.presentation.update import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.serranoie.app.media.sorter.update.UpdateManager -import com.serranoie.app.media.sorter.update.UpdateCheckResponse -import com.serranoie.app.media.sorter.update.model.UpdateInfo -import com.serranoie.app.media.sorter.update.model.Version +import com.serranoie.app.media.sorter.data.update.model.UpdateInfo import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/serranoie/app/media/sorter/update/service/GitHubRelease.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/update/service/GitHubRelease.kt similarity index 88% rename from app/src/main/java/com/serranoie/app/media/sorter/update/service/GitHubRelease.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/update/service/GitHubRelease.kt index e050152..ac6b5ff 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/update/service/GitHubRelease.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/update/service/GitHubRelease.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.media.sorter.update.service +package com.serranoie.app.media.sorter.presentation.update.service import com.google.gson.annotations.SerializedName diff --git a/app/src/main/java/com/serranoie/app/media/sorter/update/service/GitHubUpdateChecker.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/update/service/GitHubUpdateChecker.kt similarity index 92% rename from app/src/main/java/com/serranoie/app/media/sorter/update/service/GitHubUpdateChecker.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/update/service/GitHubUpdateChecker.kt index d0fa784..76e1779 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/update/service/GitHubUpdateChecker.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/update/service/GitHubUpdateChecker.kt @@ -1,14 +1,15 @@ -package com.serranoie.app.media.sorter.update.service +package com.serranoie.app.media.sorter.presentation.update.service import android.content.Context import com.serranoie.app.media.sorter.R -import com.serranoie.app.media.sorter.update.model.UpdateCheckResult -import com.serranoie.app.media.sorter.update.model.UpdateInfo -import com.serranoie.app.media.sorter.update.model.Version +import com.serranoie.app.media.sorter.data.update.model.UpdateCheckResult +import com.serranoie.app.media.sorter.data.update.model.UpdateInfo +import com.serranoie.app.media.sorter.data.update.model.Version import dagger.hilt.android.qualifiers.ApplicationContext import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.GET +import java.net.UnknownHostException import javax.inject.Inject import javax.inject.Named @@ -87,7 +88,7 @@ class GitHubUpdateChecker @Inject constructor( e.printStackTrace() val errorMessage = when { - e is java.net.UnknownHostException || + e is UnknownHostException || e.message?.contains("Unable to resolve host", ignoreCase = true) == true || e.message?.contains("No address associated with hostname", ignoreCase = true) == true -> { context.getString(R.string.update_check_no_internet) diff --git a/app/src/main/java/com/serranoie/app/media/sorter/update/service/PlayStoreUpdateChecker.kt b/app/src/main/java/com/serranoie/app/media/sorter/presentation/update/service/PlayStoreUpdateChecker.kt similarity index 79% rename from app/src/main/java/com/serranoie/app/media/sorter/update/service/PlayStoreUpdateChecker.kt rename to app/src/main/java/com/serranoie/app/media/sorter/presentation/update/service/PlayStoreUpdateChecker.kt index aea7d65..c3ff94d 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/update/service/PlayStoreUpdateChecker.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/presentation/update/service/PlayStoreUpdateChecker.kt @@ -1,7 +1,6 @@ -package com.serranoie.app.media.sorter.update.service +package com.serranoie.app.media.sorter.presentation.update.service import android.app.Activity -import com.serranoie.app.media.sorter.update.model.UpdateCheckResult import javax.inject.Inject class PlayStoreUpdateChecker @Inject constructor() { diff --git a/app/src/main/java/com/serranoie/app/media/sorter/work/UpdateCheckWorker.kt b/app/src/main/java/com/serranoie/app/media/sorter/work/UpdateCheckWorker.kt index 28267d3..b8ebcfd 100644 --- a/app/src/main/java/com/serranoie/app/media/sorter/work/UpdateCheckWorker.kt +++ b/app/src/main/java/com/serranoie/app/media/sorter/work/UpdateCheckWorker.kt @@ -12,8 +12,8 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.serranoie.app.media.sorter.MainActivity import com.serranoie.app.media.sorter.R -import com.serranoie.app.media.sorter.update.UpdateManager -import com.serranoie.app.media.sorter.update.UpdateCheckResponse +import com.serranoie.app.media.sorter.presentation.update.UpdateManager +import com.serranoie.app.media.sorter.presentation.update.UpdateCheckResponse import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 3f3959a..c403410 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,11 @@ - + diff --git a/app/src/test/java/com/serranoie/app/media/sorter/ExampleUnitTest.kt b/app/src/test/java/com/serranoie/app/media/sorter/ExampleUnitTest.kt deleted file mode 100644 index 859ba36..0000000 --- a/app/src/test/java/com/serranoie/app/media/sorter/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.serranoie.app.media.sorter - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/app/src/test/java/com/serranoie/app/media/sorter/data/MediaFileTest.kt b/app/src/test/java/com/serranoie/app/media/sorter/data/MediaFileTest.kt new file mode 100644 index 0000000..bceb37a --- /dev/null +++ b/app/src/test/java/com/serranoie/app/media/sorter/data/MediaFileTest.kt @@ -0,0 +1,256 @@ +package com.serranoie.app.media.sorter.data + +import android.net.Uri +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import java.time.LocalDate + +class MediaFileTest { + + @Test + fun `mediaFile_createsInstance_withAllProperties`() { + // Arrange + val uri = mockk() + val previewUri = mockk() + val fileDate = LocalDate.of(2024, 1, 15) + val dateTaken = 1705276800000L + + // Act + val mediaFile = MediaFile( + uri = uri, + mediaType = "image", + extension = "jpg", + fileName = "test.jpg", + folderName = "Camera", + fileSize = 1024000L, + fileDate = fileDate, + previewUri = previewUri, + dateTaken = dateTaken + ) + + // Assert + assertEquals(uri, mediaFile.uri) + assertEquals("image", mediaFile.mediaType) + assertEquals("jpg", mediaFile.extension) + assertEquals("test.jpg", mediaFile.fileName) + assertEquals("Camera", mediaFile.folderName) + assertEquals(1024000L, mediaFile.fileSize) + assertEquals(fileDate, mediaFile.fileDate) + assertEquals(previewUri, mediaFile.previewUri) + assertEquals(dateTaken, mediaFile.dateTaken) + } + + @Test + fun `mediaFile_videoType_hasCorrectExtension`() { + // Arrange + val uri = mockk() + val previewUri = mockk() + + // Act + val mediaFile = MediaFile( + uri = uri, + mediaType = "video", + extension = "mp4", + fileName = "video.mp4", + folderName = "Videos", + fileSize = 5120000L, + fileDate = LocalDate.now(), + previewUri = previewUri, + dateTaken = System.currentTimeMillis() + ) + + // Assert + assertEquals("video", mediaFile.mediaType) + assertEquals("mp4", mediaFile.extension) + } + + @Test + fun `mediaFile_equals_returnsTrueForSameData`() { + // Arrange + val uri = mockk() + val previewUri = mockk() + val fileDate = LocalDate.of(2024, 1, 15) + val dateTaken = 1705276800000L + + val mediaFile1 = MediaFile( + uri = uri, + mediaType = "image", + extension = "jpg", + fileName = "test.jpg", + folderName = "Camera", + fileSize = 1024000L, + fileDate = fileDate, + previewUri = previewUri, + dateTaken = dateTaken + ) + + val mediaFile2 = MediaFile( + uri = uri, + mediaType = "image", + extension = "jpg", + fileName = "test.jpg", + folderName = "Camera", + fileSize = 1024000L, + fileDate = fileDate, + previewUri = previewUri, + dateTaken = dateTaken + ) + + // Act & Assert + assertEquals(mediaFile1, mediaFile2) + } + + @Test + fun `mediaFile_equals_returnsFalseForDifferentData`() { + // Arrange + val uri1 = mockk() + val uri2 = mockk() + every { uri1.toString() } returns "content://media/1" + every { uri2.toString() } returns "content://media/2" + + val previewUri = mockk() + val fileDate = LocalDate.of(2024, 1, 15) + + val mediaFile1 = MediaFile( + uri = uri1, + mediaType = "image", + extension = "jpg", + fileName = "test1.jpg", + folderName = "Camera", + fileSize = 1024000L, + fileDate = fileDate, + previewUri = previewUri, + dateTaken = 1705276800000L + ) + + val mediaFile2 = MediaFile( + uri = uri2, + mediaType = "image", + extension = "jpg", + fileName = "test2.jpg", + folderName = "Camera", + fileSize = 2048000L, + fileDate = fileDate, + previewUri = previewUri, + dateTaken = 1705363200000L + ) + + // Act & Assert + assertNotEquals(mediaFile1, mediaFile2) + } + + @Test + fun `mediaFile_copy_createsNewInstanceWithChangedProperty`() { + // Arrange + val uri = mockk() + val previewUri = mockk() + val fileDate = LocalDate.of(2024, 1, 15) + + val original = MediaFile( + uri = uri, + mediaType = "image", + extension = "jpg", + fileName = "test.jpg", + folderName = "Camera", + fileSize = 1024000L, + fileDate = fileDate, + previewUri = previewUri, + dateTaken = 1705276800000L + ) + + // Act + val copied = original.copy(fileName = "renamed.jpg") + + // Assert + assertEquals("renamed.jpg", copied.fileName) + assertEquals(original.uri, copied.uri) + assertEquals(original.mediaType, copied.mediaType) + assertNotEquals(original, copied) + } + + @Test + fun `mediaFile_hashCode_sameForEqualObjects`() { + // Arrange + val uri = mockk() + val previewUri = mockk() + val fileDate = LocalDate.of(2024, 1, 15) + val dateTaken = 1705276800000L + + val mediaFile1 = MediaFile( + uri = uri, + mediaType = "image", + extension = "jpg", + fileName = "test.jpg", + folderName = "Camera", + fileSize = 1024000L, + fileDate = fileDate, + previewUri = previewUri, + dateTaken = dateTaken + ) + + val mediaFile2 = MediaFile( + uri = uri, + mediaType = "image", + extension = "jpg", + fileName = "test.jpg", + folderName = "Camera", + fileSize = 1024000L, + fileDate = fileDate, + previewUri = previewUri, + dateTaken = dateTaken + ) + + // Act & Assert + assertEquals(mediaFile1.hashCode(), mediaFile2.hashCode()) + } + + @Test + fun `mediaFile_withLargeFileSize_handlesCorrectly`() { + // Arrange + val uri = mockk() + val previewUri = mockk() + val largeSize = Long.MAX_VALUE + + // Act + val mediaFile = MediaFile( + uri = uri, + mediaType = "video", + extension = "mp4", + fileName = "large.mp4", + folderName = "Videos", + fileSize = largeSize, + fileDate = LocalDate.now(), + previewUri = previewUri, + dateTaken = System.currentTimeMillis() + ) + + // Assert + assertEquals(largeSize, mediaFile.fileSize) + } + + @Test + fun `mediaFile_withEmptyFolderName_createsSuccessfully`() { + // Arrange + val uri = mockk() + val previewUri = mockk() + + // Act + val mediaFile = MediaFile( + uri = uri, + mediaType = "image", + extension = "jpg", + fileName = "test.jpg", + folderName = "", + fileSize = 1024L, + fileDate = LocalDate.now(), + previewUri = previewUri, + dateTaken = System.currentTimeMillis() + ) + + // Assert + assertEquals("", mediaFile.folderName) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/serranoie/app/media/sorter/data/UpdatePreferencesTests.kt b/app/src/test/java/com/serranoie/app/media/sorter/data/UpdatePreferencesTests.kt new file mode 100644 index 0000000..9e82f73 --- /dev/null +++ b/app/src/test/java/com/serranoie/app/media/sorter/data/UpdatePreferencesTests.kt @@ -0,0 +1,276 @@ +package com.serranoie.app.media.sorter.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +class UpdatePreferencesTest { + + private lateinit var mockContext: Context + private lateinit var mockDataStore: DataStore + private lateinit var mockPreferences: Preferences + private lateinit var mockMutablePreferences: MutablePreferences + private lateinit var updatePreferences: UpdatePreferences + + @Before + fun setUp() { + mockContext = mockk(relaxed = true) + mockDataStore = mockk(relaxed = true) + mockPreferences = mockk(relaxed = true) + mockMutablePreferences = mockk(relaxed = true) + + mockkStatic("com.serranoie.app.media.sorter.data.UpdatePreferencesKt") + mockkStatic("androidx.datastore.preferences.core.PreferencesKt") + + every { mockContext.updateDataStore } returns mockDataStore + + every { mockDataStore.data } returns flowOf(mockPreferences) + + coEvery { mockDataStore.edit(any()) } returns mockPreferences + + updatePreferences = UpdatePreferences(mockContext) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `lastCheckedVersion emitsNull whenNoVersionStored`() = runTest { + // Arrange + val key = stringPreferencesKey("last_checked_version") + every { mockPreferences[key] } returns null + every { mockDataStore.data } returns flowOf(mockPreferences) + + // Act + val result = updatePreferences.lastCheckedVersion.first() + + // Assert + assertNull(result) + } + + @Test + fun `lastCheckedVersion emitsStoredVersion whenVersionExists`() = runTest { + // Arrange + val expectedVersion = "1.0.14" + val key = stringPreferencesKey("last_checked_version") + every { mockPreferences[key] } returns expectedVersion + every { mockDataStore.data } returns flowOf(mockPreferences) + + // Act + val result = updatePreferences.lastCheckedVersion.first() + + // Assert + assertEquals(expectedVersion, result) + } + + @Test + fun `lastCheckTime emitsNull whenNoTimeStored`() = runTest { + // Arrange + val key = stringPreferencesKey("last_check_time") + every { mockPreferences[key] } returns null + every { mockDataStore.data } returns flowOf(mockPreferences) + + // Act + val result = updatePreferences.lastCheckTime.first() + + // Assert + assertNull(result) + } + + @Test + fun `lastCheckTime emitsTimestampAsLong whenTimeExists`() = runTest { + // Arrange + val expectedTime = 1609459200000L // Jan 1, 2021 + val key = stringPreferencesKey("last_check_time") + every { mockPreferences[key] } returns expectedTime.toString() + every { mockDataStore.data } returns flowOf(mockPreferences) + + // Act + val result = updatePreferences.lastCheckTime.first() + + // Assert + assertEquals(expectedTime, result) + } + + @Test + fun `lastCheckTime emitsNull whenStoredValueIsInvalidLong`() = runTest { + // Arrange + val key = stringPreferencesKey("last_check_time") + every { mockPreferences[key] } returns "invalid" + every { mockDataStore.data } returns flowOf(mockPreferences) + + // Act & Assert + try { + updatePreferences.lastCheckTime.first() + // If we reach here, test should fail as invalid string should throw + assert(false) { "Should have thrown NumberFormatException" } + } catch (e: NumberFormatException) { + // Expected behavior + assertTrue(true) + } + } + + @Test + fun `dismissedUpdateVersion emitsNull whenNoVersionDismissed`() = runTest { + // Arrange + val key = stringPreferencesKey("dismissed_update_version") + every { mockPreferences[key] } returns null + every { mockDataStore.data } returns flowOf(mockPreferences) + + // Act + val result = updatePreferences.dismissedUpdateVersion.first() + + // Assert + assertNull(result) + } + + @Test + fun `dismissedUpdateVersion emitsStoredVersion whenVersionDismissed`() = runTest { + // Arrange + val expectedVersion = "2.0.0" + val key = stringPreferencesKey("dismissed_update_version") + every { mockPreferences[key] } returns expectedVersion + every { mockDataStore.data } returns flowOf(mockPreferences) + + // Act + val result = updatePreferences.dismissedUpdateVersion.first() + + // Assert + assertEquals(expectedVersion, result) + } + + @Test + fun `saveLastCheckedVersion savesVersionAndTimestamp successfully`() = runTest { + // Arrange + val version = "1.5.0" + coEvery { mockDataStore.edit(any()) } returns mockPreferences + + // Act + updatePreferences.saveLastCheckedVersion(version) + + // Assert + coVerify(exactly = 1) { + mockDataStore.edit(any()) + } + } + + @Test + fun `saveLastCheckedVersion handlesMultipleVersions correctly`() = runTest { + // Arrange + val version1 = "1.0.0" + val version2 = "2.0.0" + coEvery { mockDataStore.edit(any()) } returns mockPreferences + + // Act + updatePreferences.saveLastCheckedVersion(version1) + updatePreferences.saveLastCheckedVersion(version2) + + // Assert + coVerify(exactly = 2) { + mockDataStore.edit(any()) + } + } + + @Test + fun `saveDismissedUpdateVersion savesDismissedVersion successfully`() = runTest { + // Arrange + val version = "2.0.0" + coEvery { mockDataStore.edit(any()) } returns mockPreferences + + // Act + updatePreferences.saveDismissedUpdateVersion(version) + + // Assert + coVerify(exactly = 1) { + mockDataStore.edit(any()) + } + } + + @Test + fun `saveDismissedUpdateVersion handlesEmptyString correctly`() = runTest { + // Arrange + val version = "" + coEvery { mockDataStore.edit(any()) } returns mockPreferences + + // Act + updatePreferences.saveDismissedUpdateVersion(version) + + // Assert + coVerify(exactly = 1) { + mockDataStore.edit(any()) + } + } + + @Test + fun `clearDismissedUpdateVersion removesKey successfully`() = runTest { + // Arrange + coEvery { mockDataStore.edit(any()) } returns mockPreferences + + // Act + updatePreferences.clearDismissedUpdateVersion() + + // Assert + coVerify(exactly = 1) { + mockDataStore.edit(any()) + } + } + + @Test + fun `clearDismissedUpdateVersion canBeCalledMultipleTimes withoutError`() = runTest { + // Arrange + coEvery { mockDataStore.edit(any()) } returns mockPreferences + + // Act + updatePreferences.clearDismissedUpdateVersion() + updatePreferences.clearDismissedUpdateVersion() + + // Assert + coVerify(exactly = 2) { mockDataStore.edit(any()) } + } + + @Test + fun `constructor createsInstance withValidContext`() { + // Arrange & Act + val instance = UpdatePreferences(mockContext) + + // Assert + assertNotNull(instance) + } + + @Test + fun `multipleOperations workSequentially withoutConflict`() = runTest { + // Arrange + val version1 = "1.0.0" + val version2 = "2.0.0" + coEvery { mockDataStore.edit(any()) } returns mockPreferences + + // Act + updatePreferences.saveLastCheckedVersion(version1) + updatePreferences.saveDismissedUpdateVersion(version2) + updatePreferences.clearDismissedUpdateVersion() + + // Assert + coVerify(exactly = 3) { mockDataStore.edit(any()) } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/serranoie/app/media/sorter/data/datasource/AndroidMediaDataSourceTest.kt b/app/src/test/java/com/serranoie/app/media/sorter/data/datasource/AndroidMediaDataSourceTest.kt new file mode 100644 index 0000000..d5310d3 --- /dev/null +++ b/app/src/test/java/com/serranoie/app/media/sorter/data/datasource/AndroidMediaDataSourceTest.kt @@ -0,0 +1,731 @@ +package com.serranoie.app.media.sorter.data.datasource + +import android.app.PendingIntent +import android.app.RecoverableSecurityException +import android.app.RemoteAction +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import com.serranoie.app.media.sorter.domain.AppError +import com.serranoie.app.media.sorter.domain.Result +import io.mockk.clearStaticMockk +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +class AndroidMediaDataSourceTest { + + private lateinit var mockContext: Context + private lateinit var mockContentResolver: ContentResolver + private lateinit var mockCursor: Cursor + private lateinit var androidMediaDataSource: AndroidMediaDataSource + + private val testDispatcher = StandardTestDispatcher() + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + mockContext = mockk(relaxed = true) + mockContentResolver = mockk(relaxed = true) + mockCursor = mockk(relaxed = true) + + every { mockContext.contentResolver } returns mockContentResolver + + // Mock static methods + mockkStatic(Log::class) + mockkStatic(ContentUris::class) + mockkStatic(MediaStore::class) + + // Mock Log methods to avoid "not mocked" errors + every { Log.d(any(), any()) } returns 0 + every { Log.e(any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + androidMediaDataSource = AndroidMediaDataSource(mockContext) + androidMediaDataSource.imagesContentUriOverride = mockk(relaxed = true) + androidMediaDataSource.videosContentUriOverride = mockk(relaxed = true) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + clearStaticMockk(Log::class) + clearStaticMockk(ContentUris::class) + clearStaticMockk(MediaStore::class) + } + + @Test + fun `fetchImages_returnsSuccess_whenCursorHasData`() = runTest { + // Arrange + val mockUri = mockk(relaxed = true) + every { ContentUris.withAppendedId(any(), any()) } returns mockUri + + setupCursorWithData(mockCursor, listOf( + mapOf( + "id" to 1L, + "name" to "test.jpg", + "size" to 1024L, + "dateTaken" to 1609459200000L, + "dateAdded" to 1609459200L, + "dateModified" to 1609459200L, + "bucket" to "Camera" + ) + )) + + every { + mockContentResolver.query( + any(), + any(), + any(), + any(), + any() + ) + } returns mockCursor + + // Act + val result = androidMediaDataSource.fetchImages() + + // Assert + assertTrue(result is Result.Success<*>) + val mediaList = (result as Result.Success).data + assertEquals(1, mediaList.size) + assertEquals("test.jpg", mediaList[0].fileName) + assertEquals("image", mediaList[0].mediaType) + } + + @Test + fun `fetchImages_returnsNoMediaFoundError_whenCursorIsEmpty`() = runTest { + // Arrange + setupCursorWithData(mockCursor, emptyList()) + every { + mockContentResolver.query( + any(), + any(), + any(), + any(), + any() + ) + } returns mockCursor + + // Act + val result = androidMediaDataSource.fetchImages() + + // Assert + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.NoMediaFoundError) + } + + @Test + fun `fetchImages_returnsPermissionError_whenSecurityExceptionOccurs`() = runTest { + // Arrange + every { + mockContentResolver.query( + any(), + any(), + null, + null, + any() + ) + } throws SecurityException("Permission denied") + + // Act + val result = androidMediaDataSource.fetchImages() + + // Assert + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.PermissionError) + } + + @Test + fun `fetchImages_returnsMediaLoadError_whenGeneralExceptionOccurs`() = runTest { + // Arrange + every { + mockContentResolver.query( + any(), + any(), + null, + null, + any() + ) + } throws RuntimeException("Database error") + + // Act + val result = androidMediaDataSource.fetchImages() + + // Assert + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.MediaLoadError) + assertTrue((result.error as AppError.MediaLoadError).details?.contains("Failed to load images") == true) + } + + @Test + fun `fetchImages_handlesMultipleImages_correctly`() = runTest { + // Arrange + val mockUri = mockk(relaxed = true) + every { ContentUris.withAppendedId(any(), any()) } returns mockUri + + setupCursorWithData(mockCursor, listOf( + mapOf( + "id" to 1L, + "name" to "image1.jpg", + "size" to 1024L, + "dateTaken" to 1609459200000L, + "dateAdded" to 1609459200L, + "dateModified" to 1609459200L, + "bucket" to "Camera" + ), + mapOf( + "id" to 2L, + "name" to "image2.png", + "size" to 2048L, + "dateTaken" to 1609545600000L, + "dateAdded" to 1609545600L, + "dateModified" to 1609545600L, + "bucket" to "Screenshots" + ) + )) + + every { + mockContentResolver.query( + any(), + any(), + any(), + any(), + any() + ) + } returns mockCursor + + // Act + val result = androidMediaDataSource.fetchImages() + + // Assert + assertTrue(result is Result.Success<*>) + val mediaList = (result as Result.Success).data + assertEquals(2, mediaList.size) + } + + @Test + fun `fetchVideos_returnsSuccess_whenCursorHasData`() = runTest { + // Arrange + val mockUri = mockk(relaxed = true) + every { ContentUris.withAppendedId(any(), any()) } returns mockUri + + setupCursorWithData(mockCursor, listOf( + mapOf( + "id" to 2L, + "name" to "video.mp4", + "size" to 5120L, + "dateTaken" to 1609459200000L, + "dateAdded" to 1609459200L, + "dateModified" to 1609459200L, + "bucket" to "Videos" + ) + )) + + every { + mockContentResolver.query( + any(), + any(), + any(), + any(), + any() + ) + } returns mockCursor + + // Act + val result = androidMediaDataSource.fetchVideos() + + // Assert + assertTrue(result is Result.Success<*>) + val mediaList = (result as Result.Success).data + assertEquals(1, mediaList.size) + assertEquals("video.mp4", mediaList[0].fileName) + assertEquals("video", mediaList[0].mediaType) + } + + @Test + fun `fetchVideos_returnsNoMediaFoundError_whenCursorIsEmpty`() = runTest { + // Arrange + setupCursorWithData(mockCursor, emptyList()) + every { + mockContentResolver.query( + any(), + any(), + any(), + any(), + any() + ) + } returns mockCursor + + // Act + val result = androidMediaDataSource.fetchVideos() + + // Assert + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.NoMediaFoundError) + } + + @Test + fun `fetchVideos_returnsPermissionError_whenSecurityExceptionOccurs`() = runTest { + // Arrange + every { + mockContentResolver.query( + any(), + any(), + null, + null, + any() + ) + } throws SecurityException("Permission denied") + + // Act + val result = androidMediaDataSource.fetchVideos() + + // Assert + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.PermissionError) + } + + @Test + fun `fetchVideos_returnsMediaLoadError_whenGeneralExceptionOccurs`() = runTest { + // Arrange + every { + mockContentResolver.query( + any(), + any(), + null, + null, + any() + ) + } throws RuntimeException("Database error") + + // Act + val result = androidMediaDataSource.fetchVideos() + + // Assert + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.MediaLoadError) + assertTrue((result.error as AppError.MediaLoadError).details?.contains("Failed to load videos") == true) + } + + @Test + fun `fetchMediaByUri_returnsUnknownError_asNotImplemented`() = runTest { + // Arrange + val mockUri = mockk(relaxed = true) + + // Act + val result = androidMediaDataSource.fetchMediaByUri(mockUri) + + // Assert + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.UnknownError) + assertEquals("Not implemented", (result.error as AppError.UnknownError).message) + } + + // deleteMedia() Tests + + @Test + fun `deleteMedia_returnsSuccess_whenFileIsDeleted`() = runTest { + // Arrange + val mockUri = mockk(relaxed = true) + every { mockContentResolver.delete(mockUri, null, null) } returns 1 + + // Act + val result = androidMediaDataSource.deleteMedia(mockUri) + + // Assert + assertTrue(result is Result.Success<*>) + assertTrue((result as Result.Success).data) + } + + @Test + fun `deleteMedia_returnsError_whenNoRowsDeleted`() = runTest { + // Arrange + val mockUri = mockk(relaxed = true) + every { mockContentResolver.delete(mockUri, null, null) } returns 0 + + // Act + val result = androidMediaDataSource.deleteMedia(mockUri) + + // Assert + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.UnknownError) + assertEquals("Failed to delete file", (result.error as AppError.UnknownError).message) + } + + @Test + fun `deleteMedia_returnsPermissionError_whenSecurityExceptionOccurs`() = runTest { + // Arrange + val mockUri = mockk(relaxed = true) + every { mockContentResolver.delete(mockUri, null, null) } throws SecurityException("Permission denied") + + // Act + val result = androidMediaDataSource.deleteMedia(mockUri) + + // Assert + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.PermissionError) + } + + @Test + fun `deleteMedia_returnsUnknownError_whenGeneralExceptionOccurs`() = runTest { + // Arrange + val mockUri = mockk(relaxed = true) + every { mockContentResolver.delete(mockUri, null, null) } throws RuntimeException("IO error") + + // Act + val result = androidMediaDataSource.deleteMedia(mockUri) + + // Assert + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.UnknownError) + assertTrue((result.error as AppError.UnknownError).message.contains("Error deleting file")) + } + + @Test + fun `deleteMultipleMedia_returnsSuccessCount_whenAllFilesDeleted`() = runTest { + // Arrange + val uris = listOf( + mockk(relaxed = true), + mockk(relaxed = true), + mockk(relaxed = true) + ) + every { mockContentResolver.delete(any(), null, null) } returns 1 + + // Act + val result = androidMediaDataSource.deleteMultipleMedia(uris) + + // Assert + assertTrue(result is Result.Success<*>) + assertEquals(3, (result as Result.Success).data) + } + + @Test + fun `deleteMultipleMedia_returnsPartialSuccess_whenSomeFilesDeleted`() = runTest { + // Arrange + val uri1 = mockk(relaxed = true) + val uri2 = mockk(relaxed = true) + val uri3 = mockk(relaxed = true) + val uris = listOf(uri1, uri2, uri3) + + every { mockContentResolver.delete(uri1, null, null) } returns 1 + every { mockContentResolver.delete(uri2, null, null) } returns 0 + every { mockContentResolver.delete(uri3, null, null) } returns 1 + + // Act + val result = androidMediaDataSource.deleteMultipleMedia(uris) + + // Assert + assertTrue(result is Result.Success<*>) + assertEquals(2, (result as Result.Success).data) + } + + @Test + fun `deleteMultipleMedia_returnsPermissionError_whenAllNeedPermission`() = runTest { + // Arrange + val uris = listOf( + mockk(relaxed = true), + mockk(relaxed = true) + ) + every { mockContentResolver.delete(any(), null, null) } throws + RecoverableSecurityException(SecurityException("Permission needed"), "Permission needed", mockk(relaxed = true)) + + // Act + val result = androidMediaDataSource.deleteMultipleMedia(uris) + + // Assert + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.PermissionError) + } + + @Test + fun `deleteMultipleMedia_handlesMixedExceptions_correctly`() = runTest { + // Arrange + val uri1 = mockk(relaxed = true) + val uri2 = mockk(relaxed = true) + val uri3 = mockk(relaxed = true) + val uris = listOf(uri1, uri2, uri3) + + every { mockContentResolver.delete(uri1, null, null) } returns 1 + every { mockContentResolver.delete(uri2, null, null) } throws SecurityException("Denied") + every { mockContentResolver.delete(uri3, null, null) } throws RuntimeException("Error") + + // Act + val result = androidMediaDataSource.deleteMultipleMedia(uris) + + // Assert + assertTrue(result is Result.Success<*>) + assertEquals(1, (result as Result.Success).data) + } + + @Test + fun `deleteMultipleMedia_returnsError_whenOuterSecurityExceptionThrown`() = runTest { + // Arrange + val uris = listOf(mockk(relaxed = true)) + every { mockContentResolver.delete(any(), null, null) } throws SecurityException("Permission denied") + + // Act + val result = androidMediaDataSource.deleteMultipleMedia(uris) + + // Assert + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.PermissionError) + } + + @Test + fun `deleteMultipleMedia_handlesEmptyList_correctly`() = runTest { + // Arrange + val uris = emptyList() + + // Act + val result = androidMediaDataSource.deleteMultipleMedia(uris) + + // Assert + assertTrue(result is Result.Success<*>) + assertEquals(0, (result as Result.Success).data) + } + + @Test + fun `createDeleteRequest_returnsPendingIntent_onAndroid11Plus`() { + // Arrange + val uris = listOf(mockk(relaxed = true)) + val mockPendingIntent = mockk(relaxed = true) + androidMediaDataSource.sdkIntOverride = Build.VERSION_CODES.R + every { MediaStore.createDeleteRequest(any(), any()) } returns mockPendingIntent + + // Act + val result = androidMediaDataSource.createDeleteRequest(uris) + + // Assert + assertNotNull(result) + assertEquals(mockPendingIntent, result) + } + + @Test + fun `createDeleteRequest_returnsPendingIntent_onAndroid10`() { + // Arrange + val uris = listOf(mockk(relaxed = true)) + val mockPendingIntent = mockk(relaxed = true) + androidMediaDataSource.sdkIntOverride = Build.VERSION_CODES.Q + every { MediaStore.createDeleteRequest(any(), any()) } returns mockPendingIntent + + // Act + val result = androidMediaDataSource.createDeleteRequest(uris) + + // Assert + assertNotNull(result) + } + + @Test + fun `createDeleteRequest_returnsNull_onAndroidBelow10`() { + // Arrange + val uris = listOf(mockk(relaxed = true)) + androidMediaDataSource.sdkIntOverride = Build.VERSION_CODES.P + + // Act + val result = androidMediaDataSource.createDeleteRequest(uris) + + // Assert + assertNull(result) + } + + @Test + fun `createTrashRequest_returnsPendingIntent_onAndroid11Plus`() { + // Arrange + val uris = listOf(mockk(relaxed = true)) + val mockPendingIntent = mockk(relaxed = true) + androidMediaDataSource.sdkIntOverride = Build.VERSION_CODES.R + every { MediaStore.createTrashRequest(any(), any(), any()) } returns mockPendingIntent + + // Act + val result = androidMediaDataSource.createTrashRequest(uris) + + // Assert + assertNotNull(result) + assertEquals(mockPendingIntent, result) + } + + @Test + fun `createTrashRequest_returnsNull_whenExceptionOccurs`() { + // Arrange + val uris = listOf(mockk(relaxed = true)) + androidMediaDataSource.sdkIntOverride = Build.VERSION_CODES.R + every { MediaStore.createTrashRequest(any(), any(), any()) } throws RuntimeException("Error") + + // Act + val result = androidMediaDataSource.createTrashRequest(uris) + + // Assert + assertNull(result) + } + + @Test + fun `createTrashRequest_returnsNull_onAndroidBelow11`() { + // Arrange + val uris = listOf(mockk(relaxed = true)) + androidMediaDataSource.sdkIntOverride = Build.VERSION_CODES.Q + + // Act + val result = androidMediaDataSource.createTrashRequest(uris) + + // Assert + assertNull(result) + } + + @Test + fun `createDeletionRequest_usesTrash_onAndroid11PlusWhenUseTrashTrue`() { + // Arrange + val uris = listOf(mockk(relaxed = true)) + val mockPendingIntent = mockk(relaxed = true) + androidMediaDataSource.sdkIntOverride = Build.VERSION_CODES.R + every { MediaStore.createTrashRequest(any(), any(), any()) } returns mockPendingIntent + + // Act + val result = androidMediaDataSource.createDeletionRequest(uris, useTrash = true) + + // Assert + assertNotNull(result) + assertEquals(mockPendingIntent, result) + } + + @Test + fun `createDeletionRequest_fallsBackToDelete_whenTrashFails`() { + // Arrange + val uris = listOf(mockk(relaxed = true)) + val mockPendingIntent = mockk(relaxed = true) + androidMediaDataSource.sdkIntOverride = Build.VERSION_CODES.R + every { MediaStore.createTrashRequest(any(), any(), any()) } throws RuntimeException("Trash failed") + every { MediaStore.createDeleteRequest(any(), any()) } returns mockPendingIntent + + // Act + val result = androidMediaDataSource.createDeletionRequest(uris, useTrash = true) + + // Assert + assertNotNull(result) + assertEquals(mockPendingIntent, result) + } + + @Test + fun `createDeletionRequest_usesDelete_whenUseTrashFalse`() { + // Arrange + val uris = listOf(mockk(relaxed = true)) + val mockPendingIntent = mockk(relaxed = true) + androidMediaDataSource.sdkIntOverride = Build.VERSION_CODES.R + every { MediaStore.createDeleteRequest(any(), any()) } returns mockPendingIntent + + // Act + val result = androidMediaDataSource.createDeletionRequest(uris, useTrash = false) + + // Assert + assertNotNull(result) + } + + @Test + fun `createDeletionRequest_returnsNull_whenBothTrashAndDeleteFail`() { + // Arrange + val uris = listOf(mockk(relaxed = true)) + // Set SDK to P (Android 9), which is too old for both trash and delete requests + androidMediaDataSource.sdkIntOverride = Build.VERSION_CODES.P + + // Act + val result = androidMediaDataSource.createDeletionRequest(uris, useTrash = true) + + // Assert + assertNull(result) + } + + @Test + fun `createDeletionRequest_onOlderAndroid_usesDeleteRequest`() { + // Arrange + val uris = listOf(mockk(relaxed = true)) + val mockPendingIntent = mockk(relaxed = true) + androidMediaDataSource.sdkIntOverride = Build.VERSION_CODES.Q + every { MediaStore.createDeleteRequest(any(), any()) } returns mockPendingIntent + + // Act + val result = androidMediaDataSource.createDeletionRequest(uris, useTrash = true) + + // Assert + assertNotNull(result) + } + + // Helper function to setup cursor with data + private fun setupCursorWithData(cursor: Cursor, dataList: List>) { + var currentIndex = -1 + var columnCallIndex = 0 + + every { cursor.moveToNext() } answers { + currentIndex++ + currentIndex < dataList.size + } + + // Map projection positions deterministically. + // JVM unit tests often can't rely on Android's MediaStore column constants being non-null. + // AndroidMediaDataSource requests these indices in a fixed order. + val indexSequence = listOf(0, 1, 2, 3, 4, 5, 7) + every { cursor.getColumnIndexOrThrow(any()) } answers { + indexSequence.getOrElse(columnCallIndex++) { -1 } + } + + every { cursor.getLong(any()) } answers { + if (currentIndex >= 0 && currentIndex < dataList.size) { + when (firstArg()) { + 0 -> dataList[currentIndex]["id"] as? Long ?: 0L + 2 -> dataList[currentIndex]["size"] as? Long ?: 0L + 3 -> dataList[currentIndex]["dateTaken"] as? Long ?: 0L + 4 -> dataList[currentIndex]["dateAdded"] as? Long ?: 0L + 5 -> dataList[currentIndex]["dateModified"] as? Long ?: 0L + else -> 0L + } + } else 0L + } + + every { cursor.getString(any()) } answers { + if (currentIndex >= 0 && currentIndex < dataList.size) { + when (firstArg()) { + 1 -> dataList[currentIndex]["name"] as? String + 6 -> dataList[currentIndex]["path"] as? String + 7 -> dataList[currentIndex]["bucket"] as? String + else -> null + } + } else null + } + + every { cursor.getInt(any()) } answers { + if (currentIndex >= 0 && currentIndex < dataList.size) { + when (firstArg()) { + 8 -> dataList[currentIndex]["width"] as? Int ?: 0 + 9 -> dataList[currentIndex]["height"] as? Int ?: 0 + else -> 0 + } + } else 0 + } + + every { cursor.close() } returns Unit + } +} diff --git a/app/src/test/java/com/serranoie/app/media/sorter/domain/media/DeleteMediaUseCaseTest.kt b/app/src/test/java/com/serranoie/app/media/sorter/domain/media/DeleteMediaUseCaseTest.kt new file mode 100644 index 0000000..55e4d36 --- /dev/null +++ b/app/src/test/java/com/serranoie/app/media/sorter/domain/media/DeleteMediaUseCaseTest.kt @@ -0,0 +1,58 @@ +package com.serranoie.app.media.sorter.domain.media + +import android.net.Uri +import com.serranoie.app.media.sorter.domain.Result +import com.serranoie.app.media.sorter.domain.repository.MediaRepository +import io.mockk.* +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +class DeleteMediaUseCaseTest { + + private val mockRepository: MediaRepository = mockk() + private lateinit var useCase: DeleteMediaUseCase + + @Before + fun setUp() { + useCase = DeleteMediaUseCase(mockRepository) + } + + @After + fun tearDown() = clearAllMocks() + + @Test + fun invoke_success_returnsResult() = runTest { + val uri = mockk() + val expected = Result.Success(true) + coEvery { mockRepository.deleteMedia(uri) } returns expected + + val result = useCase.invoke(uri) + assertEquals(expected, result) + coVerify { mockRepository.deleteMedia(uri) } + } + + @Test + fun deleteMultiple_success_returnsResult() = runTest { + val uris = listOf(mockk(), mockk()) + val expected = Result.Success(2) + coEvery { mockRepository.deleteMultipleMedia(uris) } returns expected + + val result = useCase.deleteMultiple(uris) + assertEquals(expected, result) + coVerify { mockRepository.deleteMultipleMedia(uris) } + } + + @Test + fun invoke_error_returnsResultError() = runTest { + val uri = mockk() + val expected = Result.Error(mockk()) + coEvery { mockRepository.deleteMedia(uri) } returns expected + + val result = useCase.invoke(uri) + assertEquals(expected, result) + coVerify { mockRepository.deleteMedia(uri) } + } +} diff --git a/app/src/test/java/com/serranoie/app/media/sorter/domain/media/GetMediaRandomBatchesUseCaseTest.kt b/app/src/test/java/com/serranoie/app/media/sorter/domain/media/GetMediaRandomBatchesUseCaseTest.kt new file mode 100644 index 0000000..78ce2e5 --- /dev/null +++ b/app/src/test/java/com/serranoie/app/media/sorter/domain/media/GetMediaRandomBatchesUseCaseTest.kt @@ -0,0 +1,77 @@ +package com.serranoie.app.media.sorter.domain.media + +import com.serranoie.app.media.sorter.data.MediaFile +import com.serranoie.app.media.sorter.domain.AppError +import com.serranoie.app.media.sorter.domain.Result +import com.serranoie.app.media.sorter.domain.repository.MediaRepository +import io.mockk.* +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.time.LocalDate + +class GetMediaRandomBatchesUseCaseTest { + + private val mockRepository: MediaRepository = mockk() + private lateinit var useCase: GetMediaRandomBatchesUseCase + + @Before + fun setUp() { + useCase = GetMediaRandomBatchesUseCase(mockRepository) + } + + @After + fun tearDown() = clearAllMocks() + + @Test + fun invoke_empty_returnsNoMediaFoundError() = runTest { + coEvery { mockRepository.getMediaGroupedByDateFiltered() } returns Result.Success(emptyMap()) + + val result = useCase.invoke() + assert(result is Result.Error) + assert((result as Result.Error).error is AppError.NoMediaFoundError) + coVerify { mockRepository.getMediaGroupedByDateFiltered() } + } + + @Test + fun invoke_loading_returnsLoading() = runTest { + coEvery { mockRepository.getMediaGroupedByDateFiltered() } returns Result.Loading + + val result = useCase.invoke() + assertEquals(Result.Loading, result) + coVerify { mockRepository.getMediaGroupedByDateFiltered() } + } + + @Test + fun invoke_error_returnsError() = runTest { + val appError = AppError.MediaLoadError("fail") + coEvery { mockRepository.getMediaGroupedByDateFiltered() } returns Result.Error(appError) + + val result = useCase.invoke() + assertEquals(Result.Error(appError), result) + coVerify { mockRepository.getMediaGroupedByDateFiltered() } + } + + @Test + fun invoke_success_filtersBatches() = runTest { + val date1 = LocalDate.of(2024, 1, 1) + val date2 = LocalDate.of(2024, 1, 2) + val valid = MediaFile(mockk(), "image", "jpg", "file1", "dir", 512L, date1, mockk(), 123456789L) + val invalid = MediaFile(mockk(), "video", "mp4", "", "dir", 0L, date2, mockk(), 987654321L) + val data = mapOf( + date1 to listOf(valid), + date2 to listOf(invalid) + ) + coEvery { mockRepository.getMediaGroupedByDateFiltered() } returns Result.Success(data) + + val result = useCase.invoke() + assert(result is Result.Success) + val list = (result as Result.Success).data + assertEquals(1, list.size) + assertEquals(date1, list[0].first) + assertEquals(listOf(valid), list[0].second) + coVerify { mockRepository.getMediaGroupedByDateFiltered() } + } +} diff --git a/app/src/test/java/com/serranoie/app/media/sorter/domain/media/SettingsRepositoryTest.kt b/app/src/test/java/com/serranoie/app/media/sorter/domain/media/SettingsRepositoryTest.kt new file mode 100644 index 0000000..9333ce2 --- /dev/null +++ b/app/src/test/java/com/serranoie/app/media/sorter/domain/media/SettingsRepositoryTest.kt @@ -0,0 +1,83 @@ +package com.serranoie.app.media.sorter.data.settings + +import com.serranoie.app.media.sorter.domain.repository.SettingsRepository +import io.mockk.* +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.flow.flowOf +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsRepositoryTest { + + private val mockDataStore: SettingsDataStore = mockk(relaxed = true) + private lateinit var settingsRepository: SettingsRepositoryImpl + + @Before + fun setUp() { + settingsRepository = SettingsRepositoryImpl(mockDataStore) + } + + @After + fun tearDown() = clearAllMocks() + + @Test + fun setThemeMode_callsDataStore() = runTest { + coEvery { mockDataStore.setThemeMode(any()) } just Runs + settingsRepository.setThemeMode(ThemeMode.DARK) + coVerify { mockDataStore.setThemeMode(ThemeMode.DARK) } + } + + @Test + fun setUseDynamicColors_callsDataStore() = runTest { + coEvery { mockDataStore.setUseDynamicColors(any()) } just Runs + settingsRepository.setUseDynamicColors(true) + coVerify { mockDataStore.setUseDynamicColors(true) } + } + + @Test + fun setUseBlurredBackground_callsDataStore() = runTest { + coEvery { mockDataStore.setUseBlurredBackground(any()) } just Runs + settingsRepository.setUseBlurredBackground(false) + coVerify { mockDataStore.setUseBlurredBackground(false) } + } + + @Test + fun setTutorialCompleted_callsDataStore() = runTest { + coEvery { mockDataStore.setTutorialCompleted(any()) } just Runs + settingsRepository.setTutorialCompleted(true) + coVerify { mockDataStore.setTutorialCompleted(true) } + } + + @Test + fun resetTutorial_callsDataStore() = runTest { + coEvery { mockDataStore.resetTutorial() } just Runs + settingsRepository.resetTutorial() + coVerify { mockDataStore.resetTutorial() } + } + + @Test + fun setAutoPlayVideos_callsDataStore() = runTest { + coEvery { mockDataStore.setAutoPlayVideos(any()) } just Runs + settingsRepository.setAutoPlayVideos(true) + coVerify { mockDataStore.setAutoPlayVideos(true) } + } + + @Test + fun setUseAureaPadding_callsDataStore() = runTest { + coEvery { mockDataStore.setUseAureaPadding(any()) } just Runs + settingsRepository.setUseAureaPadding(true) + coVerify { mockDataStore.setUseAureaPadding(true) } + } + + @Test + fun resetSettings_callsClearSettings() = runTest { + coEvery { mockDataStore.clearSettings() } just Runs + settingsRepository.resetSettings() + coVerify { mockDataStore.clearSettings() } + } +} diff --git a/app/src/test/java/com/serranoie/app/media/sorter/domain/media/SorterMediaUseCaseTest.kt b/app/src/test/java/com/serranoie/app/media/sorter/domain/media/SorterMediaUseCaseTest.kt new file mode 100644 index 0000000..645f1b5 --- /dev/null +++ b/app/src/test/java/com/serranoie/app/media/sorter/domain/media/SorterMediaUseCaseTest.kt @@ -0,0 +1,66 @@ +package com.serranoie.app.media.sorter.domain.media + +import com.serranoie.app.media.sorter.data.MediaFile +import com.serranoie.app.media.sorter.domain.Result +import com.serranoie.app.media.sorter.domain.repository.MediaRepository +import io.mockk.* +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.time.LocalDate + +class SorterMediaUseCaseTest { + private val mockRepository: MediaRepository = mockk() + private lateinit var useCase: SorterMediaUseCase + + @Before + fun setUp() { + useCase = SorterMediaUseCase(mockRepository) + } + + @After + fun tearDown() = clearAllMocks() + + @Test + fun invoke_success_filtersAndSorts() = runTest { + val valid = + MediaFile(mockk(), "image", "jpg", "file1", "dir", 256L, LocalDate.now(), mockk(), 100L) + val invalid = + MediaFile(mockk(), "image", "jpg", "", "dir", 0L, LocalDate.now(), mockk(), 50L) + val data = listOf(valid, invalid) + coEvery { mockRepository.fetchMediaFiles() } returns Result.Success(data) + + val result = useCase.invoke() + assert(result is Result.Success) + val sorted = (result as Result.Success).data + assertEquals(listOf(valid), sorted) + coVerify { mockRepository.fetchMediaFiles() } + } + + @Test + fun invoke_error_returnsResultError() = runTest { + coEvery { mockRepository.fetchMediaFiles() } returns Result.Error(mockk()) + val result = useCase.invoke() + assert(result is Result.Error) + coVerify { mockRepository.fetchMediaFiles() } + } + + @Test + fun invoke_loading_returnsLoading() = runTest { + coEvery { mockRepository.fetchMediaFiles() } returns Result.Loading + val result = useCase.invoke() + assertEquals(Result.Loading, result) + coVerify { mockRepository.fetchMediaFiles() } + } + + @Test + fun getMediaByFolder_returnsFolderMap() = runTest { + val expected = Result.Success(mapOf("dir" to emptyList())) + coEvery { mockRepository.getMediaByFolder() } returns expected + val actual = useCase.getMediaByFolder() + assertEquals(expected, actual) + coVerify { mockRepository.getMediaByFolder() } + } +} diff --git a/app/src/test/java/com/serranoie/app/media/sorter/domain/settings/GetAppSettingsUseCaseTest.kt b/app/src/test/java/com/serranoie/app/media/sorter/domain/settings/GetAppSettingsUseCaseTest.kt new file mode 100644 index 0000000..021fde5 --- /dev/null +++ b/app/src/test/java/com/serranoie/app/media/sorter/domain/settings/GetAppSettingsUseCaseTest.kt @@ -0,0 +1,39 @@ +package com.serranoie.app.media.sorter.domain.settings + +import com.serranoie.app.media.sorter.data.settings.AppSettings +import com.serranoie.app.media.sorter.domain.repository.SettingsRepository +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +class GetAppSettingsUseCaseTest { + private val mockRepository: SettingsRepository = mockk() + private lateinit var useCase: GetAppSettingsUseCase + + @Before + fun setUp() { + useCase = GetAppSettingsUseCase(mockRepository) + } + + @After + fun tearDown() = clearAllMocks() + + @Test + fun invoke_returns_appSettings_flow_from_repository() = runTest { + val expected = AppSettings(tutorialCompleted = true) + every { mockRepository.appSettings } returns flowOf(expected) + + val actual = useCase.invoke().first() + + assertEquals(expected, actual) + verify { mockRepository.appSettings } + } +} diff --git a/app/src/test/java/com/serranoie/app/media/sorter/domain/settings/UpdateSettingsUseCaseTest.kt b/app/src/test/java/com/serranoie/app/media/sorter/domain/settings/UpdateSettingsUseCaseTest.kt new file mode 100644 index 0000000..19de60b --- /dev/null +++ b/app/src/test/java/com/serranoie/app/media/sorter/domain/settings/UpdateSettingsUseCaseTest.kt @@ -0,0 +1,78 @@ +package com.serranoie.app.media.sorter.domain.settings + +import com.serranoie.app.media.sorter.data.settings.ThemeMode +import com.serranoie.app.media.sorter.domain.repository.SettingsRepository +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +class UpdateSettingsUseCaseTest { + private val mockRepository: SettingsRepository = mockk(relaxed = true) + private lateinit var useCase: UpdateSettingsUseCase + + @Before + fun setUp() { + useCase = UpdateSettingsUseCase(mockRepository) + } + + @After + fun tearDown() = clearAllMocks() + + @Test + fun setThemeMode_delegates() = runTest { + coEvery { mockRepository.setThemeMode(any()) } just Runs + useCase.setThemeMode(ThemeMode.DARK) + coVerify { mockRepository.setThemeMode(ThemeMode.DARK) } + } + + @Test + fun setUseDynamicColors_delegates() = runTest { + coEvery { mockRepository.setUseDynamicColors(any()) } just Runs + useCase.setUseDynamicColors(true) + coVerify { mockRepository.setUseDynamicColors(true) } + } + + @Test + fun setUseBlurredBackground_delegates() = runTest { + coEvery { mockRepository.setUseBlurredBackground(any()) } just Runs + useCase.setUseBlurredBackground(false) + coVerify { mockRepository.setUseBlurredBackground(false) } + } + + @Test + fun setTutorialCompleted_delegates() = runTest { + coEvery { mockRepository.setTutorialCompleted(any()) } just Runs + useCase.setTutorialCompleted(true) + coVerify { mockRepository.setTutorialCompleted(true) } + } + + @Test + fun resetTutorial_delegates() = runTest { + coEvery { mockRepository.resetTutorial() } just Runs + useCase.resetTutorial() + coVerify { mockRepository.resetTutorial() } + } + + @Test + fun setAutoPlayVideos_delegates() = runTest { + coEvery { mockRepository.setAutoPlayVideos(any()) } just Runs + useCase.setAutoPlayVideos(true) + coVerify { mockRepository.setAutoPlayVideos(true) } + } + + @Test + fun setUseAureaPadding_delegates() = runTest { + coEvery { mockRepository.setUseAureaPadding(any()) } just Runs + useCase.setUseAureaPadding(true) + coVerify { mockRepository.setUseAureaPadding(true) } + } + + @Test + fun resetSettings_delegates() = runTest { + coEvery { mockRepository.resetSettings() } just Runs + useCase.resetSettings() + coVerify { mockRepository.resetSettings() } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 1eeaa23..0ae9de4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.detekt) apply false + alias(libs.plugins.ksp) apply false - id("com.google.dagger.hilt.android") version "2.57.1" apply false + id("com.google.dagger.hilt.android") version "2.57.2" apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf294eb..6ded689 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,13 @@ [versions] agp = "8.13.2" +hiltAndroidCompiler = "2.57.2" +hiltAndroid = "2.57.2" +hiltAndroidCompilerVersion = "2.57.2" +hiltAndroidVersion = "2.57.2" +hiltCompiler = "1.3.0" +hiltWorkVersion = "1.2.0" kotlin = "2.1.0" +ksp = "2.1.0-1.0.29" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" @@ -8,7 +15,7 @@ espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.2" composeBom = "2024.09.00" -hiltVersion = "2.51.1" +hiltVersion = "2.57.2" androidx-hilt-navigation-compose-version = "1.3.0" roomVersion = "2.7.0" androidx-datastore-version = "1.1.1" @@ -26,10 +33,17 @@ retrofit = "2.11.0" okhttp = "4.12.0" workRuntimeKtx = "2.11.0" hiltWork = "1.3.0" +coreSplashscreen = "1.0.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltCompiler" } +androidx-hilt-work-v120 = { module = "androidx.hilt:hilt-work", version.ref = "hiltWorkVersion" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" } +hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroidCompiler" } +hilt-android-compiler-v2572 = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroidCompilerVersion" } +hilt-android-v2572 = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroidVersion" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -68,6 +82,7 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" } androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltWork" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -76,4 +91,5 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hiltVersion" } room = { id = "androidx.room", version.ref = "roomVersion" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }