Refactored all tab-related functionalities.

* Improved the tabs UI to align with our XML-based layout. Added a border to the selected tab for better visual feedback, making it easier for users to identify the active tab.
* Added animation to smoothly scroll to the selected tab when the tab switcher is opened.
* Fixed a crash scenario when "Close All Tabs" was triggered.
* Fixed a crash that occurred when launching the app for the first time.
* Fixed an issue where the tab menu item was still visible even after all tabs were closed.
This commit is contained in:
MohitMaliFtechiz 2025-06-19 02:55:45 +05:30
parent f654a64b7e
commit b694ae3170
6 changed files with 362 additions and 143 deletions

View File

@ -42,6 +42,7 @@ import org.kiwix.kiwixmobile.core.R.string
import org.kiwix.kiwixmobile.core.base.BaseActivity import org.kiwix.kiwixmobile.core.base.BaseActivity
import org.kiwix.kiwixmobile.core.base.FragmentActivityExtensions.Super import org.kiwix.kiwixmobile.core.base.FragmentActivityExtensions.Super
import org.kiwix.kiwixmobile.core.base.FragmentActivityExtensions.Super.ShouldCall import org.kiwix.kiwixmobile.core.base.FragmentActivityExtensions.Super.ShouldCall
import org.kiwix.kiwixmobile.core.downloader.downloadManager.ZERO
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.setupDrawerToggle import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.setupDrawerToggle
import org.kiwix.kiwixmobile.core.extensions.coreMainActivity import org.kiwix.kiwixmobile.core.extensions.coreMainActivity
import org.kiwix.kiwixmobile.core.extensions.isFileExist import org.kiwix.kiwixmobile.core.extensions.isFileExist
@ -208,6 +209,26 @@ class KiwixReaderFragment : CoreReaderFragment() {
selectTab(currentWebViewIndex) selectTab(currentWebViewIndex)
} }
} }
actionBar?.setDisplayShowTitleEnabled(true)
toolbar?.let { activity?.setupDrawerToggle(it, true) }
setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
if (webViewList.isEmpty()) {
readerMenuState?.hideTabSwitcher()
exitBook(shouldCloseZimBook)
} else {
// Reset the top margin of web views to 0 to remove any previously set margin
// This ensures that the web views are displayed without any additional
// top margin for kiwix main app.
// setTopMarginToWebViews(0)
readerScreenState.update {
copy(
shouldShowBottomAppBar = true,
pageLoadingItem = false to ZERO,
)
}
readerMenuState?.showWebViewOptions(urlIsValid())
selectTab(currentWebViewIndex)
}
} }
private fun setFragmentContainerBottomMarginToSizeOfNavBar() { private fun setFragmentContainerBottomMarginToSizeOfNavBar() {

View File

@ -72,8 +72,10 @@ import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
@ -184,7 +186,6 @@ import org.kiwix.kiwixmobile.core.ui.components.NavigationIcon
import org.kiwix.kiwixmobile.core.ui.components.rememberBottomNavigationVisibility import org.kiwix.kiwixmobile.core.ui.components.rememberBottomNavigationVisibility
import org.kiwix.kiwixmobile.core.ui.models.IconItem import org.kiwix.kiwixmobile.core.ui.models.IconItem
import org.kiwix.kiwixmobile.core.utils.AnimationUtils.rotate import org.kiwix.kiwixmobile.core.utils.AnimationUtils.rotate
import org.kiwix.kiwixmobile.core.utils.DimenUtils.getToolbarHeight
import org.kiwix.kiwixmobile.core.utils.DimenUtils.getWindowWidth import org.kiwix.kiwixmobile.core.utils.DimenUtils.getWindowWidth
import org.kiwix.kiwixmobile.core.utils.DonationDialogHandler import org.kiwix.kiwixmobile.core.utils.DonationDialogHandler
import org.kiwix.kiwixmobile.core.utils.DonationDialogHandler.ShowDonationDialogCallback import org.kiwix.kiwixmobile.core.utils.DonationDialogHandler.ShowDonationDialogCallback
@ -291,7 +292,7 @@ abstract class CoreReaderFragment :
@JvmField @JvmField
@Inject @Inject
var painter: DarkModeViewPainter? = null var painter: DarkModeViewPainter? = null
protected var currentWebViewIndex = 0 protected var currentWebViewIndex by mutableStateOf(0)
private var currentTtsWebViewIndex = 0 private var currentTtsWebViewIndex = 0
protected var actionBar: ActionBar? = null protected var actionBar: ActionBar? = null
protected var mainMenu: MainMenu? = null protected var mainMenu: MainMenu? = null
@ -403,7 +404,23 @@ abstract class CoreReaderFragment :
bottomNavigationHeight = ZERO, bottomNavigationHeight = ZERO,
shouldShowBottomAppBar = true, shouldShowBottomAppBar = true,
selectedWebView = null, selectedWebView = null,
readerScreenTitle = "" readerScreenTitle = "",
showTabSwitcher = false,
darkModeViewPainter = null,
currentWebViewPosition = ZERO,
onTabClickListener = object : TabClickListener {
override fun onSelectTab(position: Int) {
hideTabSwitcher()
selectTab(position)
// Bug Fix #592
updateBottomToolbarArrowsAlpha()
}
override fun onCloseTab(position: Int) {
closeTab(position)
}
}
) )
) )
private var readerLifeCycleScope: CoroutineScope? = null private var readerLifeCycleScope: CoroutineScope? = null
@ -506,7 +523,7 @@ abstract class CoreReaderFragment :
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
val isBottomNavVisible = rememberBottomNavigationVisibility(lazyListState) val isBottomNavVisible = rememberBottomNavigationVisibility(lazyListState)
LaunchedEffect(isBottomNavVisible) { LaunchedEffect(isBottomNavVisible) {
(requireActivity() as CoreMainActivity).toggleBottomNavigation(isBottomNavVisible) (activity as? CoreMainActivity)?.toggleBottomNavigation(isBottomNavVisible)
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { webViewList.size } snapshotFlow { webViewList.size }
@ -515,11 +532,14 @@ abstract class CoreReaderFragment :
updateTabIcon(size) updateTabIcon(size)
} }
} }
LaunchedEffect(Unit) { LaunchedEffect(currentWebViewIndex, readerMenuState?.isInTabSwitcher) {
readerScreenState.update { readerScreenState.update {
copy( copy(
bottomNavigationHeight = getBottomNavigationHeight(), bottomNavigationHeight = getBottomNavigationHeight(),
readerScreenTitle = context.getString(R.string.reader) readerScreenTitle = context.getString(R.string.reader),
darkModeViewPainter = darkModeViewPainter,
currentWebViewPosition = currentWebViewIndex,
showTabSwitcher = readerMenuState?.isInTabSwitcher == true
) )
} }
} }
@ -528,8 +548,8 @@ abstract class CoreReaderFragment :
actionMenuItems = readerMenuState?.menuItems.orEmpty(), actionMenuItems = readerMenuState?.menuItems.orEmpty(),
navigationIcon = { navigationIcon = {
NavigationIcon( NavigationIcon(
iconItem = IconItem.Vector(Icons.Filled.Menu), iconItem = navigationIcon(),
contentDescription = string.open_drawer, contentDescription = navigationIconContentDescription(),
onClick = { navigationIconClick() } onClick = { navigationIconClick() }
) )
}, },
@ -640,18 +660,35 @@ abstract class CoreReaderFragment :
private fun getBottomNavigationHeight(): Int = getBottomNavigationView()?.measuredHeight ?: ZERO private fun getBottomNavigationHeight(): Int = getBottomNavigationView()?.measuredHeight ?: ZERO
private fun navigationIconClick() { private fun navigationIconContentDescription() =
// Manually handle the navigation open/close. if (readerMenuState?.isInTabSwitcher == true) {
// Since currently we are using the view based navigation drawer in other screens. R.string.search_open_in_new_tab
// Once we fully migrate to jetpack compose we will refactor this code to use the
// compose navigation.
// TODO Replace with compose based navigation when migration is done.
val activity = activity as CoreMainActivity
if (activity.navigationDrawerIsOpen()) {
activity.closeNavigationDrawer()
} else { } else {
activity.openNavigationDrawer() string.open_drawer
} }
private fun navigationIconClick() {
if (readerMenuState?.isInTabSwitcher == true) {
onHomeMenuClicked()
} else {
// Manually handle the navigation open/close.
// Since currently we are using the view based navigation drawer in other screens.
// Once we fully migrate to jetpack compose we will refactor this code to use the
// compose navigation.
// TODO Replace with compose based navigation when migration is done.
val activity = activity as CoreMainActivity
if (activity.navigationDrawerIsOpen()) {
activity.closeNavigationDrawer()
} else {
activity.openNavigationDrawer()
}
}
}
private fun navigationIcon() = if (readerMenuState?.isInTabSwitcher == true) {
IconItem.Drawable(R.drawable.ic_round_add_white_36dp)
} else {
IconItem.Vector(Icons.Filled.Menu)
} }
private fun addAlertDialogToDialogHost() { private fun addAlertDialogToDialogHost() {
@ -936,10 +973,14 @@ abstract class CoreReaderFragment :
setIsCloseAllTabButtonClickable(true) setIsCloseAllTabButtonClickable(true)
// Set a negative top margin to the web views to remove // Set a negative top margin to the web views to remove
// the unwanted blank space caused by the toolbar. // the unwanted blank space caused by the toolbar.
setTopMarginToWebViews(-requireActivity().getToolbarHeight()) // setTopMarginToWebViews(-requireActivity().getToolbarHeight())
setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
readerScreenState.update { readerScreenState.update {
copy(shouldShowBottomAppBar = false) copy(
shouldShowBottomAppBar = false,
pageLoadingItem = false to ZERO,
readerScreenTitle = ""
)
} }
contentFrame?.visibility = GONE contentFrame?.visibility = GONE
progressBar?.visibility = GONE progressBar?.visibility = GONE
@ -1030,10 +1071,16 @@ abstract class CoreReaderFragment :
} }
progressBar?.hide() progressBar?.hide()
selectTab(currentWebViewIndex) selectTab(currentWebViewIndex)
readerScreenState.update {
copy(
shouldShowBottomAppBar = true,
pageLoadingItem = false to ZERO,
)
}
readerMenuState?.showWebViewOptions(urlIsValid()) readerMenuState?.showWebViewOptions(urlIsValid())
// Reset the top margin of web views to 0 to remove any previously set margin // Reset the top margin of web views to 0 to remove any previously set margin
// This ensures that the web views are displayed without any additional top margin for kiwix custom apps. // This ensures that the web views are displayed without any additional top margin for kiwix custom apps.
setTopMarginToWebViews(0) // setTopMarginToWebViews(0)
} }
/** /**
@ -1587,11 +1634,10 @@ abstract class CoreReaderFragment :
readerScreenState.value.snackBarHostState.snack( readerScreenState.value.snackBarHostState.snack(
requireActivity().getString(R.string.tab_closed), requireActivity().getString(R.string.tab_closed),
actionLabel = requireActivity().getString(R.string.undo), actionLabel = requireActivity().getString(R.string.undo),
snackbarDuration = SnackbarDuration.Long,
actionClick = { restoreDeletedTab(index) }, actionClick = { restoreDeletedTab(index) },
lifecycleScope = lifecycleScope, lifecycleScope = lifecycleScope,
snackBarResult = { result -> snackBarResult = { result ->
if (result != SnackbarResult.Dismissed && webViewList.isEmpty() && isAdded) { if (result == SnackbarResult.Dismissed && webViewList.isEmpty() && isAdded) {
closeZimBook() closeZimBook()
} }
} }
@ -1610,7 +1656,7 @@ abstract class CoreReaderFragment :
readerScreenState.update { readerScreenState.update {
copy( copy(
shouldShowBottomAppBar = false, shouldShowBottomAppBar = false,
readerScreenTitle = requireActivity().getString(R.string.reader) readerScreenTitle = context?.getString(R.string.reader).orEmpty()
) )
} }
contentFrame?.visibility = GONE contentFrame?.visibility = GONE
@ -1657,7 +1703,7 @@ abstract class CoreReaderFragment :
webViewList.add(index, it) webViewList.add(index, it)
tabsAdapter?.notifyDataSetChanged() tabsAdapter?.notifyDataSetChanged()
readerScreenState.value.snackBarHostState.snack( readerScreenState.value.snackBarHostState.snack(
requireActivity().getString(R.string.tab_restored), context?.getString(R.string.tab_restored).orEmpty(),
lifecycleScope = lifecycleScope lifecycleScope = lifecycleScope
) )
setUpWithTextToSpeech(it) setUpWithTextToSpeech(it)
@ -1778,16 +1824,15 @@ abstract class CoreReaderFragment :
} }
override fun onHomeMenuClicked() { override fun onHomeMenuClicked() {
if (tabSwitcherRoot?.visibility == VISIBLE) { if (readerScreenState.value.showTabSwitcher) {
hideTabSwitcher() hideTabSwitcher()
} }
createNewTab() createNewTab()
} }
override fun onTabMenuClicked() { override fun onTabMenuClicked() {
if (tabSwitcherRoot?.visibility == VISIBLE) { if (readerScreenState.value.showTabSwitcher) {
hideTabSwitcher() hideTabSwitcher()
selectTab(currentWebViewIndex)
} else { } else {
showTabSwitcher() showTabSwitcher()
} }
@ -2062,8 +2107,8 @@ abstract class CoreReaderFragment :
} }
} else { } else {
readerScreenState.value.snackBarHostState.snack( readerScreenState.value.snackBarHostState.snack(
requireActivity().getString(R.string.request_storage), context?.getString(R.string.request_storage).orEmpty(),
actionLabel = requireActivity().getString(R.string.menu_settings), context?.getString(R.string.menu_settings),
snackbarDuration = SnackbarDuration.Long, snackbarDuration = SnackbarDuration.Long,
actionClick = { actionClick = {
val intent = Intent() val intent = Intent()
@ -2100,13 +2145,12 @@ abstract class CoreReaderFragment :
tabsAdapter?.notifyDataSetChanged() tabsAdapter?.notifyDataSetChanged()
openHomeScreen() openHomeScreen()
readerScreenState.value.snackBarHostState.snack( readerScreenState.value.snackBarHostState.snack(
requireActivity().getString(R.string.tabs_closed), context?.getString(R.string.tabs_closed).orEmpty(),
actionLabel = requireActivity().getString(R.string.undo), context?.getString(R.string.undo),
snackbarDuration = SnackbarDuration.Long,
actionClick = { restoreDeletedTabs() }, actionClick = { restoreDeletedTabs() },
lifecycleScope = lifecycleScope, lifecycleScope = lifecycleScope,
snackBarResult = { result -> snackBarResult = { result ->
if (result != SnackbarResult.Dismissed && webViewList.isEmpty() && isAdded) { if (result == SnackbarResult.Dismissed && webViewList.isEmpty() && isAdded) {
closeZimBook() closeZimBook()
} }
} }
@ -2122,7 +2166,7 @@ abstract class CoreReaderFragment :
webViewList.addAll(tempWebViewListForUndo) webViewList.addAll(tempWebViewListForUndo)
tabsAdapter?.notifyDataSetChanged() tabsAdapter?.notifyDataSetChanged()
readerScreenState.value.snackBarHostState.snack( readerScreenState.value.snackBarHostState.snack(
requireActivity().getString(R.string.tabs_restored), context?.getString(R.string.tabs_restored).orEmpty(),
lifecycleScope = lifecycleScope lifecycleScope = lifecycleScope
) )
reopenBook() reopenBook()
@ -2162,7 +2206,7 @@ abstract class CoreReaderFragment :
if (isBookmarked) { if (isBookmarked) {
repositoryActions?.deleteBookmark(libKiwixBook.id, articleUrl) repositoryActions?.deleteBookmark(libKiwixBook.id, articleUrl)
readerScreenState.value.snackBarHostState.snack( readerScreenState.value.snackBarHostState.snack(
requireActivity().getString(R.string.bookmark_removed), context?.getString(R.string.bookmark_removed).orEmpty(),
lifecycleScope = lifecycleScope lifecycleScope = lifecycleScope
) )
} else { } else {
@ -2171,9 +2215,9 @@ abstract class CoreReaderFragment :
LibkiwixBookmarkItem(it, articleUrl, zimFileReader, libKiwixBook) LibkiwixBookmarkItem(it, articleUrl, zimFileReader, libKiwixBook)
) )
readerScreenState.value.snackBarHostState.snack( readerScreenState.value.snackBarHostState.snack(
requireActivity().getString(R.string.bookmark_added), context?.getString(R.string.bookmark_added).orEmpty(),
lifecycleScope = lifecycleScope, lifecycleScope = lifecycleScope,
actionLabel = requireActivity().getString(R.string.open), actionLabel = context?.getString(R.string.open),
actionClick = { goToBookmarks() } actionClick = { goToBookmarks() }
) )
} }
@ -2890,9 +2934,9 @@ abstract class CoreReaderFragment :
if (isOpenNewTabInBackground) { if (isOpenNewTabInBackground) {
newTabInBackground(url) newTabInBackground(url)
readerScreenState.value.snackBarHostState.snack( readerScreenState.value.snackBarHostState.snack(
message = requireActivity().getString(R.string.new_tab_snack_bar), message = context?.getString(R.string.new_tab_snack_bar).orEmpty(),
lifecycleScope = lifecycleScope, lifecycleScope = lifecycleScope,
actionLabel = requireActivity().getString(R.string.open), actionLabel = context?.getString(R.string.open),
actionClick = { actionClick = {
if (webViewList.size > 1) { if (webViewList.size > 1) {
selectTab(webViewList.size - 1) selectTab(webViewList.size - 1)

View File

@ -46,7 +46,7 @@ import org.kiwix.kiwixmobile.core.ui.theme.White
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.MATERIAL_MINIMUM_HEIGHT_AND_WIDTH import org.kiwix.kiwixmobile.core.utils.ComposeDimens.MATERIAL_MINIMUM_HEIGHT_AND_WIDTH
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.ONE_DP import org.kiwix.kiwixmobile.core.utils.ComposeDimens.ONE_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIX_DP import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIX_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TAB_SWITCHER_CORNER_RADIUS import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TAB_SWITCHER_ICON_CORNER_RADIUS
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TAB_SWITCHER_TEXT_SIZE import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TAB_SWITCHER_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TWELVE_DP import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TWELVE_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TWENTY_DP import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TWENTY_DP
@ -105,7 +105,7 @@ class ReaderMenuState(
} }
fun showWebViewOptions(valid: Boolean) { fun showWebViewOptions(valid: Boolean) {
isInTabSwitcher = false hideTabSwitcher()
urlIsValid = valid urlIsValid = valid
setVisibility( setVisibility(
urlIsValid, urlIsValid,
@ -165,6 +165,10 @@ class ReaderMenuState(
) )
} }
fun hideTabSwitcher() {
isInTabSwitcher = false
}
private fun updateMenuItems() { private fun updateMenuItems() {
menuItems.clear() menuItems.clear()
addSearchMenuItem() addSearchMenuItem()
@ -185,15 +189,12 @@ class ReaderMenuState(
} }
private fun addTabMenuItem() { private fun addTabMenuItem() {
if (!disableTabs && urlIsValid) { if (!disableTabs && urlIsValid && webViewCount > 0) {
val tabLabel = if (webViewCount > 99) ":D" else "$webViewCount" val tabLabel = if (webViewCount > 99) ":D" else "$webViewCount"
menuItems += ActionMenuItem( menuItems += ActionMenuItem(
icon = null, icon = null,
contentDescription = R.string.switch_tabs, contentDescription = R.string.switch_tabs,
onClick = { onClick = { menuClickListener.onTabMenuClicked() },
isInTabSwitcher = true
menuClickListener.onTabMenuClicked()
},
isInOverflow = false, isInOverflow = false,
iconButtonText = tabLabel, iconButtonText = tabLabel,
testingTag = TAB_MENU_ITEM_TESTING_TAG, testingTag = TAB_MENU_ITEM_TESTING_TAG,
@ -212,9 +213,9 @@ class ReaderMenuState(
) { ) {
Box( Box(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(TAB_SWITCHER_CORNER_RADIUS)) .clip(RoundedCornerShape(TAB_SWITCHER_ICON_CORNER_RADIUS))
.background(Black) .background(Black)
.border(ONE_DP, White, RoundedCornerShape(TAB_SWITCHER_CORNER_RADIUS)) .border(ONE_DP, White, RoundedCornerShape(TAB_SWITCHER_ICON_CORNER_RADIUS))
.padding(horizontal = SIX_DP, vertical = TWO_DP) .padding(horizontal = SIX_DP, vertical = TWO_DP)
.defaultMinSize(minWidth = TWENTY_DP, minHeight = TWENTY_DP), .defaultMinSize(minWidth = TWENTY_DP, minHeight = TWENTY_DP),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@ -232,9 +233,7 @@ class ReaderMenuState(
} }
private fun addReaderMenuItems() { private fun addReaderMenuItems() {
if (!urlIsValid) return if (menuItemVisibility[MenuItemType.AddNote] == true) {
if (menuItemVisibility[MenuItemType.Search] == true) {
menuItems += ActionMenuItem( menuItems += ActionMenuItem(
icon = IconItem.Drawable(R.drawable.ic_add_note), icon = IconItem.Drawable(R.drawable.ic_add_note),
contentDescription = R.string.take_notes, contentDescription = R.string.take_notes,

View File

@ -18,8 +18,12 @@
package org.kiwix.kiwixmobile.core.main.reader package org.kiwix.kiwixmobile.core.main.reader
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
@ -30,7 +34,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -38,10 +41,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
@ -58,19 +61,24 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -80,7 +88,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.delay
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.downloader.downloadManager.HUNDERED
import org.kiwix.kiwixmobile.core.downloader.downloadManager.ZERO import org.kiwix.kiwixmobile.core.downloader.downloadManager.ZERO
import org.kiwix.kiwixmobile.core.main.DarkModeViewPainter import org.kiwix.kiwixmobile.core.main.DarkModeViewPainter
import org.kiwix.kiwixmobile.core.main.KiwixWebView import org.kiwix.kiwixmobile.core.main.KiwixWebView
@ -99,6 +109,8 @@ import org.kiwix.kiwixmobile.core.ui.theme.Black
import org.kiwix.kiwixmobile.core.ui.theme.KiwixDialogTheme import org.kiwix.kiwixmobile.core.ui.theme.KiwixDialogTheme
import org.kiwix.kiwixmobile.core.ui.theme.White import org.kiwix.kiwixmobile.core.ui.theme.White
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.CLOSE_ALL_TAB_BUTTON_BOTTOM_PADDING import org.kiwix.kiwixmobile.core.utils.ComposeDimens.CLOSE_ALL_TAB_BUTTON_BOTTOM_PADDING
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.CLOSE_TAB_ICON_ANIMATION_TIMEOUT
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.CLOSE_TAB_ICON_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.EIGHT_DP import org.kiwix.kiwixmobile.core.utils.ComposeDimens.EIGHT_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FOUR_DP import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FOUR_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.ONE_DP import org.kiwix.kiwixmobile.core.utils.ComposeDimens.ONE_DP
@ -127,11 +139,11 @@ fun ReaderScreen(
Scaffold( Scaffold(
snackbarHost = { KiwixSnackbarHost(snackbarHostState = state.snackBarHostState) }, snackbarHost = { KiwixSnackbarHost(snackbarHostState = state.snackBarHostState) },
topBar = { topBar = {
KiwixAppBar( ReaderTopBar(
state.readerScreenTitle, state,
navigationIcon,
actionMenuItems, actionMenuItems,
scrollBehavior scrollBehavior,
navigationIcon
) )
}, },
floatingActionButton = { BackToTopFab(state) }, floatingActionButton = { BackToTopFab(state) },
@ -140,34 +152,63 @@ fun ReaderScreen(
.nestedScroll(scrollBehavior.nestedScrollConnection) .nestedScroll(scrollBehavior.nestedScrollConnection)
.padding(bottom = bottomNavHeight.value) .padding(bottom = bottomNavHeight.value)
) { paddingValues -> ) { paddingValues ->
Box( ReaderContentLayout(state, Modifier.padding(paddingValues))
modifier = Modifier }
.fillMaxSize() }
.padding(paddingValues) }
) {
if (state.isNoBookOpenInReader) { @OptIn(ExperimentalMaterial3Api::class)
NoBookOpenView(state.onOpenLibraryButtonClicked) @Suppress("ComposableLambdaParameterNaming")
} else { @Composable
ShowZIMFileContent(state) private fun ReaderTopBar(
ShowProgressBarIfZIMFilePageIsLoading(state) state: ReaderScreenState,
Column( actionMenuItems: List<ActionMenuItem>,
modifier = Modifier.align(Alignment.BottomCenter) scrollBehavior: TopAppBarScrollBehavior,
) { navigationIcon: @Composable () -> Unit,
TtsControls(state) ) {
BottomAppBarOfReaderScreen( if (!state.fullScreenItem.first) {
state.bookmarkButtonItem, KiwixAppBar(
state.previousPageButtonItem, title = if (state.showTabSwitcher) "" else state.readerScreenTitle,
state.onHomeButtonClick, navigationIcon = navigationIcon,
state.nextPageButtonItem, actionMenuItems = actionMenuItems,
state.onTocClick, topAppBarScrollBehavior = scrollBehavior
state.shouldShowBottomAppBar )
) }
} }
ShowFullScreenView(state)
@Composable
private fun ReaderContentLayout(state: ReaderScreenState, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize()) {
when {
state.showTabSwitcher -> TabSwitcherView(
state.kiwixWebViewList,
state.currentWebViewPosition,
state.onTabClickListener,
state.onCloseAllTabs,
state.darkModeViewPainter
)
state.isNoBookOpenInReader -> NoBookOpenView(state.onOpenLibraryButtonClicked)
else -> {
ShowZIMFileContent(state)
ShowProgressBarIfZIMFilePageIsLoading(state)
Column(Modifier.align(Alignment.BottomCenter)) {
TtsControls(state)
BottomAppBarOfReaderScreen(
state.bookmarkButtonItem,
state.previousPageButtonItem,
state.onHomeButtonClick,
state.nextPageButtonItem,
state.onTocClick,
state.shouldShowBottomAppBar
)
} }
ShowDonationLayout(state) ShowFullScreenView(state)
} }
} }
ShowDonationLayout(state)
} }
} }
@ -378,11 +419,11 @@ private fun BoxScope.ShowDonationLayout(state: ReaderScreenState) {
fun TabSwitcherView( fun TabSwitcherView(
webViews: List<KiwixWebView>, webViews: List<KiwixWebView>,
selectedIndex: Int, selectedIndex: Int,
onSelectTab: (Int) -> Unit, onTabClickListener: TabClickListener,
onCloseTab: (Int) -> Unit,
onCloseAllTabs: () -> Unit, onCloseAllTabs: () -> Unit,
painter: DarkModeViewPainter painter: DarkModeViewPainter?
) { ) {
val state = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
LazyRow( LazyRow(
modifier = Modifier modifier = Modifier
@ -390,7 +431,8 @@ fun TabSwitcherView(
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(top = SIXTEEN_DP), .padding(top = SIXTEEN_DP),
contentPadding = PaddingValues(horizontal = SIXTEEN_DP, vertical = EIGHT_DP), contentPadding = PaddingValues(horizontal = SIXTEEN_DP, vertical = EIGHT_DP),
horizontalArrangement = Arrangement.spacedBy(EIGHT_DP) horizontalArrangement = Arrangement.spacedBy(EIGHT_DP),
state = state
) { ) {
itemsIndexed(webViews, key = { _, item -> item.hashCode() }) { index, webView -> itemsIndexed(webViews, key = { _, item -> item.hashCode() }) { index, webView ->
val context = LocalContext.current val context = LocalContext.current
@ -401,33 +443,76 @@ fun TabSwitcherView(
LaunchedEffect(webView) { LaunchedEffect(webView) {
if (title != context.getString(R.string.menu_home)) { if (title != context.getString(R.string.menu_home)) {
painter.update(webView) painter?.update(webView)
} }
} }
TabItemView( TabItemView(
index = index,
title = title, title = title,
isSelected = index == selectedIndex, isSelected = index == selectedIndex,
webView = webView, webView = webView,
onSelectTab = { onSelectTab(index) }, onTabClickListener = onTabClickListener,
onCloseTab = { onCloseTab(index) }
) )
} }
} }
LaunchedEffect(Unit) {
state.animateScrollToItem(selectedIndex)
}
CloseAllTabButton(onCloseAllTabs) CloseAllTabButton(onCloseAllTabs)
} }
} }
@Composable @Composable
private fun BoxScope.CloseAllTabButton(onCloseAllTabs: () -> Unit) { private fun BoxScope.CloseAllTabButton(onCloseAllTabs: () -> Unit) {
var isAnimating by remember { mutableStateOf(false) }
var isDone by remember { mutableStateOf(false) }
// Animate rotation from 0f to 360f
val rotation by animateFloatAsState(
targetValue = if (isAnimating) 360f else 0f,
animationSpec = tween(durationMillis = 600),
finishedListener = {
isDone = true
isAnimating = false
}
)
// ⏳ Auto-reset to close icon after delay
LaunchedEffect(isDone) {
if (isDone) {
delay(CLOSE_TAB_ICON_ANIMATION_TIMEOUT)
isDone = false
}
}
FloatingActionButton( FloatingActionButton(
onClick = onCloseAllTabs, onClick = {
isAnimating = true
onCloseAllTabs()
},
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = CLOSE_ALL_TAB_BUTTON_BOTTOM_PADDING) .padding(bottom = CLOSE_ALL_TAB_BUTTON_BOTTOM_PADDING)
.graphicsLayer {
rotationZ = rotation
}
.clickable(
enabled = !isAnimating,
onClick = {
isAnimating = true
onCloseAllTabs()
}
),
) { ) {
Icon( Icon(
painter = painterResource(R.drawable.ic_close_black_24dp), painter = painterResource(
id = if (isDone) {
R.drawable.ic_done_white_24dp
} else {
R.drawable.ic_close_black_24dp
}
),
contentDescription = stringResource(R.string.close_all_tabs) contentDescription = stringResource(R.string.close_all_tabs)
) )
} }
@ -436,71 +521,120 @@ private fun BoxScope.CloseAllTabButton(onCloseAllTabs: () -> Unit) {
@Suppress("MagicNumber") @Suppress("MagicNumber")
@Composable @Composable
fun TabItemView( fun TabItemView(
index: Int,
title: String, title: String,
isSelected: Boolean, isSelected: Boolean,
webView: KiwixWebView, webView: KiwixWebView,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onSelectTab: () -> Unit, onTabClickListener: TabClickListener
onCloseTab: () -> Unit
) { ) {
val cardElevation = if (isSelected) EIGHT_DP else TWO_DP val cardElevation = if (isSelected) EIGHT_DP else TWO_DP
val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
val (cardWidth, cardHeight) = getTabCardSize(toolbarHeightDp = 56.dp)
Box(modifier = modifier) { Box(modifier = modifier) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.padding(horizontal = EIGHT_DP, vertical = FOUR_DP) .padding(horizontal = EIGHT_DP, vertical = FOUR_DP)
.widthIn(min = 200.dp) .width(cardWidth)
) { ) {
Row( TabItemHeader(title, index, onTabClickListener)
modifier = Modifier TabItemCard(
.fillMaxWidth() webView,
.padding(horizontal = FOUR_DP), cardWidth,
verticalAlignment = Alignment.CenterVertically cardHeight,
) { onTabClickListener,
Text( borderColor,
text = title, cardElevation,
maxLines = 1, index
overflow = TextOverflow.Ellipsis, )
modifier = Modifier
.weight(1f)
.padding(end = EIGHT_DP),
style = MaterialTheme.typography.labelLarge
)
IconButton(onClick = onCloseTab) {
Icon(
painter = painterResource(id = R.drawable.ic_clear_white_24dp),
contentDescription = stringResource(R.string.close_tab)
)
}
}
// Card with WebView (non-interactive with overlay)
Card(
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
border = BorderStroke(ONE_DP, borderColor),
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.6f) // approximate height logic
.clickable { onSelectTab() }
) {
AndroidView(
factory = { context ->
// Detach if needed to avoid WebView already has a parent issue
(webView.parent as? ViewGroup)?.removeView(webView)
FrameLayout(context).apply {
addView(webView)
}
},
modifier = Modifier.fillMaxSize()
)
}
} }
} }
} }
@Composable
private fun TabItemHeader(
title: String,
index: Int,
onTabClickListener: TabClickListener
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = FOUR_DP),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(end = FOUR_DP)
.weight(1f),
style = MaterialTheme.typography.labelSmall
)
IconButton(
onClick = { onTabClickListener.onCloseTab(index) },
modifier = Modifier.size(CLOSE_TAB_ICON_SIZE)
) {
Icon(
painter = painterResource(id = R.drawable.ic_clear_white_24dp),
contentDescription = stringResource(R.string.close_tab)
)
}
}
}
@Composable
private fun TabItemCard(
webView: KiwixWebView,
cardWidth: Dp,
cardHeight: Dp,
onTabClickListener: TabClickListener,
borderColor: Color,
elevation: Dp,
index: Int
) {
Card(
elevation = CardDefaults.cardElevation(defaultElevation = elevation),
border = BorderStroke(ONE_DP, borderColor),
shape = MaterialTheme.shapes.extraSmall,
modifier = Modifier
.width(cardWidth)
.height(cardHeight)
.clickable { onTabClickListener.onSelectTab(index) }
) {
AndroidView(
factory = { context ->
FrameLayout(context).apply {
(webView.parent as? ViewGroup)?.removeView(webView)
addView(webView)
val clickableView = View(context).apply {
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
setOnClickListener { onTabClickListener.onSelectTab(index) }
}
addView(clickableView)
}
},
modifier = Modifier.fillMaxSize()
)
}
}
@Composable
fun getTabCardSize(toolbarHeightDp: Dp): Pair<Dp, Dp> {
val windowSize = LocalWindowInfo.current.containerSize
val density = LocalDensity.current
val screenWidth = with(density) { windowSize.width.toDp() }
val screenHeight = with(density) { windowSize.height.toDp() }
val cardWidth = screenWidth / 2
val cardHeight = ((screenHeight - toolbarHeightDp) / 2).coerceAtLeast(HUNDERED.dp)
return cardWidth to cardHeight
}
@Composable @Composable
fun rememberScrollBehavior( fun rememberScrollBehavior(
bottomNavigationHeight: Int, bottomNavigationHeight: Int,
@ -527,3 +661,8 @@ fun rememberScrollBehavior(
return bottomNavHeight to lazyListState return bottomNavHeight to lazyListState
} }
interface TabClickListener {
fun onSelectTab(position: Int)
fun onCloseTab(position: Int)
}

View File

@ -20,6 +20,7 @@ package org.kiwix.kiwixmobile.core.main.reader
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import org.kiwix.kiwixmobile.core.main.DarkModeViewPainter
import org.kiwix.kiwixmobile.core.main.KiwixWebView import org.kiwix.kiwixmobile.core.main.KiwixWebView
import org.kiwix.kiwixmobile.core.ui.models.IconItem.Drawable import org.kiwix.kiwixmobile.core.ui.models.IconItem.Drawable
@ -79,10 +80,18 @@ data class ReaderScreenState(
*/ */
val pauseTtsButtonText: String, val pauseTtsButtonText: String,
val onStopTtsClick: () -> Unit = {}, val onStopTtsClick: () -> Unit = {},
/**
* Holds the current selected webView position.
*/
val currentWebViewPosition: Int,
/** /**
* To show in the tabs view. * To show in the tabs view.
*/ */
val kiwixWebViewList: List<KiwixWebView>, val kiwixWebViewList: List<KiwixWebView>,
/**
* To show/hide tab switcher.
*/
val showTabSwitcher: Boolean,
/** /**
* Manages the showing of current selected webView. * Manages the showing of current selected webView.
*/ */
@ -131,5 +140,10 @@ data class ReaderScreenState(
* Manages the showing of Reader's [BottomAppBarOfReaderScreen]. * Manages the showing of Reader's [BottomAppBarOfReaderScreen].
*/ */
val shouldShowBottomAppBar: Boolean, val shouldShowBottomAppBar: Boolean,
val readerScreenTitle: String val readerScreenTitle: String,
val darkModeViewPainter: DarkModeViewPainter?,
/**
* Manages the click event on tabs.
*/
val onTabClickListener: TabClickListener,
) )

View File

@ -180,11 +180,13 @@ object ComposeDimens {
val STORAGE_LOADING_PROGRESS_BAR_SIZE = 40.dp val STORAGE_LOADING_PROGRESS_BAR_SIZE = 40.dp
val CATEGORY_TITLE_TEXT_SIZE = 14.sp val CATEGORY_TITLE_TEXT_SIZE = 14.sp
// Reader screen dimes // Reader screen dimens
val READER_BOTTOM_APP_BAR_LAYOUT_HEIGHT = 40.dp val READER_BOTTOM_APP_BAR_LAYOUT_HEIGHT = 40.dp
val READER_BOTTOM_APP_BAR_BUTTON_ICON_SIZE = 30.dp val READER_BOTTOM_APP_BAR_BUTTON_ICON_SIZE = 30.dp
const val TTS_BUTTONS_CONTROL_ALPHA = 0.6f const val TTS_BUTTONS_CONTROL_ALPHA = 0.6f
val CLOSE_ALL_TAB_BUTTON_BOTTOM_PADDING = 24.dp val CLOSE_ALL_TAB_BUTTON_BOTTOM_PADDING = 24.dp
val TAB_SWITCHER_TEXT_SIZE = 12.sp val TAB_SWITCHER_TEXT_SIZE = 12.sp
const val TAB_SWITCHER_CORNER_RADIUS = 10 const val TAB_SWITCHER_ICON_CORNER_RADIUS = 10
val CLOSE_TAB_ICON_SIZE = 20.dp
const val CLOSE_TAB_ICON_ANIMATION_TIMEOUT = 1200L
} }