Merge pull request #4258 from kiwix/Fixes#4250

Migrated the `AddNoteDialog` to jetpack.
This commit is contained in:
Kelson 2025-03-12 05:53:06 +01:00 committed by GitHub
commit d4ebe42d49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 843 additions and 332 deletions

View File

@ -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()
}
}

View File

@ -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() {

View File

@ -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() }
)
}
}

View File

@ -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<Pair<Int, MutableState<Boolean>>>,
modifier: Modifier
) {
LazyColumn(modifier = modifier) {
itemsIndexed(checkBoxData) { _, item ->
CrashCheckBox(item.first to item.second)
}
}
}

View File

@ -0,0 +1,45 @@
/*
* Kiwix Android
* Copyright (c) 2025 Kiwix <android.kiwix.org>
* 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 <http://www.gnu.org/licenses/>.
*
*/
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()
}
}
}

View File

@ -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<Toolbar>(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()
}

View File

@ -0,0 +1,139 @@
/*
* Kiwix Android
* Copyright (c) 2025 Kiwix <android.kiwix.org>
* 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 <http://www.gnu.org/licenses/>.
*
*/
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<ActionMenuItem>,
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
)
)
}

View File

@ -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<AssetFileDescriptor>? = null
) {
) : Serializable {
constructor(uri: Uri) : this(
uri = uri,
assetFileDescriptorList = getAssetFileDescriptorFromUri(CoreApp.instance, uri)

View File

@ -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<Int, MutableState<Boolean>>) {
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)
)
}

View File

@ -0,0 +1,122 @@
/*
* Kiwix Android
* Copyright (c) 2025 Kiwix <android.kiwix.org>
* 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 <http://www.gnu.org/licenses/>.
*
*/
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<ActionMenuItem> = 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<ActionMenuItem>) {
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
)
}
}
}
}

View File

@ -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)
)
}
}

View File

@ -0,0 +1,51 @@
/*
* Kiwix Android
* Copyright (c) 2025 Kiwix <android.kiwix.org>
* 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 <http://www.gnu.org/licenses/>.
*
*/
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
)
}
}

View File

@ -0,0 +1,63 @@
/*
* Kiwix Android
* Copyright (c) 2025 Kiwix <android.kiwix.org>
* 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 <http://www.gnu.org/licenses/>.
*
*/
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
)
}
}

View File

@ -0,0 +1,32 @@
/*
* Kiwix Android
* Copyright (c) 2025 Kiwix <android.kiwix.org>
* 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 <http://www.gnu.org/licenses/>.
*
*/
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
)

View File

@ -0,0 +1,29 @@
/*
* Kiwix Android
* Copyright (c) 2025 Kiwix <android.kiwix.org>
* 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 <http://www.gnu.org/licenses/>.
*
*/
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()
}

View File

@ -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)

View File

@ -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
)
}

View File

@ -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),
)

View File

@ -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
}

View File

@ -1,61 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent">
<include layout="@layout/layout_standard_app_bar" />
<ScrollView
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/app_bar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/add_note_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:hint="@string/wiki_article_title"
android:maxLines="3"
android:paddingStart="20dp"
android:paddingTop="10dp"
android:paddingEnd="20dp"
android:textAppearance="?textAppearanceSubtitle1" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:background="#000000" />
<EditText
android:id="@+id/add_note_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:gravity="top|start"
android:hint="@string/note"
android:inputType="textMultiLine|textCapSentences|textAutoCorrect"
android:minLines="6"
android:paddingStart="20dp"
android:paddingTop="5dp"
android:paddingEnd="20dp"
android:paddingBottom="10dp"
android:scrollbars="vertical"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:importantForAutofill="no" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>