diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index 18e7d40..0b9d9e9 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -1,46 +1,97 @@ -name: VentNote Build and Test CI +name: VentNote CI on: - pull_request: + push: branches: [ master ] + pull_request: + branches: [ master, staging ] jobs: - test: - name: Run Unit Tests + lint-and-unit-test: + name: Lint and Unit Tests runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'adopt' - cache: gradle - - - name: Unit tests - run: ./gradlew test --stacktrace + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Run Lint + run: ./gradlew lintDebug --stacktrace + + - name: Run Unit Tests + run: ./gradlew testDebugUnitTest --stacktrace - build: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - uses: actions/checkout@v3 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'adopt' - cache: gradle + - name: Upload Test Reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: lint-and-unit-test-reports + path: | + **/build/reports/lint-results-*.html + **/build/reports/tests/testDebugUnitTest/ - - name: Clean project - run: ./gradlew clean --stacktrace +# instrumentation-test: +# name: Instrumentation Tests +# runs-on: macos-latest +# timeout-minutes: 60 +# strategy: +# matrix: +# api-level: [34] +# steps: +# - uses: actions/checkout@v4 +# +# - name: Set up JDK 17 +# uses: actions/setup-java@v4 +# with: +# java-version: '17' +# distribution: 'temurin' +# cache: gradle +# +# - name: Run Instrumentation Tests +# uses: reactivecircus/android-emulator-runner@v2 +# with: +# api-level: ${{ matrix.api-level }} +# arch: arm64-v8a +# target: google_apis +# force-avd-creation: true +# emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim +# disable-animations: true +# # Increased RAM for better stability on ARM runners +# disk-size: 4096M +# heap-size: 512M +# script: ./gradlew connectedDebugAndroidTest --stacktrace +# +# - name: Upload Instrumentation Reports +# if: always() +# uses: actions/upload-artifact@v4 +# with: +# name: instrumentation-test-reports +# path: app/build/reports/androidTests/connected/ - - name: Lint Debug - run: ./gradlew lintDebug --stacktrace + build-check: + name: Build APK Check + runs-on: ubuntu-latest + needs: [lint-and-unit-test] + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle - - name: Build debug APK - run: ./gradlew build --stacktrace \ No newline at end of file + - name: Build Debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload Debug APK + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: app/build/outputs/apk/debug/*.apk \ No newline at end of file diff --git a/app/src/androidTest/java/com/digiventure/MainActivityTest.kt b/app/src/androidTest/java/MainActivityTest.kt similarity index 100% rename from app/src/androidTest/java/com/digiventure/MainActivityTest.kt rename to app/src/androidTest/java/MainActivityTest.kt diff --git a/app/src/androidTest/java/com/digiventure/utils/BaseAcceptanceTest.kt b/app/src/androidTest/java/com/digiventure/utils/BaseAcceptanceTest.kt index f9ff0a0..3b9b8e1 100644 --- a/app/src/androidTest/java/com/digiventure/utils/BaseAcceptanceTest.kt +++ b/app/src/androidTest/java/com/digiventure/utils/BaseAcceptanceTest.kt @@ -1,10 +1,7 @@ -//package com.digiventure.utils -// -//import androidx.test.ext.junit.runners.AndroidJUnit4 -//import org.junit.runner.RunWith -// -//@RunWith(AndroidJUnit4::class) -//abstract class BaseAcceptanceTest { -//// @get:Rule(order = 0) -//// val composeTestRule = createComposeRule() -//} \ No newline at end of file +package com.digiventure.utils + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +abstract class BaseAcceptanceTest \ No newline at end of file diff --git a/app/src/androidTest/java/com/digiventure/ventnote/NavDrawerFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/NavDrawerFeature.kt new file mode 100644 index 0000000..0759cf2 --- /dev/null +++ b/app/src/androidTest/java/com/digiventure/ventnote/NavDrawerFeature.kt @@ -0,0 +1,138 @@ +package com.digiventure.ventnote + +import android.content.Intent +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasData +import com.digiventure.utils.BaseAcceptanceTest +import com.digiventure.ventnote.commons.TestTags +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.hamcrest.Matchers.allOf +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class NavDrawerFeature : BaseAcceptanceTest() { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + @Before + fun setUp() { + hiltRule.inject() + Intents.init() + + // Open the drawer + composeTestRule.onNodeWithTag(TestTags.NOTES_PAGE).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.MENU_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(TestTags.NAV_DRAWER, useUnmergedTree = true).assertIsDisplayed() + } + + @After + fun tearDown() { + Intents.release() + } + + @Test + fun initialState_showsMenuItems() { + composeTestRule.onNodeWithTag(TestTags.RATE_APP_TILE).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.MORE_APPS_TILE).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.APP_VERSION_TILE).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.THEME_TILE).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.COLOR_MODE_TILE).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.BACKUP_TILE).assertIsDisplayed() + } + + @Test + fun rateApp_launchesPlayStore() { + composeTestRule.onNodeWithTag(TestTags.RATE_APP_TILE).performClick() + + intended(allOf( + hasAction(Intent.ACTION_VIEW), + hasData("https://play.google.com/store/apps/details?id=com.digiventure.ventnote") + )) + } + + @Test + fun moreApps_launchesDeveloperPage() { + composeTestRule.onNodeWithTag(TestTags.MORE_APPS_TILE).performClick() + + intended(allOf( + hasAction(Intent.ACTION_VIEW), + hasData("https://play.google.com/store/apps/developer?id=Mattrmost") + )) + } + + @Test + fun backupNavigation_navigatesToBackupPage() { + composeTestRule.onNodeWithTag(TestTags.BACKUP_TILE).performClick() + composeTestRule.waitForIdle() + + // Use text or tag to verify backup page (usually has "Backup" title in header) + composeTestRule.onNodeWithText("Backup Notes").assertIsDisplayed() + } + + @Test + fun themeColorChange_updatesTheme() { + // Verify we can click all theme colors + composeTestRule.onNodeWithTag(TestTags.THEME_COLOR_PURPLE).performClick() + composeTestRule.onNodeWithTag(TestTags.THEME_COLOR_CRIMSON).performClick() + composeTestRule.onNodeWithTag(TestTags.THEME_COLOR_CADMIUM_GREEN).performClick() + composeTestRule.onNodeWithTag(TestTags.THEME_COLOR_COBALT_BLUE).performClick() + + // Clicks should not crash and should update internal state (hard to verify without custom matchers) + composeTestRule.onNodeWithTag(TestTags.THEME_TILE).assertIsDisplayed() + } + + @Test + fun colorModeToggle_updatesMode() { + // Find the current mode by checking the subtitle text + val lightModeText = "switch to light mode" + val darkModeText = "switch to dark mode" + + // Initial click to toggle (assuming default is light mode or whatever) + // We look for either text to be sure + val initialNode = composeTestRule.onNode( + hasText(lightModeText, ignoreCase = true) or hasText(darkModeText, ignoreCase = true) + ) + + initialNode.assertIsDisplayed() + val isInitiallyLight = try { + composeTestRule.onNodeWithText(darkModeText, ignoreCase = true).assertIsDisplayed() + true // It says "switch to dark", so it is currently light + } catch (_: Throwable) { + false + } + + // Toggle + composeTestRule.onNodeWithTag(TestTags.COLOR_MODE_TILE).performClick() + composeTestRule.waitForIdle() + + // Verify text swapped + if (isInitiallyLight) { + composeTestRule.onNodeWithText(lightModeText, ignoreCase = true).assertIsDisplayed() + } else { + composeTestRule.onNodeWithText(darkModeText, ignoreCase = true).assertIsDisplayed() + } + } + + @Test + fun drawer_canBeClosed() { + // Click outside or use a specific close mechanism if available, + // but here we can just swipe or click the content area if accessible. + // Easiest is to just verify it closes on back press + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().sendKeyDownUpSync(android.view.KeyEvent.KEYCODE_BACK) + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(TestTags.NAV_DRAWER).assertDoesNotExist() + } +} diff --git a/app/src/androidTest/java/com/digiventure/ventnote/NoteCreationFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/NoteCreationFeature.kt new file mode 100644 index 0000000..69acb37 --- /dev/null +++ b/app/src/androidTest/java/com/digiventure/ventnote/NoteCreationFeature.kt @@ -0,0 +1,206 @@ +package com.digiventure.ventnote + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.espresso.intent.Intents +import com.digiventure.utils.BaseAcceptanceTest +import com.digiventure.ventnote.commons.TestTags +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class NoteCreationFeature : BaseAcceptanceTest() { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + @Before + fun setUp() { + hiltRule.inject() + Intents.init() + + // Wait for list and navigate to creation + composeTestRule.waitUntil(10000) { + try { + composeTestRule.onNodeWithTag(TestTags.ADD_NOTE_FAB).assertIsDisplayed() + true + } catch (e: Throwable) { + false + } + } + + composeTestRule.onNodeWithTag(TestTags.ADD_NOTE_FAB).performClick() + composeTestRule.waitForIdle() + + // Ensure we are on the creation page + composeTestRule.waitUntil(10000) { + try { + composeTestRule.onNodeWithTag(TestTags.NOTE_CREATION_PAGE).assertIsDisplayed() + true + } catch (e: Throwable) { + false + } + } + } + + @After + fun tearDown() { + Intents.release() + } + + /** + * Verifies that the initial state of the creation page shows empty fields and a save button. + */ + @Test + fun initialState_showsEmptyFields() { + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).assertTextContains("") + composeTestRule.onNodeWithTag(TestTags.BODY_TEXT_FIELD).assertTextContains("") + composeTestRule.onNodeWithTag(TestTags.SAVE_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.BACK_ICON_BUTTON).assertIsDisplayed() + } + + /** + * Verifies that a validation dialog is shown when trying to save with an empty title. + */ + @Test + fun saveFlow_validation_emptyTitle_showsRequiredDialog() { + // Leave title empty, add body + composeTestRule.onNodeWithTag(TestTags.BODY_TEXT_FIELD).performTextInput("Some content") + + composeTestRule.onNodeWithTag(TestTags.SAVE_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Validation dialog should appear + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.CONFIRM_BUTTON).performClick() + } + + /** + * Verifies that a validation dialog is shown when trying to save with an empty body. + */ + @Test + fun saveFlow_validation_emptyBody_showsRequiredDialog() { + // Add title, leave body empty + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).performTextInput("Some title") + + composeTestRule.onNodeWithTag(TestTags.SAVE_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Validation dialog should appear + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.CONFIRM_BUTTON).performClick() + } + + /** + * Verifies that filling both fields and saving adding the note and navigates back to the list. + */ + @Test + fun saveFlow_successfullyAddsNote() { + val title = "New Note Title" + val body = "New Note Body Content" + + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).performTextInput(title) + composeTestRule.onNodeWithTag(TestTags.BODY_TEXT_FIELD).performTextInput(body) + + // Ensure no validation dialog is present + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertDoesNotExist() + + // Try clicking using touch input for better resilience + composeTestRule.onNodeWithTag(TestTags.SAVE_ICON_BUTTON).performTouchInput { click() } + composeTestRule.waitForIdle() + + // Should navigate back to list + composeTestRule.waitUntil(15000) { + try { + composeTestRule.onNodeWithTag(TestTags.NOTES_PAGE).assertIsDisplayed() + true + } catch (e: Throwable) { + false + } + } + + // Use waitUntil for the text as well to handle slow list updates + composeTestRule.waitUntil(10000) { + try { + composeTestRule.onNodeWithText(title).assertIsDisplayed() + true + } catch (e: Throwable) { + false + } + } + } + + /** + * Verifies that hitting back with dirty data shows the cancel confirmation dialog. + */ + @Test + fun cancelFlow_showsConfirmationDialog() { + // Enter some text + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).performTextInput("Dirty data") + + // Use back icon instead of BackHandler for direct UI interaction + composeTestRule.onNodeWithTag(TestTags.BACK_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Cancel dialog should appear + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsDisplayed() + } + + /** + * Verifies that confirming the cancel dialog navigates back to the list. + */ + @Test + fun cancelFlow_confirm_navigatesBack() { + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).performTextInput("Dirty data") + + composeTestRule.onNodeWithTag(TestTags.BACK_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(TestTags.CONFIRM_BUTTON).performClick() // Confirm cancel + composeTestRule.waitForIdle() + + // Should return to notes page + composeTestRule.onNodeWithTag(TestTags.NOTES_PAGE).assertIsDisplayed() + } + + /** + * Verifies that dismissing the cancel dialog keeps the user on the creation page. + */ + @Test + fun cancelFlow_dismiss_staysOnPage() { + val text = "Dirty data" + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).performTextInput(text) + + composeTestRule.onNodeWithTag(TestTags.BACK_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(TestTags.DISMISS_BUTTON).performClick() // Dismiss dialog + composeTestRule.waitForIdle() + + // Should stay on creation page with data intact + composeTestRule.onNodeWithTag(TestTags.NOTE_CREATION_PAGE).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).assertTextContains(text) + } + + /** + * Verifies that focus management works between fields. + */ + @Test + fun keyboardFocusManagement() { + // Click title field + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).performClick() + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).assertIsFocused() + + // Click body field + composeTestRule.onNodeWithTag(TestTags.BODY_TEXT_FIELD).performClick() + composeTestRule.onNodeWithTag(TestTags.BODY_TEXT_FIELD).assertIsFocused() + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).assertIsNotFocused() + } +} diff --git a/app/src/androidTest/java/com/digiventure/ventnote/NoteDetailFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/NoteDetailFeature.kt new file mode 100644 index 0000000..c1afa6d --- /dev/null +++ b/app/src/androidTest/java/com/digiventure/ventnote/NoteDetailFeature.kt @@ -0,0 +1,337 @@ +package com.digiventure.ventnote + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.espresso.intent.Intents +import com.digiventure.utils.BaseAcceptanceTest +import com.digiventure.ventnote.commons.TestTags +import com.digiventure.ventnote.data.persistence.NoteModel +import com.digiventure.ventnote.module.proxy.DatabaseProxy +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class NoteDetailFeature : BaseAcceptanceTest() { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + @Inject + lateinit var databaseProxy: DatabaseProxy + + // Seeded test note + private val testNote = NoteModel(id = 1, title = "Shopping List", note = "Milk, eggs, bread") + + @Before + fun setUp() { + hiltRule.inject() + Intents.init() + + // Seed the database + runBlocking { + databaseProxy.dao().upsertNotes(listOf(testNote)) + } + + // Wait for list and navigate to detail + composeTestRule.waitUntil(10000) { + try { + composeTestRule.onNodeWithText("Shopping List").assertIsDisplayed() + true + } catch (e: Throwable) { + false + } + } + + composeTestRule.onNodeWithText("Shopping List").performClick() + composeTestRule.waitForIdle() + + // Ensure we are on the detail page using robust wait + composeTestRule.waitUntil(10000) { + try { + composeTestRule.onNodeWithTag(TestTags.NOTE_DETAIL_PAGE).assertIsDisplayed() + true + } catch (_: Throwable) { + false + } + } + } + + @After + fun tearDown() { + runBlocking { + val allNotes = databaseProxy.dao().getSyncNotes() + if (allNotes.isNotEmpty()) { + databaseProxy.dao().deleteNotes(*allNotes.toTypedArray()) + } + } + Intents.release() + } + + /** + * Verifies that the note details (title and body) are correctly displayed. + */ + @Test + fun initialState_showsNoteDetails() { + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).assertTextContains("Shopping List") + composeTestRule.onNodeWithTag(TestTags.BODY_TEXT_FIELD).assertTextContains("Milk, eggs, bread") + + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.DELETE_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.SHARE_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.BACK_ICON_BUTTON).assertIsDisplayed() + } + + /** + * Verifies that clicking the edit button changes the UI to editing mode. + */ + @Test + fun editMode_uiChanges() { + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Save and Cancel buttons should be shown + composeTestRule.onNodeWithTag(TestTags.SAVE_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.CANCEL_ICON_BUTTON).assertIsDisplayed() + + // Edit and Delete should be hidden + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).assertDoesNotExist() + composeTestRule.onNodeWithTag(TestTags.DELETE_ICON_BUTTON).assertDoesNotExist() + } + + /** + * Verifies that modifying the note and saving it updates the content and returns to view mode. + */ + @Test + fun saveFlow_updatesNote() { + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Modify content + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).performTextReplacement("Updated Title") + composeTestRule.onNodeWithTag(TestTags.BODY_TEXT_FIELD).performTextReplacement("Updated Body") + + composeTestRule.onNodeWithTag(TestTags.SAVE_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Should return to view mode (Edit button reappears) + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).assertIsDisplayed() + + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).assertTextContains("Updated Title") + composeTestRule.onNodeWithTag(TestTags.BODY_TEXT_FIELD).assertTextContains("Updated Body") + } + + /** + * Verifies that a validation dialog is shown when trying to save an empty title. + */ + @Test + fun saveFlow_validation_emptyTitle_showsRequiredDialog() { + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Clear title + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).performTextReplacement("") + + composeTestRule.onNodeWithTag(TestTags.SAVE_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Validation dialog should appear + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.CONFIRM_BUTTON).performClick() // Dismiss it + } + + /** + * Verifies that a validation dialog is shown when trying to save an empty body. + */ + @Test + fun saveFlow_validation_emptyBody_showsRequiredDialog() { + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Clear body + composeTestRule.onNodeWithTag(TestTags.BODY_TEXT_FIELD).performTextReplacement("") + + composeTestRule.onNodeWithTag(TestTags.SAVE_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Validation dialog should appear + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.CONFIRM_BUTTON).performClick() // Dismiss it + } + + /** + * Verifies that canceling an edit reverts changes to the original content. + */ + @Test + fun cancelFlow_revertsChanges() { + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Modify content + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).performTextReplacement("Dirty Title") + + composeTestRule.onNodeWithTag(TestTags.CANCEL_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Cancel dialog should appear + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.CONFIRM_BUTTON).performClick() // Confirm cancel + composeTestRule.waitForIdle() + + // Should return to original content and view mode + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).assertTextContains("Shopping List") + } + + /** + * Verifies that dismissing the cancel dialog keeps the user in edit mode with dirty data. + */ + @Test + fun cancelFlow_dismissesDialog_staysInEditMode() { + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).performTextReplacement("Dirty Title") + + composeTestRule.onNodeWithTag(TestTags.CANCEL_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Cancel dialog should appear + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.DISMISS_BUTTON).performClick() // Dismiss dialog + composeTestRule.waitForIdle() + + // Should stay in edit mode with dirty data + composeTestRule.onNodeWithTag(TestTags.SAVE_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).assertTextContains("Dirty Title") + } + + /** + * Verifies that deleting a note removes it from the database and navigates back to the list. + */ + @Test + fun deleteFlow_removesNoteAndNavigatesBack() { + composeTestRule.onNodeWithTag(TestTags.DELETE_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Confirmation dialog + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.CONFIRM_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Should navigate back to list using robust wait + composeTestRule.waitUntil(10000) { + try { + composeTestRule.onNodeWithTag(TestTags.NOTES_PAGE).assertIsDisplayed() + true + } catch (_: Throwable) { + false + } + } + + composeTestRule.onNodeWithText("Shopping List").assertDoesNotExist() + } + + /** + * Verifies that dismissing the delete dialog keeps the user on the detail page. + */ + @Test + fun deleteFlow_dismissesDialog_staysOnDetail() { + composeTestRule.onNodeWithTag(TestTags.DELETE_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Confirmation dialog + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.DISMISS_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Should stay on detail page + composeTestRule.onNodeWithTag(TestTags.NOTE_DETAIL_PAGE).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.DELETE_ICON_BUTTON).assertIsDisplayed() + } + + /** + * Verifies that clicking the share button navigates to the share preview page. + */ + @Test + fun shareFlow_navigatesToSharePage() { + composeTestRule.onNodeWithTag(TestTags.SHARE_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Verify share page is displayed using robust wait + composeTestRule.waitUntil(10000) { + try { + composeTestRule.onNodeWithTag(TestTags.SHARE_PAGE, useUnmergedTree = true).assertIsDisplayed() + true + } catch (_: Throwable) { + false + } + } + } + + /** + * Verifies that clicking the back button returns the user to the notes list. + */ + @Test + fun backNavigation_returnsToNotesPage() { + composeTestRule.onNodeWithTag(TestTags.BACK_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Verify notes page is displayed using robust wait + composeTestRule.waitUntil(10000) { + try { + composeTestRule.onNodeWithTag(TestTags.NOTES_PAGE).assertIsDisplayed() + true + } catch (_: Throwable) { + false + } + } + } + + /** + * Verifies that hitting back while in edit mode shows the cancel confirmation dialog. + */ + @Test + fun backNavigation_inEditMode_showsCancelDialog() { + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Trigger BackHandler + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().sendKeyDownUpSync(android.view.KeyEvent.KEYCODE_BACK) + composeTestRule.waitForIdle() + + // Cancel dialog should appear + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.CONFIRM_BUTTON).performClick() // Confirm cancel + composeTestRule.waitForIdle() + + // Should return to original content and view mode (Edit button reappears) + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).assertIsDisplayed() + } + + /** + * Verifies that focus management works in edit mode. + */ + @Test + fun editMode_keyboardInteractions() { + composeTestRule.onNodeWithTag(TestTags.EDIT_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Click title field + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).performClick() + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).assertIsFocused() + + // Click body field + composeTestRule.onNodeWithTag(TestTags.BODY_TEXT_FIELD).performClick() + composeTestRule.onNodeWithTag(TestTags.BODY_TEXT_FIELD).assertIsFocused() + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT_FIELD).assertIsNotFocused() + } +} diff --git a/app/src/androidTest/java/com/digiventure/ventnote/NoteWidgetFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/NoteWidgetFeature.kt new file mode 100644 index 0000000..be93e94 --- /dev/null +++ b/app/src/androidTest/java/com/digiventure/ventnote/NoteWidgetFeature.kt @@ -0,0 +1,93 @@ +package com.digiventure.ventnote + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.digiventure.utils.BaseAcceptanceTest +import com.digiventure.ventnote.data.persistence.NoteModel +import com.digiventure.ventnote.feature.widget.NoteWidgetFactory +import com.digiventure.ventnote.feature.widget.NoteWidgetProvider +import com.digiventure.ventnote.module.proxy.DatabaseProxy +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.Date +import javax.inject.Inject + +@HiltAndroidTest +class NoteWidgetFeature : BaseAcceptanceTest() { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var databaseProxy: DatabaseProxy + + private lateinit var context: Context + + @Before + fun setUp() { + hiltRule.inject() + context = ApplicationProvider.getApplicationContext() + + // Clear database + runBlocking { + val allNotes = databaseProxy.dao().getSyncNotes() + if (allNotes.isNotEmpty()) { + databaseProxy.dao().deleteNotes(*allNotes.toTypedArray()) + } + } + } + + @Test + fun widgetFactory_reflectsDatabaseState() = runBlocking { + // 1. Initial empty state + val factory = NoteWidgetFactory(context, databaseProxy) + factory.onDataSetChanged() + assertEquals(0, factory.getCount()) + + // 2. Add some notes + val notes = listOf( + NoteModel(1, "Title 1", "Content 1", Date(), Date()), + NoteModel(2, "Title 2", "Content 2", Date(), Date()) + ) + databaseProxy.dao().upsertNotes(notes) + + // 3. Refresh factory + factory.onDataSetChanged() + + // 4. Verify count + assertEquals(2, factory.getCount()) + + // 5. Verify basic RemoteViews generation + val view1 = factory.getViewAt(0) + val view2 = factory.getViewAt(1) + + assertNotNull(view1) + assertNotNull(view2) + assertEquals(R.layout.note_widget_item, view1.layoutId) + + // Note: We cannot easily check RemoteViews content (text) without complex reflection + // but checking the layoutId and packageName confirms the factory is producing the right items. + } + + @Test + fun widgetFactory_handlesOutOfBounds() = runBlocking { + val factory = NoteWidgetFactory(context, databaseProxy) + factory.onDataSetChanged() + + // Should return a placeholder or at least not crash + val oobView = factory.getViewAt(100) + assertNotNull(oobView) + } + + @Test + fun widgetProvider_refreshLogic_doesNotCrash() { + // This verifies that the static refresh logic executes without exceptions in the instrumentation context + NoteWidgetProvider.refreshWidgets(context) + } +} diff --git a/app/src/androidTest/java/com/digiventure/ventnote/NotesFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/NotesFeature.kt index 65f47d7..122b6ed 100644 --- a/app/src/androidTest/java/com/digiventure/ventnote/NotesFeature.kt +++ b/app/src/androidTest/java/com/digiventure/ventnote/NotesFeature.kt @@ -1,204 +1,481 @@ -//package com.digiventure.ventnote -// -//import com.digiventure.utils.BaseAcceptanceTest -//import dagger.hilt.android.testing.HiltAndroidTest -// -//@HiltAndroidTest -//class NotesFeature: BaseAcceptanceTest() { -// @get:Rule(order = 0) -// var hiltRule = HiltAndroidRule(this) -// -// @get:Rule(order = 1) -// var composeTestRule = createAndroidComposeRule(MainActivity::class.java) -// -// // Variables related to TopAppBar -// private lateinit var topAppBar: SemanticsNodeInteraction -// private lateinit var searchIconButton: SemanticsNodeInteraction -// private lateinit var menuIconButton: SemanticsNodeInteraction -// private lateinit var topAppBarTitle: SemanticsNodeInteraction -// private lateinit var topAppBarTextField: SemanticsNodeInteraction -// private lateinit var closeSearchIconButton: SemanticsNodeInteraction -// private lateinit var selectedCount: SemanticsNodeInteraction -// private lateinit var dropdownSelect: SemanticsNodeInteraction -// private lateinit var selectAllOption: SemanticsNodeInteraction -// private lateinit var unselectAllOption: SemanticsNodeInteraction -// private lateinit var closeSelectIconButton: SemanticsNodeInteraction -// private lateinit var deleteIconButton: SemanticsNodeInteraction -// private lateinit var selectedCountContainer: SemanticsNodeInteraction -// -// private lateinit var navDrawer: SemanticsNodeInteraction -// private lateinit var rateAppTile: SemanticsNodeInteraction -// -// private lateinit var noteListRecyclerView: SemanticsNodeInteraction -// private lateinit var addNoteFloatingActionButton: SemanticsNodeInteraction -// -// @Before -// fun setUp() { -// hiltRule.inject() -// Intents.init() -// -// // Initialize all widgets -// topAppBar = composeTestRule.onNodeWithTag(TestTags.TOP_APPBAR) -// searchIconButton = composeTestRule.onNodeWithTag(TestTags.SEARCH_ICON_BUTTON) -// menuIconButton = composeTestRule.onNodeWithTag(TestTags.MENU_ICON_BUTTON) -// topAppBarTitle = composeTestRule.onNodeWithTag(TestTags.TOP_APPBAR_TITLE) -//// topAppBarTextField = composeTestRule.onNodeWithTag(TestTags.TOP_APPBAR_TEXTFIELD) -// closeSearchIconButton = composeTestRule.onNodeWithTag(TestTags.CLOSE_SEARCH_ICON_BUTTON) -// selectedCount = composeTestRule.onNodeWithTag(TestTags.SELECTED_COUNT) -// dropdownSelect = composeTestRule.onNodeWithTag(TestTags.DROPDOWN_SELECT) -// selectAllOption = composeTestRule.onNodeWithTag(TestTags.SELECT_ALL_OPTION) -// unselectAllOption = composeTestRule.onNodeWithTag(TestTags.UNSELECT_ALL_OPTION) -// closeSelectIconButton = composeTestRule.onNodeWithTag(TestTags.CLOSE_SELECT_ICON_BUTTON) -// deleteIconButton = composeTestRule.onNodeWithTag(TestTags.DELETE_ICON_BUTTON) -// selectedCountContainer = composeTestRule.onNodeWithTag(TestTags.SELECTED_COUNT_CONTAINER) -// -// navDrawer = composeTestRule.onNodeWithTag(TestTags.NAV_DRAWER) -// rateAppTile = composeTestRule.onNodeWithTag(TestTags.RATE_APP_TILE) -// -// noteListRecyclerView = composeTestRule.onNodeWithTag(TestTags.NOTE_RV) -// addNoteFloatingActionButton = composeTestRule.onNodeWithTag(TestTags.ADD_NOTE_FAB) -// } -// -// @After -// fun tearDown() { -// Intents.release() -// } -// -// /** -// * Ensure all top appBar initial functionality -// * */ -// @Test -// fun ensureTopAppBarFunctionality() { -// // Initial state -// // Scenario : when app is launched, it will show title, menu icon, search icon -// topAppBar.assertIsDisplayed() -// searchIconButton.assertIsDisplayed() -// menuIconButton.assertIsDisplayed() -// topAppBarTitle.assertIsDisplayed() -// -// // When search button is pressed -// // Scenario : when pressed, a text field is shown -// searchIconButton.performClick() -// topAppBarTextField.assertIsDisplayed() -// -// /// 1. When textField is being edited -// /// Scenario : when pressed, it will gain focus then write text on it -// topAppBarTextField.performClick() -// topAppBarTextField.performTextInput("Input Text") -// composeTestRule.onNodeWithText("Input Text").assertExists() -// -// /// 2. When close button is pressed -// /// Scenario : when pressed, textField is dismissed -// closeSearchIconButton.assertIsDisplayed() -// closeSearchIconButton.performClick() -// topAppBarTextField.assertDoesNotExist() -// } -// -// /** -// * Ensure noteList functionality (make sure there are few items) -// * reside in the local database) -// * you can use App Inspection -> Databases -> New Query to seed the data) or -// * simply using add feature. -// * -// * Ensure it has three items -// * (1, "title 1", "note 1", 1678158383000, 1678158383000), -// * (2, "title 2", "note 2", 1678071983000, 1678071983000), -// * (3, "title 3", "note 3", 1677899183000, 1677899183000); -// * */ -// @Test -// fun ensureNoteListFunctionality() { -// // Initial state -// // Scenario : assert if lazy column are displayed (it will not exist if there are no item exists) -// noteListRecyclerView.assertIsDisplayed() -// -// /// 1. When the three children is showing -// /// Scenario : then assert the children by perform scroll and assert displayed -// val nodeWithText1 = composeTestRule.onNodeWithText("title 1") -// val nodeWithText2 = composeTestRule.onNodeWithText("title 2") -// val nodeWithText3 = composeTestRule.onNodeWithText("title 3") -// nodeWithText1.performScrollTo() -// nodeWithText2.assertIsDisplayed() -// nodeWithText3.assertIsDisplayed() -// -// // When node with text title 1 is long pressed -// // Scenario : the toolbar will show delete icon, close button, and selected count with -// // dropdown menu, also the tile checkbox will checked -// nodeWithText1.performTouchInput { -// longClick() -// } -// -// val checkBoxForNodeWithText1 = composeTestRule.onNodeWithTag("title 1") -// checkBoxForNodeWithText1.assertIsOn() -// composeTestRule.onNodeWithText("1").assertIsDisplayed() -// nodeWithText1.performClick() -// composeTestRule.onNodeWithText("0").assertIsDisplayed() -// -// closeSelectIconButton.assertIsDisplayed() -// deleteIconButton.assertIsDisplayed() -// selectedCountContainer.assertIsDisplayed() -// -// /// 1. When selected count container is pressed -// /// Scenario : it will show dropdown menu with select all and unselect all tile -// selectedCountContainer.performClick() -// dropdownSelect.assertIsDisplayed() -// dropdownSelect.performClick() -// unselectAllOption.performClick() -// composeTestRule.onNodeWithText("0").assertIsDisplayed() -// -// selectedCountContainer.performClick() -// dropdownSelect.assertIsDisplayed() -// dropdownSelect.performClick() -// selectAllOption.performClick() -// composeTestRule.onNodeWithText("3").assertIsDisplayed() -// -// /// 2. Delete selected note -// /// Scenario : it will show loading dialog when delete is being processed -// /// then it will show snackbar either success or failed -// /// note : insert the data again after this action -// deleteIconButton.performClick() -// // TODO : Assert dialog displayed (it returned error that the dialog show two times at same time) -// // TODO : the functionality is good when tested manually -// val dismissButton = composeTestRule.onNodeWithTag(TestTags.DISMISS_BUTTON) -// dismissButton.assertIsDisplayed() -// val confirmButton = composeTestRule.onNodeWithTag(TestTags.CONFIRM_BUTTON) -// confirmButton.assertIsDisplayed() -// -// dismissButton.performClick() -// composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertDoesNotExist() -// -// // TODO : check delete action that will show snackBar when success or error -// // TODO : the functionality is good when tested manually -// -// /// 2. When close button is pressed -// /// Scenario : it will turn into initial state -// closeSelectIconButton.performClick() -// closeSelectIconButton.assertDoesNotExist() -// -// // TODO : check filter functionality (it returned error that the note tile show two times at same time) -// // TODO : the functionality is good when tested manually -// } -// -// /** -// * Ensure all navDrawer initial functionality -// * -// * Ensure the emulator / device has play store and an account already -// * logged in there -// * */ -// @Test -// fun ensureNavDrawerFunctionality() { -// // When menu button is pressed -// // Scenario : there is hamburger button, when it was pressed a nav drawer will shows -// menuIconButton.performClick() -// navDrawer.assertIsDisplayed() -// -// // When drawer is displayed -// // Scenario : assert the children is displayed -// rateAppTile.assertIsDisplayed() -// -// /// 1. When rate app is pressed -// /// Scenario : the app will navigated to VentNote PlayStore Page -// rateAppTile.performClick() -// intended(hasAction(Intent.ACTION_VIEW)) -// intended(hasData(Uri.parse("https://play.google.com/store/apps/details?id=com.digiventure.ventnote"))) -// } -//} \ No newline at end of file +package com.digiventure.ventnote + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.longClick +import androidx.test.espresso.intent.Intents +import com.digiventure.utils.BaseAcceptanceTest +import com.digiventure.ventnote.commons.TestTags +import com.digiventure.ventnote.data.persistence.NoteModel +import com.digiventure.ventnote.module.proxy.DatabaseProxy +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +/** + * Comprehensive E2E instrumentation tests for the NotesPage. + * + * These tests cover all features of the Notes list screen: + * - Initial UI state + * - Search bar filtering + * - Note item interactions (click, long-press) + * - Selection / marking mode (select, deselect, select all, unselect all, close) + * - Delete flow (dialog, dismiss, confirm) + * - Filter / sort bottom sheet + * - Navigation (FAB → Creation, Note tap → Detail, Menu → Drawer) + * + * The database is seeded with 3 deterministic notes before each test and + * cleaned up afterwards to ensure full isolation. + */ +@HiltAndroidTest +class NotesFeature : BaseAcceptanceTest() { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + @Inject + lateinit var databaseProxy: DatabaseProxy + + // Seeded test notes + private val note1 = NoteModel(id = 0, title = "Shopping List", note = "Milk, eggs, bread") + private val note2 = NoteModel(id = 0, title = "Meeting Notes", note = "Discuss Q1 roadmap") + private val note3 = NoteModel(id = 0, title = "Ideas", note = "Build a widget for Android") + + @Before + fun setUp() { + hiltRule.inject() + Intents.init() + + // Seed the database with deterministic notes + runBlocking { + databaseProxy.dao().upsertNotes(listOf(note1, note2, note3)) + } + + // Wait for the UI to settle after seeding + composeTestRule.waitForIdle() + } + + @After + fun tearDown() { + // Clean up all seeded notes to ensure test isolation + runBlocking { + val allNotes = databaseProxy.dao().getSyncNotes() + if (allNotes.isNotEmpty()) { + databaseProxy.dao().deleteNotes(*allNotes.toTypedArray()) + } + } + Intents.release() + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. Initial State + // ───────────────────────────────────────────────────────────────────────── + + /** + * Verifies that the core UI elements are visible when the app launches. + */ + @Test + fun initialState_showsAppBarAndFab() { + composeTestRule.onNodeWithTag(TestTags.TOP_APPBAR).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.TOP_APPBAR_TITLE).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.MENU_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.SORT_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.ADD_NOTE_FAB).assertIsDisplayed() + } + + /** + * Verifies that the note list is visible and contains the seeded notes. + */ + @Test + fun initialState_showsSeededNotes() { + // Wait for the list and data to be fully displayed + composeTestRule.waitUntil(10000) { + try { + composeTestRule.onNodeWithTag(TestTags.NOTE_RV, useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Shopping List").assertIsDisplayed() + true + } catch (_: Throwable) { + false + } + } + + composeTestRule.onNodeWithTag(TestTags.NOTE_RV, useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Shopping List").assertIsDisplayed() + composeTestRule.onNodeWithText("Meeting Notes").assertIsDisplayed() + composeTestRule.onNodeWithText("Ideas").assertIsDisplayed() + } + + /** + * Verifies that the search bar is visible in the note list. + */ + @Test + fun initialState_showsSearchBar() { + composeTestRule.waitUntil(10000) { + try { + composeTestRule.onNodeWithTag(TestTags.TOP_APPBAR_TEXT_FIELD).assertIsDisplayed() + true + } catch (_: Throwable) { + false + } + } + composeTestRule.onNodeWithTag(TestTags.TOP_APPBAR_TEXT_FIELD).assertIsDisplayed() + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. Search Bar + // ───────────────────────────────────────────────────────────────────────── + + /** + * Verifies that typing in the search bar filters notes by title. + */ + @Test + fun searchBar_filtersByTitle() { + composeTestRule.onNodeWithTag(TestTags.TOP_APPBAR_TEXT_FIELD) + .performClick() + .performTextInput("Shopping") + + // Wait for debounce (300ms) + UI settle + composeTestRule.mainClock.advanceTimeBy(400) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Shopping List").assertIsDisplayed() + composeTestRule.onNodeWithText("Meeting Notes").assertIsNotDisplayed() + composeTestRule.onNodeWithText("Ideas").assertIsNotDisplayed() + } + + /** + * Verifies that typing in the search bar filters notes by content/body text. + */ + @Test + fun searchBar_filtersByContent() { + composeTestRule.onNodeWithTag(TestTags.TOP_APPBAR_TEXT_FIELD) + .performClick() + .performTextInput("roadmap") + + composeTestRule.mainClock.advanceTimeBy(400) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Meeting Notes").assertIsDisplayed() + composeTestRule.onNodeWithText("Shopping List").assertIsNotDisplayed() + composeTestRule.onNodeWithText("Ideas").assertIsNotDisplayed() + } + + /** + * Verifies that a search query that matches nothing shows an empty list. + */ + @Test + fun searchBar_noMatch_showsEmptyList() { + composeTestRule.onNodeWithTag(TestTags.TOP_APPBAR_TEXT_FIELD) + .performClick() + .performTextInput("xyzzy_no_match") + + composeTestRule.mainClock.advanceTimeBy(400) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Shopping List").assertIsNotDisplayed() + composeTestRule.onNodeWithText("Meeting Notes").assertIsNotDisplayed() + composeTestRule.onNodeWithText("Ideas").assertIsNotDisplayed() + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. Selection / Marking Mode + // ───────────────────────────────────────────────────────────────────────── + + /** + * Verifies that long-pressing a note enters marking mode, showing the + * selection UI (close button, delete button, selected count). + */ + @Test + fun longPressNote_entersMarkingMode() { + composeTestRule.onNodeWithText("Shopping List") + .performTouchInput { longClick() } + + composeTestRule.waitForIdle() + + // Selection UI should appear + composeTestRule.onNodeWithTag(TestTags.CLOSE_SELECT_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.DELETE_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.SELECTED_COUNT_CONTAINER).assertIsDisplayed() + + // Normal mode UI should disappear + composeTestRule.onNodeWithTag(TestTags.MENU_ICON_BUTTON).assertIsNotDisplayed() + composeTestRule.onNodeWithTag(TestTags.SORT_ICON_BUTTON).assertIsNotDisplayed() + composeTestRule.onNodeWithTag(TestTags.ADD_NOTE_FAB).assertIsNotDisplayed() + } + + /** + * Verifies that the selected count updates correctly when notes are + * toggled in marking mode. + */ + @Test + fun markingMode_tapNote_togglesSelectionCount() { + // Enter marking mode by long-pressing the first note + composeTestRule.onNodeWithText("Shopping List") + .performTouchInput { longClick() } + composeTestRule.waitForIdle() + + // "1 of 3 selected" should be shown + composeTestRule.onNodeWithText("1 of 3 selected").assertIsDisplayed() + + // Tap another note to add it to selection + composeTestRule.onNodeWithText("Meeting Notes").performClick() + composeTestRule.waitForIdle() + + // "2 of 3 selected" should be shown + composeTestRule.onNodeWithText("2 of 3 selected").assertIsDisplayed() + + // Tap the first note again to deselect it + composeTestRule.onNodeWithText("Shopping List").performClick() + composeTestRule.waitForIdle() + + // "1 of 3 selected" should be shown again + composeTestRule.onNodeWithText("1 of 3 selected").assertIsDisplayed() + } + + /** + * Verifies that "Select All" from the dropdown marks all notes. + */ + @Test + fun markingMode_selectAll_selectsAllNotes() { + // Enter marking mode + composeTestRule.onNodeWithText("Shopping List") + .performTouchInput { longClick() } + composeTestRule.waitForIdle() + + // Open the dropdown + composeTestRule.onNodeWithTag(TestTags.SELECTED_COUNT_CONTAINER).performClick() + composeTestRule.waitForIdle() + + // Tap "Select All" + composeTestRule.onNodeWithTag(TestTags.SELECT_ALL_OPTION).performClick() + composeTestRule.waitForIdle() + + // All 3 notes should be selected + composeTestRule.onNodeWithText("3 of 3 selected").assertIsDisplayed() + } + + /** + * Verifies that "Unselect All" from the dropdown clears all selections. + */ + @Test + fun markingMode_unselectAll_clearsSelection() { + // Enter marking mode and select all first + composeTestRule.onNodeWithText("Shopping List") + .performTouchInput { longClick() } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(TestTags.SELECTED_COUNT_CONTAINER).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(TestTags.SELECT_ALL_OPTION).performClick() + composeTestRule.waitForIdle() + + // Now open dropdown and unselect all + composeTestRule.onNodeWithTag(TestTags.SELECTED_COUNT_CONTAINER).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(TestTags.UNSELECT_ALL_OPTION).performClick() + composeTestRule.waitForIdle() + + // 0 notes should be selected + composeTestRule.onNodeWithText("0 of 3 selected").assertIsDisplayed() + } + + /** + * Verifies that the close button exits marking mode and restores normal UI. + */ + @Test + fun markingMode_closeButton_exitsMarkingMode() { + // Enter marking mode + composeTestRule.onNodeWithText("Shopping List") + .performTouchInput { longClick() } + composeTestRule.waitForIdle() + + // Press the close button + composeTestRule.onNodeWithTag(TestTags.CLOSE_SELECT_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Normal mode UI should be restored + composeTestRule.onNodeWithTag(TestTags.MENU_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.SORT_ICON_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.ADD_NOTE_FAB).assertIsDisplayed() + + // Selection UI should be gone + composeTestRule.onNodeWithTag(TestTags.CLOSE_SELECT_ICON_BUTTON).assertIsNotDisplayed() + composeTestRule.onNodeWithTag(TestTags.DELETE_ICON_BUTTON).assertIsNotDisplayed() + } + + // ───────────────────────────────────────────────────────────────────────── + // 4. Delete Flow + // ───────────────────────────────────────────────────────────────────────── + + /** + * Verifies that tapping the delete icon shows the confirmation dialog. + */ + @Test + fun deleteFlow_showsConfirmationDialog() { + // Enter marking mode and select a note + composeTestRule.onNodeWithText("Shopping List") + .performTouchInput { longClick() } + composeTestRule.waitForIdle() + + // Tap delete + composeTestRule.onNodeWithTag(TestTags.DELETE_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Confirmation dialog should appear + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.CONFIRM_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.DISMISS_BUTTON).assertIsDisplayed() + } + + /** + * Verifies that tapping "Dismiss" in the delete dialog cancels the operation. + */ + @Test + fun deleteFlow_dismissDialog_cancelsDelete() { + // Enter marking mode, select a note, and open delete dialog + composeTestRule.onNodeWithText("Shopping List") + .performTouchInput { longClick() } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(TestTags.DELETE_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Dismiss the dialog + composeTestRule.onNodeWithTag(TestTags.DISMISS_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Dialog should be gone, note should still exist + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsNotDisplayed() + composeTestRule.onNodeWithText("Shopping List").assertIsDisplayed() + } + + /** + * Verifies that confirming delete removes the selected note and shows a snackbar. + */ + @Test + fun deleteFlow_confirmDelete_removesNote() { + // Enter marking mode and select "Shopping List" + composeTestRule.onNodeWithText("Shopping List") + .performTouchInput { longClick() } + composeTestRule.waitForIdle() + + // Open delete dialog and confirm + composeTestRule.onNodeWithTag(TestTags.DELETE_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(TestTags.CONFIRM_BUTTON).performClick() + composeTestRule.waitForIdle() + + // The deleted note should no longer be visible + composeTestRule.onNodeWithText("Shopping List").assertIsNotDisplayed() + + // The other notes should still be visible + composeTestRule.onNodeWithText("Meeting Notes").assertIsDisplayed() + composeTestRule.onNodeWithText("Ideas").assertIsDisplayed() + } + + // ───────────────────────────────────────────────────────────────────────── + // 5. Filter / Sort Bottom Sheet + // ───────────────────────────────────────────────────────────────────────── + + /** + * Verifies that tapping the sort icon opens the filter bottom sheet. + */ + @Test + fun sortButton_opensFilterSheet() { + composeTestRule.onNodeWithTag(TestTags.SORT_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(TestTags.BOTTOM_SHEET).assertIsDisplayed() + } + + /** + * Verifies that the filter sheet can be dismissed by tapping the dismiss button. + */ + @Test + fun filterSheet_dismissButton_closesSheet() { + composeTestRule.onNodeWithTag(TestTags.SORT_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Bottom sheet should be open + composeTestRule.onNodeWithTag(TestTags.BOTTOM_SHEET).assertIsDisplayed() + + // Tap the "Dismiss" button inside the sheet + composeTestRule.onNodeWithText("Dismiss").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(TestTags.BOTTOM_SHEET).assertIsNotDisplayed() + } + + /** + * Verifies that selecting a sort option and confirming applies the filter + * and closes the sheet. + */ + @Test + fun filterSheet_selectSortByTitle_andConfirm_closesSheet() { + composeTestRule.onNodeWithTag(TestTags.SORT_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + // Select "Title" as sort option + composeTestRule.onNodeWithText("Title").performClick() + composeTestRule.waitForIdle() + + // Tap confirm + composeTestRule.onNodeWithText("Confirm").performClick() + composeTestRule.waitForIdle() + + // Sheet should be dismissed + composeTestRule.onNodeWithTag(TestTags.BOTTOM_SHEET).assertIsNotDisplayed() + + // Notes should still be visible (sorted by title) + composeTestRule.onNodeWithText("Shopping List").assertIsDisplayed() + } + + // ───────────────────────────────────────────────────────────────────────── + // 6. Navigation + // ───────────────────────────────────────────────────────────────────────── + + /** + * Verifies that tapping the FAB navigates to the Note Creation page. + */ + @Test + fun fab_click_navigatesToCreationPage() { + composeTestRule.onNodeWithTag(TestTags.ADD_NOTE_FAB).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(TestTags.NOTE_CREATION_PAGE).assertIsDisplayed() + } + + /** + * Verifies that tapping a note item navigates to the Note Detail page. + */ + @Test + fun noteItem_click_navigatesToDetailPage() { + composeTestRule.onNodeWithText("Shopping List").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(TestTags.NOTE_DETAIL_PAGE).assertIsDisplayed() + } + + /** + * Verifies that tapping the hamburger menu icon opens the navigation drawer. + */ + @Test + fun menuButton_click_opensNavDrawer() { + composeTestRule.onNodeWithTag(TestTags.MENU_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(TestTags.NAV_DRAWER).assertIsDisplayed() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/digiventure/ventnote/SharePreviewFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/SharePreviewFeature.kt new file mode 100644 index 0000000..8a24484 --- /dev/null +++ b/app/src/androidTest/java/com/digiventure/ventnote/SharePreviewFeature.kt @@ -0,0 +1,133 @@ +package com.digiventure.ventnote + +import android.content.Intent +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra +import com.digiventure.utils.BaseAcceptanceTest +import com.digiventure.ventnote.commons.TestTags +import com.digiventure.ventnote.data.persistence.NoteModel +import com.digiventure.ventnote.module.proxy.DatabaseProxy +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.`is` +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.TimeZone +import javax.inject.Inject + +@HiltAndroidTest +class SharePreviewFeature : BaseAcceptanceTest() { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + @Inject + lateinit var databaseProxy: DatabaseProxy + + @Before + fun setUp() { + hiltRule.inject() + Intents.init() + + // Seed a note for sharing with a fixed date + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + sdf.timeZone = TimeZone.getTimeZone("UTC") + val testDate = sdf.parse("2026-02-19T10:00:00Z")!! + + runBlocking { + databaseProxy.dao().upsertNotes(listOf( + NoteModel(1, "Test Title", "Test Note Content", testDate, testDate) + )) + } + + // Wait for list and navigate to detail -> Share Preview + composeTestRule.waitUntil(10000) { + try { + composeTestRule.onNodeWithText("Test Title").assertIsDisplayed() + true + } catch (_: Throwable) { + false + } + } + + composeTestRule.onNodeWithText("Test Title").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(TestTags.SHARE_ICON_BUTTON).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(TestTags.SHARE_PAGE, useUnmergedTree = true).assertIsDisplayed() + } + + @After + fun tearDown() { + runBlocking { + val allNotes = databaseProxy.dao().getSyncNotes() + if (allNotes.isNotEmpty()) { + databaseProxy.dao().deleteNotes(*allNotes.toTypedArray()) + } + } + Intents.release() + } + + @Test + fun initialState_showsNoteContent() { + // Verify Title and Body are displayed (Date format might vary so we check tag existence) + composeTestRule.onNodeWithTag(TestTags.TITLE_TEXT).assertTextContains("Test Title") + composeTestRule.onNodeWithTag(TestTags.BODY_TEXT).assertTextContains("Test Note Content") + composeTestRule.onNodeWithTag(TestTags.DATE_TEXT).assertIsDisplayed() + } + + @Test + fun helpDialog_visibility() { + composeTestRule.onNodeWithTag(TestTags.HELP_ICON_BUTTON).performClick() + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertIsDisplayed() + + composeTestRule.onNodeWithTag(TestTags.DISMISS_BUTTON).performClick() + composeTestRule.onNodeWithTag(TestTags.CONFIRMATION_DIALOG).assertDoesNotExist() + } + + @Test + fun shareTrigger_launchesIntent() { + // Click the share button in bottom bar + composeTestRule.onNodeWithTag(TestTags.SHARE_ICON_BUTTON).performClick() + + // Verify Share Sheet is displayed (Wait for it if needed, but it's usually immediate) + composeTestRule.onNodeWithText("Share Note").assertIsDisplayed() + + // Click Share in the bottom sheet using its text + composeTestRule.onNodeWithText("Share Note as Text").performClick() + + // Verify intent was sent + intended(allOf( + hasAction(Intent.ACTION_CHOOSER), + hasExtra(`is`(Intent.EXTRA_INTENT), allOf( + hasAction(Intent.ACTION_SEND), + hasExtra(`is`(Intent.EXTRA_TEXT), allOf( + containsString("Test Title"), + containsString("Test Note Content") + )) + )) + )) + } + + @Test + fun backNavigation_returnsToDetail() { + // Using System Back via NavController or Hardware back + composeTestRule.onNodeWithTag(TestTags.BACK_ICON_BUTTON).performClick() + composeTestRule.onNodeWithTag(TestTags.NOTE_DETAIL_PAGE).assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/digiventure/ventnote/commons/TestTags.kt b/app/src/main/java/com/digiventure/ventnote/commons/TestTags.kt index 5f16d25..a51a7aa 100644 --- a/app/src/main/java/com/digiventure/ventnote/commons/TestTags.kt +++ b/app/src/main/java/com/digiventure/ventnote/commons/TestTags.kt @@ -5,6 +5,7 @@ object TestTags { const val NOTES_PAGE = "notes_feature" const val NOTE_DETAIL_PAGE = "note_detail_page" const val NOTE_CREATION_PAGE = "note_creation_page" + const val SHARE_PAGE = "share_page" // Appbar test tags const val TOP_APPBAR = "top_appbar" @@ -22,10 +23,22 @@ object TestTags { const val SELECT_ALL_OPTION = "select_all_option" const val UNSELECT_ALL_OPTION = "unselect_all_option" const val SELECTED_COUNT_CONTAINER = "selected_count_container" + const val HELP_ICON_BUTTON = "help_icon_button" // Nav drawer test tags const val NAV_DRAWER = "nav_drawer" const val RATE_APP_TILE = "rate_app_tile" + const val MORE_APPS_TILE = "more_apps_tile" + const val APP_VERSION_TILE = "app_version_tile" + const val THEME_TILE = "theme_tile" + const val COLOR_MODE_TILE = "color_mode_tile" + const val BACKUP_TILE = "backup_tile" + + // Theme color tags + const val THEME_COLOR_PURPLE = "theme_color_purple" + const val THEME_COLOR_CRIMSON = "theme_color_crimson" + const val THEME_COLOR_CADMIUM_GREEN = "theme_color_cadmium_green" + const val THEME_COLOR_COBALT_BLUE = "theme_color_cobalt_blue" // Note lists test tags const val ADD_NOTE_FAB = "add_note_fab" @@ -36,4 +49,16 @@ object TestTags { // Dialog Button const val CONFIRM_BUTTON = "confirm_button" const val DISMISS_BUTTON = "dismiss_button" + + // Note Detail test tags + const val EDIT_ICON_BUTTON = "edit_icon_button" + const val SAVE_ICON_BUTTON = "save_icon_button" + const val CANCEL_ICON_BUTTON = "cancel_icon_button" + const val SHARE_ICON_BUTTON = "share_icon_button" + const val BACK_ICON_BUTTON = "back_icon_button" + const val TITLE_TEXT_FIELD = "title_text_field" + const val BODY_TEXT_FIELD = "body_text_field" + const val DATE_TEXT = "date_text" + const val TITLE_TEXT = "title_text" + const val BODY_TEXT = "body_text" } \ No newline at end of file diff --git a/app/src/main/java/com/digiventure/ventnote/feature/drawer/NavDrawer.kt b/app/src/main/java/com/digiventure/ventnote/feature/drawer/NavDrawer.kt index 82f5b11..d73bc33 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/drawer/NavDrawer.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/drawer/NavDrawer.kt @@ -60,9 +60,9 @@ private fun openPlayStore(context: Context, appURL: String, onError: (String) -> } try { context.startActivity(playIntent) - } catch (e: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { onError("Cannot open URL: Play Store not found or no app can handle this action.") - } catch (e: Exception) { + } catch (_: Exception) { onError("Cannot open URL") } } @@ -109,11 +109,13 @@ fun NavDrawer( NavDrawerItem(leftIcon = Icons.Filled.Search, title = stringResource(id = R.string.more_apps), subtitle = stringResource(id = R.string.more_apps_description), + testTagName = TestTags.MORE_APPS_TILE, onClick = { openPlayStore(context, devPagePath, onError) }) NavDrawerItem(leftIcon = Icons.Filled.Info, title = stringResource(id = R.string.app_version), subtitle = BuildConfig.VERSION_NAME, + testTagName = TestTags.APP_VERSION_TILE, onClick = { onUpdateCheckPressed() }) SectionTitle(title = stringResource(id = R.string.preferences)) @@ -121,6 +123,7 @@ fun NavDrawer( NavDrawerColorPicker( leftIcon = Icons.Filled.Settings, title = stringResource(id = R.string.theme_color), + testTagName = TestTags.THEME_TILE ) { themeViewModel.updateColorPallet(it.second) } @@ -129,6 +132,7 @@ fun NavDrawer( leftIcon = Icons.Filled.Person, title = stringResource(id = R.string.theme_setting), currentScheme = currentSchemeName, + testTagName = TestTags.COLOR_MODE_TILE ) { themeViewModel.updateColorScheme(it) } @@ -139,6 +143,7 @@ fun NavDrawer( leftIcon = Icons.Filled.Share, title = stringResource(id = R.string.backup), subtitle = stringResource(id = R.string.backup_description), + testTagName = TestTags.BACKUP_TILE, onClick = { onBackupPressed() }) } } diff --git a/app/src/main/java/com/digiventure/ventnote/feature/drawer/components/NavDrawerColorPicker.kt b/app/src/main/java/com/digiventure/ventnote/feature/drawer/components/NavDrawerColorPicker.kt index 61326a6..f5e01c2 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/drawer/components/NavDrawerColorPicker.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/drawer/components/NavDrawerColorPicker.kt @@ -31,6 +31,13 @@ import com.digiventure.ventnote.ui.theme.components.CadmiumGreenLightPrimary import com.digiventure.ventnote.ui.theme.components.CobaltBlueLightPrimary import com.digiventure.ventnote.ui.theme.components.CrimsonLightPrimary import com.digiventure.ventnote.ui.theme.components.PurpleLightPrimary +import com.digiventure.ventnote.commons.TestTags + +data class ColorPickerOption( + val color: Color, + val name: String, + val tag: String +) @Composable fun NavDrawerColorPicker( @@ -40,10 +47,10 @@ fun NavDrawerColorPicker( onColorPicked: (color: Pair) -> Unit ) { val colorList = listOf( - Pair(PurpleLightPrimary, ColorPalletName.PURPLE), - Pair(CrimsonLightPrimary, ColorPalletName.CRIMSON), - Pair(CadmiumGreenLightPrimary, ColorPalletName.CADMIUM_GREEN), - Pair(CobaltBlueLightPrimary, ColorPalletName.COBALT_BLUE) + ColorPickerOption(PurpleLightPrimary, ColorPalletName.PURPLE, TestTags.THEME_COLOR_PURPLE), + ColorPickerOption(CrimsonLightPrimary, ColorPalletName.CRIMSON, TestTags.THEME_COLOR_CRIMSON), + ColorPickerOption(CadmiumGreenLightPrimary, ColorPalletName.CADMIUM_GREEN, TestTags.THEME_COLOR_CADMIUM_GREEN), + ColorPickerOption(CobaltBlueLightPrimary, ColorPalletName.COBALT_BLUE, TestTags.THEME_COLOR_COBALT_BLUE) ) Row( @@ -80,14 +87,15 @@ fun NavDrawerColorPicker( modifier = Modifier.padding(bottom = 2.dp) ) Row { - colorList.forEach { + for (option in colorList) { Box(modifier = Modifier .clip(RoundedCornerShape(8.dp)) .width(24.dp) .height(24.dp) - .background(it.first) + .background(option.color) + .semantics { testTag = option.tag } .clickable { - onColorPicked(it) + onColorPicked(Pair(option.color, option.name)) }) Box(modifier = Modifier.padding(start = 2.dp, end = 2.dp)) } diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/NoteCreationPage.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/NoteCreationPage.kt index 218da39..55a46d2 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/NoteCreationPage.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/NoteCreationPage.kt @@ -42,7 +42,9 @@ import com.digiventure.ventnote.feature.note_creation.components.section.TitleSe import com.digiventure.ventnote.feature.note_creation.viewmodel.NoteCreationPageBaseVM import com.digiventure.ventnote.feature.note_creation.viewmodel.NoteCreationPageMockVM import com.digiventure.ventnote.feature.note_creation.viewmodel.NoteCreationPageVM +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -69,25 +71,27 @@ fun NoteCreationPage( // Extracted and optimized addNote function val noteIsSuccessfullyAddedText = stringResource(R.string.successfully_added) - val addNote = remember { - { - if (viewModel.titleText.value.isEmpty() || viewModel.descriptionText.value.isEmpty()) { - requiredDialogState.value = true - } else { - scope.launch { - viewModel.addNote( - NoteModel( - id = 0, - title = viewModel.titleText.value, - note = viewModel.descriptionText.value - ) - ).onSuccess { + fun addNote() { + if (viewModel.titleText.value.isEmpty() || viewModel.descriptionText.value.isEmpty()) { + requiredDialogState.value = true + } else { + scope.launch { + viewModel.addNote( + NoteModel( + id = 0, + title = viewModel.titleText.value, + note = viewModel.descriptionText.value + ) + ).onSuccess { + withContext(Dispatchers.Main) { navHostController.popBackStack() snackBarHostState.showSnackbar( message = noteIsSuccessfullyAddedText, withDismissAction = true ) - }.onFailure { + } + }.onFailure { + withContext(Dispatchers.Main) { snackBarHostState.showSnackbar( message = it.message ?: EMPTY_STRING, withDismissAction = true @@ -172,7 +176,8 @@ fun NoteCreationPage( description = stringResource(R.string.required_confirmation_text, missingFieldName), isOpened = requiredDialogState.value, onDismissCallback = { requiredDialogState.value = false }, - onConfirmCallback = { requiredDialogState.value = false }) + onConfirmCallback = { requiredDialogState.value = false }, + modifier = Modifier.semantics { testTag = TestTags.CONFIRMATION_DIALOG }) } if (cancelDialogState.value) { @@ -184,7 +189,8 @@ fun NoteCreationPage( onConfirmCallback = { navHostController.popBackStack() cancelDialogState.value = false - }) + }, + modifier = Modifier.semantics { testTag = TestTags.CONFIRMATION_DIALOG }) } } diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/navbar/AppBar.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/navbar/AppBar.kt index c2e8536..45e0568 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/navbar/AppBar.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/navbar/AppBar.kt @@ -12,8 +12,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.TestTags import com.digiventure.ventnote.components.navbar.TopNavBarIcon @OptIn(ExperimentalMaterial3Api::class) @@ -36,11 +38,11 @@ fun NoteCreationAppBar( containerColor = MaterialTheme.colorScheme.surface, ), navigationIcon = { - TopNavBarIcon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back_nav_icon), Modifier.semantics { }) { + TopNavBarIcon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back_nav_icon), Modifier.semantics { testTag = TestTags.BACK_ICON_BUTTON }) { onBackPressed() } }, scrollBehavior = scrollBehavior, - modifier = Modifier.semantics { }, + modifier = Modifier.semantics { testTag = TestTags.TOP_APPBAR }, ) } \ No newline at end of file diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/navbar/EnhancedBottomAppBar.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/navbar/EnhancedBottomAppBar.kt index 75a815b..157ae33 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/navbar/EnhancedBottomAppBar.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/navbar/EnhancedBottomAppBar.kt @@ -31,9 +31,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.TestTags @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -56,7 +59,8 @@ fun EnhancedBottomAppBar( onClick = onSaveClick, containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - isProminent = true + isProminent = true, + modifier = Modifier.semantics { testTag = TestTags.SAVE_ICON_BUTTON } ) } } @@ -70,7 +74,8 @@ private fun EnhancedBottomBarButton( onClick: () -> Unit, containerColor: Color, contentColor: Color, - isProminent: Boolean = false + isProminent: Boolean = false, + modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current val scale by animateFloatAsState( @@ -82,7 +87,7 @@ private fun EnhancedBottomBarButton( Row ( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier + modifier = modifier .scale(scale) .clip(RoundedCornerShape(16.dp)) .background( diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/section/NoteSection.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/section/NoteSection.kt index 2fea88e..424bd21 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/section/NoteSection.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/section/NoteSection.kt @@ -31,9 +31,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.TestTags import com.digiventure.ventnote.feature.note_creation.viewmodel.NoteCreationPageBaseVM @Composable @@ -119,7 +121,10 @@ fun ImprovedDescriptionTextField( modifier = Modifier .fillMaxWidth() .fillMaxHeight() - .semantics { contentDescription = bodyTextField }, + .semantics { + contentDescription = bodyTextField + testTag = TestTags.BODY_TEXT_FIELD + }, placeholder = { Text( text = bodyInput, diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/section/TitleSection.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/section/TitleSection.kt index 1f60d79..4c2c4b8 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/section/TitleSection.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/components/section/TitleSection.kt @@ -29,9 +29,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.TestTags import com.digiventure.ventnote.feature.note_creation.viewmodel.NoteCreationPageBaseVM @Composable @@ -115,7 +117,10 @@ fun ImprovedTitleTextField( ), modifier = Modifier .fillMaxWidth() - .semantics { contentDescription = titleTextField }, + .semantics { + contentDescription = titleTextField + testTag = TestTags.TITLE_TEXT_FIELD + }, placeholder = { Text( text = titleInput, diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/viewmodel/NoteCreationPageVM.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/viewmodel/NoteCreationPageVM.kt index fcc63a0..9847fb7 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/viewmodel/NoteCreationPageVM.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/viewmodel/NoteCreationPageVM.kt @@ -8,7 +8,7 @@ import com.digiventure.ventnote.data.persistence.NoteModel import com.digiventure.ventnote.data.persistence.NoteRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext import javax.inject.Inject @@ -26,7 +26,7 @@ class NoteCreationPageVM @Inject constructor( try { repository.insertNote(note).onEach { loader.postValue(false) - }.last() + }.first() } catch (e: Exception) { loader.postValue(false) Result.failure(e) diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/NoteDetailPage.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/NoteDetailPage.kt index 1fdd167..49688b2 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/NoteDetailPage.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/NoteDetailPage.kt @@ -1,5 +1,6 @@ package com.digiventure.ventnote.feature.note_detail +import android.annotation.SuppressLint import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable @@ -51,6 +52,7 @@ import com.digiventure.ventnote.feature.note_detail.viewmodel.NoteDetailPageVM import com.digiventure.ventnote.navigation.PageNavigation import com.google.gson.Gson import kotlinx.coroutines.launch +import kotlinx.coroutines.Dispatchers @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -111,7 +113,9 @@ fun NoteDetailPage( viewModel.deleteNoteList(noteData) .onSuccess { deleteDialogState.value = false - navHostController.navigateUp() + scope.launch(Dispatchers.Main) { + navigationActions.navigateToNotesPage() + } } .onFailure { error -> deleteDialogState.value = false @@ -277,7 +281,8 @@ fun NoteDetailPage( description = stringResource(R.string.required_confirmation_text, missingFieldName), isOpened = requiredDialogState.value, onDismissCallback = { requiredDialogState.value = false }, - onConfirmCallback = { requiredDialogState.value = false } + onConfirmCallback = { requiredDialogState.value = false }, + modifier = Modifier.semantics { testTag = TestTags.CONFIRMATION_DIALOG } ) } @@ -291,7 +296,8 @@ fun NoteDetailPage( viewModel.isEditing.value = false cancelDialogState.value = false initData() - } + }, + modifier = Modifier.semantics { testTag = TestTags.CONFIRMATION_DIALOG } ) } @@ -299,14 +305,16 @@ fun NoteDetailPage( TextDialog( isOpened = deleteDialogState.value, onDismissCallback = { deleteDialogState.value = false }, - onConfirmCallback = { deleteNote() } + onConfirmCallback = { deleteNote() }, + modifier = Modifier.semantics { testTag = TestTags.CONFIRMATION_DIALOG } ) } if (openLoadingDialog.value) { LoadingDialog( isOpened = openLoadingDialog.value, - onDismissCallback = { openLoadingDialog.value = false } + onDismissCallback = { openLoadingDialog.value = false }, + modifier = Modifier.semantics { testTag = TestTags.LOADING_DIALOG } ) } @@ -319,6 +327,7 @@ fun NoteDetailPage( } } +@SuppressLint("ViewModelConstructorInComposable") @Preview @Composable fun NoteDetailPagePreview() { diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/navbar/AppBar.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/navbar/AppBar.kt index 67c3d4a..7789fdf 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/navbar/AppBar.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/navbar/AppBar.kt @@ -13,8 +13,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.TestTags import com.digiventure.ventnote.components.navbar.TopNavBarIcon @OptIn(ExperimentalMaterial3Api::class) @@ -40,14 +42,14 @@ fun NoteDetailAppBar( ), navigationIcon = { if (!isEditing) { - TopNavBarIcon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back_nav_icon), Modifier.semantics { }) { + TopNavBarIcon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back_nav_icon), Modifier.semantics { testTag = TestTags.BACK_ICON_BUTTON }) { onBackPressed() } } }, actions = { if (!isEditing) { - TopNavBarIcon(Icons.Filled.Share, stringResource(R.string.share_nav_icon), Modifier.semantics { }) { + TopNavBarIcon(Icons.Filled.Share, stringResource(R.string.share_nav_icon), Modifier.semantics { testTag = TestTags.SHARE_ICON_BUTTON }) { onSharePressed() } } diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/navbar/EnhancedBottomAppBar.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/navbar/EnhancedBottomAppBar.kt index d0f65df..69b775a 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/navbar/EnhancedBottomAppBar.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/navbar/EnhancedBottomAppBar.kt @@ -31,6 +31,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -39,6 +41,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.TestTags @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,9 +69,10 @@ fun EnhancedBottomAppBar( label = stringResource(R.string.cancel), onClick = onCancelClick, containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.semantics { testTag = TestTags.CANCEL_ICON_BUTTON } ) - + // Save button in editing mode EnhancedBottomBarButton( icon = Icons.Filled.Check, @@ -76,7 +80,8 @@ fun EnhancedBottomAppBar( onClick = onSaveClick, containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - isProminent = true + isProminent = true, + modifier = Modifier.semantics { testTag = TestTags.SAVE_ICON_BUTTON } ) } else { // Edit button in view mode @@ -85,23 +90,25 @@ fun EnhancedBottomAppBar( label = stringResource(R.string.edit), onClick = onEditClick, containerColor = MaterialTheme.colorScheme.secondary, - contentColor = MaterialTheme.colorScheme.onSecondary + contentColor = MaterialTheme.colorScheme.onSecondary, + modifier = Modifier.semantics { testTag = TestTags.EDIT_ICON_BUTTON } ) - + // Delete button in view mode EnhancedBottomBarButton( icon = Icons.Filled.Delete, label = stringResource(R.string.delete), onClick = onDeleteClick, containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.semantics { testTag = TestTags.DELETE_ICON_BUTTON } ) } } } ) } - + @Composable private fun EnhancedBottomBarButton( icon: ImageVector, @@ -109,7 +116,8 @@ private fun EnhancedBottomBarButton( onClick: () -> Unit, containerColor: Color, contentColor: Color, - isProminent: Boolean = false + modifier: Modifier = Modifier, + isProminent: Boolean = false, ) { val haptics = LocalHapticFeedback.current val scale by animateFloatAsState( @@ -117,11 +125,11 @@ private fun EnhancedBottomBarButton( animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), label = "button_scale" ) - + Row ( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier + modifier = modifier .scale(scale) .clip(RoundedCornerShape(16.dp)) .background( diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/section/NoteSection.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/section/NoteSection.kt index 6b1199f..35f1c76 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/section/NoteSection.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/section/NoteSection.kt @@ -31,9 +31,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.TestTags import com.digiventure.ventnote.feature.note_detail.viewmodel.NoteDetailPageBaseVM @Composable @@ -127,7 +129,10 @@ fun ImprovedDescriptionTextField( modifier = Modifier .fillMaxWidth() .fillMaxHeight() - .semantics { contentDescription = bodyTextField }, + .semantics { + contentDescription = bodyTextField + testTag = TestTags.BODY_TEXT_FIELD + }, placeholder = { if (isEditingState) { Text( diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/section/TitleSection.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/section/TitleSection.kt index 0b11653..e789ec6 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/section/TitleSection.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/section/TitleSection.kt @@ -29,9 +29,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.TestTags import com.digiventure.ventnote.feature.note_detail.viewmodel.NoteDetailPageBaseVM @Composable @@ -123,7 +125,10 @@ fun ImprovedTitleTextField( ), modifier = Modifier .fillMaxWidth() - .semantics { contentDescription = titleTextField }, + .semantics { + contentDescription = titleTextField + testTag = TestTags.TITLE_TEXT_FIELD + }, placeholder = { if (isEditingState) { Text( diff --git a/app/src/main/java/com/digiventure/ventnote/feature/share_preview/SharePreviewPage.kt b/app/src/main/java/com/digiventure/ventnote/feature/share_preview/SharePreviewPage.kt index b293fd0..3f3042f 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/share_preview/SharePreviewPage.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/share_preview/SharePreviewPage.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -51,6 +52,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.digiventure.ventnote.R import com.digiventure.ventnote.commons.DateUtil +import com.digiventure.ventnote.commons.TestTags import com.digiventure.ventnote.components.dialog.TextDialog import com.digiventure.ventnote.data.persistence.NoteModel import com.digiventure.ventnote.feature.share_preview.components.navbar.EnhancedBottomAppBar @@ -78,7 +80,7 @@ fun SharePreviewPage( "EEE, MMM dd HH:mm yyyy", note?.createdAt?.toString() ?: Date().toString() ) - } catch (e: Exception) { + } catch (_: Exception) { DateUtil.convertDateString("EEE, MMM dd HH:mm yyyy", Date().toString()) } } @@ -114,7 +116,7 @@ fun SharePreviewPage( coroutineScope.launch { try { shareText(joinedText, context) - } catch (e: Exception) { + } catch (_: Exception) { snackBarHostState.showSnackbar( message = context.getString(R.string.failed_to_share_note), duration = SnackbarDuration.Short @@ -140,6 +142,7 @@ fun SharePreviewPage( }, snackbarHost = { SnackbarHost(snackBarHostState) }, modifier = Modifier + .semantics { testTag = TestTags.SHARE_PAGE } .nestedScroll(scrollBehavior.nestedScrollConnection), containerColor = MaterialTheme.colorScheme.surface, content = { @@ -177,6 +180,7 @@ fun SharePreviewPage( color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), fontWeight = FontWeight.Medium ), + modifier = Modifier.semantics { testTag = TestTags.DATE_TEXT } ) } } @@ -190,7 +194,9 @@ fun SharePreviewPage( fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, ), - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .semantics { testTag = TestTags.TITLE_TEXT } ) } } @@ -211,7 +217,9 @@ fun SharePreviewPage( fontWeight = FontWeight.Normal, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f), ), - modifier = Modifier.padding(20.dp) + modifier = Modifier + .padding(20.dp) + .semantics { testTag = TestTags.BODY_TEXT } ) } } @@ -232,7 +240,7 @@ fun SharePreviewPage( description = stringResource(R.string.share_note_information), isOpened = true, onDismissCallback = { shareNoteDialogState.value = false }, - modifier = Modifier.semantics { } + modifier = Modifier.semantics { testTag = TestTags.CONFIRMATION_DIALOG } ) } diff --git a/app/src/main/java/com/digiventure/ventnote/feature/share_preview/components/navbar/AppBar.kt b/app/src/main/java/com/digiventure/ventnote/feature/share_preview/components/navbar/AppBar.kt index f3d7163..a1fdfcf 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/share_preview/components/navbar/AppBar.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/share_preview/components/navbar/AppBar.kt @@ -13,8 +13,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.TestTags import com.digiventure.ventnote.components.navbar.TopNavBarIcon @OptIn(ExperimentalMaterial3Api::class) @@ -38,16 +40,16 @@ fun SharePreviewAppBar( containerColor = MaterialTheme.colorScheme.surface, ), navigationIcon = { - TopNavBarIcon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back_nav_icon), Modifier.semantics { }) { + TopNavBarIcon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back_nav_icon), Modifier.semantics { testTag = TestTags.BACK_ICON_BUTTON }) { onBackPressed() } }, actions = { - TopNavBarIcon(Icons.Filled.Info, stringResource(R.string.menu_nav_icon), Modifier.semantics { }) { + TopNavBarIcon(Icons.Filled.Info, stringResource(R.string.menu_nav_icon), Modifier.semantics { testTag = TestTags.HELP_ICON_BUTTON }) { onHelpPressed() } }, scrollBehavior = scrollBehavior, - modifier = Modifier.semantics { }, + modifier = Modifier.semantics { testTag = TestTags.TOP_APPBAR }, ) } \ No newline at end of file diff --git a/app/src/main/java/com/digiventure/ventnote/feature/share_preview/components/navbar/EnhancedBottomAppBar.kt b/app/src/main/java/com/digiventure/ventnote/feature/share_preview/components/navbar/EnhancedBottomAppBar.kt index 9324b49..a8db0ba 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/share_preview/components/navbar/EnhancedBottomAppBar.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/share_preview/components/navbar/EnhancedBottomAppBar.kt @@ -32,9 +32,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.TestTags @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -56,7 +59,8 @@ fun EnhancedBottomAppBar( label = stringResource(R.string.share_note), onClick = onCancelClick, containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.semantics { testTag = TestTags.SHARE_ICON_BUTTON } ) } } @@ -70,6 +74,7 @@ private fun EnhancedBottomBarButton( onClick: () -> Unit, containerColor: Color, contentColor: Color, + modifier: Modifier = Modifier, isProminent: Boolean = false ) { val haptics = LocalHapticFeedback.current @@ -82,7 +87,7 @@ private fun EnhancedBottomBarButton( Row ( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier + modifier = modifier .scale(scale) .clip(RoundedCornerShape(16.dp)) .background(