Merge pull request #4284 from kiwix/Fixes#4243

Migrated `BookmarkFragment`, `HistoryFragment`, and `NotesFragment` to Jetpack Compose.
This commit is contained in:
Kelson 2025-04-18 08:29:24 +02:00 committed by GitHub
commit 201c16bd8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 748 additions and 294 deletions

View File

@ -38,6 +38,7 @@ import org.kiwix.kiwixmobile.Findable.StringId.TextId
import org.kiwix.kiwixmobile.Findable.ViewId
import org.kiwix.kiwixmobile.R
import org.kiwix.kiwixmobile.core.R.string
import org.kiwix.kiwixmobile.core.page.SEARCH_ICON_TESTING_TAG
import org.kiwix.kiwixmobile.language.composables.LANGUAGE_ITEM_CHECKBOX_TESTING_TAG
import org.kiwix.kiwixmobile.testutils.TestUtils

View File

@ -119,13 +119,13 @@ class TopLevelDestinationTest : BaseActivityTest() {
}
}
clickBookmarksOnNavDrawer {
assertBookMarksDisplayed()
clickOnTrashIcon()
assertBookMarksDisplayed(composeTestRule)
clickOnTrashIcon(composeTestRule)
assertDeleteBookmarksDialogDisplayed()
}
clickHistoryOnSideNav {
assertHistoryDisplayed()
clickOnTrashIcon()
assertHistoryDisplayed(composeTestRule)
clickOnTrashIcon(composeTestRule)
assertDeleteHistoryDialogDisplayed()
}
clickHostBooksOnSideNav(ZimHostRobot::assertMenuWifiHotspotDiplayed)

View File

@ -121,9 +121,8 @@ class NoteFragmentTest : BaseActivityTest() {
it.navigate(R.id.notesFragment)
}
note {
assertToolbarExist()
assertNoteRecyclerViewExist()
assertSwitchWidgetExist()
assertToolbarExist(composeTestRule)
assertSwitchWidgetExist(composeTestRule)
}
LeakAssertions.assertNoLeaks()
}
@ -140,9 +139,8 @@ class NoteFragmentTest : BaseActivityTest() {
saveNote(composeTestRule)
pressBack()
openNoteFragment()
assertToolbarExist()
assertNoteRecyclerViewExist()
clickOnSavedNote()
assertToolbarExist(composeTestRule)
clickOnSavedNote(composeTestRule)
clickOnOpenNote()
assertNoteSaved(composeTestRule)
// to close the note dialog.
@ -164,9 +162,8 @@ class NoteFragmentTest : BaseActivityTest() {
note {
openNoteFragment()
assertToolbarExist()
assertNoteRecyclerViewExist()
clickOnSavedNote()
assertToolbarExist(composeTestRule)
clickOnSavedNote(composeTestRule)
clickOnOpenNote()
assertNoteSaved(composeTestRule)
pressBack()
@ -189,9 +186,8 @@ class NoteFragmentTest : BaseActivityTest() {
saveNote(composeTestRule)
pressBack()
openNoteFragment()
assertToolbarExist()
assertNoteRecyclerViewExist()
clickOnSavedNote()
assertToolbarExist(composeTestRule)
clickOnSavedNote(composeTestRule)
clickOnOpenNote()
assertNoteSaved(composeTestRule)
// to close the note dialog.
@ -213,14 +209,13 @@ class NoteFragmentTest : BaseActivityTest() {
saveNote(composeTestRule)
pressBack()
openNoteFragment()
assertToolbarExist()
assertNoteRecyclerViewExist()
clickOnSavedNote()
assertToolbarExist(composeTestRule)
clickOnSavedNote(composeTestRule)
clickOnOpenNote()
assertNoteSaved(composeTestRule)
clickOnDeleteIcon(composeTestRule)
pressBack()
assertNoNotesTextDisplayed()
assertNoNotesTextDisplayed(composeTestRule)
}
}
}
@ -252,12 +247,11 @@ class NoteFragmentTest : BaseActivityTest() {
// delete the notes if any saved to properly run the test scenario
note {
openNoteFragment()
assertToolbarExist()
assertNoteRecyclerViewExist()
clickOnTrashIcon()
assertToolbarExist(composeTestRule)
clickOnTrashIcon(composeTestRule)
assertDeleteNoteDialogDisplayed()
clickOnDeleteButton()
assertNoNotesTextDisplayed()
assertNoNotesTextDisplayed(composeTestRule)
pressBack()
}
}

View File

@ -22,18 +22,15 @@ 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.onAllNodesWithTag
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.click
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
@ -42,11 +39,14 @@ import com.adevinta.android.barista.interaction.BaristaSleepInteractions
import org.kiwix.kiwixmobile.BaseRobot
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.page.DELETE_MENU_ICON_TESTING_TAG
import org.kiwix.kiwixmobile.core.page.NO_ITEMS_TEXT_TESTING_TAG
import org.kiwix.kiwixmobile.core.page.PAGE_ITEM_TESTING_TAG
import org.kiwix.kiwixmobile.core.page.SWITCH_TEXT_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
@ -57,16 +57,20 @@ fun note(func: NoteRobot.() -> Unit) = NoteRobot().apply(func)
class NoteRobot : BaseRobot() {
private val noteText = "Test Note"
fun assertToolbarExist() {
isVisible(ViewId(R.id.toolbar))
fun assertToolbarExist(composeTestRule: ComposeContentTestRule) {
testFlakyView({
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag(TOOLBAR_TITLE_TESTING_TAG)
.assertTextEquals(context.getString(R.string.pref_notes))
})
}
fun assertNoteRecyclerViewExist() {
isVisible(ViewId(R.id.recycler_view))
}
fun assertSwitchWidgetExist() {
isVisible(ViewId(R.id.page_switch))
fun assertSwitchWidgetExist(composeTestRule: ComposeContentTestRule) {
composeTestRule.apply {
waitForIdle()
onNodeWithTag(SWITCH_TEXT_TESTING_TAG)
.assertTextEquals(context.getString(R.string.notes_from_all_books))
}
}
fun clickOnNoteMenuItem(context: Context) {
@ -117,15 +121,11 @@ class NoteRobot : BaseRobot() {
testFlakyView({ onView(withText(R.string.pref_notes)).perform(click()) })
}
fun clickOnSavedNote() {
testFlakyView({
onView(withId(R.id.recycler_view)).perform(
actionOnItemAtPosition<RecyclerView.ViewHolder>(
0,
click()
)
)
})
fun clickOnSavedNote(composeTestRule: ComposeContentTestRule) {
composeTestRule.apply {
waitForIdle()
onAllNodesWithTag(PAGE_ITEM_TESTING_TAG)[0].performClick()
}
}
fun clickOnOpenNote() {
@ -158,8 +158,14 @@ class NoteRobot : BaseRobot() {
})
}
fun clickOnTrashIcon() {
testFlakyView({ onView(withContentDescription(R.string.pref_clear_notes)).perform(click()) })
fun clickOnTrashIcon(composeTestRule: ComposeContentTestRule) {
testFlakyView({
composeTestRule.apply {
waitForIdle()
onNodeWithTag(DELETE_MENU_ICON_TESTING_TAG)
.performClick()
}
})
}
fun assertDeleteNoteDialogDisplayed() {
@ -171,8 +177,12 @@ class NoteRobot : BaseRobot() {
testFlakyView({ onView(ViewMatchers.withText("DELETE")).perform(click()) })
}
fun assertNoNotesTextDisplayed() {
testFlakyView({ isVisible(TextId(R.string.no_notes)) })
fun assertNoNotesTextDisplayed(composeTestRule: ComposeContentTestRule) {
composeTestRule.apply {
waitForIdle()
onNodeWithTag(NO_ITEMS_TEXT_TESTING_TAG)
.assertTextEquals(context.getString(R.string.no_notes))
}
}
fun assertHomePageIsLoadedOfTestZimFile() {

View File

@ -18,25 +18,29 @@
package org.kiwix.kiwixmobile.page.bookmarks
import androidx.recyclerview.widget.RecyclerView
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.longClick
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import applyWithViewHierarchyPrinting
import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions.assertDisplayed
import com.adevinta.android.barista.interaction.BaristaSleepInteractions
import org.kiwix.kiwixmobile.BaseRobot
import org.kiwix.kiwixmobile.Findable.StringId.ContentDesc
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.page.DELETE_MENU_ICON_TESTING_TAG
import org.kiwix.kiwixmobile.core.page.NO_ITEMS_TEXT_TESTING_TAG
import org.kiwix.kiwixmobile.core.page.PAGE_LIST_TEST_TAG
import org.kiwix.kiwixmobile.core.page.SWITCH_TEXT_TESTING_TAG
import org.kiwix.kiwixmobile.core.page.bookmark.adapter.LibkiwixBookmarkItem
import org.kiwix.kiwixmobile.testutils.TestUtils
import org.kiwix.kiwixmobile.testutils.TestUtils.testFlakyView
@ -49,12 +53,20 @@ fun bookmarks(func: BookmarksRobot.() -> Unit) =
class BookmarksRobot : BaseRobot() {
private var retryCountForBookmarkAddedButton = 5
fun assertBookMarksDisplayed() {
assertDisplayed(R.string.bookmarks_from_current_book)
fun assertBookMarksDisplayed(composeTestRule: ComposeContentTestRule) {
composeTestRule.apply {
waitForIdle()
onNodeWithTag(SWITCH_TEXT_TESTING_TAG)
.assertTextEquals(context.getString(R.string.bookmarks_from_current_book))
}
}
fun clickOnTrashIcon() {
clickOn(ContentDesc(R.string.pref_clear_all_bookmarks_title))
fun clickOnTrashIcon(composeTestRule: ComposeContentTestRule) {
composeTestRule.apply {
waitForIdle()
onNodeWithTag(DELETE_MENU_ICON_TESTING_TAG)
.performClick()
}
}
fun assertDeleteBookmarksDialogDisplayed() {
@ -66,8 +78,12 @@ class BookmarksRobot : BaseRobot() {
testFlakyView({ onView(withText("DELETE")).perform(click()) })
}
fun assertNoBookMarkTextDisplayed() {
testFlakyView({ isVisible(TextId(R.string.no_bookmarks)) })
fun assertNoBookMarkTextDisplayed(composeTestRule: ComposeTestRule) {
composeTestRule.apply {
waitForIdle()
onNodeWithTag(NO_ITEMS_TEXT_TESTING_TAG)
.assertTextEquals(context.getString(R.string.no_bookmarks))
}
}
fun clickOnSaveBookmarkImage() {
@ -97,14 +113,20 @@ class BookmarksRobot : BaseRobot() {
}
}
fun assertBookmarkSaved() {
fun assertBookmarkSaved(composeTestRule: ComposeContentTestRule) {
pauseForBetterTestPerformance()
isVisible(Text("Test Zim"))
composeTestRule.apply {
waitForIdle()
composeTestRule.onNodeWithText("Test Zim").assertExists()
}
}
fun assertBookmarkRemoved() {
fun assertBookmarkRemoved(composeTestRule: ComposeTestRule) {
pauseForBetterTestPerformance()
onView(withText("Test Zim")).check(ViewAssertions.doesNotExist())
composeTestRule.apply {
waitForIdle()
composeTestRule.onNodeWithText("Test Zim").assertDoesNotExist()
}
}
private fun pauseForBetterTestPerformance() {
@ -118,13 +140,19 @@ class BookmarksRobot : BaseRobot() {
})
}
fun testAllBookmarkShowing(bookmarkList: ArrayList<LibkiwixBookmarkItem>) {
bookmarkList.forEachIndexed { index, libkiwixBookmarkItem ->
testFlakyView({
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
.check(matches(hasDescendant(withText(libkiwixBookmarkItem.title))))
})
fun testAllBookmarkShowing(
bookmarkList: ArrayList<LibkiwixBookmarkItem>,
composeTestRule: ComposeTestRule
) {
composeTestRule.apply {
waitForIdle()
bookmarkList.forEachIndexed { index, libkiwixBookmarkItem ->
testFlakyView({
composeTestRule.onNodeWithTag(PAGE_LIST_TEST_TAG)
.performScrollToNode(hasText(libkiwixBookmarkItem.title))
composeTestRule.onNodeWithText(libkiwixBookmarkItem.title).assertExists()
})
}
}
}
}

View File

@ -18,6 +18,7 @@
package org.kiwix.kiwixmobile.page.bookmarks
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
@ -42,6 +43,7 @@ import org.kiwix.kiwixmobile.core.main.CoreReaderFragment
import org.kiwix.kiwixmobile.core.page.bookmark.adapter.LibkiwixBookmarkItem
import org.kiwix.kiwixmobile.core.utils.LanguageUtils.Companion.handleLocaleChange
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.TestingUtils.COMPOSE_TEST_RULE_ORDER
import org.kiwix.kiwixmobile.core.utils.TestingUtils.RETRY_RULE_ORDER
import org.kiwix.kiwixmobile.main.KiwixMainActivity
import org.kiwix.kiwixmobile.main.topLevel
@ -59,6 +61,9 @@ class LibkiwixBookmarkTest : BaseActivityTest() {
@JvmField
val retryRule = RetryRule()
@get:Rule(order = COMPOSE_TEST_RULE_ORDER)
val composeTestRule = createComposeRule()
private lateinit var kiwixMainActivity: KiwixMainActivity
@Before
@ -119,20 +124,20 @@ class LibkiwixBookmarkTest : BaseActivityTest() {
bookmarks {
// delete any bookmark if already saved to properly perform this test case.
longClickOnSaveBookmarkImage()
clickOnTrashIcon()
clickOnTrashIcon(composeTestRule)
assertDeleteBookmarksDialogDisplayed()
clickOnDeleteButton()
assertNoBookMarkTextDisplayed()
assertNoBookMarkTextDisplayed(composeTestRule)
pressBack()
// Test saving bookmark
clickOnSaveBookmarkImage()
clickOnOpenSavedBookmarkButton()
assertBookmarkSaved()
assertBookmarkSaved(composeTestRule)
pressBack()
// Test removing bookmark
clickOnSaveBookmarkImage()
longClickOnSaveBookmarkImage()
assertBookmarkRemoved()
assertBookmarkRemoved(composeTestRule)
pressBack()
// Save the bookmark to test whether it remains saved after the application restarts or not.
clickOnSaveBookmarkImage()
@ -142,7 +147,7 @@ class LibkiwixBookmarkTest : BaseActivityTest() {
@Test
fun testBookmarkRemainsSavedOrNot() {
topLevel {
clickBookmarksOnNavDrawer(BookmarksRobot::assertBookmarkSaved)
clickBookmarksOnNavDrawer { assertBookmarkSaved(composeTestRule) }
}
}
@ -160,10 +165,10 @@ class LibkiwixBookmarkTest : BaseActivityTest() {
bookmarks {
// delete any bookmark if already saved to properly perform this test case.
longClickOnSaveBookmarkImage()
clickOnTrashIcon()
clickOnTrashIcon(composeTestRule)
assertDeleteBookmarksDialogDisplayed()
clickOnDeleteButton()
assertNoBookMarkTextDisplayed()
assertNoBookMarkTextDisplayed(composeTestRule)
pressBack()
}
val navHostFragment: NavHostFragment =
@ -198,7 +203,7 @@ class LibkiwixBookmarkTest : BaseActivityTest() {
bookmarks {
// test all the saved bookmarks are showing on the bookmarks screen
openBookmarkScreen()
testAllBookmarkShowing(bookmarkList)
testAllBookmarkShowing(bookmarkList, composeTestRule)
}
}

View File

@ -18,23 +18,35 @@
package org.kiwix.kiwixmobile.page.history
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import applyWithViewHierarchyPrinting
import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions.assertDisplayed
import org.kiwix.kiwixmobile.BaseRobot
import org.kiwix.kiwixmobile.Findable.StringId.ContentDesc
import org.kiwix.kiwixmobile.Findable.StringId.TextId
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.page.DELETE_MENU_ICON_TESTING_TAG
import org.kiwix.kiwixmobile.core.page.SWITCH_TEXT_TESTING_TAG
import org.kiwix.kiwixmobile.testutils.TestUtils.testFlakyView
fun history(func: HistoryRobot.() -> Unit) = HistoryRobot().applyWithViewHierarchyPrinting(func)
class HistoryRobot : BaseRobot() {
fun assertHistoryDisplayed() {
assertDisplayed(R.string.history_from_current_book)
fun assertHistoryDisplayed(composeTestRule: ComposeTestRule) {
composeTestRule.apply {
waitForIdle()
onNodeWithTag(SWITCH_TEXT_TESTING_TAG)
.assertTextEquals(context.getString(R.string.history_from_current_book))
}
}
fun clickOnTrashIcon() {
clickOn(ContentDesc(R.string.pref_clear_all_history_title))
fun clickOnTrashIcon(composeTestRule: ComposeTestRule) {
composeTestRule.apply {
waitForIdle()
onNodeWithTag(DELETE_MENU_ICON_TESTING_TAG)
.performClick()
}
}
fun assertDeleteHistoryDialogDisplayed() {

View File

@ -123,13 +123,13 @@ class GetContentShortcutTest {
}
}
clickBookmarksOnNavDrawer {
assertBookMarksDisplayed()
clickOnTrashIcon()
assertBookMarksDisplayed(composeTestRule)
clickOnTrashIcon(composeTestRule)
assertDeleteBookmarksDialogDisplayed()
}
clickHistoryOnSideNav {
assertHistoryDisplayed()
clickOnTrashIcon()
assertHistoryDisplayed(composeTestRule)
clickOnTrashIcon(composeTestRule)
assertDeleteHistoryDialogDisplayed()
}
clickHostBooksOnSideNav(ZimHostRobot::assertMenuWifiHotspotDiplayed)

View File

@ -38,6 +38,7 @@ import org.kiwix.kiwixmobile.core.base.BaseActivity
import org.kiwix.kiwixmobile.core.base.BaseFragment
import org.kiwix.kiwixmobile.core.extensions.viewModel
import org.kiwix.kiwixmobile.core.main.CoreMainActivity
import org.kiwix.kiwixmobile.core.page.SEARCH_ICON_TESTING_TAG
import org.kiwix.kiwixmobile.core.ui.components.NavigationIcon
import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem
import org.kiwix.kiwixmobile.core.ui.models.IconItem
@ -46,7 +47,6 @@ import org.kiwix.kiwixmobile.language.viewmodel.Action
import org.kiwix.kiwixmobile.language.viewmodel.LanguageViewModel
import javax.inject.Inject
const val SEARCH_ICON_TESTING_TAG = "search"
const val SAVE_ICON_TESTING_TAG = "saveLanguages"
const val SEARCH_FIELD_TESTING_TAG = "searchField"

View File

@ -133,7 +133,7 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
private val disposable = CompositeDisposable()
private var permissionDeniedLayoutShowing = false
private var zimFileUri: Uri? = null
val libraryScreenState = mutableStateOf(
private val libraryScreenState = mutableStateOf(
LocalLibraryScreenState(
fileSelectListState = FileSelectListState(emptyList()),
snackBarHostState = SnackbarHostState(),
@ -156,13 +156,15 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
val isGranted = permissionResult.values.all { it }
val isPermanentlyDenied = readStorageHasBeenPermanentlyDenied(isGranted)
permissionDeniedLayoutShowing = isPermanentlyDenied
updateLibraryScreenState(
noFilesViewItem = Triple(
requireActivity().resources.getString(string.grant_read_storage_permission),
requireActivity().resources.getString(string.go_to_settings_label),
isPermanentlyDenied
if (permissionDeniedLayoutShowing) {
updateLibraryScreenState(
noFilesViewItem = Triple(
requireActivity().resources.getString(string.grant_read_storage_permission),
requireActivity().resources.getString(string.go_to_settings_label),
true
)
)
)
}
}
override fun inject(baseActivity: BaseActivity) {

View File

@ -44,7 +44,8 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.extensions.faviconToPainter
import org.kiwix.kiwixmobile.core.downloader.model.Base64String
import org.kiwix.kiwixmobile.core.downloader.model.toPainter
import org.kiwix.kiwixmobile.core.ui.theme.KiwixTheme
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.BOOK_ICON_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.EIGHT_DP
@ -54,8 +55,8 @@ import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TWO_DP
import org.kiwix.kiwixmobile.core.zim_manager.KiloByte
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.ArticleCount
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.SelectionMode
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.BooksOnDiskListItem.BookOnDisk
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.SelectionMode
const val BOOK_ITEM_CHECKBOX_TESTING_TAG = "bookItemCheckboxTestingTag"
const val BOOK_ITEM_TESTING_TAG = "bookItemTestingTag"
@ -115,7 +116,7 @@ private fun BookContent(
if (selectionMode == SelectionMode.MULTI) {
BookCheckbox(bookOnDisk, selectionMode, onMultiSelect, onClick, index)
}
BookIcon(bookOnDisk.book.faviconToPainter())
BookIcon(Base64String(bookOnDisk.book.favicon).toPainter())
BookDetails(Modifier.weight(1f), bookOnDisk)
}
}

View File

@ -94,13 +94,11 @@
<fragment
android:id="@+id/bookmarksFragment"
android:name="org.kiwix.kiwixmobile.core.page.bookmark.BookmarksFragment"
android:label="BookmarksFragment"
tools:layout="@layout/fragment_page" />
android:label="BookmarksFragment" />
<fragment
android:id="@+id/notesFragment"
android:name="org.kiwix.kiwixmobile.core.page.notes.NotesFragment"
android:label="NotesFragment"
tools:layout="@layout/fragment_page" />
android:label="NotesFragment" />
<fragment
android:id="@+id/introFragment"
android:name="org.kiwix.kiwixmobile.intro.IntroFragment"
@ -115,8 +113,7 @@
<fragment
android:id="@+id/historyFragment"
android:name="org.kiwix.kiwixmobile.core.page.history.HistoryFragment"
android:label="HistoryFragment"
tools:layout="@layout/fragment_page" />
android:label="HistoryFragment" />
<fragment
android:id="@+id/languageFragment"
android:name="org.kiwix.kiwixmobile.language.LanguageFragment"

View File

@ -21,6 +21,13 @@ package org.kiwix.kiwixmobile.core.downloader.model
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import org.kiwix.kiwixmobile.core.R
@JvmInline
value class Base64String(private val encodedString: String?) {
@ -35,3 +42,13 @@ value class Base64String(private val encodedString: String?) {
null
}
}
@Composable
fun Base64String.toPainter(): Painter {
val bitmap = remember(this) { toBitmap() }
return if (bitmap != null) {
BitmapPainter(bitmap.asImageBitmap())
} else {
painterResource(id = R.drawable.default_zim_file_icon)
}
}

View File

@ -18,15 +18,7 @@
package org.kiwix.kiwixmobile.core.extensions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import org.kiwix.kiwixmobile.core.CoreApp
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.downloader.model.Base64String
import org.kiwix.kiwixmobile.core.entity.LibraryNetworkEntity.Book
import org.kiwix.kiwixmobile.core.utils.BookUtils
import org.kiwix.kiwixmobile.core.utils.NetworkUtils
@ -62,14 +54,3 @@ fun Book.buildSearchableText(bookUtils: BookUtils): String =
append("|")
}
}.toString()
@Composable
fun Book.faviconToPainter(): Painter {
val base64String = Base64String(favicon)
val bitmap = remember(base64String) { base64String.toBitmap() }
return if (bitmap != null) {
BitmapPainter(bitmap.asImageBitmap())
} else {
painterResource(id = R.drawable.default_zim_file_icon)
}
}

View File

@ -21,44 +21,41 @@ package org.kiwix.kiwixmobile.core.page
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.lifecycle.Lifecycle
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.referentialEqualityPolicy
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.disposables.CompositeDisposable
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.base.BaseFragment
import org.kiwix.kiwixmobile.core.base.FragmentActivityExtensions
import org.kiwix.kiwixmobile.core.databinding.FragmentPageBinding
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.isCustomApp
import org.kiwix.kiwixmobile.core.extensions.closeKeyboard
import org.kiwix.kiwixmobile.core.extensions.setToolTipWithContentDescription
import org.kiwix.kiwixmobile.core.extensions.setUpSearchView
import org.kiwix.kiwixmobile.core.downloader.downloadManager.ZERO
import org.kiwix.kiwixmobile.core.main.CoreMainActivity
import org.kiwix.kiwixmobile.core.page.adapter.OnItemClickListener
import org.kiwix.kiwixmobile.core.page.adapter.Page
import org.kiwix.kiwixmobile.core.page.adapter.PageAdapter
import org.kiwix.kiwixmobile.core.page.notes.viewmodel.NotesState
import org.kiwix.kiwixmobile.core.page.viewmodel.Action
import org.kiwix.kiwixmobile.core.page.viewmodel.PageState
import org.kiwix.kiwixmobile.core.page.viewmodel.PageViewModel
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.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.SimpleRecyclerViewScrollListener
import org.kiwix.kiwixmobile.core.utils.SimpleTextListener
import javax.inject.Inject
const val SEARCH_ICON_TESTING_TAG = "search"
const val DELETE_MENU_ICON_TESTING_TAG = "deleteMenuIconTestingTag"
abstract class PageFragment : OnItemClickListener, BaseFragment(), FragmentActivityExtensions {
abstract val pageViewModel: PageViewModel<*, *>
@ -67,18 +64,44 @@ abstract class PageFragment : OnItemClickListener, BaseFragment(), FragmentActiv
@Inject lateinit var sharedPreferenceUtil: SharedPreferenceUtil
private var actionMode: ActionMode? = null
val compositeDisposable = CompositeDisposable()
abstract val screenTitle: String
abstract val screenTitle: Int
abstract val noItemsString: String
abstract val switchString: String
abstract val searchQueryHint: String
abstract val pageAdapter: PageAdapter
abstract val switchIsChecked: Boolean
abstract val deleteIconTitle: String
private var fragmentPageBinding: FragmentPageBinding? = null
override val fragmentToolbar: Toolbar? by lazy {
fragmentPageBinding?.root?.findViewById(R.id.toolbar)
}
override val fragmentTitle: String? by lazy { screenTitle }
abstract val deleteIconTitle: Int
private val pageState: MutableState<PageState<*>> =
mutableStateOf(
NotesState(
emptyList(),
true,
""
),
policy = referentialEqualityPolicy()
)
private val pageScreenState = mutableStateOf(
// Initial values are empty because this is an abstract class.
// Before the view is created, the abstract variables have no values.
// We update this state in `onViewCreated`, once the view is created and the
// abstract variables are initialized.
PageFragmentScreenState(
pageState = pageState.value,
isSearchActive = false,
searchQueryHint = "",
searchText = "",
searchValueChangedListener = {},
screenTitle = ZERO,
noItemsString = "",
switchString = "",
switchIsChecked = true,
switchIsEnabled = true,
onSwitchCheckedChanged = {},
deleteIconTitle = ZERO,
clearSearchButtonClickListener = {}
)
)
private val actionModeCallback: ActionMode.Callback =
object : ActionMode.Callback {
@ -104,77 +127,23 @@ abstract class PageFragment : OnItemClickListener, BaseFragment(), FragmentActiv
}
}
private fun setupMenu() {
(requireActivity() as MenuHost).addMenuProvider(
object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.menu_page, menu)
val search = menu.findItem(R.id.menu_page_search).actionView as SearchView
search.apply {
setUpSearchView(requireActivity())
queryHint = searchQueryHint
setOnQueryTextListener(
SimpleTextListener { query, _ ->
pageViewModel.actions.offer(Action.Filter(query))
}
)
}
menu.findItem(R.id.menu_pages_clear).title = deleteIconTitle // Bug fix #3825
}
@Suppress("ReturnCount")
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
android.R.id.home -> {
pageViewModel.actions.offer(Action.Exit)
return true
}
R.id.menu_pages_clear -> {
pageViewModel.actions.offer(Action.UserClickedDeleteButton)
return true
}
}
return false
}
},
viewLifecycleOwner,
Lifecycle.State.RESUMED
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupMenu()
val activity = requireActivity() as CoreMainActivity
fragmentPageBinding?.recyclerView?.apply {
layoutManager =
LinearLayoutManager(activity, RecyclerView.VERTICAL, false)
adapter = pageAdapter
fragmentTitle?.let(::setToolTipWithContentDescription)
}
fragmentPageBinding?.noPage?.text = noItemsString
fragmentPageBinding?.pageSwitch?.apply {
text = switchString
isChecked = switchIsChecked
// hide switches for custom apps, see more info here https://github.com/kiwix/kiwix-android/issues/3523
visibility = if (requireActivity().isCustomApp()) GONE else VISIBLE
}
compositeDisposable.add(pageViewModel.effects.subscribe { it.invokeWith(activity) })
fragmentPageBinding?.pageSwitch?.setOnCheckedChangeListener { _, isChecked ->
pageViewModel.actions.offer(Action.UserClickedShowAllToggle(isChecked))
}
pageViewModel.state.observe(viewLifecycleOwner, Observer(::render))
// hides keyboard when scrolled
fragmentPageBinding?.recyclerView?.addOnScrollListener(
SimpleRecyclerViewScrollListener { _, newState ->
if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
fragmentPageBinding?.recyclerView?.closeKeyboard()
}
}
pageScreenState.value = pageScreenState.value.copy(
searchQueryHint = searchQueryHint,
searchText = "",
searchValueChangedListener = { onTextChanged(it) },
clearSearchButtonClickListener = { onTextChanged("") },
screenTitle = screenTitle,
noItemsString = noItemsString,
switchString = switchString,
switchIsChecked = switchIsChecked,
onSwitchCheckedChanged = { onSwitchCheckedChanged(it).invoke() },
deleteIconTitle = deleteIconTitle
)
val activity = requireActivity() as CoreMainActivity
compositeDisposable.add(pageViewModel.effects.subscribe { it.invokeWith(activity) })
pageViewModel.state.observe(viewLifecycleOwner, Observer(::render))
}
override fun onCreateView(
@ -182,24 +151,121 @@ abstract class PageFragment : OnItemClickListener, BaseFragment(), FragmentActiv
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
fragmentPageBinding = FragmentPageBinding.inflate(inflater, container, false)
return fragmentPageBinding?.root
return ComposeView(requireContext()).apply {
setContent {
PageScreen(
state = pageScreenState.value,
itemClickListener = this@PageFragment,
navigationIcon = {
NavigationIcon(
onClick = navigationIconClick()
)
},
actionMenuItems = actionMenuList(
isSearchActive = pageScreenState.value.isSearchActive,
onSearchClick = {
// Set the `isSearchActive` when the search button is clicked.
pageScreenState.value = pageScreenState.value.copy(isSearchActive = true)
},
onDeleteClick = { pageViewModel.actions.offer(Action.UserClickedDeleteButton) }
)
)
}
}
}
/**
* Handles changes to the search text input.
* - Updates the UI state with the latest search query.
* - Sends a filter action to the ViewModel to perform search/filtering logic.
*
* @param searchText The current text entered in the search bar.
*/
private fun onTextChanged(searchText: String) {
pageScreenState.value = pageScreenState.value.copy(searchText = searchText)
pageViewModel.actions.offer(Action.Filter(searchText))
}
/**
* Returns a lambda to handle switch toggle changes.
* - Updates the UI state to reflect the new checked status.
* - Sends an action to the ViewModel to handle the toggle event (e.g., show all items or filter).
*
* @param isChecked The new checked state of the switch.
*/
private fun onSwitchCheckedChanged(isChecked: Boolean): () -> Unit = {
pageScreenState.value = pageScreenState.value.copy(switchIsChecked = isChecked)
pageViewModel.actions.offer(Action.UserClickedShowAllToggle(isChecked))
}
/**
* Handles the click event for the navigation icon.
* - If search is active, it deactivates the search mode and clears the search text.
* - Otherwise, it triggers the default back navigation.
*/
private fun navigationIconClick(): () -> Unit = {
if (pageScreenState.value.isSearchActive) {
pageScreenState.value = pageScreenState.value.copy(isSearchActive = false)
onTextChanged("")
} else {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
}
/**
* Builds the list of action menu items for the app bar.
*
* @param isSearchActive Whether the search mode is currently active.
* @param onSearchClick Callback to invoke when the search icon is clicked.
* @param onDeleteClick Callback to invoke when the delete icon is clicked.
* @return A list of [ActionMenuItem]s to be displayed in the app bar.
*
* - Shows the search icon only when search is not active.
* - Always includes the delete icon, with a content description for accessibility (#3825).
*/
private fun actionMenuList(
isSearchActive: Boolean,
onSearchClick: () -> Unit,
onDeleteClick: () -> Unit
): List<ActionMenuItem> {
return listOfNotNull(
when {
!isSearchActive -> ActionMenuItem(
icon = IconItem.Drawable(R.drawable.action_search),
contentDescription = R.string.search_label,
onClick = onSearchClick,
testingTag = SEARCH_ICON_TESTING_TAG
)
else -> null
},
ActionMenuItem(
icon = IconItem.Vector(Icons.Default.Delete),
// Adding content description for #3825.
contentDescription = deleteIconTitle,
onClick = onDeleteClick,
testingTag = DELETE_MENU_ICON_TESTING_TAG
)
)
}
override fun onDestroyView() {
super.onDestroyView()
compositeDisposable.clear()
fragmentPageBinding?.apply {
recyclerView.adapter = null
root.removeAllViews()
}
fragmentPageBinding = null
}
private fun render(state: PageState<*>) {
pageAdapter.items = state.visiblePageItems
fragmentPageBinding?.pageSwitch?.isEnabled = !state.isInSelectionState
fragmentPageBinding?.noPage?.visibility = if (state.pageItems.isEmpty()) VISIBLE else GONE
pageScreenState.value = pageScreenState.value.copy(
switchIsEnabled = !state.isInSelectionState,
// First, assign the existing state to force Compose to recognize a change.
// This helps when internal properties of items (like `isSelected`) change,
// but the list reference itself remains the same — Compose won't detect it otherwise.
pageState = pageState.value
)
// Then, assign the actual updated state to trigger full recomposition.
pageScreenState.value = pageScreenState.value.copy(
pageState = state
)
if (state.isInSelectionState) {
if (actionMode == null) {
actionMode =

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.page
import androidx.annotation.StringRes
import org.kiwix.kiwixmobile.core.page.viewmodel.PageState
/**
* Represents the UI state for the PageFragment Screen.
* A Base screen for Bookmarks, History, and Notes screens.
*
* This data class encapsulates all UI-related states in a single object,
* reducing complexity in the Fragment.
*/
data class PageFragmentScreenState(
val pageState: PageState<*>,
val isSearchActive: Boolean,
val searchQueryHint: String,
val searchText: String,
val searchValueChangedListener: (String) -> Unit,
val clearSearchButtonClickListener: () -> Unit,
@StringRes val screenTitle: Int,
val noItemsString: String,
val switchString: String,
val switchIsChecked: Boolean,
val switchIsEnabled: Boolean = true,
val onSwitchCheckedChanged: (Boolean) -> Unit,
@StringRes val deleteIconTitle: Int
)

View File

@ -0,0 +1,94 @@
/*
* 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.page
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.style.TextOverflow
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.downloader.model.Base64String
import org.kiwix.kiwixmobile.core.downloader.model.toPainter
import org.kiwix.kiwixmobile.core.page.adapter.OnItemClickListener
import org.kiwix.kiwixmobile.core.page.adapter.Page
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.EIGHT_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.PAGE_LIST_ITEM_FAVICON_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP
const val PAGE_ITEM_TESTING_TAG = "pageItemTestingTag"
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PageListItem(
page: Page,
itemClickListener: OnItemClickListener
) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = { itemClickListener.onItemClick(page) },
onLongClick = { itemClickListener.onItemLongClick(page) }
)
.background(MaterialTheme.colorScheme.surface)
.padding(
horizontal = SIXTEEN_DP,
vertical = EIGHT_DP
)
.semantics { testTag = PAGE_ITEM_TESTING_TAG },
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = if (page.isSelected) {
painterResource(id = R.drawable.ic_check_circle_blue_24dp)
} else {
Base64String(page.favicon).toPainter()
},
contentDescription = stringResource(R.string.fav_icon),
modifier = Modifier
.size(PAGE_LIST_ITEM_FAVICON_SIZE)
)
Spacer(modifier = Modifier.width(SIXTEEN_DP))
Text(
text = page.title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

View File

@ -0,0 +1,247 @@
/*
* 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.page
import android.app.Activity
import android.content.Context
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.TextStyle
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.isCustomApp
import org.kiwix.kiwixmobile.core.main.CoreMainActivity
import org.kiwix.kiwixmobile.core.page.adapter.OnItemClickListener
import org.kiwix.kiwixmobile.core.page.adapter.Page
import org.kiwix.kiwixmobile.core.page.history.adapter.HistoryListItem.DateItem
import org.kiwix.kiwixmobile.core.ui.components.KiwixAppBar
import org.kiwix.kiwixmobile.core.ui.components.KiwixSearchView
import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem
import org.kiwix.kiwixmobile.core.ui.theme.AlabasterWhite
import org.kiwix.kiwixmobile.core.ui.theme.Black
import org.kiwix.kiwixmobile.core.ui.theme.KiwixTheme
import org.kiwix.kiwixmobile.core.ui.theme.White
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FOURTEEN_SP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.PAGE_SWITCH_LEFT_RIGHT_MARGIN
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.PAGE_SWITCH_ROW_BOTTOM_MARGIN
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP
import org.threeten.bp.LocalDate
import org.threeten.bp.format.DateTimeFormatter
import org.threeten.bp.format.DateTimeParseException
const val SWITCH_TEXT_TESTING_TAG = "switchTextTestingTag"
const val NO_ITEMS_TEXT_TESTING_TAG = "noItemsTextTestingTag"
const val PAGE_LIST_TEST_TAG = "pageListTestingTag"
@Suppress("ComposableLambdaParameterNaming")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PageScreen(
state: PageFragmentScreenState,
itemClickListener: OnItemClickListener,
actionMenuItems: List<ActionMenuItem>,
navigationIcon: @Composable () -> Unit
) {
KiwixTheme {
Scaffold(
topBar = {
Column {
KiwixAppBar(
titleId = state.screenTitle,
navigationIcon = navigationIcon,
actionMenuItems = actionMenuItems,
searchBar = searchBarIfActive(state)
)
PageSwitchRow(state)
}
}
) { padding ->
val items = state.pageState.pageItems
Box(
modifier = Modifier
.padding(
top = padding.calculateTopPadding(),
start = padding.calculateStartPadding(LocalLayoutDirection.current),
end = padding.calculateEndPadding(LocalLayoutDirection.current)
)
.fillMaxSize()
) {
if (items.isEmpty()) {
Text(
text = state.noItemsString,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier
.align(Alignment.Center)
.semantics { testTag = NO_ITEMS_TEXT_TESTING_TAG }
)
} else {
PageList(
state = state,
itemClickListener = itemClickListener
)
}
}
}
}
}
@Composable
private fun PageList(
state: PageFragmentScreenState,
itemClickListener: OnItemClickListener
) {
val listState = rememberLazyListState()
val context = LocalContext.current
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.collect { isScrolling ->
if (isScrolling) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow((context as? Activity)?.currentFocus?.windowToken, 0)
}
}
}
LazyColumn(state = listState, modifier = Modifier.semantics { testTag = PAGE_LIST_TEST_TAG }) {
items(state.pageState.visiblePageItems) { item ->
when (item) {
is Page -> PageListItem(page = item, itemClickListener = itemClickListener)
is DateItem -> DateItemText(item)
}
}
}
}
@Composable
private fun searchBarIfActive(
state: PageFragmentScreenState
): (@Composable () -> Unit)? = if (state.isSearchActive) {
{
KiwixSearchView(
placeholder = state.searchQueryHint,
value = state.searchText,
testTag = "",
onValueChange = { state.searchValueChangedListener(it) },
onClearClick = { state.clearSearchButtonClickListener.invoke() }
)
}
} else {
null
}
@Composable
fun PageSwitchRow(
state: PageFragmentScreenState
) {
val context = LocalActivity.current as CoreMainActivity
// hide switches for custom apps, see more info here https://github.com/kiwix/kiwix-android/issues/3523
if (!context.isCustomApp()) {
val switchTextColor = if (isSystemInDarkTheme()) {
AlabasterWhite
} else {
White
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(Black)
.padding(bottom = PAGE_SWITCH_ROW_BOTTOM_MARGIN),
horizontalArrangement = Arrangement.Absolute.Right,
verticalAlignment = Alignment.CenterVertically
) {
Text(
state.switchString,
color = switchTextColor,
style = TextStyle(fontSize = FOURTEEN_SP),
modifier = Modifier.testTag(SWITCH_TEXT_TESTING_TAG)
)
Switch(
checked = state.switchIsChecked,
onCheckedChange = { state.onSwitchCheckedChanged(it) },
enabled = state.switchIsEnabled,
modifier = Modifier
.padding(horizontal = PAGE_SWITCH_LEFT_RIGHT_MARGIN),
colors = SwitchDefaults.colors(
uncheckedTrackColor = White
)
)
}
}
}
@Composable
fun DateItemText(dateItem: DateItem) {
Text(
text = getFormattedDateLabel(dateItem.dateString),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(SIXTEEN_DP)
)
}
@Composable
private fun getFormattedDateLabel(dateString: String): String {
val today = LocalDate.now()
val yesterday = today.minusDays(1)
val parsedDate = parseDateSafely(dateString)
return when (parsedDate) {
today -> stringResource(R.string.time_today)
yesterday -> stringResource(R.string.time_yesterday)
else -> dateString
}
}
private fun parseDateSafely(dateString: String): LocalDate? {
return try {
LocalDate.parse(dateString, DateTimeFormatter.ofPattern("d MMM yyyy"))
} catch (_: DateTimeParseException) {
null
}
}

View File

@ -16,11 +16,11 @@ class BookmarksFragment : PageFragment() {
PageAdapter(PageItemDelegate(this))
}
override val screenTitle: String by lazy { getString(R.string.bookmarks) }
override val screenTitle: Int = R.string.bookmarks
override val noItemsString: String by lazy { getString(R.string.no_bookmarks) }
override val switchString: String by lazy { getString(R.string.bookmarks_from_current_book) }
override val deleteIconTitle: String by lazy {
getString(R.string.pref_clear_all_bookmarks_title)
override val deleteIconTitle: Int by lazy {
R.string.pref_clear_all_bookmarks_title
}
override val switchIsChecked: Boolean by lazy { sharedPreferenceUtil.showBookmarksAllBooks }

View File

@ -21,9 +21,9 @@ class HistoryFragment : PageFragment() {
override val noItemsString: String by lazy { getString(R.string.no_history) }
override val switchString: String by lazy { getString(R.string.history_from_current_book) }
override val screenTitle: String by lazy { getString(R.string.history) }
override val deleteIconTitle: String by lazy {
getString(R.string.pref_clear_all_history_title)
override val screenTitle: Int = R.string.history
override val deleteIconTitle: Int by lazy {
R.string.pref_clear_all_history_title
}
override val switchIsChecked: Boolean by lazy { sharedPreferenceUtil.showHistoryAllBooks }

View File

@ -30,8 +30,7 @@ import org.kiwix.kiwixmobile.core.page.notes.viewmodel.NotesViewModel
class NotesFragment : PageFragment() {
override val pageViewModel by lazy { viewModel<NotesViewModel>(viewModelFactory) }
override val screenTitle: String
get() = getString(R.string.pref_notes)
override val screenTitle: Int = R.string.pref_notes
override val pageAdapter: PageAdapter by lazy {
PageAdapter(PageDelegate.PageItemDelegate(this))
@ -39,8 +38,8 @@ class NotesFragment : PageFragment() {
override val noItemsString: String by lazy { getString(R.string.no_notes) }
override val switchString: String by lazy { getString(R.string.notes_from_all_books) }
override val deleteIconTitle: String by lazy {
getString(R.string.pref_clear_notes)
override val deleteIconTitle: Int by lazy {
R.string.pref_clear_notes
}
override val switchIsChecked: Boolean by lazy { sharedPreferenceUtil.showNotesAllBooks }

View File

@ -39,8 +39,9 @@ import org.kiwix.kiwixmobile.core.utils.ComposeDimens
@Composable
fun KiwixSearchView(
modifier: Modifier,
modifier: Modifier = Modifier,
value: String,
placeholder: String = stringResource(R.string.search_label),
testTag: String = "",
onValueChange: (String) -> Unit,
onClearClick: () -> Unit
@ -65,7 +66,7 @@ fun KiwixSearchView(
value = value,
placeholder = {
Text(
text = stringResource(R.string.search_label),
text = placeholder,
color = Color.LightGray,
fontSize = ComposeDimens.EIGHTEEN_SP
)

View File

@ -106,4 +106,9 @@ object ComposeDimens {
val HELP_SCREEN_ITEM_TITLE_TEXT_SIZE = 20.sp
val HELP_SCREEN_ITEM_TITLE_LETTER_SPACING = 0.0125.em
val HELP_SCREEN_ARROW_ICON_SIZE = 35.dp
// Page dimens
val PAGE_LIST_ITEM_FAVICON_SIZE = 40.dp
val PAGE_SWITCH_LEFT_RIGHT_MARGIN = 10.dp
val PAGE_SWITCH_ROW_BOTTOM_MARGIN = 8.dp
}

View File

@ -1,51 +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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar"
app:popupTheme="@style/KiwixTheme"
tools:showIn="@layout/fragment_search" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/page_switch"
style="@style/switch_style" />
</com.google.android.material.appbar.AppBarLayout>
<TextView
android:id="@+id/no_page"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.KiwixTheme.Headline5"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/app_bar" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/app_bar"
tools:listitem="@layout/item_bookmark_history" />
</androidx.constraintlayout.widget.ConstraintLayout>