diff --git a/app/src/androidTest/java/org/kiwix/kiwixmobile/note/NoteFragmentTest.kt b/app/src/androidTest/java/org/kiwix/kiwixmobile/note/NoteFragmentTest.kt index 41c937fe9..a44f39d40 100644 --- a/app/src/androidTest/java/org/kiwix/kiwixmobile/note/NoteFragmentTest.kt +++ b/app/src/androidTest/java/org/kiwix/kiwixmobile/note/NoteFragmentTest.kt @@ -19,6 +19,7 @@ package org.kiwix.kiwixmobile.note import android.os.Build +import androidx.compose.ui.test.junit4.createComposeRule import androidx.core.content.edit import androidx.core.net.toUri import androidx.lifecycle.Lifecycle @@ -33,6 +34,7 @@ import androidx.test.uiautomator.UiDevice import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesCheck import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesViews import com.google.android.apps.common.testing.accessibility.framework.checks.DuplicateClickableBoundsCheck +import com.google.android.apps.common.testing.accessibility.framework.checks.SpeakableTextPresentCheck import com.google.android.apps.common.testing.accessibility.framework.checks.TouchTargetSizeCheck import leakcanary.LeakAssertions import org.hamcrest.Matchers.allOf @@ -64,6 +66,9 @@ class NoteFragmentTest : BaseActivityTest() { private lateinit var kiwixMainActivity: KiwixMainActivity + @get:Rule + val composeTestRule = createComposeRule() + @Before override fun waitForIdle() { UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).apply { @@ -107,7 +112,8 @@ class NoteFragmentTest : BaseActivityTest() { allOf( matchesCheck(TouchTargetSizeCheck::class.java), matchesViews(withContentDescription("More options")) - ) + ), + matchesCheck(SpeakableTextPresentCheck::class.java) ) ) } @@ -133,16 +139,16 @@ class NoteFragmentTest : BaseActivityTest() { StandardActions.closeDrawer() // close the drawer if open before running the test cases. note { clickOnNoteMenuItem(context) - assertNoteDialogDisplayed() - writeDemoNote() - saveNote() + assertNoteDialogDisplayed(composeTestRule) + writeDemoNote(composeTestRule) + saveNote(composeTestRule) pressBack() openNoteFragment() assertToolbarExist() assertNoteRecyclerViewExist() clickOnSavedNote() clickOnOpenNote() - assertNoteSaved() + assertNoteSaved(composeTestRule) // to close the note dialog. pressBack() // to close the notes fragment. @@ -166,7 +172,7 @@ class NoteFragmentTest : BaseActivityTest() { assertNoteRecyclerViewExist() clickOnSavedNote() clickOnOpenNote() - assertNoteSaved() + assertNoteSaved(composeTestRule) pressBack() } if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N_MR1) { @@ -182,16 +188,16 @@ class NoteFragmentTest : BaseActivityTest() { note { assertHomePageIsLoadedOfTestZimFile() clickOnNoteMenuItem(context) - assertNoteDialogDisplayed() - writeDemoNote() - saveNote() + assertNoteDialogDisplayed(composeTestRule) + writeDemoNote(composeTestRule) + saveNote(composeTestRule) pressBack() openNoteFragment() assertToolbarExist() assertNoteRecyclerViewExist() clickOnSavedNote() clickOnOpenNote() - assertNoteSaved() + assertNoteSaved(composeTestRule) // to close the note dialog. pressBack() // to close the notes fragment. @@ -201,23 +207,25 @@ class NoteFragmentTest : BaseActivityTest() { @Test fun testNoteEntryIsRemovedFromDatabaseWhenDeletedInAddNoteDialog() { - deletePreviouslySavedNotes() - loadZimFileInReader("testzim.zim") - note { - clickOnNoteMenuItem(context) - assertNoteDialogDisplayed() - writeDemoNote() - saveNote() - pressBack() - openNoteFragment() - assertToolbarExist() - assertNoteRecyclerViewExist() - clickOnSavedNote() - clickOnOpenNote() - assertNoteSaved() - clickOnDeleteIcon() - pressBack() - assertNoNotesTextDisplayed() + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N_MR1) { + deletePreviouslySavedNotes() + loadZimFileInReader("testzim.zim") + note { + clickOnNoteMenuItem(context) + assertNoteDialogDisplayed(composeTestRule) + writeDemoNote(composeTestRule) + saveNote(composeTestRule) + pressBack() + openNoteFragment() + assertToolbarExist() + assertNoteRecyclerViewExist() + clickOnSavedNote() + clickOnOpenNote() + assertNoteSaved(composeTestRule) + clickOnDeleteIcon(composeTestRule) + pressBack() + assertNoNotesTextDisplayed() + } } } @@ -228,9 +236,9 @@ class NoteFragmentTest : BaseActivityTest() { // Save a note. note { clickOnNoteMenuItem(context) - assertNoteDialogDisplayed() - writeDemoNote() - saveNote() + assertNoteDialogDisplayed(composeTestRule) + writeDemoNote(composeTestRule) + saveNote(composeTestRule) pressBack() } // Delete that note from "Note" screen. @@ -238,8 +246,8 @@ class NoteFragmentTest : BaseActivityTest() { // Test the note file is deleted or not. note { clickOnNoteMenuItem(context) - assertNoteDialogDisplayed() - assertNotDoesNotExist() + assertNoteDialogDisplayed(composeTestRule) + assertNotDoesNotExist(composeTestRule) pressBack() } } diff --git a/app/src/androidTest/java/org/kiwix/kiwixmobile/note/NoteRobot.kt b/app/src/androidTest/java/org/kiwix/kiwixmobile/note/NoteRobot.kt index 9a5659c5c..1017e588d 100644 --- a/app/src/androidTest/java/org/kiwix/kiwixmobile/note/NoteRobot.kt +++ b/app/src/androidTest/java/org/kiwix/kiwixmobile/note/NoteRobot.kt @@ -19,14 +19,17 @@ package org.kiwix.kiwixmobile.note import android.content.Context +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.closeSoftKeyboard import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu -import androidx.test.espresso.action.ViewActions.clearText import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.action.ViewActions.typeText -import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.withContentDescription @@ -41,6 +44,10 @@ import org.kiwix.kiwixmobile.Findable.StringId.TextId import org.kiwix.kiwixmobile.Findable.Text import org.kiwix.kiwixmobile.Findable.ViewId import org.kiwix.kiwixmobile.core.R +import org.kiwix.kiwixmobile.core.main.ADD_NOTE_TEXT_FILED_TESTING_TAG +import org.kiwix.kiwixmobile.core.main.DELETE_MENU_BUTTON_TESTING_TAG +import org.kiwix.kiwixmobile.core.main.SAVE_MENU_BUTTON_TESTING_TAG +import org.kiwix.kiwixmobile.core.ui.components.TOOLBAR_TITLE_TESTING_TAG import org.kiwix.kiwixmobile.testutils.TestUtils import org.kiwix.kiwixmobile.testutils.TestUtils.testFlakyView import org.kiwix.kiwixmobile.utils.StandardActions.openDrawer @@ -49,7 +56,6 @@ fun note(func: NoteRobot.() -> Unit) = NoteRobot().apply(func) class NoteRobot : BaseRobot() { private val noteText = "Test Note" - private val editTextId = R.id.add_note_edit_text fun assertToolbarExist() { isVisible(ViewId(R.id.toolbar)) @@ -71,19 +77,39 @@ class NoteRobot : BaseRobot() { clickOn(TextId(R.string.take_notes)) } - fun assertNoteDialogDisplayed() { - pauseForBetterTestPerformance() - isVisible(TextId(R.string.note)) + fun assertNoteDialogDisplayed(composeTestRule: ComposeContentTestRule) { + testFlakyView({ + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(TOOLBAR_TITLE_TESTING_TAG) + .assertTextEquals(context.getString(R.string.note)) + }) } - fun writeDemoNote() { - onView(withId(editTextId)).perform(clearText(), typeText(noteText)) - closeSoftKeyboard() + fun writeDemoNote(composeTestRule: ComposeContentTestRule) { + testFlakyView({ + composeTestRule.waitForIdle() + // Click on the TextField to focus it + composeTestRule.onNodeWithTag(ADD_NOTE_TEXT_FILED_TESTING_TAG) + .assertExists("TextField not found in dialog") + .performClick() + .performTextReplacement(noteText) + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(ADD_NOTE_TEXT_FILED_TESTING_TAG) + .assertTextContains(noteText, substring = true) + + // Close the keyboard after typing + closeSoftKeyboard() + }) } - fun saveNote() { - pauseForBetterTestPerformance() - clickOn(ViewId(R.id.save_note)) + fun saveNote(composeTestRule: ComposeContentTestRule) { + testFlakyView({ + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(SAVE_MENU_BUTTON_TESTING_TAG) + .performClick() + }) } fun openNoteFragment() { @@ -106,19 +132,30 @@ class NoteRobot : BaseRobot() { testFlakyView({ clickOn(Text("OPEN NOTE")) }) } - fun assertNoteSaved() { + fun assertNoteSaved(composeTestRule: ComposeContentTestRule) { // This is flaky since it is shown in a dialog and sometimes // UIDevice does not found the view immediately due to rendering process. - testFlakyView({ isVisible(Text(noteText)) }) + testFlakyView({ + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(ADD_NOTE_TEXT_FILED_TESTING_TAG) + .assertTextEquals(noteText) + }) } - fun assertNotDoesNotExist() { - testFlakyView({ onView(withText(noteText)).check(doesNotExist()) }) + fun assertNotDoesNotExist(composeTestRule: ComposeContentTestRule) { + testFlakyView({ + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(ADD_NOTE_TEXT_FILED_TESTING_TAG) + .assertTextContains("", ignoreCase = true) + }) } - fun clickOnDeleteIcon() { - pauseForBetterTestPerformance() - testFlakyView({ clickOn(ViewId(R.id.delete_note)) }) + fun clickOnDeleteIcon(composeTestRule: ComposeContentTestRule) { + testFlakyView({ + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(DELETE_MENU_BUTTON_TESTING_TAG) + .performClick() + }) } fun clickOnTrashIcon() { diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/error/ErrorActivity.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/error/ErrorActivity.kt index 07d3ac48a..335a9ab21 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/error/ErrorActivity.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/error/ErrorActivity.kt @@ -41,7 +41,6 @@ import org.kiwix.kiwixmobile.core.compat.ResolveInfoFlagsCompat import org.kiwix.kiwixmobile.core.dao.NewBookDao import org.kiwix.kiwixmobile.core.extensions.toast import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer -import org.kiwix.kiwixmobile.core.ui.theme.KiwixTheme import org.kiwix.kiwixmobile.core.utils.CRASH_AND_FEEDBACK_EMAIL_ADDRESS import org.kiwix.kiwixmobile.core.utils.LanguageUtils.Companion.getCurrentLocale import org.kiwix.kiwixmobile.core.utils.files.FileLogger @@ -92,15 +91,13 @@ open class ErrorActivity : BaseActivity() { checkBoxItems = remember { getCrashCheckBoxItems().map { it.first to mutableStateOf(it.second) } } - KiwixTheme { - ErrorActivityScreen( - crashTitle, - crashDescription, - checkBoxItems, - { restartApp() }, - { sendDetailsOnMail() } - ) - } + ErrorActivityScreen( + crashTitle, + crashDescription, + checkBoxItems, + { restartApp() }, + { sendDetailsOnMail() } + ) } } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/error/ErrorActivityScreen.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/error/ErrorActivityScreen.kt index 4557a7c1d..856d84e9c 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/error/ErrorActivityScreen.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/error/ErrorActivityScreen.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import com.tonyodev.fetch2.R.string @@ -49,6 +48,10 @@ import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.extensions.loadBitmapFromMipmap import org.kiwix.kiwixmobile.core.ui.components.CrashCheckBox import org.kiwix.kiwixmobile.core.ui.components.KiwixButton +import org.kiwix.kiwixmobile.core.ui.theme.AlabasterWhite +import org.kiwix.kiwixmobile.core.ui.theme.ErrorActivityBackground +import org.kiwix.kiwixmobile.core.ui.theme.KiwixTheme +import org.kiwix.kiwixmobile.core.ui.theme.White import org.kiwix.kiwixmobile.core.utils.ComposeDimens.CRASH_IMAGE_SIZE import org.kiwix.kiwixmobile.core.utils.ComposeDimens.EIGHT_DP import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SEVENTEEN_DP @@ -64,63 +67,91 @@ fun ErrorActivityScreen( noThanksButtonClickListener: () -> Unit, sendDetailsButtonClickListener: () -> Unit ) { - Column( - modifier = Modifier - .fillMaxSize() - .background(colorResource(id = R.color.error_activity_background)) - .systemBarsPadding() - .imePadding() - .padding(SIXTEEN_DP), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(crashTitleStringId), - style = MaterialTheme.typography.headlineSmall, - color = colorResource(id = R.color.alabaster_white), - modifier = Modifier.padding(top = SIXTY_DP, start = EIGHT_DP, end = EIGHT_DP) - ) - - Image( - bitmap = ImageBitmap.loadBitmapFromMipmap(LocalContext.current, R.mipmap.ic_launcher), - contentDescription = stringResource(id = string.app_name), + KiwixTheme { + Column( modifier = Modifier - .height(CRASH_IMAGE_SIZE) - .padding(top = TWELVE_DP, start = EIGHT_DP, end = EIGHT_DP) - ) - - Text( - text = stringResource(messageStringId), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - color = colorResource(id = R.color.white), - modifier = Modifier.padding(start = EIGHT_DP, top = EIGHT_DP, end = EIGHT_DP) - ) - - Column(modifier = Modifier.weight(1f).padding(top = SEVENTEEN_DP, bottom = EIGHT_DP)) { - LazyColumn( - modifier = Modifier.fillMaxWidth() - ) { - itemsIndexed(checkBoxData) { _, item -> - CrashCheckBox(item.first to item.second) - } - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding(), - horizontalArrangement = Arrangement.SpaceEvenly + .fillMaxSize() + .background(ErrorActivityBackground) + .systemBarsPadding() + .imePadding() + .padding(SIXTEEN_DP), + horizontalAlignment = Alignment.CenterHorizontally ) { - KiwixButton( - buttonTextId = R.string.crash_button_confirm, - clickListener = { sendDetailsButtonClickListener.invoke() }, + CrashTitle(crashTitleStringId) + AppIcon() + CrashMessage(messageStringId) + CrashCheckBoxList( + checkBoxData, + Modifier + .weight(1f) + .padding(top = SEVENTEEN_DP, bottom = EIGHT_DP) ) - KiwixButton( - clickListener = { noThanksButtonClickListener.invoke() }, - buttonTextId = R.string.no_thanks - ) + // Buttons on the ErrorActivity. + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + KiwixButton( + buttonTextId = R.string.crash_button_confirm, + clickListener = { sendDetailsButtonClickListener.invoke() }, + ) + + KiwixButton( + clickListener = { noThanksButtonClickListener.invoke() }, + buttonTextId = R.string.no_thanks + ) + } + } + } +} + +@Composable +private fun CrashTitle( + @StringRes titleId: Int +) { + Text( + text = stringResource(titleId), + style = MaterialTheme.typography.headlineSmall, + color = AlabasterWhite, + modifier = Modifier.padding(top = SIXTY_DP, start = EIGHT_DP, end = EIGHT_DP) + ) +} + +@Composable +private fun AppIcon() { + Image( + bitmap = ImageBitmap.loadBitmapFromMipmap(LocalContext.current, R.mipmap.ic_launcher), + contentDescription = stringResource(id = string.app_name), + modifier = Modifier + .height(CRASH_IMAGE_SIZE) + .padding(top = TWELVE_DP, start = EIGHT_DP, end = EIGHT_DP) + ) +} + +@Composable +private fun CrashMessage( + @StringRes messageId: Int +) { + Text( + text = stringResource(messageId), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = White, + modifier = Modifier.padding(start = EIGHT_DP, top = EIGHT_DP, end = EIGHT_DP) + ) +} + +@Composable +private fun CrashCheckBoxList( + checkBoxData: List>>, + modifier: Modifier +) { + LazyColumn(modifier = modifier) { + itemsIndexed(checkBoxData) { _, item -> + CrashCheckBox(item.first to item.second) } } } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/extensions/SnackbarHostStateExtension.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/extensions/SnackbarHostStateExtension.kt new file mode 100644 index 000000000..2cc75c3c7 --- /dev/null +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/extensions/SnackbarHostStateExtension.kt @@ -0,0 +1,45 @@ +/* + * Kiwix Android + * Copyright (c) 2025 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.core.extensions + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +fun SnackbarHostState.snack( + message: String, + actionLabel: String? = null, + actionClick: (() -> Unit)? = null, + // Default duration is 4 seconds. + snackbarDuration: SnackbarDuration = SnackbarDuration.Short, + lifecycleScope: CoroutineScope +) { + lifecycleScope.launch { + val result = showSnackbar( + message = message, + actionLabel = actionLabel?.uppercase(), + duration = snackbarDuration + ) + if (result == SnackbarResult.ActionPerformed) { + actionClick?.invoke() + } + } +} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/main/AddNoteDialog.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/main/AddNoteDialog.kt index 2f707c16f..e2b529d36 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/main/AddNoteDialog.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/main/AddNoteDialog.kt @@ -19,38 +19,43 @@ package org.kiwix.kiwixmobile.core.main import android.Manifest import android.app.Dialog -import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle -import android.text.Editable import android.view.LayoutInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import android.widget.EditText import android.widget.Toast import androidx.activity.OnBackPressedCallback -import androidx.appcompat.widget.Toolbar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope import org.kiwix.kiwixmobile.core.CoreApp.Companion.coreComponent import org.kiwix.kiwixmobile.core.CoreApp.Companion.instance import org.kiwix.kiwixmobile.core.R -import org.kiwix.kiwixmobile.core.databinding.DialogAddNoteBinding import org.kiwix.kiwixmobile.core.extensions.closeKeyboard -import org.kiwix.kiwixmobile.core.extensions.getToolbarNavigationIcon -import org.kiwix.kiwixmobile.core.extensions.setToolTipWithContentDescription import org.kiwix.kiwixmobile.core.extensions.snack import org.kiwix.kiwixmobile.core.extensions.toast import org.kiwix.kiwixmobile.core.page.notes.adapter.NoteListItem import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer import org.kiwix.kiwixmobile.core.reader.ZimReaderSource +import org.kiwix.kiwixmobile.core.ui.components.NavigationIcon +import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem +import org.kiwix.kiwixmobile.core.ui.models.IconItem +import org.kiwix.kiwixmobile.core.ui.models.IconItem.Drawable +import org.kiwix.kiwixmobile.core.ui.models.IconItem.Vector import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil -import org.kiwix.kiwixmobile.core.utils.SimpleTextWatcher import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog import org.kiwix.kiwixmobile.core.utils.files.Log @@ -77,13 +82,14 @@ class AddNoteDialog : DialogFragment() { private lateinit var zimFileUrl: String private var articleTitle: String? = null + private val menuItems = mutableStateOf(actionMenuItems()) + private val noteText = mutableStateOf(TextFieldValue("")) + private lateinit var snackBarHostState: SnackbarHostState + // Corresponds to "ArticleUrl" of "{External Storage}/Kiwix/Notes/ZimFileName/ArticleUrl.txt" private lateinit var articleNoteFileName: String - private var noteFileExists = false private var noteEdited = false - private var dialogNoteAddNoteBinding: DialogAddNoteBinding? = null - // Keeps track of state of the note (whether edited since last save) // Stores path to directory for the currently open zim's notes private var zimNotesDirectory: String? = null @@ -100,16 +106,6 @@ class AddNoteDialog : DialogFragment() { @Inject lateinit var mainRepositoryActions: MainRepositoryActions - private val toolbar by lazy { - dialogNoteAddNoteBinding?.root?.findViewById(R.id.toolbar) - } - - private val saveItem by lazy { toolbar?.menu?.findItem(R.id.save_note) } - - private val shareItem by lazy { toolbar?.menu?.findItem(R.id.share_note) } - - private val deleteItem by lazy { toolbar?.menu?.findItem(R.id.delete_note) } - private var noteListItem: NoteListItem? = null private var zimReaderSource: ZimReaderSource? = null private var favicon: String? = null @@ -174,10 +170,81 @@ class AddNoteDialog : DialogFragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - super.onCreateView(inflater, container, savedInstanceState) - dialogNoteAddNoteBinding = DialogAddNoteBinding.inflate(inflater, container, false) - return dialogNoteAddNoteBinding?.root + ): View? = ComposeView(requireContext()).apply { + setContent { + snackBarHostState = remember { SnackbarHostState() } + AddNoteDialogScreen( + articleTitle.toString(), + navigationIcon = { + NavigationIcon( + iconItem = IconItem.Drawable(R.drawable.ic_close_white_24dp), + onClick = { + exitAddNoteDialog() + closeKeyboard() + } + ) + }, + noteText = noteText.value, + actionMenuItems = menuItems.value, + onTextChange = { textInputFiled -> + enableSaveAndShareMenuButtonAndSetTextEdited(textInputFiled) + }, + snackBarHostState = snackBarHostState + ) + } + } + + fun updateMenuItem(vararg contentDescription: Int, isEnabled: Boolean) { + menuItems.value = menuItems.value.map { item -> + if (contentDescription.contains(item.contentDescription)) { + item.copy(isEnabled = isEnabled) + } else { + item + } + } + } + + private fun actionMenuItems() = listOf( + ActionMenuItem( + Vector(Icons.Default.Delete), + R.string.delete, + { deleteNote() }, + isEnabled = false, + testingTag = DELETE_MENU_BUTTON_TESTING_TAG + ), + ActionMenuItem( + Vector(Icons.Default.Share), + R.string.share, + { shareNote() }, + isEnabled = false, + testingTag = SHARE_MENU_BUTTON_TESTING_TAG + ), + ActionMenuItem( + Drawable(R.drawable.ic_save), + R.string.save, + { saveNote() }, + isEnabled = false, + testingTag = SAVE_MENU_BUTTON_TESTING_TAG + ) + ) + + /** + * Updates the note text and enables the save/share menu buttons when the user edits the text. + * + * ⚠️ Jetpack Compose triggers `onTextChange` not only when the text changes + * but also when the cursor position moves due to recomposition or rendering updates. + * This function ensures that menu buttons are only enabled when the actual text is modified, + * preventing unnecessary state updates caused by cursor movement. + * + * @param textFieldValue The latest value of the TextField, including text and cursor position. + */ + private fun enableSaveAndShareMenuButtonAndSetTextEdited(textFieldValue: TextFieldValue) { + if (noteText.value.text != textFieldValue.text) { + noteEdited = true + enableSaveNoteMenuItem() + enableShareNoteMenuItem() + } + noteText.value = textFieldValue } private val zimNoteDirectoryName: String @@ -211,7 +278,7 @@ class AddNoteDialog : DialogFragment() { // Add onBackPressedCallBack to respond to user pressing 'Back' button on navigation bar override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = Dialog(requireContext(), theme) + val dialog = Dialog(requireContext()) requireActivity().onBackPressedDispatcher.addCallback(onBackPressedCallBack) return dialog } @@ -230,58 +297,28 @@ class AddNoteDialog : DialogFragment() { // Closing unedited note dialog straightaway dismissAddNoteDialog() } - if (dialogNoteAddNoteBinding?.addNoteEditText?.isFocused == true) { - dialogNoteAddNoteBinding?.addNoteEditText?.clearFocus() - } } private fun disableMenuItems() { - if (toolbar?.menu != null) { - saveItem?.isEnabled = false - shareItem?.isEnabled = false - deleteItem?.isEnabled = false - saveItem?.icon?.alpha = DISABLE_ICON_ITEM_ALPHA - shareItem?.icon?.alpha = DISABLE_ICON_ITEM_ALPHA - deleteItem?.icon?.alpha = DISABLE_ICON_ITEM_ALPHA - } else { - Log.d(TAG, "Toolbar without inflated menu") - } + updateMenuItem(R.string.delete, R.string.share, R.string.save, isEnabled = false) } private fun disableSaveNoteMenuItem() { - if (toolbar?.menu != null) { - saveItem?.isEnabled = false - saveItem?.icon?.alpha = DISABLE_ICON_ITEM_ALPHA - } else { - Log.d(TAG, "Toolbar without inflated menu") - } + updateMenuItem(R.string.save, isEnabled = false) } private fun enableSaveNoteMenuItem() { - if (toolbar?.menu != null && isZimFileExist()) { - saveItem?.isEnabled = true - saveItem?.icon?.alpha = ENABLE_ICON_ITEM_ALPHA - } else { - Log.d(TAG, "Toolbar without inflated menu") + if (isZimFileExist()) { + updateMenuItem(R.string.save, isEnabled = true) } } private fun enableDeleteNoteMenuItem() { - if (toolbar?.menu != null) { - deleteItem?.isEnabled = true - deleteItem?.icon?.alpha = ENABLE_ICON_ITEM_ALPHA - } else { - Log.d(TAG, "Toolbar without inflated menu") - } + updateMenuItem(R.string.delete, isEnabled = true) } private fun enableShareNoteMenuItem() { - if (toolbar?.menu != null) { - shareItem?.isEnabled = true - shareItem?.icon?.alpha = ENABLE_ICON_ITEM_ALPHA - } else { - Log.d(TAG, "Toolbar without inflated menu") - } + updateMenuItem(R.string.share, isEnabled = true) } override fun onViewCreated( @@ -289,62 +326,13 @@ class AddNoteDialog : DialogFragment() { savedInstanceState: Bundle? ) { super.onViewCreated(view, savedInstanceState) - toolbar?.apply { - setTitle(R.string.note) - setNavigationIcon(R.drawable.ic_close_white_24dp) - setNavigationOnClickListener { - exitAddNoteDialog() - closeKeyboard() - } - // set the navigation close button contentDescription - getToolbarNavigationIcon()?.setToolTipWithContentDescription( - getString(R.string.toolbar_back_button_content_description) - ) - setOnMenuItemClickListener { item: MenuItem -> - when (item.itemId) { - R.id.share_note -> shareNote() - R.id.save_note -> saveNote(dialogNoteAddNoteBinding?.addNoteEditText?.text.toString()) - R.id.delete_note -> deleteNote() - } - true - } - inflateMenu(R.menu.menu_add_note_dialog) - } // 'Share' disabled for empty notes, 'Save' disabled for unedited notes disableMenuItems() - dialogNoteAddNoteBinding?.addNoteTextView?.text = articleTitle - // Show the previously saved note if it exists displayNote() - dialogNoteAddNoteBinding?.addNoteEditText?.addTextChangedListener( - SimpleTextWatcher { _, _, _, _ -> - noteEdited = true - enableSaveNoteMenuItem() - enableShareNoteMenuItem() - } - ) - if (!noteFileExists) { - // Prepare for input in case of empty/new note - dialogNoteAddNoteBinding?.addNoteEditText?.apply { - requestFocus() - showKeyboard(this) - } - } } - @Suppress("MagicNumber") - private fun showKeyboard(editText: EditText) { - val inputMethodManager = - requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - editText.postDelayed( - { - inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) - }, - 100 - ) - } - - private fun saveNote(noteText: String) { + private fun saveNote() { /* String content of the EditText, given by noteText, is saved into the text file given by: * "{External Storage}/Kiwix/Notes/ZimFileTitle/ArticleTitle.txt" * */ @@ -375,7 +363,7 @@ class AddNoteDialog : DialogFragment() { // Save note text-file code: try { - noteFile.writeText(noteText) + noteFile.writeText(noteText.value.text) context.toast(R.string.note_save_successful, Toast.LENGTH_SHORT) noteEdited = false // As no unsaved changes remain enableDeleteNoteMenuItem() @@ -433,15 +421,16 @@ class AddNoteDialog : DialogFragment() { val noteFile = File(notesFolder.absolutePath, "$articleNoteFileName.txt") val noteDeleted = noteFile.delete() - val noteText = dialogNoteAddNoteBinding?.addNoteEditText?.text.toString() + val editedNoteText = noteText.value.text if (noteDeleted) { - dialogNoteAddNoteBinding?.addNoteEditText?.text?.clear() + noteText.value = TextFieldValue("") mainRepositoryActions.deleteNote(getNoteTitle()) disableMenuItems() - view?.snack( - stringId = R.string.note_delete_successful, - actionStringId = R.string.undo, - actionClick = { restoreDeletedNote(noteText) } + snackBarHostState.snack( + message = requireActivity().getString(R.string.note_delete_successful), + actionLabel = requireActivity().getString(R.string.undo), + actionClick = { restoreDeletedNote(editedNoteText) }, + lifecycleScope = lifecycleScope ) } else { context.toast(R.string.note_delete_unsuccessful, Toast.LENGTH_LONG) @@ -449,7 +438,12 @@ class AddNoteDialog : DialogFragment() { } private fun restoreDeletedNote(text: String) { - dialogNoteAddNoteBinding?.addNoteEditText?.setText(text) + val restoreNoteTextFieldValue = TextFieldValue( + text = text, + // Moves cursor to end + selection = TextRange(text.length) + ) + enableSaveAndShareMenuButtonAndSetTextEdited(restoreNoteTextFieldValue) } /* String content of the note text file given at: @@ -466,15 +460,8 @@ class AddNoteDialog : DialogFragment() { } private fun readNoteFromFile(noteFile: File) { - noteFileExists = true - val contents = noteFile.readText() - dialogNoteAddNoteBinding?.addNoteEditText?.apply { - setText(contents) // Display the note content - text?.takeIf(Editable::isNotEmpty)?.let { text -> - val selection = text.length - 1 - setSelection(selection.coerceAtLeast(0)) - } - } + val noteFileText = noteFile.readText() + noteText.value = TextFieldValue(noteFileText, selection = TextRange(noteFileText.length)) enableShareNoteMenuItem() // As note content exists which can be shared enableDeleteNoteMenuItem() if (!isZimFileExist()) { @@ -490,7 +477,7 @@ class AddNoteDialog : DialogFragment() { * */ if (noteEdited && isZimFileExist()) { // Save edited note before sharing the text file - saveNote(dialogNoteAddNoteBinding?.addNoteEditText?.text.toString()) + saveNote() } val noteFile = File("$zimNotesDirectory$articleNoteFileName.txt") if (noteFile.exists()) { @@ -526,7 +513,6 @@ class AddNoteDialog : DialogFragment() { override fun onDestroyView() { super.onDestroyView() mainRepositoryActions.dispose() - dialogNoteAddNoteBinding = null onBackPressedCallBack.remove() } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/main/AddNoteDialogScreen.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/main/AddNoteDialogScreen.kt new file mode 100644 index 000000000..47809e15a --- /dev/null +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/main/AddNoteDialogScreen.kt @@ -0,0 +1,139 @@ +/* + * Kiwix Android + * Copyright (c) 2025 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.core.main + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import org.kiwix.kiwixmobile.core.R +import org.kiwix.kiwixmobile.core.ui.components.KiwixAppBar +import org.kiwix.kiwixmobile.core.ui.components.KiwixSnackbarHost +import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem +import org.kiwix.kiwixmobile.core.ui.theme.KiwixDialogTheme +import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FIVE_DP +import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FOUR_DP +import org.kiwix.kiwixmobile.core.utils.ComposeDimens.MINIMUM_HEIGHT_OF_NOTE_TEXT_FILED +import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TEN_DP +import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TWENTY_DP + +const val ADD_NOTE_TEXT_FILED_TESTING_TAG = "addNoteTextFiledTestingTag" +const val SAVE_MENU_BUTTON_TESTING_TAG = "saveMenuButtonTestingTag" +const val SHARE_MENU_BUTTON_TESTING_TAG = "shareMenuButtonTestingTag" +const val DELETE_MENU_BUTTON_TESTING_TAG = "deleteMenuButtonTestingTag" + +@Suppress("ComposableLambdaParameterNaming") +@Composable +fun AddNoteDialogScreen( + articleTitle: String, + noteText: TextFieldValue, + actionMenuItems: List, + onTextChange: (TextFieldValue) -> Unit, + snackBarHostState: SnackbarHostState, + navigationIcon: @Composable () -> Unit +) { + KiwixDialogTheme { + Scaffold( + snackbarHost = { KiwixSnackbarHost(snackbarHostState = snackBarHostState) } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.Transparent) + .imePadding() + .padding(paddingValues), + ) { + KiwixAppBar(R.string.note, navigationIcon, actionMenuItems) + ArticleTitleText(articleTitle) + HorizontalDivider( + modifier = Modifier.padding(vertical = FIVE_DP), + color = MaterialTheme.colorScheme.onSurface + ) + NoteTextField( + noteText = noteText, + onTextChange = onTextChange + ) + } + } + } +} + +@Composable +private fun ArticleTitleText(articleTitle: String) { + Text( + text = articleTitle, + maxLines = 3, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = TEN_DP, start = TWENTY_DP, end = TWENTY_DP) + ) +} + +@Composable +private fun NoteTextField( + noteText: TextFieldValue, + onTextChange: (TextFieldValue) -> Unit +) { + TextField( + value = noteText, + onValueChange = { onTextChange(it) }, + maxLines = 6, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = MINIMUM_HEIGHT_OF_NOTE_TEXT_FILED) + .padding(bottom = TEN_DP) + .padding(horizontal = FOUR_DP) + .testTag(ADD_NOTE_TEXT_FILED_TESTING_TAG), + placeholder = { Text(text = stringResource(R.string.note)) }, + singleLine = false, + shape = RectangleShape, + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = true, + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text + ), + textStyle = MaterialTheme.typography.bodyLarge, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + errorContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) + ) +} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimReaderSource.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimReaderSource.kt index 7ee41da17..3022ee422 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimReaderSource.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimReaderSource.kt @@ -31,12 +31,13 @@ import org.kiwix.kiwixmobile.core.utils.files.FileUtils.isFileDescriptorCanOpenW import org.kiwix.libzim.Archive import org.kiwix.libzim.FdInput import java.io.File +import java.io.Serializable class ZimReaderSource( val file: File? = null, val uri: Uri? = null, val assetFileDescriptorList: List? = null -) { +) : Serializable { constructor(uri: Uri) : this( uri = uri, assetFileDescriptorList = getAssetFileDescriptorFromUri(CoreApp.instance, uri) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/CrashCheckBox.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/CrashCheckBox.kt index e7cfd0c7b..52906b598 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/CrashCheckBox.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/CrashCheckBox.kt @@ -29,9 +29,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource -import org.kiwix.kiwixmobile.core.R +import org.kiwix.kiwixmobile.core.ui.theme.DenimBlue200 +import org.kiwix.kiwixmobile.core.ui.theme.ErrorActivityBackground +import org.kiwix.kiwixmobile.core.ui.theme.White import org.kiwix.kiwixmobile.core.utils.ComposeDimens.CRASH_CHECKBOX_START_PADDING import org.kiwix.kiwixmobile.core.utils.ComposeDimens.CRASH_CHECKBOX_TOP_PADDING @@ -47,15 +48,15 @@ fun CrashCheckBox(checkBoxItem: Pair>) { checked = checkBoxItem.second.value, onCheckedChange = { checkBoxItem.second.value = it }, colors = CheckboxDefaults.colors( - checkedColor = colorResource(id = R.color.denim_blue200), - checkmarkColor = colorResource(id = R.color.error_activity_background), - uncheckedColor = colorResource(R.color.denim_blue200) + checkedColor = DenimBlue200, + checkmarkColor = ErrorActivityBackground, + uncheckedColor = DenimBlue200 ) ) Text( style = MaterialTheme.typography.bodyMedium, text = stringResource(id = checkBoxItem.first), - color = colorResource(id = R.color.white), + color = White, modifier = Modifier.padding(start = CRASH_CHECKBOX_TOP_PADDING) ) } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixAppBar.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixAppBar.kt new file mode 100644 index 000000000..3df7a1c09 --- /dev/null +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixAppBar.kt @@ -0,0 +1,122 @@ +/* + * Kiwix Android + * Copyright (c) 2025 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.core.ui.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight.Companion.SemiBold +import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem +import org.kiwix.kiwixmobile.core.ui.models.IconItem +import org.kiwix.kiwixmobile.core.ui.theme.Black +import org.kiwix.kiwixmobile.core.ui.theme.KiwixTheme +import org.kiwix.kiwixmobile.core.ui.theme.MineShaftGray350 +import org.kiwix.kiwixmobile.core.ui.theme.White +import org.kiwix.kiwixmobile.core.utils.ComposeDimens.KIWIX_APP_BAR_HEIGHT +import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP + +const val TOOLBAR_TITLE_TESTING_TAG = "toolbarTitle" + +@Composable +fun KiwixAppBar( + @StringRes titleId: Int, + navigationIcon: @Composable () -> Unit, + actionMenuItems: List = emptyList(), + // Optional search bar, used in fragments that require it + searchBar: (@Composable () -> Unit)? = null +) { + KiwixTheme { + Row( + modifier = Modifier + .fillMaxWidth() + .height(KIWIX_APP_BAR_HEIGHT) + .background(color = Black), + verticalAlignment = Alignment.CenterVertically + ) { + navigationIcon() + searchBar?.let { + // Display the search bar when provided + it() + } ?: run { + // Otherwise, show the title + AppBarTitle(titleId) + } + Spacer(Modifier.weight(1f)) + ActionMenu(actionMenuItems) + } + } +} + +@Composable +private fun AppBarTitle( + @StringRes titleId: Int +) { + val appBarTitleColor = if (isSystemInDarkTheme()) { + MineShaftGray350 + } else { + White + } + Text( + text = stringResource(titleId), + color = appBarTitleColor, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = SemiBold), + modifier = Modifier + .padding(horizontal = SIXTEEN_DP) + .testTag(TOOLBAR_TITLE_TESTING_TAG) + ) +} + +@Composable +private fun ActionMenu(actionMenuItems: List) { + Row { + actionMenuItems.forEach { menuItem -> + IconButton( + enabled = menuItem.isEnabled, + onClick = menuItem.onClick, + modifier = Modifier.testTag(menuItem.testingTag) + ) { + Icon( + painter = when (val icon = menuItem.icon) { + is IconItem.Vector -> rememberVectorPainter(icon.imageVector) + is IconItem.Drawable -> painterResource(icon.drawableRes) + }, + contentDescription = stringResource(menuItem.contentDescription), + tint = if (menuItem.isEnabled) menuItem.iconTint else Color.Gray + ) + } + } + } +} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixButton.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixButton.kt index 2aa511a42..41113d96f 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixButton.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixButton.kt @@ -26,10 +26,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource -import org.kiwix.kiwixmobile.core.R +import androidx.compose.ui.text.font.FontWeight.Companion.SemiBold +import org.kiwix.kiwixmobile.core.ui.theme.DenimBlue800 +import org.kiwix.kiwixmobile.core.ui.theme.White import org.kiwix.kiwixmobile.core.utils.ComposeDimens.BUTTON_DEFAULT_ELEVATION import org.kiwix.kiwixmobile.core.utils.ComposeDimens.BUTTON_DEFAULT_PADDING import org.kiwix.kiwixmobile.core.utils.ComposeDimens.BUTTON_PRESSED_ELEVATION @@ -46,8 +46,8 @@ fun KiwixButton( Button( onClick = { clickListener.invoke() }, colors = ButtonDefaults.buttonColors( - containerColor = colorResource(id = R.color.denim_blue800), - contentColor = Color.White + containerColor = DenimBlue800, + contentColor = White ), modifier = Modifier.padding(BUTTON_DEFAULT_PADDING), shape = MaterialTheme.shapes.extraSmall, @@ -58,7 +58,8 @@ fun KiwixButton( ) { Text( text = stringResource(id = buttonTextId).uppercase(), - letterSpacing = DEFAULT_LETTER_SPACING + letterSpacing = DEFAULT_LETTER_SPACING, + style = MaterialTheme.typography.labelLarge.copy(fontWeight = SemiBold) ) } } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixSnackbarHost.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixSnackbarHost.kt new file mode 100644 index 000000000..f5ef0a449 --- /dev/null +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixSnackbarHost.kt @@ -0,0 +1,51 @@ +/* + * Kiwix Android + * Copyright (c) 2025 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.core.ui.components + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import org.kiwix.kiwixmobile.core.ui.theme.DenimBlue400 + +/** + * A custom SnackbarHost for displaying snackbars with theme-aware action button colors. + * + * This function ensures that the action button color follows the app's theme: + * - In **light mode**, the action button color is `DenimBlue400`. + * - In **dark mode**, the action button color is `surface`, similar to the XML-based styling. + * + * @param snackbarHostState The state that controls the Snackbar display. + */ +@Composable +fun KiwixSnackbarHost(snackbarHostState: SnackbarHostState) { + val actionColor = if (isSystemInDarkTheme()) { + MaterialTheme.colorScheme.surface + } else { + DenimBlue400 + } + SnackbarHost(hostState = snackbarHostState) { snackbarData -> + Snackbar( + snackbarData = snackbarData, + actionColor = actionColor + ) + } +} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/NavigationIcon.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/NavigationIcon.kt new file mode 100644 index 000000000..6af4bc84f --- /dev/null +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/NavigationIcon.kt @@ -0,0 +1,63 @@ +/* + * Kiwix Android + * Copyright (c) 2025 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.core.ui.components + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import org.kiwix.kiwixmobile.core.R +import org.kiwix.kiwixmobile.core.ui.models.IconItem +import org.kiwix.kiwixmobile.core.ui.theme.White + +/** + * A composable function that renders a navigation icon, which can be either a vector + * or drawable image. + * + * @param iconItem The icon to be displayed. It can be either a vector (`IconItem.Vector`) + * or a drawable (`IconItem.Drawable`). + * @param onClick Callback function triggered when the icon is clicked. + * @param contentDescription A string resource ID for accessibility purposes, describing + * the icon's function. + * @param iconTint The color used to tint the icon. Default is white. + */ +@Composable +fun NavigationIcon( + iconItem: IconItem = IconItem.Vector(Icons.AutoMirrored.Filled.ArrowBack), + onClick: () -> Unit, + @StringRes contentDescription: Int = R.string.toolbar_back_button_content_description, + iconTint: Color = White +) { + IconButton(onClick = onClick) { + Icon( + painter = when (val icon = iconItem) { + is IconItem.Vector -> rememberVectorPainter(icon.imageVector) + is IconItem.Drawable -> painterResource(icon.drawableRes) + }, + contentDescription = stringResource(contentDescription), + tint = iconTint + ) + } +} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/models/ActionMenuItem.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/models/ActionMenuItem.kt new file mode 100644 index 000000000..6ed601275 --- /dev/null +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/models/ActionMenuItem.kt @@ -0,0 +1,32 @@ +/* + * Kiwix Android + * Copyright (c) 2025 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.core.ui.models + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.Color +import org.kiwix.kiwixmobile.core.ui.theme.White + +data class ActionMenuItem( + val icon: IconItem, + @StringRes val contentDescription: Int, + val onClick: () -> Unit, + val iconTint: Color = White, + val isEnabled: Boolean = true, + val testingTag: String +) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/models/IconItem.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/models/IconItem.kt new file mode 100644 index 000000000..cdc2078e8 --- /dev/null +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/models/IconItem.kt @@ -0,0 +1,29 @@ +/* + * Kiwix Android + * Copyright (c) 2025 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.core.ui.models + +import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.vector.ImageVector + +sealed class IconItem { + data class Vector(val imageVector: ImageVector) : IconItem() + data class Drawable( + @DrawableRes val drawableRes: Int + ) : IconItem() +} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/theme/Color.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/theme/Color.kt index 8473c7072..77a12dfd0 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/theme/Color.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/theme/Color.kt @@ -26,6 +26,7 @@ val MineShaftGray350 = Color(0xFFD6D6D6) val MineShaftGray500 = Color(0xFF9E9E9E) val ScorpionGray = Color(0xFF5A5A5A) val MineShaftGray600 = Color(0xFF737373) +val MineShaftGray700 = Color(0xFF424242) val MineShaftGray850 = Color(0xFF303030) val MineShaftGray900 = Color(0xFF212121) val Black = Color(0xFF000000) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/theme/Theme.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/theme/Theme.kt index a1d18d025..d57b12269 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/theme/Theme.kt @@ -34,7 +34,8 @@ private val DarkColorScheme = darkColorScheme( onSecondary = MineShaftGray350, onBackground = White, onSurface = White, - onError = White + onError = White, + onTertiary = MineShaftGray500 ) private val LightColorScheme = lightColorScheme( @@ -47,7 +48,8 @@ private val LightColorScheme = lightColorScheme( onSecondary = ScorpionGray, onBackground = Black, onSurface = Black, - onError = AlabasterWhite + onError = AlabasterWhite, + onTertiary = MineShaftGray600 ) @Composable @@ -67,3 +69,27 @@ fun KiwixTheme( typography = KiwixTypography ) } + +/** + * A custom MaterialTheme specifically for dialogs in the Kiwix app. + * + * This theme applies a modified dark mode background for dialogs while keeping + * the rest of the color scheme unchanged. In light mode, it uses the + * standard app theme(KiwixTheme). + */ +@Composable +fun KiwixDialogTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = when { + darkTheme -> DarkColorScheme.copy(background = MineShaftGray700) + else -> LightColorScheme + } + MaterialTheme( + colorScheme = colorScheme, + content = content, + shapes = shapes, + typography = KiwixTypography + ) +} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/theme/Typography.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/theme/Typography.kt index cf1db0883..71c0f26b6 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/theme/Typography.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/theme/Typography.kt @@ -20,7 +20,6 @@ package org.kiwix.kiwixmobile.core.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import org.kiwix.kiwixmobile.core.utils.ComposeDimens.LARGE_BODY_TEXT_SIZE import org.kiwix.kiwixmobile.core.utils.ComposeDimens.LARGE_HEADLINE_TEXT_SIZE import org.kiwix.kiwixmobile.core.utils.ComposeDimens.LARGE_LABEL_TEXT_SIZE @@ -50,10 +49,7 @@ val KiwixTypography = Typography( bodyLarge = TextStyle(fontSize = LARGE_BODY_TEXT_SIZE), bodyMedium = TextStyle(fontSize = MEDIUM_BODY_TEXT_SIZE), bodySmall = TextStyle(fontSize = SMALL_BODY_TEXT_SIZE), - labelLarge = TextStyle( - fontSize = LARGE_LABEL_TEXT_SIZE, - fontWeight = FontWeight.SemiBold, - ), + labelLarge = TextStyle(fontSize = LARGE_LABEL_TEXT_SIZE), labelMedium = TextStyle(fontSize = MEDIUM_LABEL_TEXT_SIZE), labelSmall = TextStyle(fontSize = SMALL_LABEL_TEXT_SIZE), ) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/ComposeDimens.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/ComposeDimens.kt index 7815613fd..fd04ae553 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/ComposeDimens.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/ComposeDimens.kt @@ -33,16 +33,19 @@ object ComposeDimens { val BUTTON_DEFAULT_PADDING = 4.dp // Error screen dimens - val CRASH_TITLE_TEXT_SIZE = 24.sp - val CRASH_MESSAGE_TEXT_SIZE = 14.sp val CRASH_IMAGE_SIZE = 70.dp + // KiwixAppBar(Toolbar) height + val KIWIX_APP_BAR_HEIGHT = 56.dp + // Padding & Margins val SIXTY_DP = 60.dp val THIRTY_TWO_DP = 30.dp + val TWENTY_DP = 20.dp val SEVENTEEN_DP = 17.dp val SIXTEEN_DP = 16.dp val TWELVE_DP = 12.dp + val TEN_DP = 10.dp val EIGHT_DP = 8.dp val FIVE_DP = 5.dp val FOUR_DP = 4.dp @@ -76,4 +79,7 @@ object ComposeDimens { val LARGE_LABEL_TEXT_SIZE = 14.sp val MEDIUM_LABEL_TEXT_SIZE = 12.sp val SMALL_LABEL_TEXT_SIZE = 10.sp + + // AddNoteDialog dimens + val MINIMUM_HEIGHT_OF_NOTE_TEXT_FILED = 120.dp } diff --git a/core/src/main/res/layout/dialog_add_note.xml b/core/src/main/res/layout/dialog_add_note.xml deleted file mode 100644 index 3295dd137..000000000 --- a/core/src/main/res/layout/dialog_add_note.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - -