From 2a88af88336a5014545682b04e5bd86d899b40aa Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Thu, 19 Feb 2026 09:05:46 +0700 Subject: [PATCH 01/16] Feat integration-tests-1.3.0: Implement e2e instrumentation tests for NotesFeature --- .../digiventure/utils/BaseAcceptanceTest.kt | 17 +- .../com/digiventure/ventnote/NotesFeature.kt | 685 ++++++++++++------ 2 files changed, 488 insertions(+), 214 deletions(-) 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/NotesFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/NotesFeature.kt index 65f47d7..bb1f93a 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 (e: 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 (e: 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 From 2caec4e95706aec0c6a25c33ad1410f470c6191f Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Thu, 19 Feb 2026 12:28:47 +0700 Subject: [PATCH 02/16] Feat integration-tests-1.3.0: Implement e2e instrumentation tests for NoteDetailPage.kt --- .../digiventure/ventnote/NoteDetailFeature.kt | 337 ++++++++++++++++++ .../digiventure/ventnote/commons/TestTags.kt | 10 + .../feature/note_detail/NoteDetailPage.kt | 17 +- .../note_detail/components/navbar/AppBar.kt | 6 +- .../components/navbar/EnhancedBottomAppBar.kt | 28 +- .../components/section/NoteSection.kt | 7 +- .../components/section/TitleSection.kt | 7 +- .../feature/share_preview/SharePreviewPage.kt | 3 + 8 files changed, 396 insertions(+), 19 deletions(-) create mode 100644 app/src/androidTest/java/com/digiventure/ventnote/NoteDetailFeature.kt 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..02eed18 --- /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 (e: 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 (e: 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 (e: 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 (e: 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/main/java/com/digiventure/ventnote/commons/TestTags.kt b/app/src/main/java/com/digiventure/ventnote/commons/TestTags.kt index 5f16d25..eb96ee9 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" @@ -36,4 +37,13 @@ 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" } \ No newline at end of file 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..ac0de61 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 @@ -51,6 +51,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 +112,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 +280,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 +295,8 @@ fun NoteDetailPage( viewModel.isEditing.value = false cancelDialogState.value = false initData() - } + }, + modifier = Modifier.semantics { testTag = TestTags.CONFIRMATION_DIALOG } ) } @@ -299,14 +304,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 } ) } 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..e8a3365 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 + isProminent: Boolean = false, + modifier: Modifier = Modifier ) { 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..fe513d5 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 @@ -140,6 +142,7 @@ fun SharePreviewPage( }, snackbarHost = { SnackbarHost(snackBarHostState) }, modifier = Modifier + .semantics { testTag = TestTags.SHARE_PAGE } .nestedScroll(scrollBehavior.nestedScrollConnection), containerColor = MaterialTheme.colorScheme.surface, content = { From bd3a021ee31ebb717736f02969d2b07038374ba2 Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Thu, 19 Feb 2026 14:47:08 +0700 Subject: [PATCH 03/16] Feat integration-tests-1.3.0: Implement e2e instrumentation tests for NoteCreationPage.kt --- .../ventnote/NoteCreationFeature.kt | 206 ++++++++++++++++++ .../feature/note_creation/NoteCreationPage.kt | 37 ++-- .../note_creation/components/navbar/AppBar.kt | 6 +- .../components/navbar/EnhancedBottomAppBar.kt | 11 +- .../components/section/NoteSection.kt | 7 +- .../components/section/TitleSection.kt | 7 +- .../viewmodel/NoteCreationPageVM.kt | 4 +- 7 files changed, 253 insertions(+), 25 deletions(-) create mode 100644 app/src/androidTest/java/com/digiventure/ventnote/NoteCreationFeature.kt 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/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..e201977 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,6 +42,7 @@ 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 @OptIn(ExperimentalMaterial3Api::class) @@ -69,25 +70,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 { + launch(Dispatchers.Main) { navHostController.popBackStack() snackBarHostState.showSnackbar( message = noteIsSuccessfullyAddedText, withDismissAction = true ) - }.onFailure { + } + }.onFailure { + launch(Dispatchers.Main) { snackBarHostState.showSnackbar( message = it.message ?: EMPTY_STRING, withDismissAction = true @@ -172,7 +175,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 +188,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) From dd32f0f313185e7bde124e4a91b86bbed8f9b957 Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Thu, 19 Feb 2026 14:55:06 +0700 Subject: [PATCH 04/16] Feat integration-tests-1.3.0: Adjust warning implementations --- .../java/com/digiventure/ventnote/NoteDetailFeature.kt | 8 ++++---- .../java/com/digiventure/ventnote/NotesFeature.kt | 4 ++-- .../ventnote/feature/note_detail/NoteDetailPage.kt | 2 ++ .../note_detail/components/navbar/EnhancedBottomAppBar.kt | 2 +- .../ventnote/feature/share_preview/SharePreviewPage.kt | 4 ++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/src/androidTest/java/com/digiventure/ventnote/NoteDetailFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/NoteDetailFeature.kt index 02eed18..c1afa6d 100644 --- a/app/src/androidTest/java/com/digiventure/ventnote/NoteDetailFeature.kt +++ b/app/src/androidTest/java/com/digiventure/ventnote/NoteDetailFeature.kt @@ -59,7 +59,7 @@ class NoteDetailFeature : BaseAcceptanceTest() { try { composeTestRule.onNodeWithTag(TestTags.NOTE_DETAIL_PAGE).assertIsDisplayed() true - } catch (e: Throwable) { + } catch (_: Throwable) { false } } @@ -232,7 +232,7 @@ class NoteDetailFeature : BaseAcceptanceTest() { try { composeTestRule.onNodeWithTag(TestTags.NOTES_PAGE).assertIsDisplayed() true - } catch (e: Throwable) { + } catch (_: Throwable) { false } } @@ -271,7 +271,7 @@ class NoteDetailFeature : BaseAcceptanceTest() { try { composeTestRule.onNodeWithTag(TestTags.SHARE_PAGE, useUnmergedTree = true).assertIsDisplayed() true - } catch (e: Throwable) { + } catch (_: Throwable) { false } } @@ -290,7 +290,7 @@ class NoteDetailFeature : BaseAcceptanceTest() { try { composeTestRule.onNodeWithTag(TestTags.NOTES_PAGE).assertIsDisplayed() true - } catch (e: Throwable) { + } catch (_: Throwable) { false } } diff --git a/app/src/androidTest/java/com/digiventure/ventnote/NotesFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/NotesFeature.kt index bb1f93a..122b6ed 100644 --- a/app/src/androidTest/java/com/digiventure/ventnote/NotesFeature.kt +++ b/app/src/androidTest/java/com/digiventure/ventnote/NotesFeature.kt @@ -108,7 +108,7 @@ class NotesFeature : BaseAcceptanceTest() { composeTestRule.onNodeWithTag(TestTags.NOTE_RV, useUnmergedTree = true).assertIsDisplayed() composeTestRule.onNodeWithText("Shopping List").assertIsDisplayed() true - } catch (e: Throwable) { + } catch (_: Throwable) { false } } @@ -128,7 +128,7 @@ class NotesFeature : BaseAcceptanceTest() { try { composeTestRule.onNodeWithTag(TestTags.TOP_APPBAR_TEXT_FIELD).assertIsDisplayed() true - } catch (e: Throwable) { + } catch (_: Throwable) { false } } 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 ac0de61..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 @@ -326,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/EnhancedBottomAppBar.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/components/navbar/EnhancedBottomAppBar.kt index e8a3365..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 @@ -116,8 +116,8 @@ private fun EnhancedBottomBarButton( onClick: () -> Unit, containerColor: Color, contentColor: Color, + modifier: Modifier = Modifier, isProminent: Boolean = false, - modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current val scale by animateFloatAsState( 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 fe513d5..d3413af 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 @@ -80,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()) } } @@ -116,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 From dda16cca98e7dc26c54f4b03b84ad7b7ddb9488c Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Fri, 20 Feb 2026 00:06:01 +0700 Subject: [PATCH 05/16] Feat integration-tests-1.3.0: Add instrumentation tests for NoteCreationPage.kt, NavDrawer.kt, SharePreviewPage.kt, and NoteWidgetProvider --- .../{com/digiventure => }/MainActivityTest.kt | 0 .../digiventure/ventnote/NavDrawerFeature.kt | 138 ++++++++++++++++++ .../digiventure/ventnote/NoteWidgetFeature.kt | 93 ++++++++++++ .../ventnote/SharePreviewFeature.kt | 134 +++++++++++++++++ .../digiventure/ventnote/commons/TestTags.kt | 15 ++ .../ventnote/feature/drawer/NavDrawer.kt | 5 + .../drawer/components/NavDrawerColorPicker.kt | 22 ++- .../feature/note_creation/NoteCreationPage.kt | 5 +- .../feature/share_preview/SharePreviewPage.kt | 11 +- .../share_preview/components/navbar/AppBar.kt | 8 +- .../components/navbar/EnhancedBottomAppBar.kt | 9 +- 11 files changed, 423 insertions(+), 17 deletions(-) rename app/src/androidTest/java/{com/digiventure => }/MainActivityTest.kt (100%) create mode 100644 app/src/androidTest/java/com/digiventure/ventnote/NavDrawerFeature.kt create mode 100644 app/src/androidTest/java/com/digiventure/ventnote/NoteWidgetFeature.kt create mode 100644 app/src/androidTest/java/com/digiventure/ventnote/SharePreviewFeature.kt 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/ventnote/NavDrawerFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/NavDrawerFeature.kt new file mode 100644 index 0000000..2feaa4e --- /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 (e: 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/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/SharePreviewFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/SharePreviewFeature.kt new file mode 100644 index 0000000..2c5b002 --- /dev/null +++ b/app/src/androidTest/java/com/digiventure/ventnote/SharePreviewFeature.kt @@ -0,0 +1,134 @@ +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.Date +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 (e: 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 eb96ee9..a51a7aa 100644 --- a/app/src/main/java/com/digiventure/ventnote/commons/TestTags.kt +++ b/app/src/main/java/com/digiventure/ventnote/commons/TestTags.kt @@ -23,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" @@ -46,4 +58,7 @@ object TestTags { 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..64f08a6 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 @@ -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 e201977..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 @@ -44,6 +44,7 @@ import com.digiventure.ventnote.feature.note_creation.viewmodel.NoteCreationPage 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 @@ -82,7 +83,7 @@ fun NoteCreationPage( note = viewModel.descriptionText.value ) ).onSuccess { - launch(Dispatchers.Main) { + withContext(Dispatchers.Main) { navHostController.popBackStack() snackBarHostState.showSnackbar( message = noteIsSuccessfullyAddedText, @@ -90,7 +91,7 @@ fun NoteCreationPage( ) } }.onFailure { - launch(Dispatchers.Main) { + withContext(Dispatchers.Main) { snackBarHostState.showSnackbar( message = it.message ?: EMPTY_STRING, withDismissAction = true 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 d3413af..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 @@ -180,6 +180,7 @@ fun SharePreviewPage( color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), fontWeight = FontWeight.Medium ), + modifier = Modifier.semantics { testTag = TestTags.DATE_TEXT } ) } } @@ -193,7 +194,9 @@ fun SharePreviewPage( fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, ), - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .semantics { testTag = TestTags.TITLE_TEXT } ) } } @@ -214,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 } ) } } @@ -235,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( From 4a2ff39cec41da599315df7b6887faf13b70821a Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Fri, 20 Feb 2026 00:07:56 +0700 Subject: [PATCH 06/16] Feat integration-tests-1.3.0: Remove unused imports --- .../java/com/digiventure/ventnote/NavDrawerFeature.kt | 2 +- .../java/com/digiventure/ventnote/SharePreviewFeature.kt | 3 +-- .../java/com/digiventure/ventnote/feature/drawer/NavDrawer.kt | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/java/com/digiventure/ventnote/NavDrawerFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/NavDrawerFeature.kt index 2feaa4e..0759cf2 100644 --- a/app/src/androidTest/java/com/digiventure/ventnote/NavDrawerFeature.kt +++ b/app/src/androidTest/java/com/digiventure/ventnote/NavDrawerFeature.kt @@ -110,7 +110,7 @@ class NavDrawerFeature : BaseAcceptanceTest() { val isInitiallyLight = try { composeTestRule.onNodeWithText(darkModeText, ignoreCase = true).assertIsDisplayed() true // It says "switch to dark", so it is currently light - } catch (e: Throwable) { + } catch (_: Throwable) { false } diff --git a/app/src/androidTest/java/com/digiventure/ventnote/SharePreviewFeature.kt b/app/src/androidTest/java/com/digiventure/ventnote/SharePreviewFeature.kt index 2c5b002..8a24484 100644 --- a/app/src/androidTest/java/com/digiventure/ventnote/SharePreviewFeature.kt +++ b/app/src/androidTest/java/com/digiventure/ventnote/SharePreviewFeature.kt @@ -22,7 +22,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import java.text.SimpleDateFormat -import java.util.Date import java.util.TimeZone import javax.inject.Inject @@ -59,7 +58,7 @@ class SharePreviewFeature : BaseAcceptanceTest() { try { composeTestRule.onNodeWithText("Test Title").assertIsDisplayed() true - } catch (e: Throwable) { + } catch (_: Throwable) { false } } 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 64f08a6..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") } } From ecc72f5a25b790532a1acb01134bf0f1251f0da7 Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Fri, 20 Feb 2026 00:29:33 +0700 Subject: [PATCH 07/16] Feat integration-tests-1.3.0: Add instrumentation tests in the GitHub workflows --- .github/workflows/android_ci.yml | 120 +++++++++++++++++++++---------- 1 file changed, 84 insertions(+), 36 deletions(-) diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index 18e7d40..bd3aab4 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -1,46 +1,94 @@ -name: VentNote Build and Test CI +name: VentNote CI on: - pull_request: + push: branches: [ master ] + pull_request: + branches: [ master, '1.3.0' ] jobs: - test: - name: Run Unit Tests + lint-and-unit-test: + name: Lint and Unit Tests runs-on: ubuntu-latest + 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 Lint + run: ./gradlew lintDebug --stacktrace + + - name: Run Unit Tests + run: ./gradlew testDebugUnitTest --stacktrace + + - 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/ + instrumentation-test: + name: Instrumentation Tests + runs-on: macos-latest + timeout-minutes: 60 + strategy: + matrix: + api-level: [31] 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 - - build: + - 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: x86_64 + target: google_apis + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim + disable-animations: true + 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/ + + build-check: + name: Build APK Check runs-on: ubuntu-latest - permissions: - contents: read - packages: write - + needs: [lint-and-unit-test] steps: - - uses: actions/checkout@v3 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'adopt' - cache: gradle - - - name: Clean project - run: ./gradlew clean --stacktrace - - - name: Lint Debug - run: ./gradlew lintDebug --stacktrace - - - name: Build debug APK - run: ./gradlew build --stacktrace \ No newline at end of file + - 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 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 From 0b133707646fb3f76cab13c90b37217e47781e4d Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Fri, 20 Feb 2026 01:03:44 +0700 Subject: [PATCH 08/16] Feat integration-tests-1.3.0: Change emulator architecture --- .github/workflows/android_ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index bd3aab4..fbd5fa1 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -56,7 +56,7 @@ jobs: uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} - arch: x86_64 + arch: arm64-v8a target: google_apis force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim From 13c9cf3d722a16850554ada739a12d124055724f Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Fri, 20 Feb 2026 01:12:37 +0700 Subject: [PATCH 09/16] Feat integration-tests-1.3.0: Force recreate avd every job run --- .github/workflows/android_ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index fbd5fa1..3de62a2 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -58,7 +58,7 @@ jobs: api-level: ${{ matrix.api-level }} arch: arm64-v8a target: google_apis - force-avd-creation: false + force-avd-creation: true emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim disable-animations: true script: ./gradlew connectedDebugAndroidTest --stacktrace From 15de2ee865d82c9e6a1e16a6517b2a0c435c54a6 Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Fri, 20 Feb 2026 01:20:25 +0700 Subject: [PATCH 10/16] Feat integration-tests-1.3.0: Remove instrumentation tests run in the github workflows --- .github/workflows/android_ci.yml | 73 +++++++++++++++++--------------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index 3de62a2..0b9d9e9 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -4,7 +4,7 @@ on: push: branches: [ master ] pull_request: - branches: [ master, '1.3.0' ] + branches: [ master, staging ] jobs: lint-and-unit-test: @@ -35,40 +35,43 @@ jobs: **/build/reports/lint-results-*.html **/build/reports/tests/testDebugUnitTest/ - instrumentation-test: - name: Instrumentation Tests - runs-on: macos-latest - timeout-minutes: 60 - strategy: - matrix: - api-level: [31] - 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 - 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/ +# 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/ build-check: name: Build APK Check From 0363d985168826c1b265fd78692a28826be82bd7 Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Fri, 20 Feb 2026 08:57:10 +0700 Subject: [PATCH 11/16] Feat richtext-editor-1.3.0: (WIP) implement fully functional bold, italic, underline styling to title & description both in the note detail & creations --- .../commons/richtext/FormattingToolbar.kt | 131 ++++ .../commons/richtext/MarkdownParser.kt | 269 ++++++++ .../commons/richtext/RichTextEditor.kt | 118 ++++ .../commons/richtext/RichTextState.kt | 597 ++++++++++++++++++ .../commons/richtext/RichTextStyle.kt | 32 + .../feature/note_creation/NoteCreationPage.kt | 45 +- .../components/navbar/EnhancedBottomAppBar.kt | 51 +- .../components/section/NoteSection.kt | 102 +-- .../components/section/TitleSection.kt | 97 +-- .../viewmodel/NoteCreationPageBaseVM.kt | 7 + .../viewmodel/NoteCreationPageMockVM.kt | 3 + .../viewmodel/NoteCreationPageVM.kt | 3 + .../feature/note_detail/NoteDetailPage.kt | 38 +- .../components/navbar/EnhancedBottomAppBar.kt | 123 ++-- .../components/section/NoteSection.kt | 109 +--- .../components/section/TitleSection.kt | 104 +-- .../viewmodel/NoteDetailPageBaseVM.kt | 7 + .../viewmodel/NoteDetailPageMockVM.kt | 3 + .../note_detail/viewmodel/NoteDetailPageVM.kt | 3 + .../commons/richtext/MarkdownParserTest.kt | 167 +++++ 20 files changed, 1553 insertions(+), 456 deletions(-) create mode 100644 app/src/main/java/com/digiventure/ventnote/commons/richtext/FormattingToolbar.kt create mode 100644 app/src/main/java/com/digiventure/ventnote/commons/richtext/MarkdownParser.kt create mode 100644 app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextEditor.kt create mode 100644 app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextState.kt create mode 100644 app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextStyle.kt create mode 100644 app/src/test/java/com/digiventure/ventnote/commons/richtext/MarkdownParserTest.kt diff --git a/app/src/main/java/com/digiventure/ventnote/commons/richtext/FormattingToolbar.kt b/app/src/main/java/com/digiventure/ventnote/commons/richtext/FormattingToolbar.kt new file mode 100644 index 0000000..facb9f5 --- /dev/null +++ b/app/src/main/java/com/digiventure/ventnote/commons/richtext/FormattingToolbar.kt @@ -0,0 +1,131 @@ +package com.digiventure.ventnote.commons.richtext + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Formatting toolbar with toggle buttons for bold, italic, underline, and bullet list. + * Uses text-based buttons to avoid the Material Icons Extended dependency. + */ +@Composable +fun FormattingToolbar( + richTextState: RichTextState, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + FormatToggleButton( + label = "B", + fontWeight = FontWeight.ExtraBold, + isActive = richTextState.isStyleActive(RichTextStyle.Bold), + onClick = { richTextState.toggleStyle(RichTextStyle.Bold) } + ) + + FormatToggleButton( + label = "I", + fontStyle = FontStyle.Italic, + isActive = richTextState.isStyleActive(RichTextStyle.Italic), + onClick = { richTextState.toggleStyle(RichTextStyle.Italic) } + ) + + FormatToggleButton( + label = "U", + textDecoration = TextDecoration.Underline, + isActive = richTextState.isStyleActive(RichTextStyle.Underline), + onClick = { richTextState.toggleStyle(RichTextStyle.Underline) } + ) + + FormatToggleButton( + label = "•", + fontSize = 20, + isActive = richTextState.isBulletListActive(), + onClick = { richTextState.toggleBulletList() } + ) + } +} + +@Composable +private fun FormatToggleButton( + label: String, + isActive: Boolean, + onClick: () -> Unit, + fontWeight: FontWeight = FontWeight.SemiBold, + fontStyle: FontStyle = FontStyle.Normal, + textDecoration: TextDecoration = TextDecoration.None, + fontSize: Int = 18 +) { + val haptics = LocalHapticFeedback.current + + val backgroundColor by animateColorAsState( + targetValue = if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.01f) + }, + animationSpec = tween(200), + label = "format_button_bg" + ) + + val textColor by animateColorAsState( + targetValue = if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + animationSpec = tween(200), + label = "format_button_text" + ) + + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(10.dp)) + .background(backgroundColor, RoundedCornerShape(10.dp)) + .clickable { + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onClick() + }, + contentAlignment = Alignment.Center + ) { + Text( + text = label, + fontSize = fontSize.sp, + fontWeight = fontWeight, + fontStyle = fontStyle, + textDecoration = textDecoration, + color = textColor + ) + } +} diff --git a/app/src/main/java/com/digiventure/ventnote/commons/richtext/MarkdownParser.kt b/app/src/main/java/com/digiventure/ventnote/commons/richtext/MarkdownParser.kt new file mode 100644 index 0000000..c1e88ce --- /dev/null +++ b/app/src/main/java/com/digiventure/ventnote/commons/richtext/MarkdownParser.kt @@ -0,0 +1,269 @@ +package com.digiventure.ventnote.commons.richtext + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle + +/** + * Two-way converter between markdown String and AnnotatedString. + * + * Supported markdown subset: + * - **bold** → Bold + * - *italic* → Italic + * - __underline__ → Underline + * - "- " line prefix → Bullet list (stored as annotation tag) + */ +object MarkdownParser { + + const val BULLET_TAG = "BULLET" + + /** + * Parse a markdown string into an AnnotatedString with proper SpanStyles. + */ + fun parseToAnnotatedString(markdown: String): AnnotatedString { + return buildAnnotatedString { + val lines = markdown.split("\n") + lines.forEachIndexed { lineIndex, line -> + val isBullet = line.startsWith("- ") + val contentLine = if (isBullet) line.removePrefix("- ") else line + + if (isBullet) { + // Add bullet annotation for the entire line range + val startOffset = length + append("• ") + parseInlineStyles(this, contentLine) + addStringAnnotation( + tag = BULLET_TAG, + annotation = "true", + start = startOffset, + end = length + ) + } else { + parseInlineStyles(this, contentLine) + } + + if (lineIndex < lines.lastIndex) { + append("\n") + } + } + } + } + + /** + * Convert an AnnotatedString back to markdown string for storage. + */ + fun toMarkdown(annotatedString: AnnotatedString): String { + val text = annotatedString.text + if (text.isEmpty()) return "" + + val lines = text.split("\n") + val result = StringBuilder() + var charOffset = 0 + + lines.forEachIndexed { lineIndex, line -> + val lineStart = charOffset + val lineEnd = charOffset + line.length + + // Check if this line has a bullet annotation + val isBullet = annotatedString.getStringAnnotations( + tag = BULLET_TAG, + start = lineStart, + end = lineEnd + ).isNotEmpty() + + val displayLine = if (isBullet && line.startsWith("• ")) { + line.removePrefix("• ") + } else if (isBullet && line.startsWith("• ")) { + line.removePrefix("• ") + } else { + line + } + + if (isBullet) { + result.append("- ") + } + + // Build the markdown for this line by processing characters + val lineContentStart = if (isBullet) lineStart + (line.length - displayLine.length) else lineStart + result.append(buildLineMarkdown(annotatedString, displayLine, lineContentStart)) + + if (lineIndex < lines.lastIndex) { + result.append("\n") + } + + charOffset = lineEnd + 1 // +1 for newline + } + + return result.toString() + } + + // ─────────────────────────── Internal helpers ─────────────────────────── + + /** + * Parse inline markdown styles (**bold**, *italic*, __underline__) and append to builder. + */ + private fun parseInlineStyles(builder: AnnotatedString.Builder, text: String) { + var i = 0 + while (i < text.length) { + when { + // Bold+Italic: ***text*** + text.startsWith("***", i) -> { + val end = text.indexOf("***", i + 3) + if (end != -1) { + val content = text.substring(i + 3, end) + val startOffset = builder.length + builder.append(content) + builder.addStyle(RichTextStyle.Bold.spanStyle, startOffset, builder.length) + builder.addStringAnnotation(RichTextStyle.Bold.tag, "", startOffset, builder.length) + builder.addStyle(RichTextStyle.Italic.spanStyle, startOffset, builder.length) + builder.addStringAnnotation(RichTextStyle.Italic.tag, "", startOffset, builder.length) + i = end + 3 + } else { + builder.append(text[i]) + i++ + } + } + // Underline: __text__ + text.startsWith("__", i) -> { + val end = text.indexOf("__", i + 2) + if (end != -1) { + val content = text.substring(i + 2, end) + val startOffset = builder.length + builder.append(content) + builder.addStyle(RichTextStyle.Underline.spanStyle, startOffset, builder.length) + builder.addStringAnnotation(RichTextStyle.Underline.tag, "", startOffset, builder.length) + i = end + 2 + } else { + builder.append(text[i]) + i++ + } + } + // Bold: **text** + text.startsWith("**", i) -> { + val end = text.indexOf("**", i + 2) + if (end != -1) { + val content = text.substring(i + 2, end) + val startOffset = builder.length + builder.append(content) + builder.addStyle(RichTextStyle.Bold.spanStyle, startOffset, builder.length) + builder.addStringAnnotation(RichTextStyle.Bold.tag, "", startOffset, builder.length) + i = end + 2 + } else { + builder.append(text[i]) + i++ + } + } + // Italic: *text* + text.startsWith("*", i) -> { + val end = text.indexOf("*", i + 1) + if (end != -1) { + val content = text.substring(i + 1, end) + val startOffset = builder.length + builder.append(content) + builder.addStyle(RichTextStyle.Italic.spanStyle, startOffset, builder.length) + builder.addStringAnnotation(RichTextStyle.Italic.tag, "", startOffset, builder.length) + i = end + 1 + } else { + builder.append(text[i]) + i++ + } + } + else -> { + builder.append(text[i]) + i++ + } + } + } + } + + /** + * Build markdown string for a single line content by reading span annotations. + */ + private fun buildLineMarkdown( + annotatedString: AnnotatedString, + displayText: String, + globalOffset: Int + ): String { + if (displayText.isEmpty()) return "" + + data class StyleRange(val start: Int, val end: Int, val style: RichTextStyle) + + val styleRanges = mutableListOf() + + // Collect all style annotations within this line range + for (style in RichTextStyle.entries) { + val annotations = annotatedString.getStringAnnotations( + tag = style.tag, + start = globalOffset, + end = globalOffset + displayText.length + ) + for (ann in annotations) { + val relStart = (ann.start - globalOffset).coerceIn(0, displayText.length) + val relEnd = (ann.end - globalOffset).coerceIn(0, displayText.length) + if (relStart < relEnd) { + styleRanges.add(StyleRange(relStart, relEnd, style)) + } + } + } + + if (styleRanges.isEmpty()) return displayText + + // Group styles by exact same range + data class RangeKey(val start: Int, val end: Int) + + val groupedStyles = styleRanges.groupBy { RangeKey(it.start, it.end) } + + // Build result by walking through the text + val result = StringBuilder() + val sortedKeys = groupedStyles.keys.sortedBy { it.start } + + var pos = 0 + for (key in sortedKeys) { + // Append unstyled text before this range + if (key.start > pos) { + result.append(displayText.substring(pos, key.start)) + } + + val styles = groupedStyles[key]!!.map { it.style }.toSet() + val content = displayText.substring(key.start, key.end) + + // Handle combined styles + val hasBold = RichTextStyle.Bold in styles + val hasItalic = RichTextStyle.Italic in styles + val hasUnderline = RichTextStyle.Underline in styles + + when { + hasBold && hasItalic -> { + result.append("***").append(content).append("***") + } + hasBold -> { + result.append("**").append(content).append("**") + } + hasItalic -> { + result.append("*").append(content).append("*") + } + hasUnderline -> { + result.append("__").append(content).append("__") + } + } + + // If underline is combined with bold/italic, we'd need nesting. + // For simplicity, underline is standalone. Combined bold+underline + // would store as **__text__** which our parser doesn't handle. + // This is a known limitation for v1. + + pos = key.end + } + + // Append remaining unstyled text + if (pos < displayText.length) { + result.append(displayText.substring(pos)) + } + + return result.toString() + } +} diff --git a/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextEditor.kt b/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextEditor.kt new file mode 100644 index 0000000..886e7e9 --- /dev/null +++ b/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextEditor.kt @@ -0,0 +1,118 @@ +package com.digiventure.ventnote.commons.richtext + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Rich text editor composable that replaces the standard TextField. + * Supports styled text (bold, italic, underline) via AnnotatedString. + */ +@Composable +fun RichTextEditor( + richTextState: RichTextState, + modifier: Modifier = Modifier, + readOnly: Boolean = false, + isEditing: Boolean = true, + placeholder: String = "", + contentDescriptionText: String = "", + testTagText: String = "", + minHeight: Dp = 200.dp, + onFocusChanged: ((Boolean) -> Unit)? = null +) { + val label = "border_color" + val borderColor by animateColorAsState( + targetValue = if (isEditing) MaterialTheme.colorScheme.primary else Color.Transparent, + animationSpec = tween(300), + label = label + ) + + Card( + modifier = modifier + .fillMaxWidth() + .animateContentSize() + .heightIn(min = minHeight), + colors = CardDefaults.cardColors( + containerColor = if (isEditing) { + MaterialTheme.colorScheme.surface + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isEditing) 4.dp else 0.dp + ), + border = BorderStroke( + width = if (isEditing) 2.dp else 0.dp, + color = borderColor + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(16.dp) + .semantics { + if (contentDescriptionText.isNotEmpty()) { + contentDescription = contentDescriptionText + } + if (testTagText.isNotEmpty()) { + testTag = testTagText + } + } + ) { + if (richTextState.toPlainText().isEmpty() && isEditing) { + Text( + text = placeholder, + style = MaterialTheme.typography.titleMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + ) + } + + BasicTextField( + value = richTextState.textFieldValue, + onValueChange = { newValue -> + if (!readOnly) { + richTextState.onTextFieldValueChange(newValue) + } + }, + readOnly = readOnly, + textStyle = MaterialTheme.typography.titleMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .onFocusChanged { focusState -> + onFocusChanged?.invoke(focusState.isFocused) + } + ) + } + } +} diff --git a/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextState.kt b/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextState.kt new file mode 100644 index 0000000..fcee5b6 --- /dev/null +++ b/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextState.kt @@ -0,0 +1,597 @@ +package com.digiventure.ventnote.commons.richtext + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.TextFieldValue + +/** + * A style range tracked independently of AnnotatedString. + * This is the source of truth for styling; AnnotatedString is rebuilt from these. + */ +data class StyleRange( + val start: Int, + val end: Int, + val style: RichTextStyle +) + +/** + * State holder for the rich text editor. + * + * Key design: Compose's BasicTextField strips all AnnotatedString annotations + * on every onValueChange call. So we maintain our own list of [StyleRange]s + * as the source of truth and rebuild the AnnotatedString on every change. + */ +class RichTextState { + + /** The plain text + selection. AnnotatedString is rebuilt from [styleRanges]. */ + var textFieldValue by mutableStateOf(TextFieldValue("")) + private set + + /** Our source of truth for styles — independent of AnnotatedString. */ + val styleRanges = mutableStateListOf() + + /** Tracks which line indices are bullet list items (0-indexed). */ + val bulletLines = mutableStateListOf() + + /** Currently active toggles (applied to next typed text). */ + var activeStyles by mutableStateOf(setOf()) + private set + + /** Flag to suppress re-processing when we set the value internally. */ + private var isInternalUpdate = false + + /** + * Initialize from a markdown string (e.g. when loading from database). + */ + fun setFromMarkdown(markdown: String) { + // Parse markdown to get styled text + val parsed = MarkdownParser.parseToAnnotatedString(markdown) + + // Extract style ranges from the parsed AnnotatedString + styleRanges.clear() + for (style in RichTextStyle.entries) { + val annotations = parsed.getStringAnnotations(style.tag, 0, parsed.length) + for (ann in annotations) { + styleRanges.add(StyleRange(ann.start, ann.end, style)) + } + } + + // Extract bullet lines + bulletLines.clear() + val lines = parsed.text.split("\n") + lines.forEachIndexed { index, line -> + if (line.startsWith("• ") || line.startsWith("• ")) { + bulletLines.add(index) + } + } + + isInternalUpdate = true + textFieldValue = TextFieldValue( + annotatedString = buildStyledText(parsed.text), + selection = TextRange(parsed.length) + ) + isInternalUpdate = false + activeStyles = emptySet() + } + + /** + * Export the current editor content as a markdown string for storage. + */ + fun toMarkdown(): String { + val text = textFieldValue.text + if (text.isEmpty()) return "" + + val lines = text.split("\n") + val result = StringBuilder() + var charOffset = 0 + + lines.forEachIndexed { lineIndex, line -> + val lineStart = charOffset + val lineEnd = charOffset + line.length + val isBullet = lineIndex in bulletLines + + // Get the content (strip bullet prefix for markdown) + val displayContent = if (isBullet && (line.startsWith("• ") || line.startsWith("• "))) { + val prefix = if (line.startsWith("• ")) "• " else "• " + line.removePrefix(prefix) + } else { + line + } + + val contentStart = if (isBullet && (line.startsWith("• ") || line.startsWith("• "))) { + lineStart + (line.length - displayContent.length) + } else { + lineStart + } + + if (isBullet) { + result.append("- ") + } + + // Build markdown for this line's content + result.append(buildLineWithMarkdown(displayContent, contentStart)) + + if (lineIndex < lines.lastIndex) { + result.append("\n") + } + + charOffset = lineEnd + 1 + } + + return result.toString() + } + + /** + * Get the plain text content (for validation checks like isEmpty). + */ + fun toPlainText(): String = textFieldValue.text + + /** + * Called when the text field value changes (user typing or selection change). + */ + fun onTextFieldValueChange(newValue: TextFieldValue) { + if (isInternalUpdate) return + + val oldText = textFieldValue.text + val newText = newValue.text + + if (oldText != newText) { + // Text changed — adjust our style ranges + val lengthDiff = newText.length - oldText.length + + if (lengthDiff > 0) { + // Text was INSERTED + val insertPos = findInsertionPoint(oldText, newText, newValue.selection) + val insertLen = lengthDiff + adjustRangesForInsertion(insertPos, insertLen) + adjustBulletLinesForInsertion(oldText, newText, insertPos, insertLen) + + // Apply active styles to the inserted text + if (activeStyles.isNotEmpty()) { + for (style in activeStyles) { + styleRanges.add(StyleRange(insertPos, insertPos + insertLen, style)) + } + mergeOverlappingRanges() + } + } else if (lengthDiff < 0) { + // Text was DELETED + val deleteLen = -lengthDiff + val deletePos = findDeletionPoint(oldText, newText, newValue.selection) + adjustRangesForDeletion(deletePos, deleteLen) + adjustBulletLinesForDeletion(oldText, newText, deletePos, deleteLen) + } + + // Rebuild the annotated string with our style ranges + isInternalUpdate = true + textFieldValue = TextFieldValue( + annotatedString = buildStyledText(newText), + selection = newValue.selection, + composition = newValue.composition + ) + isInternalUpdate = false + } else { + // Only selection/composition changed + isInternalUpdate = true + textFieldValue = TextFieldValue( + annotatedString = buildStyledText(newText), + selection = newValue.selection, + composition = newValue.composition + ) + isInternalUpdate = false + } + + // Update active styles from cursor position + updateActiveStylesFromCursor() + } + + /** + * Toggle a style on/off for the current selection or future typing. + */ + fun toggleStyle(style: RichTextStyle) { + val selection = textFieldValue.selection + + if (selection.collapsed) { + // No selection — toggle for future typing + activeStyles = if (style in activeStyles) { + activeStyles - style + } else { + activeStyles + style + } + } else { + // Has selection — apply/remove style to selected range + val start = selection.min + val end = selection.max + val hasStyle = hasStyleInRange(style, start, end) + + if (hasStyle) { + removeStyleFromRange(style, start, end) + } else { + styleRanges.add(StyleRange(start, end, style)) + mergeOverlappingRanges() + } + + // Rebuild display + isInternalUpdate = true + textFieldValue = TextFieldValue( + annotatedString = buildStyledText(textFieldValue.text), + selection = selection, + composition = textFieldValue.composition + ) + isInternalUpdate = false + } + } + + /** + * Toggle bullet list for the current line. + */ + fun toggleBulletList() { + val text = textFieldValue.text + val cursorPos = textFieldValue.selection.start.coerceIn(0, text.length) + + // Find current line index + val lineIndex = text.substring(0, cursorPos).count { it == '\n' } + val lines = text.split("\n") + if (lineIndex >= lines.size) return + + val lineStart = lines.take(lineIndex).sumOf { it.length + 1 } + val currentLine = lines[lineIndex] + + if (lineIndex in bulletLines) { + // Remove bullet + bulletLines.remove(lineIndex) + val newText = if (currentLine.startsWith("• ")) { + text.substring(0, lineStart) + + currentLine.removePrefix("• ") + + text.substring(lineStart + currentLine.length) + } else { + text + } + val prefixLen = if (currentLine.startsWith("• ")) 3 else 0 + if (prefixLen > 0) { + adjustRangesForDeletion(lineStart, prefixLen) + } + val newCursor = (cursorPos - prefixLen).coerceIn(0, newText.length) + + isInternalUpdate = true + textFieldValue = TextFieldValue( + annotatedString = buildStyledText(newText), + selection = TextRange(newCursor) + ) + isInternalUpdate = false + } else { + // Add bullet + bulletLines.add(lineIndex) + val bulletPrefix = "• " + val newText = text.substring(0, lineStart) + + bulletPrefix + currentLine + + text.substring(lineStart + currentLine.length) + adjustRangesForInsertion(lineStart, bulletPrefix.length) + val newCursor = (cursorPos + bulletPrefix.length).coerceIn(0, newText.length) + + isInternalUpdate = true + textFieldValue = TextFieldValue( + annotatedString = buildStyledText(newText), + selection = TextRange(newCursor) + ) + isInternalUpdate = false + } + } + + /** + * Check if a style is currently active. + */ + fun isStyleActive(style: RichTextStyle): Boolean = style in activeStyles + + /** + * Check if the current line is a bullet list item. + */ + fun isBulletListActive(): Boolean { + val text = textFieldValue.text + val cursorPos = textFieldValue.selection.start.coerceIn(0, text.length) + val lineIndex = text.substring(0, cursorPos).count { it == '\n' } + return lineIndex in bulletLines + } + + // ═══════════════════════════════ Private helpers ═══════════════════════════════ + + /** + * Build an AnnotatedString by applying our style ranges to plain text. + */ + private fun buildStyledText(text: String): AnnotatedString { + return buildAnnotatedString { + append(text) + for (range in styleRanges) { + val s = range.start.coerceIn(0, text.length) + val e = range.end.coerceIn(0, text.length) + if (s < e) { + addStyle(range.style.spanStyle, s, e) + addStringAnnotation(range.style.tag, "", s, e) + } + } + } + } + + /** + * Find where text was inserted by comparing old and new text. + */ + private fun findInsertionPoint(oldText: String, newText: String, selection: TextRange): Int { + // The cursor is typically right after the insertion + val cursorPos = selection.start + val insertLen = newText.length - oldText.length + return (cursorPos - insertLen).coerceIn(0, oldText.length) + } + + /** + * Find where text was deleted by comparing old and new text. + */ + private fun findDeletionPoint(oldText: String, newText: String, selection: TextRange): Int { + // Find the first position where old and new text differ + val cursorPos = selection.start + return cursorPos.coerceIn(0, newText.length) + } + + /** + * Shift style ranges to account for inserted text. + */ + private fun adjustRangesForInsertion(insertPos: Int, insertLen: Int) { + val adjusted = styleRanges.map { range -> + when { + // Range is entirely before insertion — no change + range.end <= insertPos -> range + // Range is entirely after insertion — shift right + range.start >= insertPos -> range.copy( + start = range.start + insertLen, + end = range.end + insertLen + ) + // Insertion is inside the range — expand end + else -> range.copy(end = range.end + insertLen) + } + } + styleRanges.clear() + styleRanges.addAll(adjusted) + } + + /** + * Shrink/remove style ranges to account for deleted text. + */ + private fun adjustRangesForDeletion(deletePos: Int, deleteLen: Int) { + val deleteEnd = deletePos + deleteLen + val adjusted = styleRanges.mapNotNull { range -> + when { + // Range is entirely before deletion — no change + range.end <= deletePos -> range + // Range is entirely after deletion — shift left + range.start >= deleteEnd -> range.copy( + start = range.start - deleteLen, + end = range.end - deleteLen + ) + // Range is entirely within deletion — remove + range.start >= deletePos && range.end <= deleteEnd -> null + // Deletion overlaps start of range + range.start < deletePos && range.end <= deleteEnd -> { + range.copy(end = deletePos) + } + // Deletion overlaps end of range + range.start >= deletePos && range.end > deleteEnd -> { + range.copy(start = deletePos, end = range.end - deleteLen) + } + // Deletion is inside the range — shrink + range.start < deletePos && range.end > deleteEnd -> { + range.copy(end = range.end - deleteLen) + } + else -> range + } + }.filter { it.start < it.end } + styleRanges.clear() + styleRanges.addAll(adjusted) + } + + /** + * Adjust bullet line indices when text is inserted. + */ + private fun adjustBulletLinesForInsertion( + oldText: String, newText: String, + insertPos: Int, insertLen: Int + ) { + // Count how many newlines were in the inserted text + val insertedText = newText.substring(insertPos, insertPos + insertLen) + val newlineCount = insertedText.count { it == '\n' } + if (newlineCount == 0) return + + // Find which line the insertion happened on + val insertLineIndex = oldText.substring(0, insertPos.coerceIn(0, oldText.length)) + .count { it == '\n' } + + // Shift bullet lines that are after the insertion line + val adjusted = bulletLines.map { lineIdx -> + if (lineIdx > insertLineIndex) lineIdx + newlineCount else lineIdx + } + bulletLines.clear() + bulletLines.addAll(adjusted) + } + + /** + * Adjust bullet line indices when text is deleted. + */ + private fun adjustBulletLinesForDeletion( + oldText: String, newText: String, + deletePos: Int, deleteLen: Int + ) { + val deletedText = oldText.substring(deletePos, (deletePos + deleteLen).coerceIn(0, oldText.length)) + val newlineCount = deletedText.count { it == '\n' } + if (newlineCount == 0) return + + val deleteLineIndex = oldText.substring(0, deletePos.coerceIn(0, oldText.length)) + .count { it == '\n' } + + val adjusted = bulletLines.mapNotNull { lineIdx -> + when { + lineIdx <= deleteLineIndex -> lineIdx + lineIdx > deleteLineIndex + newlineCount -> lineIdx - newlineCount + else -> null // Line was deleted + } + } + bulletLines.clear() + bulletLines.addAll(adjusted) + } + + /** + * Check if all text in [start, end) has the given style. + */ + private fun hasStyleInRange(style: RichTextStyle, start: Int, end: Int): Boolean { + val matching = styleRanges.filter { it.style == style } + // Check if the matching ranges fully cover [start, end) + if (matching.isEmpty()) return false + + val sorted = matching.sortedBy { it.start } + var covered = start + for (range in sorted) { + if (range.start > covered) return false + if (range.end > covered) covered = range.end + if (covered >= end) return true + } + return covered >= end + } + + /** + * Remove a style from the range [start, end), splitting existing ranges if needed. + */ + private fun removeStyleFromRange(style: RichTextStyle, start: Int, end: Int) { + val toRemove = mutableListOf() + val toAdd = mutableListOf() + + for (range in styleRanges) { + if (range.style != style) continue + if (range.end <= start || range.start >= end) continue // No overlap + + toRemove.add(range) + + // Keep portion before the removal range + if (range.start < start) { + toAdd.add(range.copy(end = start)) + } + // Keep portion after the removal range + if (range.end > end) { + toAdd.add(range.copy(start = end)) + } + } + + styleRanges.removeAll(toRemove) + styleRanges.addAll(toAdd) + } + + /** + * Merge overlapping ranges of the same style. + */ + private fun mergeOverlappingRanges() { + for (style in RichTextStyle.entries) { + val matching = styleRanges.filter { it.style == style }.sortedBy { it.start } + if (matching.size <= 1) continue + + val merged = mutableListOf() + var current = matching[0] + + for (i in 1 until matching.size) { + val next = matching[i] + if (next.start <= current.end) { + // Overlapping or adjacent — merge + current = current.copy(end = maxOf(current.end, next.end)) + } else { + merged.add(current) + current = next + } + } + merged.add(current) + + // Replace in styleRanges + styleRanges.removeAll { it.style == style } + styleRanges.addAll(merged) + } + } + + /** + * Update active styles based on cursor position. + */ + private fun updateActiveStylesFromCursor() { + val selection = textFieldValue.selection + if (!selection.collapsed) return // Don't update when there's a selection + + val pos = selection.start + if (pos <= 0) { + // At the beginning, keep activeStyles as-is for user intent + return + } + + val styles = mutableSetOf() + for (range in styleRanges) { + // Cursor is inside this range + if (pos > range.start && pos <= range.end) { + styles.add(range.style) + } + } + activeStyles = styles + } + + /** + * Build markdown for a single line's content by checking style ranges. + */ + private fun buildLineWithMarkdown(content: String, globalOffset: Int): String { + if (content.isEmpty()) return "" + + // Collect all style events within this line + data class StyledSegment(val start: Int, val end: Int, val styles: Set) + + // Build events at each boundary + val boundaries = mutableSetOf(0, content.length) + for (range in styleRanges) { + val relStart = (range.start - globalOffset).coerceIn(0, content.length) + val relEnd = (range.end - globalOffset).coerceIn(0, content.length) + if (relStart < relEnd) { + boundaries.add(relStart) + boundaries.add(relEnd) + } + } + + val sortedBoundaries = boundaries.sorted() + val segments = mutableListOf() + + for (i in 0 until sortedBoundaries.size - 1) { + val segStart = sortedBoundaries[i] + val segEnd = sortedBoundaries[i + 1] + if (segStart >= segEnd) continue + + val styles = mutableSetOf() + for (range in styleRanges) { + val relStart = (range.start - globalOffset).coerceIn(0, content.length) + val relEnd = (range.end - globalOffset).coerceIn(0, content.length) + if (relStart <= segStart && relEnd >= segEnd) { + styles.add(range.style) + } + } + segments.add(StyledSegment(segStart, segEnd, styles)) + } + + val result = StringBuilder() + for (seg in segments) { + val text = content.substring(seg.start, seg.end) + val hasBold = RichTextStyle.Bold in seg.styles + val hasItalic = RichTextStyle.Italic in seg.styles + val hasUnderline = RichTextStyle.Underline in seg.styles + + when { + hasBold && hasItalic -> result.append("***").append(text).append("***") + hasBold -> result.append("**").append(text).append("**") + hasItalic -> result.append("*").append(text).append("*") + hasUnderline -> result.append("__").append(text).append("__") + else -> result.append(text) + } + } + + return result.toString() + } +} diff --git a/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextStyle.kt b/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextStyle.kt new file mode 100644 index 0000000..fdd2d16 --- /dev/null +++ b/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextStyle.kt @@ -0,0 +1,32 @@ +package com.digiventure.ventnote.commons.richtext + +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration + +/** + * Supported rich text styles for the editor. + * Each style defines its markdown markers and corresponding SpanStyle. + */ +enum class RichTextStyle( + val tag: String, + val spanStyle: SpanStyle +) { + Bold( + tag = "BOLD", + spanStyle = SpanStyle(fontWeight = FontWeight.Bold) + ), + Italic( + tag = "ITALIC", + spanStyle = SpanStyle(fontStyle = FontStyle.Italic) + ), + Underline( + tag = "UNDERLINE", + spanStyle = SpanStyle(textDecoration = TextDecoration.Underline) + ); + + companion object { + fun fromTag(tag: String): RichTextStyle? = entries.find { it.tag == tag } + } +} 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 55a46d2..00e9fbc 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 @@ -64,6 +64,9 @@ fun NoteCreationPage( val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() + // Track which field is focused for the formatting toolbar + val activeRichTextState = remember { mutableStateOf(viewModel.richTextState) } + // Optimized state management - using delegation for better performance val requiredDialogState = remember { mutableStateOf(false) } val cancelDialogState = remember { mutableStateOf(false) } @@ -72,7 +75,14 @@ fun NoteCreationPage( // Extracted and optimized addNote function val noteIsSuccessfullyAddedText = stringResource(R.string.successfully_added) fun addNote() { - if (viewModel.titleText.value.isEmpty() || viewModel.descriptionText.value.isEmpty()) { + // Sync both richTextStates before saving + viewModel.titleText.value = viewModel.titleRichTextState.toMarkdown() + viewModel.descriptionText.value = viewModel.richTextState.toMarkdown() + + val titlePlain = viewModel.titleRichTextState.toPlainText() + val bodyPlain = viewModel.richTextState.toPlainText() + + if (titlePlain.isEmpty() || bodyPlain.isEmpty()) { requiredDialogState.value = true } else { scope.launch { @@ -141,17 +151,32 @@ fun NoteCreationPage( verticalArrangement = Arrangement.spacedBy(16.dp) ) { item { - TitleSection(viewModel, titleTextField, titleInput) + TitleSection( + titleRichTextState = viewModel.titleRichTextState, + titleTextField = titleTextField, + titleInput = titleInput, + onFocusChanged = { focused -> + if (focused) activeRichTextState.value = viewModel.titleRichTextState + } + ) } item { - NoteSection(viewModel, bodyTextField, bodyInput) + NoteSection( + richTextState = viewModel.richTextState, + bodyTextField = bodyTextField, + bodyInput = bodyInput, + onFocusChanged = { focused -> + if (focused) activeRichTextState.value = viewModel.richTextState + } + ) } } }, bottomBar = { - EnhancedBottomAppBar { - addNote() - } + EnhancedBottomAppBar( + richTextState = activeRichTextState.value, + onSaveClick = { addNote() } + ) }, modifier = Modifier .semantics { testTag = TestTags.NOTE_CREATION_PAGE } @@ -162,10 +187,12 @@ fun NoteCreationPage( // Optimized missing field calculation val emptyTitleText = stringResource(R.string.empty_note_title_placeholder) val emptyNoteText = stringResource(R.string.empty_note_placeholder) - val missingFieldName = remember(viewModel.titleText.value, viewModel.descriptionText.value) { + val titlePlainText = viewModel.titleRichTextState.toPlainText() + val noteBodyText = viewModel.richTextState.toPlainText() + val missingFieldName = remember(titlePlainText, noteBodyText) { when { - viewModel.titleText.value.isEmpty() -> emptyTitleText - viewModel.descriptionText.value.isEmpty() -> emptyNoteText + titlePlainText.isEmpty() -> emptyTitleText + noteBodyText.isEmpty() -> emptyNoteText else -> EMPTY_STRING } } 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 157ae33..4f93650 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 @@ -6,6 +6,7 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -37,34 +38,42 @@ 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.commons.richtext.FormattingToolbar +import com.digiventure.ventnote.commons.richtext.RichTextState @OptIn(ExperimentalMaterial3Api::class) @Composable fun EnhancedBottomAppBar( + richTextState: RichTextState, onSaveClick: () -> Unit, ) { - BottomAppBar( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 12.dp, - actions = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - EnhancedBottomBarButton( - icon = Icons.Filled.Check, - label = stringResource(R.string.save), - onClick = onSaveClick, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - isProminent = true, - modifier = Modifier.semantics { testTag = TestTags.SAVE_ICON_BUTTON } - ) + Column { + // Formatting toolbar + FormattingToolbar(richTextState = richTextState) + + BottomAppBar( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 12.dp, + actions = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + EnhancedBottomBarButton( + icon = Icons.Filled.Check, + label = stringResource(R.string.save), + onClick = onSaveClick, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + isProminent = true, + modifier = Modifier.semantics { testTag = TestTags.SAVE_ICON_BUTTON } + ) + } } - } - ) + ) + } } @Composable 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 424bd21..2f95eea 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 @@ -1,48 +1,33 @@ package com.digiventure.ventnote.feature.note_creation.components.section -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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 +import com.digiventure.ventnote.commons.richtext.RichTextEditor +import com.digiventure.ventnote.commons.richtext.RichTextState @Composable fun NoteSection( - viewModel: NoteCreationPageBaseVM, + richTextState: RichTextState, bodyTextField: String, - bodyInput: String + bodyInput: String, + onFocusChanged: (Boolean) -> Unit = {} ) { Column { Row( @@ -64,75 +49,14 @@ fun NoteSection( ) } - ImprovedDescriptionTextField( - viewModel = viewModel, - bodyTextField = bodyTextField, - bodyInput = bodyInput - ) - } -} - -@Composable -fun ImprovedDescriptionTextField( - viewModel: NoteCreationPageBaseVM, - bodyTextField: String, - bodyInput: String -) { - val label = "border_color" - val borderColor by animateColorAsState( - targetValue = MaterialTheme.colorScheme.primary, - animationSpec = tween(300), - label = label - ) - - Card( - modifier = Modifier - .fillMaxWidth() - .animateContentSize() - .heightIn(min = 200.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation( - defaultElevation = 4.dp - ), - border = BorderStroke( - width = 2.dp, - color = borderColor - ) - ) { - TextField( - value = viewModel.descriptionText.value, - onValueChange = { viewModel.descriptionText.value = it }, - textStyle = MaterialTheme.typography.titleMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - singleLine = false, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ), - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .semantics { - contentDescription = bodyTextField - testTag = TestTags.BODY_TEXT_FIELD - }, - placeholder = { - Text( - text = bodyInput, - style = MaterialTheme.typography.titleMedium.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ), - ) - } + RichTextEditor( + richTextState = richTextState, + isEditing = true, + readOnly = false, + placeholder = bodyInput, + contentDescriptionText = bodyTextField, + testTagText = TestTags.BODY_TEXT_FIELD, + onFocusChanged = onFocusChanged ) } } \ No newline at end of file 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 4c2c4b8..b6ce052 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 @@ -3,44 +3,34 @@ package com.digiventure.ventnote.feature.note_creation.components.section import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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 +import com.digiventure.ventnote.commons.richtext.RichTextEditor +import com.digiventure.ventnote.commons.richtext.RichTextState @Composable fun TitleSection( - viewModel: NoteCreationPageBaseVM, + titleRichTextState: RichTextState, titleTextField: String, - titleInput: String + titleInput: String, + onFocusChanged: (Boolean) -> Unit = {} ) { Column { Row( @@ -62,74 +52,15 @@ fun TitleSection( ) } - ImprovedTitleTextField( - viewModel = viewModel, - titleTextField = titleTextField, - titleInput = titleInput - ) - } -} - -@Composable -fun ImprovedTitleTextField( - viewModel: NoteCreationPageBaseVM, - titleTextField: String, - titleInput: String -) { - val label = "border_color" - val borderColor by animateColorAsState( - targetValue = MaterialTheme.colorScheme.primary, - animationSpec = tween(300), - label = label - ) - - Card( - modifier = Modifier - .fillMaxWidth() - .animateContentSize(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation( - defaultElevation = 4.dp - ), - border = BorderStroke( - width = 2.dp, - color = borderColor - ) - ) { - TextField( - value = viewModel.titleText.value, - onValueChange = { viewModel.titleText.value = it }, - textStyle = MaterialTheme.typography.titleMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - singleLine = false, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ), - modifier = Modifier - .fillMaxWidth() - .semantics { - contentDescription = titleTextField - testTag = TestTags.TITLE_TEXT_FIELD - }, - placeholder = { - Text( - text = titleInput, - style = MaterialTheme.typography.titleMedium.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - fontWeight = FontWeight.Medium, - ), - ) - } + RichTextEditor( + richTextState = titleRichTextState, + isEditing = true, + readOnly = false, + placeholder = titleInput, + contentDescriptionText = titleTextField, + testTagText = TestTags.TITLE_TEXT_FIELD, + minHeight = 56.dp, + onFocusChanged = onFocusChanged ) } } \ No newline at end of file diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/viewmodel/NoteCreationPageBaseVM.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/viewmodel/NoteCreationPageBaseVM.kt index 511cc74..efada5e 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/viewmodel/NoteCreationPageBaseVM.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/viewmodel/NoteCreationPageBaseVM.kt @@ -2,6 +2,7 @@ package com.digiventure.ventnote.feature.note_creation.viewmodel import androidx.compose.runtime.MutableState import androidx.lifecycle.MutableLiveData +import com.digiventure.ventnote.commons.richtext.RichTextState import com.digiventure.ventnote.data.persistence.NoteModel interface NoteCreationPageBaseVM { @@ -16,6 +17,12 @@ interface NoteCreationPageBaseVM { val titleText: MutableState val descriptionText: MutableState + /** + * Rich text state for the title and body editors + * */ + val titleRichTextState: RichTextState + val richTextState: RichTextState + /** * create note * @param note is a note model diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/viewmodel/NoteCreationPageMockVM.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/viewmodel/NoteCreationPageMockVM.kt index e0ba8a4..279bc30 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_creation/viewmodel/NoteCreationPageMockVM.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_creation/viewmodel/NoteCreationPageMockVM.kt @@ -4,12 +4,15 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.digiventure.ventnote.commons.richtext.RichTextState import com.digiventure.ventnote.data.persistence.NoteModel class NoteCreationPageMockVM: ViewModel(), NoteCreationPageBaseVM { override val loader: MutableLiveData = MutableLiveData(false) override val titleText: MutableState = mutableStateOf("") override val descriptionText: MutableState = mutableStateOf("") + override val titleRichTextState: RichTextState = RichTextState() + override val richTextState: RichTextState = RichTextState() override suspend fun addNote(note: NoteModel): Result { return Result.success(true) 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 9847fb7..b86d905 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 @@ -4,6 +4,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.digiventure.ventnote.commons.richtext.RichTextState import com.digiventure.ventnote.data.persistence.NoteModel import com.digiventure.ventnote.data.persistence.NoteRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -20,6 +21,8 @@ class NoteCreationPageVM @Inject constructor( override val loader: MutableLiveData = MutableLiveData() override val titleText: MutableState = mutableStateOf("") override val descriptionText: MutableState = mutableStateOf("") + override val titleRichTextState: RichTextState = RichTextState() + override val richTextState: RichTextState = RichTextState() override suspend fun addNote(note: NoteModel): Result = withContext(Dispatchers.IO) { loader.postValue(true) 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 49688b2..01991ca 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 @@ -91,6 +91,9 @@ fun NoteDetailPage( val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() + // Track which field is focused for the formatting toolbar + val activeRichTextState = remember { mutableStateOf(viewModel.richTextState) } + // Dialog states - using derivedStateOf where appropriate val requiredDialogState = remember { mutableStateOf(false) } val deleteDialogState = remember { mutableStateOf(false) } @@ -103,6 +106,8 @@ fun NoteDetailPage( data?.let { viewModel.titleText.value = it.title viewModel.descriptionText.value = it.note + viewModel.titleRichTextState.setFromMarkdown(it.title) + viewModel.richTextState.setFromMarkdown(it.note) } } @@ -137,10 +142,16 @@ fun NoteDetailPage( strings["successFullyUpdatedText"] ) { { + // Sync both richTextStates before saving + viewModel.titleText.value = viewModel.titleRichTextState.toMarkdown() + viewModel.descriptionText.value = viewModel.richTextState.toMarkdown() + val titleText = viewModel.titleText.value val descriptionText = viewModel.descriptionText.value + val titlePlain = viewModel.titleRichTextState.toPlainText() + val bodyPlain = viewModel.richTextState.toPlainText() - if (titleText.isEmpty() || descriptionText.isEmpty()) { + if (titlePlain.isEmpty() || bodyPlain.isEmpty()) { requiredDialogState.value = true } else { data?.let { noteData -> @@ -225,19 +236,25 @@ fun NoteDetailPage( ) { item { TitleSection( - viewModel = viewModel, + titleRichTextState = viewModel.titleRichTextState, isEditingState = isEditingState, titleTextField = strings["titleTextField"] ?: EMPTY_STRING, - titleInput = strings["titleInput"] ?: EMPTY_STRING + titleInput = strings["titleInput"] ?: EMPTY_STRING, + onFocusChanged = { focused -> + if (focused) activeRichTextState.value = viewModel.titleRichTextState + } ) } item { NoteSection( - viewModel = viewModel, + richTextState = viewModel.richTextState, isEditingState = isEditingState, bodyTextField = strings["bodyTextField"] ?: EMPTY_STRING, - bodyInput = strings["bodyInput"] ?: EMPTY_STRING + bodyInput = strings["bodyInput"] ?: EMPTY_STRING, + onFocusChanged = { focused -> + if (focused) activeRichTextState.value = viewModel.richTextState + } ) } } @@ -245,6 +262,7 @@ fun NoteDetailPage( bottomBar = { EnhancedBottomAppBar( isEditing = isEditingState, + richTextState = activeRichTextState.value, onEditClick = { haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) viewModel.isEditing.value = true @@ -264,13 +282,15 @@ fun NoteDetailPage( val titlePlaceholderText = stringResource(R.string.title_textField_input) val notePlaceholderText = stringResource(R.string.body_textField_input) + val titlePlainText = viewModel.titleRichTextState.toPlainText() + val noteBodyText = viewModel.richTextState.toPlainText() val missingFieldName = remember( - viewModel.titleText.value, - viewModel.descriptionText.value + titlePlainText, + noteBodyText ) { when { - viewModel.titleText.value.isEmpty() -> titlePlaceholderText - viewModel.descriptionText.value.isEmpty() -> notePlaceholderText + titlePlainText.isEmpty() -> titlePlaceholderText + noteBodyText.isEmpty() -> notePlaceholderText else -> EMPTY_STRING } } 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 69b775a..b506bc4 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 @@ -6,6 +6,7 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -18,8 +19,6 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit - - import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -42,73 +41,83 @@ 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.commons.richtext.FormattingToolbar +import com.digiventure.ventnote.commons.richtext.RichTextState @OptIn(ExperimentalMaterial3Api::class) @Composable fun EnhancedBottomAppBar( isEditing: Boolean, + richTextState: RichTextState, onEditClick: () -> Unit, onSaveClick: () -> Unit, onDeleteClick: () -> Unit, onCancelClick: () -> Unit ) { - BottomAppBar( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 12.dp, - actions = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - if (isEditing) { - // Cancel button in editing mode - EnhancedBottomBarButton( - icon = Icons.Filled.Close, - label = stringResource(R.string.cancel), - onClick = onCancelClick, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.semantics { testTag = TestTags.CANCEL_ICON_BUTTON } - ) - - // Save button in editing mode - EnhancedBottomBarButton( - icon = Icons.Filled.Check, - label = stringResource(R.string.save), - onClick = onSaveClick, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - isProminent = true, - modifier = Modifier.semantics { testTag = TestTags.SAVE_ICON_BUTTON } - ) - } else { - // Edit button in view mode - EnhancedBottomBarButton( - icon = Icons.Filled.Edit, - label = stringResource(R.string.edit), - onClick = onEditClick, - containerColor = MaterialTheme.colorScheme.secondary, - 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, - modifier = Modifier.semantics { testTag = TestTags.DELETE_ICON_BUTTON } - ) + Column { + // Show formatting toolbar only when editing + if (isEditing) { + FormattingToolbar(richTextState = richTextState) + } + + BottomAppBar( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 12.dp, + actions = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + if (isEditing) { + // Cancel button in editing mode + EnhancedBottomBarButton( + icon = Icons.Filled.Close, + label = stringResource(R.string.cancel), + onClick = onCancelClick, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.semantics { testTag = TestTags.CANCEL_ICON_BUTTON } + ) + + // Save button in editing mode + EnhancedBottomBarButton( + icon = Icons.Filled.Check, + label = stringResource(R.string.save), + onClick = onSaveClick, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + isProminent = true, + modifier = Modifier.semantics { testTag = TestTags.SAVE_ICON_BUTTON } + ) + } else { + // Edit button in view mode + EnhancedBottomBarButton( + icon = Icons.Filled.Edit, + label = stringResource(R.string.edit), + onClick = onEditClick, + containerColor = MaterialTheme.colorScheme.secondary, + 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, + modifier = Modifier.semantics { testTag = TestTags.DELETE_ICON_BUTTON } + ) + } } } - } - ) + ) + } } - + @Composable private fun EnhancedBottomBarButton( icon: ImageVector, @@ -125,7 +134,7 @@ private fun EnhancedBottomBarButton( animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), label = "button_scale" ) - + Row ( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), 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 35f1c76..f1d8ae2 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 @@ -1,49 +1,34 @@ package com.digiventure.ventnote.feature.note_detail.components.section -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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 +import com.digiventure.ventnote.commons.richtext.RichTextEditor +import com.digiventure.ventnote.commons.richtext.RichTextState @Composable fun NoteSection( - viewModel: NoteDetailPageBaseVM, + richTextState: RichTextState, isEditingState: Boolean, bodyTextField: String, - bodyInput: String + bodyInput: String, + onFocusChanged: (Boolean) -> Unit = {} ) { Column { Row( @@ -65,84 +50,14 @@ fun NoteSection( ) } - ImprovedDescriptionTextField( - viewModel = viewModel, - isEditingState = isEditingState, - bodyTextField = bodyTextField, - bodyInput = bodyInput - ) - } -} - -@Composable -fun ImprovedDescriptionTextField( - viewModel: NoteDetailPageBaseVM, - isEditingState: Boolean, - bodyTextField: String, - bodyInput: String -) { - val label = "border_color" - val borderColor by animateColorAsState( - targetValue = if (isEditingState) MaterialTheme.colorScheme.primary else Color.Transparent, - animationSpec = tween(300), - label = label - ) - - Card( - modifier = Modifier - .fillMaxWidth() - .animateContentSize() - .heightIn(min = 200.dp), - colors = CardDefaults.cardColors( - containerColor = if (isEditingState) { - MaterialTheme.colorScheme.surface - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - } - ), - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation( - defaultElevation = if (isEditingState) 4.dp else 0.dp - ), - border = BorderStroke( - width = if (isEditingState) 2.dp else 0.dp, - color = borderColor - ) - ) { - TextField( - value = viewModel.descriptionText.value, - onValueChange = { viewModel.descriptionText.value = it }, - textStyle = MaterialTheme.typography.titleMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - singleLine = false, + RichTextEditor( + richTextState = richTextState, + isEditing = isEditingState, readOnly = !isEditingState, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ), - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .semantics { - contentDescription = bodyTextField - testTag = TestTags.BODY_TEXT_FIELD - }, - placeholder = { - if (isEditingState) { - Text( - text = bodyInput, - style = MaterialTheme.typography.titleMedium.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ), - ) - } - } + placeholder = if (isEditingState) bodyInput else "", + contentDescriptionText = bodyTextField, + testTagText = TestTags.BODY_TEXT_FIELD, + onFocusChanged = onFocusChanged ) } } \ No newline at end of file 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 e789ec6..b65c016 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 @@ -3,45 +3,35 @@ package com.digiventure.ventnote.feature.note_detail.components.section import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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 +import com.digiventure.ventnote.commons.richtext.RichTextEditor +import com.digiventure.ventnote.commons.richtext.RichTextState @Composable fun TitleSection( - viewModel: NoteDetailPageBaseVM, + titleRichTextState: RichTextState, isEditingState: Boolean, titleTextField: String, - titleInput: String + titleInput: String, + onFocusChanged: (Boolean) -> Unit = {} ) { Column { Row( @@ -63,83 +53,15 @@ fun TitleSection( ) } - ImprovedTitleTextField( - viewModel = viewModel, - isEditingState = isEditingState, - titleTextField = titleTextField, - titleInput = titleInput - ) - } -} - -@Composable -fun ImprovedTitleTextField( - viewModel: NoteDetailPageBaseVM, - isEditingState: Boolean, - titleTextField: String, - titleInput: String -) { - val label = "border_color" - val borderColor by animateColorAsState( - targetValue = if (isEditingState) MaterialTheme.colorScheme.primary else Color.Transparent, - animationSpec = tween(300), - label = label - ) - - Card( - modifier = Modifier - .fillMaxWidth() - .animateContentSize(), - colors = CardDefaults.cardColors( - containerColor = if (isEditingState) { - MaterialTheme.colorScheme.surface - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - } - ), - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation( - defaultElevation = if (isEditingState) 4.dp else 0.dp - ), - border = BorderStroke( - width = if (isEditingState) 2.dp else 0.dp, - color = borderColor - ) - ) { - TextField( - value = viewModel.titleText.value, - onValueChange = { viewModel.titleText.value = it }, - textStyle = MaterialTheme.typography.titleMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - singleLine = false, + RichTextEditor( + richTextState = titleRichTextState, + isEditing = isEditingState, readOnly = !isEditingState, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ), - modifier = Modifier - .fillMaxWidth() - .semantics { - contentDescription = titleTextField - testTag = TestTags.TITLE_TEXT_FIELD - }, - placeholder = { - if (isEditingState) { - Text( - text = titleInput, - style = MaterialTheme.typography.titleMedium.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - fontWeight = FontWeight.Medium, - ), - ) - } - } + placeholder = if (isEditingState) titleInput else "", + contentDescriptionText = titleTextField, + testTagText = TestTags.TITLE_TEXT_FIELD, + minHeight = 56.dp, + onFocusChanged = onFocusChanged ) } } \ No newline at end of file diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/viewmodel/NoteDetailPageBaseVM.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/viewmodel/NoteDetailPageBaseVM.kt index c3278ed..9b85e4f 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/viewmodel/NoteDetailPageBaseVM.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/viewmodel/NoteDetailPageBaseVM.kt @@ -2,6 +2,7 @@ package com.digiventure.ventnote.feature.note_detail.viewmodel import androidx.compose.runtime.MutableState import androidx.lifecycle.MutableLiveData +import com.digiventure.ventnote.commons.richtext.RichTextState import com.digiventure.ventnote.data.persistence.NoteModel interface NoteDetailPageBaseVM { @@ -21,6 +22,12 @@ interface NoteDetailPageBaseVM { var titleText: MutableState var descriptionText: MutableState + /** + * Rich text state for the title and body editors + * */ + val titleRichTextState: RichTextState + val richTextState: RichTextState + /** * State for handling isEditing * */ diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/viewmodel/NoteDetailPageMockVM.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/viewmodel/NoteDetailPageMockVM.kt index 7b78516..a6d13b1 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/viewmodel/NoteDetailPageMockVM.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/viewmodel/NoteDetailPageMockVM.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.digiventure.ventnote.commons.richtext.RichTextState import com.digiventure.ventnote.data.persistence.NoteModel @@ -13,6 +14,8 @@ class NoteDetailPageMockVM: ViewModel(), NoteDetailPageBaseVM { override var titleText: MutableState = mutableStateOf("This is sample title text") override var descriptionText: MutableState = mutableStateOf("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus pretium odio maximus tellus pellentesque, a dignissim massa commodo.\n") + override val titleRichTextState: RichTextState = RichTextState() + override val richTextState: RichTextState = RichTextState() override var isEditing: MutableState = mutableStateOf(false) init { diff --git a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/viewmodel/NoteDetailPageVM.kt b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/viewmodel/NoteDetailPageVM.kt index cdaf90f..6e7fd83 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/note_detail/viewmodel/NoteDetailPageVM.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/note_detail/viewmodel/NoteDetailPageVM.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.digiventure.ventnote.commons.richtext.RichTextState import com.digiventure.ventnote.data.persistence.NoteModel import com.digiventure.ventnote.data.persistence.NoteRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -22,6 +23,8 @@ class NoteDetailPageVM @Inject constructor( override var titleText: MutableState = mutableStateOf("") override var descriptionText: MutableState = mutableStateOf("") + override val titleRichTextState: RichTextState = RichTextState() + override val richTextState: RichTextState = RichTextState() override var isEditing: MutableState = mutableStateOf(false) diff --git a/app/src/test/java/com/digiventure/ventnote/commons/richtext/MarkdownParserTest.kt b/app/src/test/java/com/digiventure/ventnote/commons/richtext/MarkdownParserTest.kt new file mode 100644 index 0000000..992648b --- /dev/null +++ b/app/src/test/java/com/digiventure/ventnote/commons/richtext/MarkdownParserTest.kt @@ -0,0 +1,167 @@ +package com.digiventure.ventnote.commons.richtext + +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkdownParserTest { + + @Test + fun `plain text roundtrip preserves content`() { + val input = "Hello world" + val annotated = MarkdownParser.parseToAnnotatedString(input) + assertEquals("Hello world", annotated.text) + + val markdown = MarkdownParser.toMarkdown(annotated) + assertEquals(input, markdown) + } + + @Test + fun `empty string returns empty`() { + val annotated = MarkdownParser.parseToAnnotatedString("") + assertEquals("", annotated.text) + + val markdown = MarkdownParser.toMarkdown(annotated) + assertEquals("", markdown) + } + + @Test + fun `bold text is parsed correctly`() { + val input = "Hello **world**" + val annotated = MarkdownParser.parseToAnnotatedString(input) + assertEquals("Hello world", annotated.text) + + // Check that "world" has bold span style + val spanStyles = annotated.spanStyles + assertTrue(spanStyles.any { + it.item.fontWeight == FontWeight.Bold && + it.start == 6 && it.end == 11 + }) + } + + @Test + fun `italic text is parsed correctly`() { + val input = "Hello *world*" + val annotated = MarkdownParser.parseToAnnotatedString(input) + assertEquals("Hello world", annotated.text) + + val spanStyles = annotated.spanStyles + assertTrue(spanStyles.any { + it.item.fontStyle == FontStyle.Italic && + it.start == 6 && it.end == 11 + }) + } + + @Test + fun `underline text is parsed correctly`() { + val input = "Hello __world__" + val annotated = MarkdownParser.parseToAnnotatedString(input) + assertEquals("Hello world", annotated.text) + + val spanStyles = annotated.spanStyles + assertTrue(spanStyles.any { + it.item.textDecoration == TextDecoration.Underline && + it.start == 6 && it.end == 11 + }) + } + + @Test + fun `bold italic text is parsed correctly`() { + val input = "Hello ***world***" + val annotated = MarkdownParser.parseToAnnotatedString(input) + assertEquals("Hello world", annotated.text) + + val spanStyles = annotated.spanStyles + assertTrue(spanStyles.any { + it.item.fontWeight == FontWeight.Bold && + it.start == 6 && it.end == 11 + }) + assertTrue(spanStyles.any { + it.item.fontStyle == FontStyle.Italic && + it.start == 6 && it.end == 11 + }) + } + + @Test + fun `bullet list is parsed correctly`() { + val input = "- Buy groceries\n- Clean house" + val annotated = MarkdownParser.parseToAnnotatedString(input) + + // Bullet prefix should be replaced with bullet character + assertTrue(annotated.text.contains("•")) + assertTrue(annotated.text.contains("Buy groceries")) + assertTrue(annotated.text.contains("Clean house")) + + // Check bullet annotations exist + val bulletAnnotations = annotated.getStringAnnotations( + MarkdownParser.BULLET_TAG, 0, annotated.length + ) + assertEquals(2, bulletAnnotations.size) + } + + @Test + fun `mixed content with bullet and formatting`() { + val input = "- **Bold item**\n- *Italic item*" + val annotated = MarkdownParser.parseToAnnotatedString(input) + + assertTrue(annotated.text.contains("Bold item")) + assertTrue(annotated.text.contains("Italic item")) + + // Should have bold styling + val spanStyles = annotated.spanStyles + assertTrue(spanStyles.any { it.item.fontWeight == FontWeight.Bold }) + assertTrue(spanStyles.any { it.item.fontStyle == FontStyle.Italic }) + } + + @Test + fun `multiline text preserves newlines`() { + val input = "Line one\nLine two\nLine three" + val annotated = MarkdownParser.parseToAnnotatedString(input) + assertEquals("Line one\nLine two\nLine three", annotated.text) + } + + @Test + fun `bold text roundtrip`() { + val input = "Hello **bold** world" + val annotated = MarkdownParser.parseToAnnotatedString(input) + val markdown = MarkdownParser.toMarkdown(annotated) + assertEquals(input, markdown) + } + + @Test + fun `italic text roundtrip`() { + val input = "Hello *italic* world" + val annotated = MarkdownParser.parseToAnnotatedString(input) + val markdown = MarkdownParser.toMarkdown(annotated) + assertEquals(input, markdown) + } + + @Test + fun `underline text roundtrip`() { + val input = "Hello __underline__ world" + val annotated = MarkdownParser.parseToAnnotatedString(input) + val markdown = MarkdownParser.toMarkdown(annotated) + assertEquals(input, markdown) + } + + @Test + fun `unclosed markers treated as plain text`() { + val input = "Hello **world" + val annotated = MarkdownParser.parseToAnnotatedString(input) + // Unclosed markers should be treated as plain text + assertTrue(annotated.text.contains("*")) + } + + @Test + fun `text without formatting has no spans`() { + val input = "Just plain text here" + val annotated = MarkdownParser.parseToAnnotatedString(input) + assertEquals("Just plain text here", annotated.text) + assertTrue(annotated.spanStyles.isEmpty()) + } +} From fd771f1d4dfd5e1e225e4ff94b264bc7c04ff6d4 Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Sun, 22 Feb 2026 00:02:11 +0700 Subject: [PATCH 12/16] Feat richtext-editor-1.3.0: Implement Markdown to Spannable conversion and add development scripts, with related updates to note components and strings. --- .../commons/richtext/MarkdownToSpannable.kt | 129 ++++++++++++++++++ .../feature/notes/components/item/NoteItem.kt | 5 +- .../feature/widget/NoteWidgetFactory.kt | 9 +- build_debug.sh | 15 ++ run_app.sh | 16 +++ run_emulator.sh | 8 ++ 6 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/digiventure/ventnote/commons/richtext/MarkdownToSpannable.kt create mode 100755 build_debug.sh create mode 100755 run_app.sh create mode 100755 run_emulator.sh diff --git a/app/src/main/java/com/digiventure/ventnote/commons/richtext/MarkdownToSpannable.kt b/app/src/main/java/com/digiventure/ventnote/commons/richtext/MarkdownToSpannable.kt new file mode 100644 index 0000000..b7e2ca4 --- /dev/null +++ b/app/src/main/java/com/digiventure/ventnote/commons/richtext/MarkdownToSpannable.kt @@ -0,0 +1,129 @@ +package com.digiventure.ventnote.commons.richtext + +import android.graphics.Typeface +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan + +/** + * Converts a markdown string to an Android SpannableString for use with + * traditional Views (e.g. RemoteViews in widgets). + * + * This is the View-system equivalent of [MarkdownParser.parseToAnnotatedString], + * which produces a Compose AnnotatedString. Both parse the same markdown format. + */ +object MarkdownToSpannable { + + /** + * Convert a markdown string to a SpannableStringBuilder suitable for + * RemoteViews.setTextViewText() and standard TextViews. + */ + fun convert(markdown: String): SpannableStringBuilder { + if (markdown.isEmpty()) return SpannableStringBuilder("") + + val result = SpannableStringBuilder() + val lines = markdown.split("\n") + + lines.forEachIndexed { lineIndex, line -> + val isBullet = line.startsWith("- ") + val contentLine = if (isBullet) line.removePrefix("- ") else line + + if (isBullet) { + result.append("• ") + } + + parseInlineStyles(result, contentLine) + + if (lineIndex < lines.lastIndex) { + result.append("\n") + } + } + + return result + } + + private fun parseInlineStyles(builder: SpannableStringBuilder, text: String) { + var i = 0 + while (i < text.length) { + when { + // Bold+Italic: ***text*** + text.startsWith("***", i) -> { + val endIdx = text.indexOf("***", i + 3) + if (endIdx != -1) { + val content = text.substring(i + 3, endIdx) + val start = builder.length + builder.append(content) + builder.setSpan( + StyleSpan(Typeface.BOLD_ITALIC), + start, builder.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + i = endIdx + 3 + } else { + builder.append(text[i]) + i++ + } + } + // Bold: **text** + text.startsWith("**", i) -> { + val endIdx = text.indexOf("**", i + 2) + if (endIdx != -1) { + val content = text.substring(i + 2, endIdx) + val start = builder.length + builder.append(content) + builder.setSpan( + StyleSpan(Typeface.BOLD), + start, builder.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + i = endIdx + 2 + } else { + builder.append(text[i]) + i++ + } + } + // Underline: __text__ + text.startsWith("__", i) -> { + val endIdx = text.indexOf("__", i + 2) + if (endIdx != -1) { + val content = text.substring(i + 2, endIdx) + val start = builder.length + builder.append(content) + builder.setSpan( + UnderlineSpan(), + start, builder.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + i = endIdx + 2 + } else { + builder.append(text[i]) + i++ + } + } + // Italic: *text* + text.startsWith("*", i) -> { + val endIdx = text.indexOf("*", i + 1) + if (endIdx != -1) { + val content = text.substring(i + 1, endIdx) + val start = builder.length + builder.append(content) + builder.setSpan( + StyleSpan(Typeface.ITALIC), + start, builder.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + i = endIdx + 1 + } else { + builder.append(text[i]) + i++ + } + } + else -> { + builder.append(text[i]) + i++ + } + } + } + } +} diff --git a/app/src/main/java/com/digiventure/ventnote/feature/notes/components/item/NoteItem.kt b/app/src/main/java/com/digiventure/ventnote/feature/notes/components/item/NoteItem.kt index cefd627..b6bb59d 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/notes/components/item/NoteItem.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/notes/components/item/NoteItem.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.digiventure.ventnote.commons.DateUtil +import com.digiventure.ventnote.commons.richtext.MarkdownParser import com.digiventure.ventnote.components.navbar.TopNavBarIcon import com.digiventure.ventnote.data.persistence.NoteModel @@ -74,7 +75,7 @@ fun NotesItem( } } Text( - text = data.title, + text = MarkdownParser.parseToAnnotatedString(data.title), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium.copy( @@ -96,7 +97,7 @@ fun NotesItem( ) { Column { Text( - text = data.note, + text = MarkdownParser.parseToAnnotatedString(data.note), maxLines = 4, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium.copy( diff --git a/app/src/main/java/com/digiventure/ventnote/feature/widget/NoteWidgetFactory.kt b/app/src/main/java/com/digiventure/ventnote/feature/widget/NoteWidgetFactory.kt index 38f61ee..3ff5328 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/widget/NoteWidgetFactory.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/widget/NoteWidgetFactory.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.widget.RemoteViews import android.widget.RemoteViewsService import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.richtext.MarkdownToSpannable import com.digiventure.ventnote.module.proxy.DatabaseProxy import com.digiventure.ventnote.data.persistence.NoteModel @@ -33,8 +34,12 @@ class NoteWidgetFactory( val note = notes[position] val views = RemoteViews(context.packageName, R.layout.note_widget_item) - views.setTextViewText(R.id.widget_item_title, note.title) - views.setTextViewText(R.id.widget_item_content, note.note) + // Convert markdown to SpannableString for rich text rendering in widget + val styledTitle = MarkdownToSpannable.convert(note.title) + val styledContent = MarkdownToSpannable.convert(note.note) + + views.setTextViewText(R.id.widget_item_title, styledTitle) + views.setTextViewText(R.id.widget_item_content, styledContent) // Fill in specific data for the click template val fillInIntent = Intent().apply { diff --git a/build_debug.sh b/build_debug.sh new file mode 100755 index 0000000..1f83c07 --- /dev/null +++ b/build_debug.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Script to build the VentNote debug APK + +echo "Building debug APK..." +./gradlew assembleDebug + +if [ $? -eq 0 ]; then + echo "=====================================" + echo "Build successful!" + echo "APK location: app/build/outputs/apk/debug/app-debug.apk" +else + echo "=====================================" + echo "Build failed. Please check the errors above." + exit 1 +fi diff --git a/run_app.sh b/run_app.sh new file mode 100755 index 0000000..64d2229 --- /dev/null +++ b/run_app.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Script to build and run the VentNote app on the connected device/emulator + +echo "Building and installing debug APK..." +./gradlew installDebug + +if [ $? -eq 0 ]; then + echo "=====================================" + echo "Build successful! Launching the app..." + adb shell am start -n com.digiventure.ventnote/.MainActivity + echo "App launched successfully." +else + echo "=====================================" + echo "Build failed. Please check the errors above." + exit 1 +fi diff --git a/run_emulator.sh b/run_emulator.sh new file mode 100755 index 0000000..81e0341 --- /dev/null +++ b/run_emulator.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Script to run the Android Emulator +# By default, it uses Pixel_8_Pro as the AVD. You can pass a different AVD name as an argument. + +AVD_NAME=${1:-Pixel_8_Pro} + +echo "Starting emulator with AVD: $AVD_NAME..." +~/Library/Android/sdk/emulator/emulator -avd "$AVD_NAME" From 1b15229bcc44e81c8ec422c8b51d5bda04efca34 Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Sun, 22 Feb 2026 15:32:36 +0700 Subject: [PATCH 13/16] Feat ui-redesign-1.3.0: Revamp the whole style to flat design --- .../commons/richtext/RichTextEditor.kt | 6 +- .../bottomSheet/RegularBottomSheet.kt | 3 + .../components/dialog/LoadingDialog.kt | 2 +- .../ventnote/components/dialog/TextDialog.kt | 6 +- .../ventnote/feature/backup/BackupPage.kt | 2 +- .../backup/components/button/SignInButton.kt | 6 +- .../backup/components/list/BackupFileList.kt | 82 ++++++------ .../drawer/components/NavDrawerItem.kt | 2 +- .../ventnote/feature/notes/NotesPage.kt | 5 +- .../feature/notes/components/item/NoteItem.kt | 16 +-- .../ventnote/ui/theme/components/Shape.kt | 18 +++ .../ventnote/ui/theme/components/Theme.kt | 1 + .../ventnote/ui/theme/components/Type.kt | 118 +++++++++++++++++- 13 files changed, 196 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/com/digiventure/ventnote/ui/theme/components/Shape.kt diff --git a/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextEditor.kt b/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextEditor.kt index 886e7e9..acd8460 100644 --- a/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextEditor.kt +++ b/app/src/main/java/com/digiventure/ventnote/commons/richtext/RichTextEditor.kt @@ -62,12 +62,12 @@ fun RichTextEditor( MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) } ), - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.small, elevation = CardDefaults.cardElevation( - defaultElevation = if (isEditing) 4.dp else 0.dp + defaultElevation = 0.dp ), border = BorderStroke( - width = if (isEditing) 2.dp else 0.dp, + width = if (isEditing) 1.dp else 0.dp, color = borderColor ) ) { diff --git a/app/src/main/java/com/digiventure/ventnote/components/bottomSheet/RegularBottomSheet.kt b/app/src/main/java/com/digiventure/ventnote/components/bottomSheet/RegularBottomSheet.kt index c36bcc8..e819fc0 100644 --- a/app/src/main/java/com/digiventure/ventnote/components/bottomSheet/RegularBottomSheet.kt +++ b/app/src/main/java/com/digiventure/ventnote/components/bottomSheet/RegularBottomSheet.kt @@ -6,6 +6,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -22,6 +23,8 @@ fun RegularBottomSheet( sheetState = bottomSheetState, modifier = modifier ?: Modifier, containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 0.dp, + shape = MaterialTheme.shapes.large, ) { content() } diff --git a/app/src/main/java/com/digiventure/ventnote/components/dialog/LoadingDialog.kt b/app/src/main/java/com/digiventure/ventnote/components/dialog/LoadingDialog.kt index b341f93..85db197 100644 --- a/app/src/main/java/com/digiventure/ventnote/components/dialog/LoadingDialog.kt +++ b/app/src/main/java/com/digiventure/ventnote/components/dialog/LoadingDialog.kt @@ -30,7 +30,7 @@ fun LoadingDialog( BasicAlertDialog(onDismissRequest = { onDismissCallback() }, modifier = modifier, content = { - Surface(shape = RoundedCornerShape(8.dp)) { + Surface(shape = MaterialTheme.shapes.medium) { Row( modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.Center, diff --git a/app/src/main/java/com/digiventure/ventnote/components/dialog/TextDialog.kt b/app/src/main/java/com/digiventure/ventnote/components/dialog/TextDialog.kt index 1de1bd3..74a1ae2 100644 --- a/app/src/main/java/com/digiventure/ventnote/components/dialog/TextDialog.kt +++ b/app/src/main/java/com/digiventure/ventnote/components/dialog/TextDialog.kt @@ -53,7 +53,7 @@ fun TextDialog( if (onConfirmCallback != null) { TextButton( onClick = { onConfirmCallback() }, - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.small, modifier = Modifier.semantics { testTag = TestTags.CONFIRM_BUTTON } ) { Text( @@ -68,7 +68,7 @@ fun TextDialog( dismissButton = { TextButton( onClick = { onDismissCallback() }, - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.small, modifier = Modifier.semantics { testTag = TestTags.DISMISS_BUTTON } ) { Text( @@ -80,7 +80,7 @@ fun TextDialog( } }, containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.medium, modifier = modifier ) } diff --git a/app/src/main/java/com/digiventure/ventnote/feature/backup/BackupPage.kt b/app/src/main/java/com/digiventure/ventnote/feature/backup/BackupPage.kt index 04d1851..d0856f0 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/backup/BackupPage.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/backup/BackupPage.kt @@ -274,7 +274,7 @@ private fun SignedOutStateContent( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer ), - shape = CircleShape + shape = MaterialTheme.shapes.extraLarge ) { Box( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/digiventure/ventnote/feature/backup/components/button/SignInButton.kt b/app/src/main/java/com/digiventure/ventnote/feature/backup/components/button/SignInButton.kt index 6ee544f..38afa60 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/backup/components/button/SignInButton.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/backup/components/button/SignInButton.kt @@ -65,14 +65,14 @@ fun SignInButton( launcher.launch(authViewModel.getSignInIntent()) }, modifier = Modifier.height(56.dp), - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.medium, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary ), elevation = ButtonDefaults.buttonElevation( - defaultElevation = 4.dp, - pressedElevation = 8.dp + defaultElevation = 0.dp, + pressedElevation = 0.dp ), enabled = !isLoading ) { diff --git a/app/src/main/java/com/digiventure/ventnote/feature/backup/components/list/BackupFileList.kt b/app/src/main/java/com/digiventure/ventnote/feature/backup/components/list/BackupFileList.kt index 75de8c2..6d37741 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/backup/components/list/BackupFileList.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/backup/components/list/BackupFileList.kt @@ -28,10 +28,9 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -218,14 +217,14 @@ fun EmptyBackupListContainer( onBackupRequest() }, modifier = Modifier.height(56.dp), - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.medium, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary ), elevation = ButtonDefaults.buttonElevation( - defaultElevation = 4.dp, - pressedElevation = 8.dp + defaultElevation = 0.dp, + pressedElevation = 0.dp ), ) { Row( @@ -265,14 +264,14 @@ fun BackupListContainer( onBackupRequest() }, modifier = Modifier.fillMaxWidth().height(56.dp), - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.medium, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary ), elevation = ButtonDefaults.buttonElevation( - defaultElevation = 4.dp, - pressedElevation = 8.dp + defaultElevation = 0.dp, + pressedElevation = 0.dp ), ) { Row( @@ -301,14 +300,15 @@ fun BackupListContainer( modifier = Modifier .fillMaxWidth() .semantics { contentDescription = EMPTY_STRING }, - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), elevation = CardDefaults.cardElevation( - defaultElevation = 2.dp, - hoveredElevation = 4.dp - ) + defaultElevation = 0.dp, + hoveredElevation = 0.dp + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Row( modifier = Modifier @@ -332,37 +332,27 @@ fun BackupListContainer( Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - FilledTonalButton( - onClick = { onRestoreRequest(file) }, - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = stringResource(R.string.restore_icon), - modifier = Modifier.size(18.dp) - ) - } - - OutlinedButton( - onClick = { onDeleteRequest(file) }, - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = stringResource(R.string.delete_icon), - modifier = Modifier.size(18.dp) - ) - } + IconButton( + onClick = { onRestoreRequest(file) } + ) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringResource(R.string.restore_icon), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + + IconButton( + onClick = { onDeleteRequest(file) } + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.delete_icon), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp) + ) + } } } } @@ -416,10 +406,14 @@ fun BackupFailedContainer( onClick = { onGetBackupList() }, - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary + ), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp ) ) { Icon( diff --git a/app/src/main/java/com/digiventure/ventnote/feature/drawer/components/NavDrawerItem.kt b/app/src/main/java/com/digiventure/ventnote/feature/drawer/components/NavDrawerItem.kt index 55c02fd..f3852a3 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/drawer/components/NavDrawerItem.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/drawer/components/NavDrawerItem.kt @@ -35,7 +35,7 @@ fun NavDrawerItem( ) { Box( modifier = Modifier - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.background) ) { Box(modifier = Modifier.padding(8.dp)) { diff --git a/app/src/main/java/com/digiventure/ventnote/feature/notes/NotesPage.kt b/app/src/main/java/com/digiventure/ventnote/feature/notes/NotesPage.kt index 8785e08..1f22dd9 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/notes/NotesPage.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/notes/NotesPage.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -242,7 +243,9 @@ fun NotesPage( ) }, containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary + contentColor = MaterialTheme.colorScheme.onPrimary, + elevation = FloatingActionButtonDefaults.elevation(0.dp,0.dp,0.dp,0.dp), + shape = MaterialTheme.shapes.medium ) } }, diff --git a/app/src/main/java/com/digiventure/ventnote/feature/notes/components/item/NoteItem.kt b/app/src/main/java/com/digiventure/ventnote/feature/notes/components/item/NoteItem.kt index b6bb59d..313ff91 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/notes/components/item/NoteItem.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/notes/components/item/NoteItem.kt @@ -41,16 +41,16 @@ fun NotesItem( onLongClick: () -> Unit, onCheckClick: () -> Unit ) { - val overallItemShape = RoundedCornerShape(16.dp) - val titleContainerShape = RoundedCornerShape(12.dp) - val descriptionContainerShape = RoundedCornerShape(10.dp) + val overallItemShape = MaterialTheme.shapes.medium + val titleContainerShape = MaterialTheme.shapes.small + val descriptionContainerShape = MaterialTheme.shapes.small Box( modifier = Modifier .fillMaxWidth() .semantics { contentDescription = "Note item ${data.id}" } .clip(overallItemShape) - .background(MaterialTheme.colorScheme.surfaceContainerLow) + .background(MaterialTheme.colorScheme.surface) .combinedClickable( onClick = { if (isMarking) onCheckClick() else onClick() }, onLongClick = { onLongClick() } @@ -59,8 +59,6 @@ fun NotesItem( Column( modifier = Modifier .fillMaxWidth() - .clip(titleContainerShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) .padding(2.dp, 12.dp, 2.dp, 2.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -79,8 +77,8 @@ fun NotesItem( maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, ), modifier = Modifier.padding(horizontal = 12.dp) ) @@ -91,8 +89,6 @@ fun NotesItem( Box( modifier = Modifier .fillMaxWidth() - .clip(descriptionContainerShape) - .background(MaterialTheme.colorScheme.surface) .padding(horizontal = 12.dp, vertical = 8.dp) ) { Column { diff --git a/app/src/main/java/com/digiventure/ventnote/ui/theme/components/Shape.kt b/app/src/main/java/com/digiventure/ventnote/ui/theme/components/Shape.kt new file mode 100644 index 0000000..b38c221 --- /dev/null +++ b/app/src/main/java/com/digiventure/ventnote/ui/theme/components/Shape.kt @@ -0,0 +1,18 @@ +package com.digiventure.ventnote.ui.theme.components + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +/** + * Defines the flat shapes for the application. + * In a flat design, components generally use solid edges or slight, uniform rounding + * instead of full pills. Here we use 8.dp to signify a very subtle rounded box look. + */ +val Shapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(6.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(12.dp), + extraLarge = RoundedCornerShape(16.dp) +) diff --git a/app/src/main/java/com/digiventure/ventnote/ui/theme/components/Theme.kt b/app/src/main/java/com/digiventure/ventnote/ui/theme/components/Theme.kt index f331875..5256c4c 100644 --- a/app/src/main/java/com/digiventure/ventnote/ui/theme/components/Theme.kt +++ b/app/src/main/java/com/digiventure/ventnote/ui/theme/components/Theme.kt @@ -34,6 +34,7 @@ fun VentNoteTheme( MaterialTheme( colorScheme = colorScheme, typography = Typography, + shapes = Shapes, content = content ) } diff --git a/app/src/main/java/com/digiventure/ventnote/ui/theme/components/Type.kt b/app/src/main/java/com/digiventure/ventnote/ui/theme/components/Type.kt index 91ff11b..1089164 100644 --- a/app/src/main/java/com/digiventure/ventnote/ui/theme/components/Type.kt +++ b/app/src/main/java/com/digiventure/ventnote/ui/theme/components/Type.kt @@ -2,9 +2,119 @@ package com.digiventure.ventnote.ui.theme.components import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + /** - * Defines the typography styles for the application, based on Material Design principles. - * This set includes overrides for bodyLarge, titleLarge, and labelSmall text styles, - * using the default font family and adjusting font weight, size, line height, and letter spacing. + * Defines the typography styles for the application, transitioning to a Flat Design aesthetic. + * This set defines a clear, crisp hierarchy by explicitly setting weights and sizes. */ -val Typography = Typography() \ No newline at end of file +val Typography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp + ), + displaySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) \ No newline at end of file From 5d479759d83c0a54c383e5718b2098c061431142 Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Sun, 22 Feb 2026 18:55:47 +0700 Subject: [PATCH 14/16] Feat list-notes-ux-enhancement-1.3.0: Implement staggered grid view for notes with a toggle to switch between list and grid layouts. --- .../digiventure/ventnote/commons/Constants.kt | 4 + .../ventnote/components/dialog/TextDialog.kt | 4 +- .../ventnote/feature/backup/BackupPage.kt | 4 +- .../backup/components/button/SignInButton.kt | 4 +- .../backup/components/list/BackupFileList.kt | 22 +-- .../backup/components/navbar/AppBar.kt | 8 +- .../ventnote/feature/drawer/NavDrawer.kt | 24 +-- .../NavDrawerItemColorSchemeSwitch.kt | 4 +- .../note_creation/components/navbar/AppBar.kt | 4 +- .../components/navbar/EnhancedBottomAppBar.kt | 4 +- .../components/section/NoteSection.kt | 4 +- .../components/section/TitleSection.kt | 4 +- .../note_detail/components/navbar/AppBar.kt | 8 +- .../components/navbar/EnhancedBottomAppBar.kt | 16 +- .../components/section/NoteSection.kt | 4 +- .../components/section/TitleSection.kt | 4 +- .../ventnote/feature/notes/NotesPage.kt | 147 +++++++++++++----- .../feature/notes/components/item/NoteItem.kt | 9 +- .../feature/notes/components/navbar/AppBar.kt | 138 ++++++++++++++-- .../notes/components/searchbar/SearchBar.kt | 4 +- .../notes/components/sheets/FilterSheet.kt | 26 ++-- .../notes/viewmodel/NotesPageBaseVM.kt | 6 + .../notes/viewmodel/NotesPageMockVM.kt | 5 + .../feature/notes/viewmodel/NotesPageVM.kt | 21 +++ .../feature/share_preview/SharePreviewPage.kt | 4 +- .../share_preview/components/navbar/AppBar.kt | 8 +- .../components/navbar/EnhancedBottomAppBar.kt | 4 +- .../components/sheets/ShareSheet.kt | 4 +- .../ventnote/notes/NotesPageVMShould.kt | 16 +- 29 files changed, 367 insertions(+), 147 deletions(-) diff --git a/app/src/main/java/com/digiventure/ventnote/commons/Constants.kt b/app/src/main/java/com/digiventure/ventnote/commons/Constants.kt index 059bfd6..77001dc 100644 --- a/app/src/main/java/com/digiventure/ventnote/commons/Constants.kt +++ b/app/src/main/java/com/digiventure/ventnote/commons/Constants.kt @@ -11,6 +11,10 @@ object Constants { const val COLOR_PALLET = "COLOR_PALLET" const val BACKUP_FILE_NAME = "backup" const val EMPTY_STRING = "" + + const val VIEW_MODE_LIST = "LIST" + const val VIEW_MODE_STAGGERED = "STAGGERED" + const val NOTE_VIEW_MODE = "NOTE_VIEW_MODE" } object ColorPalletName { diff --git a/app/src/main/java/com/digiventure/ventnote/components/dialog/TextDialog.kt b/app/src/main/java/com/digiventure/ventnote/components/dialog/TextDialog.kt index 74a1ae2..019f7f9 100644 --- a/app/src/main/java/com/digiventure/ventnote/components/dialog/TextDialog.kt +++ b/app/src/main/java/com/digiventure/ventnote/components/dialog/TextDialog.kt @@ -2,7 +2,7 @@ package com.digiventure.ventnote.components.dialog import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.rounded.Info import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -32,7 +32,7 @@ fun TextDialog( onDismissRequest = { onDismissCallback() }, icon = { Icon( - imageVector = Icons.Default.Info, + imageVector = Icons.Rounded.Info, contentDescription = null, tint = MaterialTheme.colorScheme.primary ) diff --git a/app/src/main/java/com/digiventure/ventnote/feature/backup/BackupPage.kt b/app/src/main/java/com/digiventure/ventnote/feature/backup/BackupPage.kt index d0856f0..4dbdf12 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/backup/BackupPage.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/backup/BackupPage.kt @@ -11,7 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.rounded.Lock import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -281,7 +281,7 @@ private fun SignedOutStateContent( contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Filled.Lock, + imageVector = Icons.Rounded.Lock, contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onPrimaryContainer diff --git a/app/src/main/java/com/digiventure/ventnote/feature/backup/components/button/SignInButton.kt b/app/src/main/java/com/digiventure/ventnote/feature/backup/components/button/SignInButton.kt index 38afa60..7a8a54a 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/backup/components/button/SignInButton.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/backup/components/button/SignInButton.kt @@ -16,7 +16,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.rounded.Person import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -105,7 +105,7 @@ fun SignInButton( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Filled.Person, + imageVector = Icons.Rounded.Person, contentDescription = null, modifier = Modifier.size(20.dp) ) diff --git a/app/src/main/java/com/digiventure/ventnote/feature/backup/components/list/BackupFileList.kt b/app/src/main/java/com/digiventure/ventnote/feature/backup/components/list/BackupFileList.kt index 6d37741..ad4c6cc 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/backup/components/list/BackupFileList.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/backup/components/list/BackupFileList.kt @@ -19,10 +19,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -184,7 +184,7 @@ fun EmptyBackupListContainer( contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Filled.Warning, + imageVector = Icons.Rounded.Warning, contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onPrimaryContainer @@ -232,7 +232,7 @@ fun EmptyBackupListContainer( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Filled.Share, + imageVector = Icons.Rounded.Share, contentDescription = null, modifier = Modifier.size(20.dp) ) @@ -279,7 +279,7 @@ fun BackupListContainer( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Filled.Share, + imageVector = Icons.Rounded.Share, contentDescription = null, modifier = Modifier.size(20.dp) ) @@ -336,7 +336,7 @@ fun BackupListContainer( onClick = { onRestoreRequest(file) } ) { Icon( - imageVector = Icons.Filled.Refresh, + imageVector = Icons.Rounded.Refresh, contentDescription = stringResource(R.string.restore_icon), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp) @@ -347,7 +347,7 @@ fun BackupListContainer( onClick = { onDeleteRequest(file) } ) { Icon( - imageVector = Icons.Filled.Delete, + imageVector = Icons.Rounded.Delete, contentDescription = stringResource(R.string.delete_icon), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp) @@ -385,7 +385,7 @@ fun BackupFailedContainer( contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Filled.Warning, + imageVector = Icons.Rounded.Warning, contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error @@ -417,7 +417,7 @@ fun BackupFailedContainer( ) ) { Icon( - imageVector = Icons.Filled.Refresh, + imageVector = Icons.Rounded.Refresh, contentDescription = null, modifier = Modifier.size(18.dp) ) diff --git a/app/src/main/java/com/digiventure/ventnote/feature/backup/components/navbar/AppBar.kt b/app/src/main/java/com/digiventure/ventnote/feature/backup/components/navbar/AppBar.kt index 6384265..e656cf6 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/backup/components/navbar/AppBar.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/backup/components/navbar/AppBar.kt @@ -2,8 +2,8 @@ package com.digiventure.ventnote.feature.backup.components.navbar import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Lock import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -47,7 +47,7 @@ fun BackupPageAppBar( ), navigationIcon = { TopNavBarIcon( - Icons.AutoMirrored.Filled.ArrowBack, + Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.backup_nav_icon), Modifier.semantics { }) { onBackRequest() @@ -70,7 +70,7 @@ fun TrailingMenuIcons( onLogoutRequest: () -> Unit, ) { TopNavBarIcon( - Icons.Filled.Lock, + Icons.Rounded.Lock, stringResource(R.string.logout_nav_icon), modifier = Modifier.semantics { }) { onLogoutRequest() 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 d73bc33..f6a4819 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 @@ -12,12 +12,12 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material.icons.rounded.ThumbUp import androidx.compose.material3.DrawerDefaults import androidx.compose.material3.DrawerState @@ -100,19 +100,19 @@ fun NavDrawer( ) { SectionTitle(title = stringResource(id = R.string.about_us)) - NavDrawerItem(leftIcon = Icons.Filled.ThumbUp, + NavDrawerItem(leftIcon = Icons.Rounded.ThumbUp, title = stringResource(id = R.string.rate_app), subtitle = stringResource(id = R.string.rate_app_description), testTagName = TestTags.RATE_APP_TILE, onClick = { openPlayStore(context, appPath, onError) }) - NavDrawerItem(leftIcon = Icons.Filled.Search, + NavDrawerItem(leftIcon = Icons.Rounded.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, + NavDrawerItem(leftIcon = Icons.Rounded.Info, title = stringResource(id = R.string.app_version), subtitle = BuildConfig.VERSION_NAME, testTagName = TestTags.APP_VERSION_TILE, @@ -121,7 +121,7 @@ fun NavDrawer( SectionTitle(title = stringResource(id = R.string.preferences)) NavDrawerColorPicker( - leftIcon = Icons.Filled.Settings, + leftIcon = Icons.Rounded.Settings, title = stringResource(id = R.string.theme_color), testTagName = TestTags.THEME_TILE ) { @@ -129,7 +129,7 @@ fun NavDrawer( } NavDrawerItemColorSchemeSwitch( - leftIcon = Icons.Filled.Person, + leftIcon = Icons.Rounded.Person, title = stringResource(id = R.string.theme_setting), currentScheme = currentSchemeName, testTagName = TestTags.COLOR_MODE_TILE @@ -140,7 +140,7 @@ fun NavDrawer( SectionTitle(title = stringResource(id = R.string.settings)) NavDrawerItem( - leftIcon = Icons.Filled.Share, + leftIcon = Icons.Rounded.Share, title = stringResource(id = R.string.backup), subtitle = stringResource(id = R.string.backup_description), testTagName = TestTags.BACKUP_TILE, diff --git a/app/src/main/java/com/digiventure/ventnote/feature/drawer/components/NavDrawerItemColorSchemeSwitch.kt b/app/src/main/java/com/digiventure/ventnote/feature/drawer/components/NavDrawerItemColorSchemeSwitch.kt index 12788ce..5c719ce 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/drawer/components/NavDrawerItemColorSchemeSwitch.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/drawer/components/NavDrawerItemColorSchemeSwitch.kt @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.rounded.ArrowForward import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -97,7 +97,7 @@ fun NavDrawerItemColorSchemeSwitch( } Icon( - Icons.AutoMirrored.Filled.ArrowForward, + Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp) 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 45e0568..af7cf89 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 @@ -1,7 +1,7 @@ package com.digiventure.ventnote.feature.note_creation.components.navbar import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -38,7 +38,7 @@ fun NoteCreationAppBar( containerColor = MaterialTheme.colorScheme.surface, ), navigationIcon = { - TopNavBarIcon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back_nav_icon), Modifier.semantics { testTag = TestTags.BACK_ICON_BUTTON }) { + TopNavBarIcon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back_nav_icon), Modifier.semantics { testTag = TestTags.BACK_ICON_BUTTON }) { onBackPressed() } }, 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 4f93650..20f7b05 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 @@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -62,7 +62,7 @@ fun EnhancedBottomAppBar( verticalAlignment = Alignment.CenterVertically ) { EnhancedBottomBarButton( - icon = Icons.Filled.Check, + icon = Icons.Rounded.Check, label = stringResource(R.string.save), onClick = onSaveClick, containerColor = MaterialTheme.colorScheme.primaryContainer, 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 2f95eea..5accc50 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 @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -35,7 +35,7 @@ fun NoteSection( modifier = Modifier.padding(bottom = 12.dp) ) { Icon( - imageVector = Icons.Filled.Edit, + imageVector = Icons.Rounded.Edit, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp) 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 b6ce052..d56d643 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 @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.rounded.Info import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -38,7 +38,7 @@ fun TitleSection( modifier = Modifier.padding(bottom = 12.dp) ) { Icon( - imageVector = Icons.Filled.Info, + imageVector = Icons.Rounded.Info, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp) 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 7789fdf..1728d23 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 @@ -1,8 +1,8 @@ package com.digiventure.ventnote.feature.note_detail.components.navbar import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Share import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -42,14 +42,14 @@ fun NoteDetailAppBar( ), navigationIcon = { if (!isEditing) { - TopNavBarIcon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back_nav_icon), Modifier.semantics { testTag = TestTags.BACK_ICON_BUTTON }) { + TopNavBarIcon(Icons.AutoMirrored.Rounded.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 { testTag = TestTags.SHARE_ICON_BUTTON }) { + TopNavBarIcon(Icons.Rounded.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 b506bc4..9d97093 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 @@ -15,10 +15,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -73,7 +73,7 @@ fun EnhancedBottomAppBar( if (isEditing) { // Cancel button in editing mode EnhancedBottomBarButton( - icon = Icons.Filled.Close, + icon = Icons.Rounded.Close, label = stringResource(R.string.cancel), onClick = onCancelClick, containerColor = MaterialTheme.colorScheme.primaryContainer, @@ -83,7 +83,7 @@ fun EnhancedBottomAppBar( // Save button in editing mode EnhancedBottomBarButton( - icon = Icons.Filled.Check, + icon = Icons.Rounded.Check, label = stringResource(R.string.save), onClick = onSaveClick, containerColor = MaterialTheme.colorScheme.primaryContainer, @@ -94,7 +94,7 @@ fun EnhancedBottomAppBar( } else { // Edit button in view mode EnhancedBottomBarButton( - icon = Icons.Filled.Edit, + icon = Icons.Rounded.Edit, label = stringResource(R.string.edit), onClick = onEditClick, containerColor = MaterialTheme.colorScheme.secondary, @@ -104,7 +104,7 @@ fun EnhancedBottomAppBar( // Delete button in view mode EnhancedBottomBarButton( - icon = Icons.Filled.Delete, + icon = Icons.Rounded.Delete, label = stringResource(R.string.delete), onClick = onDeleteClick, containerColor = MaterialTheme.colorScheme.secondaryContainer, 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 f1d8ae2..21d906f 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 @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -36,7 +36,7 @@ fun NoteSection( modifier = Modifier.padding(bottom = 12.dp) ) { Icon( - imageVector = Icons.Filled.Edit, + imageVector = Icons.Rounded.Edit, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp) 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 b65c016..dda6675 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 @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.rounded.Info import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -39,7 +39,7 @@ fun TitleSection( modifier = Modifier.padding(bottom = 12.dp) ) { Icon( - imageVector = Icons.Filled.Info, + imageVector = Icons.Rounded.Info, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp) diff --git a/app/src/main/java/com/digiventure/ventnote/feature/notes/NotesPage.kt b/app/src/main/java/com/digiventure/ventnote/feature/notes/NotesPage.kt index 1f22dd9..fa47916 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/notes/NotesPage.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/notes/NotesPage.kt @@ -11,8 +11,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.rounded.Add import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults @@ -49,6 +53,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.Constants import com.digiventure.ventnote.commons.TestTags import com.digiventure.ventnote.components.dialog.LoadingDialog import com.digiventure.ventnote.components.dialog.TextDialog @@ -204,6 +209,14 @@ fun NotesPage( NotesAppBar( isMarking = isMarking, markedNoteListSize = markedNoteList.size, + noteViewMode = viewModel.noteViewMode.value, + viewModeCallback = { + val nextMode = when (viewModel.noteViewMode.value) { + Constants.VIEW_MODE_LIST -> Constants.VIEW_MODE_STAGGERED + else -> Constants.VIEW_MODE_LIST + } + viewModel.setNoteViewMode(nextMode) + }, toggleDrawerCallback = openDrawer, selectAllCallback = { noteListState?.getOrNull()?.let { notes -> @@ -238,7 +251,7 @@ fun NotesPage( }, icon = { Icon( - imageVector = Icons.Filled.Add, + imageVector = Icons.Rounded.Add, contentDescription = stringResource(R.string.fab) ) }, @@ -261,51 +274,101 @@ fun NotesPage( } .padding(contentPadding) ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .nestedScroll(scrollBehavior.nestedScrollConnection) - .semantics { testTag = TestTags.NOTE_RV }, - verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(bottom = 96.dp) - ) { - item(key = "search_bar") { - Box( - modifier = Modifier - .onGloballyPositioned { coords -> - searchBarHeightPx = coords.size.height.toFloat() - scrollBehavior.state.heightOffsetLimit = -searchBarHeightPx - } - .fillMaxWidth() - .padding(16.dp, 24.dp, 16.dp, 8.dp) + val listModifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .semantics { testTag = TestTags.NOTE_RV } + + when (viewModel.noteViewMode.value) { + Constants.VIEW_MODE_STAGGERED -> { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + modifier = listModifier, + verticalItemSpacing = 16.dp, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp) ) { - SearchBar( - query = searchQuery, - onQueryChange = { newQuery -> - viewModel.searchedTitleText.value = newQuery + item(key = "search_bar", span = StaggeredGridItemSpan.FullLine) { + Box( + modifier = Modifier + .onGloballyPositioned { coords -> + searchBarHeightPx = coords.size.height.toFloat() + scrollBehavior.state.heightOffsetLimit = -searchBarHeightPx + } + .fillMaxWidth() + .padding(top = 24.dp, bottom = 8.dp) + ) { + SearchBar( + query = searchQuery, + onQueryChange = { newQuery -> + viewModel.searchedTitleText.value = newQuery + } + ) } - ) + } + + items( + items = filteredNotes, + key = { note -> note.id } + ) { note -> + NotesItem( + isMarking = isMarking, + isMarked = note in markedNoteList, + data = note, + noteViewMode = viewModel.noteViewMode.value, + onClick = { onNoteClick(note) }, + onLongClick = { onNoteLongClick(note) }, + onCheckClick = { onNoteCheckClick(note) } + ) + } } } - - items( - items = filteredNotes, - key = { note -> note.id } - ) { note -> - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .animateItem() + else -> { + LazyColumn( + modifier = listModifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(bottom = 96.dp) ) { - NotesItem( - isMarking = isMarking, - isMarked = note in markedNoteList, - data = note, - onClick = { onNoteClick(note) }, - onLongClick = { onNoteLongClick(note) }, - onCheckClick = { onNoteCheckClick(note) } - ) + item(key = "search_bar") { + Box( + modifier = Modifier + .onGloballyPositioned { coords -> + searchBarHeightPx = coords.size.height.toFloat() + scrollBehavior.state.heightOffsetLimit = -searchBarHeightPx + } + .fillMaxWidth() + .padding(16.dp, 24.dp, 16.dp, 8.dp) + ) { + SearchBar( + query = searchQuery, + onQueryChange = { newQuery -> + viewModel.searchedTitleText.value = newQuery + } + ) + } + } + + items( + items = filteredNotes, + key = { note -> note.id } + ) { note -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .animateItem() + ) { + NotesItem( + isMarking = isMarking, + isMarked = note in markedNoteList, + data = note, + noteViewMode = viewModel.noteViewMode.value, + onClick = { onNoteClick(note) }, + onLongClick = { onNoteLongClick(note) }, + onCheckClick = { onNoteCheckClick(note) } + ) + } + } } } } diff --git a/app/src/main/java/com/digiventure/ventnote/feature/notes/components/item/NoteItem.kt b/app/src/main/java/com/digiventure/ventnote/feature/notes/components/item/NoteItem.kt index 313ff91..a512170 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/notes/components/item/NoteItem.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/notes/components/item/NoteItem.kt @@ -13,7 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -26,6 +26,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.digiventure.ventnote.commons.Constants import com.digiventure.ventnote.commons.DateUtil import com.digiventure.ventnote.commons.richtext.MarkdownParser import com.digiventure.ventnote.components.navbar.TopNavBarIcon @@ -37,6 +38,7 @@ fun NotesItem( isMarking: Boolean, isMarked: Boolean, data: NoteModel, + noteViewMode: String = Constants.VIEW_MODE_LIST, onClick: () -> Unit, onLongClick: () -> Unit, onCheckClick: () -> Unit @@ -64,7 +66,7 @@ fun NotesItem( Row(verticalAlignment = Alignment.CenterVertically) { if (isMarked) { TopNavBarIcon( - image = Icons.Filled.Check, + image = Icons.Rounded.Check, "", modifier = Modifier .padding(start = 12.dp) @@ -91,10 +93,11 @@ fun NotesItem( .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp) ) { + val descriptionMaxLines = if (noteViewMode == Constants.VIEW_MODE_STAGGERED) 8 else 4 Column { Text( text = MarkdownParser.parseToAnnotatedString(data.note), - maxLines = 4, + maxLines = descriptionMaxLines, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium.copy( color = MaterialTheme.colorScheme.onSurface, diff --git a/app/src/main/java/com/digiventure/ventnote/feature/notes/components/navbar/AppBar.kt b/app/src/main/java/com/digiventure/ventnote/feature/notes/components/navbar/AppBar.kt index 9d03b05..d071147 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/notes/components/navbar/AppBar.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/notes/components/navbar/AppBar.kt @@ -14,13 +14,17 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.automirrored.rounded.List +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Menu +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -46,6 +50,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.digiventure.ventnote.R +import com.digiventure.ventnote.commons.Constants import com.digiventure.ventnote.commons.TestTags @OptIn(ExperimentalMaterial3Api::class) @@ -60,6 +65,8 @@ fun NotesAppBar( closeMarkingCallback: () -> Unit, sortCallback: () -> Unit, deleteCallback: () -> Unit, + noteViewMode: String = Constants.VIEW_MODE_LIST, + viewModeCallback: () -> Unit = {} ) { val expanded = remember { mutableStateOf(false) } @@ -100,7 +107,9 @@ fun NotesAppBar( isMarking = isMarking, markedItemsCount = markedNoteListSize, sortCallback = sortCallback, - deleteCallback = deleteCallback + deleteCallback = deleteCallback, + noteViewMode = noteViewMode, + viewModeCallback = viewModeCallback ) }, modifier = Modifier.semantics { @@ -109,6 +118,86 @@ fun NotesAppBar( ) } +val ViewAgendaIcon: ImageVector + get() = ImageVector.Builder( + name = "ViewAgenda", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(20f, 3f) + lineTo(3f, 3f) + curveTo(2.45f, 3f, 2f, 3.45f, 2f, 4f) + verticalLineToRelative(6f) + curveToRelative(0f, 0.55f, 0.45f, 1f, 1f, 1f) + horizontalLineToRelative(17f) + curveToRelative(0.55f, 0f, 1f, -0.45f, 1f, -1f) + lineTo(21f, 4f) + curveToRelative(0f, -0.55f, -0.45f, -1f, -1f, -1f) + close() + moveTo(20f, 13f) + lineTo(3f, 13f) + curveToRelative(-0.55f, 0f, -1f, 0.45f, -1f, 1f) + verticalLineToRelative(6f) + curveToRelative(0f, 0.55f, 0.45f, 1f, 1f, 1f) + horizontalLineToRelative(17f) + curveToRelative(0.55f, 0f, 1f, -0.45f, 1f, -1f) + verticalLineToRelative(-6f) + curveToRelative(0f, -0.55f, -0.45f, -1f, -1f, -1f) + close() + } + }.build() + +val ViewListIcon: ImageVector + get() = ImageVector.Builder( + name = "ViewList", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(3f, 14f) + horizontalLineToRelative(4f) + verticalLineToRelative(-4f) + lineTo(3f, 10f) + verticalLineToRelative(4f) + close() + moveTo(3f, 19f) + horizontalLineToRelative(4f) + verticalLineToRelative(-4f) + lineTo(3f, 15f) + verticalLineToRelative(4f) + close() + moveTo(3f, 9f) + horizontalLineToRelative(4f) + lineTo(7f, 5f) + lineTo(3f, 5f) + verticalLineToRelative(4f) + close() + moveTo(8f, 14f) + horizontalLineToRelative(13f) + verticalLineToRelative(-4f) + lineTo(8f, 10f) + verticalLineToRelative(4f) + close() + moveTo(8f, 19f) + horizontalLineToRelative(13f) + verticalLineToRelative(-4f) + lineTo(8f, 15f) + verticalLineToRelative(4f) + close() + moveTo(8f, 5f) + verticalLineToRelative(4f) + horizontalLineToRelative(13f) + lineTo(21f, 5f) + lineTo(8f, 5f) + close() + } + }.build() + @Composable private fun SelectionTitle( markedNoteListSize: Int, @@ -132,7 +221,7 @@ private fun SelectionTitle( } ) { Icon( - imageVector = Icons.Filled.Check, + imageVector = Icons.Rounded.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp) @@ -164,7 +253,7 @@ private fun SelectionTitle( Spacer(modifier = Modifier.width(4.dp)) Icon( - imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + imageVector = if (expanded) Icons.Rounded.KeyboardArrowUp else Icons.Rounded.KeyboardArrowDown, contentDescription = stringResource(R.string.dropdown_nav_icon), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp) @@ -215,7 +304,7 @@ private fun EnhancedDropdownMenu( modifier = Modifier.fillMaxWidth() ) { Icon( - imageVector = if (allSelected) Icons.Filled.Check else Icons.Filled.Check, + imageVector = if (allSelected) Icons.Rounded.Check else Icons.Rounded.Check, contentDescription = null, tint = if (allSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), @@ -268,7 +357,7 @@ private fun EnhancedDropdownMenu( modifier = Modifier.fillMaxWidth() ) { Icon( - imageVector = Icons.Filled.Close, + imageVector = Icons.Rounded.Close, contentDescription = null, tint = if (noneSelected) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), @@ -327,14 +416,13 @@ fun LeadingIcon( closeMarkingCallback: () -> Unit, toggleDrawerCallback: () -> Unit ) { - // Option 1: Use separate variables (cleaner and more readable) if (isMarking) { IconButton( onClick = closeMarkingCallback, modifier = Modifier.semantics { testTag = TestTags.CLOSE_SELECT_ICON_BUTTON } ) { Icon( - imageVector = Icons.Filled.Close, + imageVector = Icons.Rounded.Close, contentDescription = stringResource(R.string.close_nav_icon), tint = MaterialTheme.colorScheme.onSurface ) @@ -345,7 +433,7 @@ fun LeadingIcon( modifier = Modifier.semantics { testTag = TestTags.MENU_ICON_BUTTON } ) { Icon( - imageVector = Icons.Filled.Menu, + imageVector = Icons.Rounded.Menu, contentDescription = stringResource(R.string.drawer_nav_icon), tint = MaterialTheme.colorScheme.onSurface ) @@ -359,6 +447,8 @@ fun TrailingMenuIcons( markedItemsCount: Int, sortCallback: () -> Unit, deleteCallback: () -> Unit, + noteViewMode: String, + viewModeCallback: () -> Unit ) { if (isMarking) { val deleteEnabled = markedItemsCount > 0 @@ -368,7 +458,7 @@ fun TrailingMenuIcons( modifier = Modifier.semantics { testTag = TestTags.DELETE_ICON_BUTTON } ) { Icon( - imageVector = Icons.Filled.Delete, + imageVector = Icons.Rounded.Delete, contentDescription = stringResource(R.string.delete_nav_icon), tint = if (deleteEnabled) { MaterialTheme.colorScheme.primary @@ -378,12 +468,26 @@ fun TrailingMenuIcons( ) } } else { + IconButton( + onClick = viewModeCallback, + modifier = Modifier.semantics { testTag = "view_mode_icon_button" } + ) { + val viewIcon = when (noteViewMode) { + Constants.VIEW_MODE_STAGGERED -> ViewAgendaIcon + else -> ViewListIcon + } + Icon( + imageVector = viewIcon, + contentDescription = stringResource(R.string.drawer_nav_icon), // generic placeholder + tint = MaterialTheme.colorScheme.onSurface + ) + } IconButton( onClick = sortCallback, modifier = Modifier.semantics { testTag = TestTags.SORT_ICON_BUTTON } ) { Icon( - imageVector = Icons.AutoMirrored.Filled.List, + imageVector = Icons.AutoMirrored.Rounded.List, contentDescription = stringResource(R.string.sort_nav_icon), tint = MaterialTheme.colorScheme.onSurface ) diff --git a/app/src/main/java/com/digiventure/ventnote/feature/notes/components/searchbar/SearchBar.kt b/app/src/main/java/com/digiventure/ventnote/feature/notes/components/searchbar/SearchBar.kt index c796bfe..e5dec99 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/notes/components/searchbar/SearchBar.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/notes/components/searchbar/SearchBar.kt @@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -44,7 +44,7 @@ fun SearchBar( ), leadingIcon = { Icon( - imageVector = Icons.Default.Search, + imageVector = Icons.Rounded.Search, contentDescription = "Search", tint = Color.Gray ) diff --git a/app/src/main/java/com/digiventure/ventnote/feature/notes/components/sheets/FilterSheet.kt b/app/src/main/java/com/digiventure/ventnote/feature/notes/components/sheets/FilterSheet.kt index ee76101..47cd4e3 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/notes/components/sheets/FilterSheet.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/notes/components/sheets/FilterSheet.kt @@ -11,12 +11,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.filled.DateRange -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.automirrored.rounded.List +import androidx.compose.material.icons.rounded.DateRange +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -62,16 +62,16 @@ fun FilterSheet( // Memoized sort options with constants mapping val sortOptions = remember { listOf( - SortOption(R.string.sort_title, Constants.TITLE, Icons.Filled.Info), - SortOption(R.string.sort_created_date, Constants.CREATED_AT, Icons.Filled.DateRange), - SortOption(R.string.sort_modified_date, Constants.UPDATED_AT, Icons.Filled.Refresh) + SortOption(R.string.sort_title, Constants.TITLE, Icons.Rounded.Info), + SortOption(R.string.sort_created_date, Constants.CREATED_AT, Icons.Rounded.DateRange), + SortOption(R.string.sort_modified_date, Constants.UPDATED_AT, Icons.Rounded.Refresh) ) } val orderOptions = remember { listOf( - OrderOption(R.string.order_ascending, Constants.ASCENDING, Icons.Filled.KeyboardArrowUp), - OrderOption(R.string.order_descending, Constants.DESCENDING, Icons.Filled.KeyboardArrowDown) + OrderOption(R.string.order_ascending, Constants.ASCENDING, Icons.Rounded.KeyboardArrowUp), + OrderOption(R.string.order_descending, Constants.DESCENDING, Icons.Rounded.KeyboardArrowDown) ) } @@ -94,7 +94,7 @@ fun FilterSheet( ) { FilterSection( title = stringResource(R.string.sort_by), - icon = Icons.AutoMirrored.Filled.List + icon = Icons.AutoMirrored.Rounded.List ) { LazyRow ( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -117,7 +117,7 @@ fun FilterSheet( // Order By Section FilterSection( title = stringResource(R.string.order_by), - icon = Icons.AutoMirrored.Filled.List + icon = Icons.AutoMirrored.Rounded.List ) { LazyRow( horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app/src/main/java/com/digiventure/ventnote/feature/notes/viewmodel/NotesPageBaseVM.kt b/app/src/main/java/com/digiventure/ventnote/feature/notes/viewmodel/NotesPageBaseVM.kt index 42f62f9..9aff303 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/notes/viewmodel/NotesPageBaseVM.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/notes/viewmodel/NotesPageBaseVM.kt @@ -22,6 +22,12 @@ interface NotesPageBaseVM { * */ fun sortAndOrder(sortBy: String, orderBy: String) + /** + * Handle note view layout mode + */ + val noteViewMode: MutableState + fun setNoteViewMode(mode: String) + /** * Handle NoteList state * */ diff --git a/app/src/main/java/com/digiventure/ventnote/feature/notes/viewmodel/NotesPageMockVM.kt b/app/src/main/java/com/digiventure/ventnote/feature/notes/viewmodel/NotesPageMockVM.kt index 3b83c91..d9c6bdc 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/notes/viewmodel/NotesPageMockVM.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/notes/viewmodel/NotesPageMockVM.kt @@ -15,6 +15,11 @@ class NotesPageMockVM : ViewModel(), NotesPageBaseVM { // Mock implementation if needed } + override val noteViewMode = mutableStateOf(com.digiventure.ventnote.commons.Constants.VIEW_MODE_LIST) + override fun setNoteViewMode(mode: String) { + noteViewMode.value = mode + } + // More preview-friendly way to expose a list override val noteList: LiveData>> = MutableLiveData( // Use MutableLiveData and set its value directly diff --git a/app/src/main/java/com/digiventure/ventnote/feature/notes/viewmodel/NotesPageVM.kt b/app/src/main/java/com/digiventure/ventnote/feature/notes/viewmodel/NotesPageVM.kt index e9bc05b..8d53315 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/notes/viewmodel/NotesPageVM.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/notes/viewmodel/NotesPageVM.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.digiventure.ventnote.commons.Constants +import com.digiventure.ventnote.data.local.NoteDataStore import com.digiventure.ventnote.data.persistence.NoteModel import com.digiventure.ventnote.data.persistence.NoteRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -22,6 +23,7 @@ import javax.inject.Inject @HiltViewModel class NotesPageVM @Inject constructor( private val repository: NoteRepository, + private val noteDataStore: NoteDataStore ): ViewModel(), NotesPageBaseVM { override val loader = MutableLiveData() override val sortAndOrderData: MutableLiveData> = MutableLiveData( @@ -40,6 +42,25 @@ class NotesPageVM @Inject constructor( override val searchedTitleText = mutableStateOf("") + override val noteViewMode = mutableStateOf(Constants.VIEW_MODE_LIST) + + init { + viewModelScope.launch { + noteDataStore.getStringData(Constants.NOTE_VIEW_MODE).collectLatest { mode -> + if (mode.isNotEmpty()) { + noteViewMode.value = mode + } + } + } + } + + override fun setNoteViewMode(mode: String) { + noteViewMode.value = mode + viewModelScope.launch { + noteDataStore.setStringData(Constants.NOTE_VIEW_MODE, mode) + } + } + override val isMarking = mutableStateOf(false) override val markedNoteList = mutableStateListOf() 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 3f3042f..d976824 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 @@ -17,7 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.rounded.DateRange import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -168,7 +168,7 @@ fun SharePreviewPage( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Filled.DateRange, + imageVector = Icons.Rounded.DateRange, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) 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 a1fdfcf..2a75fe2 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 @@ -1,8 +1,8 @@ package com.digiventure.ventnote.feature.share_preview.components.navbar import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Info import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -40,12 +40,12 @@ fun SharePreviewAppBar( containerColor = MaterialTheme.colorScheme.surface, ), navigationIcon = { - TopNavBarIcon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back_nav_icon), Modifier.semantics { testTag = TestTags.BACK_ICON_BUTTON }) { + TopNavBarIcon(Icons.AutoMirrored.Rounded.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 { testTag = TestTags.HELP_ICON_BUTTON }) { + TopNavBarIcon(Icons.Rounded.Info, stringResource(R.string.menu_nav_icon), Modifier.semantics { testTag = TestTags.HELP_ICON_BUTTON }) { onHelpPressed() } }, 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 a8db0ba..d5a21a8 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 @@ -14,7 +14,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.rounded.Share import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api @@ -55,7 +55,7 @@ fun EnhancedBottomAppBar( verticalAlignment = Alignment.CenterVertically ) { EnhancedBottomBarButton( - icon = Icons.Filled.Share, + icon = Icons.Rounded.Share, label = stringResource(R.string.share_note), onClick = onCancelClick, containerColor = MaterialTheme.colorScheme.primaryContainer, diff --git a/app/src/main/java/com/digiventure/ventnote/feature/share_preview/components/sheets/ShareSheet.kt b/app/src/main/java/com/digiventure/ventnote/feature/share_preview/components/sheets/ShareSheet.kt index 3f708d9..85fb1b8 100644 --- a/app/src/main/java/com/digiventure/ventnote/feature/share_preview/components/sheets/ShareSheet.kt +++ b/app/src/main/java/com/digiventure/ventnote/feature/share_preview/components/sheets/ShareSheet.kt @@ -13,7 +13,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.rounded.Share import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -53,7 +53,7 @@ fun ShareSheet( verticalArrangement = Arrangement.spacedBy(8.dp) ) { ShareOptionItem( - icon = Icons.Default.Share, + icon = Icons.Rounded.Share, title = stringResource(R.string.share_note_as_text), subtitle = stringResource(R.string.share_note_as_text_subtitle), onClick = { onShareRequest() } diff --git a/app/src/test/java/com/digiventure/ventnote/notes/NotesPageVMShould.kt b/app/src/test/java/com/digiventure/ventnote/notes/NotesPageVMShould.kt index 58c68bb..c2d722b 100644 --- a/app/src/test/java/com/digiventure/ventnote/notes/NotesPageVMShould.kt +++ b/app/src/test/java/com/digiventure/ventnote/notes/NotesPageVMShould.kt @@ -4,6 +4,7 @@ import com.digiventure.utils.BaseUnitTest import com.digiventure.utils.captureValues import com.digiventure.utils.getValueForTest import com.digiventure.ventnote.commons.Constants +import com.digiventure.ventnote.data.local.NoteDataStore import com.digiventure.ventnote.data.persistence.NoteModel import com.digiventure.ventnote.data.persistence.NoteRepository import com.digiventure.ventnote.feature.notes.viewmodel.NotesPageVM @@ -23,6 +24,7 @@ import org.mockito.kotlin.whenever class NotesPageVMShould: BaseUnitTest() { private val repository: NoteRepository = mock() + private val noteDataStore: NoteDataStore = mock() private val notes = listOf( NoteModel(1, "title1", "description1"), NoteModel(2, "title2", "description2") @@ -41,7 +43,12 @@ class NotesPageVMShould: BaseUnitTest() { @Before fun setup() { - viewModel = NotesPageVM(repository) + runBlocking { + whenever(noteDataStore.getStringData(Constants.NOTE_VIEW_MODE)).thenReturn( + flowOf(Constants.VIEW_MODE_LIST) + ) + } + viewModel = NotesPageVM(repository, noteDataStore) } @Test @@ -136,6 +143,13 @@ class NotesPageVMShould: BaseUnitTest() { assertEquals(false, viewModel.isMarking.value) } + @Test + fun verifySetNoteViewModeUpdatesStateAndDataStore() = runTest { + viewModel.setNoteViewMode(Constants.VIEW_MODE_STAGGERED) + assertEquals(Constants.VIEW_MODE_STAGGERED, viewModel.noteViewMode.value) + verify(noteDataStore, times(1)).setStringData(Constants.NOTE_VIEW_MODE, Constants.VIEW_MODE_STAGGERED) + } + @Test fun verifyMarkedNoteListCanBeAddedOrRemoved() = runTest { viewModel.markedNoteList.add(note) From ec852e29cb2a4d9540d05edc01d5bdcc03e8c7dd Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Tue, 24 Feb 2026 00:26:04 +0700 Subject: [PATCH 15/16] Chore staging: Increase version code & change version name --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 1b91524..1db0278 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -27,8 +27,8 @@ android { applicationId "com.digiventure.ventnote" minSdk 23 targetSdk = 36 - versionCode 46 - versionName "1.2.0" + versionCode 47 + versionName "1.3.0" testInstrumentationRunner "com.digiventure.utils.CustomTestRunner" vectorDrawables { From bb4aeed71700b748bff97e98ce28b67fe3e22b0c Mon Sep 17 00:00:00 2001 From: Syubban Fakhriya Date: Tue, 24 Feb 2026 00:27:34 +0700 Subject: [PATCH 16/16] Chore staging: Increase version code --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 1db0278..4a4be7f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -27,7 +27,7 @@ android { applicationId "com.digiventure.ventnote" minSdk 23 targetSdk = 36 - versionCode 47 + versionCode 48 versionName "1.3.0" testInstrumentationRunner "com.digiventure.utils.CustomTestRunner"