Created LocalLibraryScreenState to encapsulate all UI-related states, reducing state management's complexity.

This commit is contained in:
MohitMaliFtechiz 2025-03-19 17:33:35 +05:30
parent 5b507d8f3d
commit 80fbf74d9a
3 changed files with 193 additions and 144 deletions

View File

@ -40,9 +40,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.ComposeView
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
@ -87,9 +85,6 @@ import org.kiwix.kiwixmobile.core.utils.EXTERNAL_SELECT_POSITION
import org.kiwix.kiwixmobile.core.utils.INTERNAL_SELECT_POSITION
import org.kiwix.kiwixmobile.core.utils.LanguageUtils
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.SimpleRecyclerViewScrollListener
import org.kiwix.kiwixmobile.core.utils.SimpleRecyclerViewScrollListener.Companion.SCROLL_DOWN
import org.kiwix.kiwixmobile.core.utils.SimpleRecyclerViewScrollListener.Companion.SCROLL_UP
import org.kiwix.kiwixmobile.core.utils.TAG_KIWIX
import org.kiwix.kiwixmobile.core.utils.dialog.DialogShower
import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog
@ -97,7 +92,6 @@ import org.kiwix.kiwixmobile.core.utils.files.FileUtils
import org.kiwix.kiwixmobile.core.utils.files.FileUtils.isSplittedZimFile
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.adapter.BooksOnDiskListItem
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.adapter.BooksOnDiskListItem.BookOnDisk
import org.kiwix.kiwixmobile.databinding.FragmentDestinationLibraryBinding
import org.kiwix.kiwixmobile.main.KiwixMainActivity
import org.kiwix.kiwixmobile.nav.destination.library.CopyMoveFileHandler
import org.kiwix.kiwixmobile.zimManager.MAX_PROGRESS
@ -134,34 +128,19 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
private var actionMode: ActionMode? = null
private val disposable = CompositeDisposable()
private var fragmentDestinationLibraryBinding: FragmentDestinationLibraryBinding? = null
private var permissionDeniedLayoutShowing = false
private var zimFileUri: Uri? = null
private lateinit var snackBarHostState: SnackbarHostState
private var fileSelectListState = mutableStateOf(FileSelectListState(emptyList()))
/**
* This is a Triple which is responsible for showing and hiding the "No file here",
* and "Download books" button.
*
* A [Triple] containing:
* - [String]: The title text displayed when no files are available.
* - [String]: The label for the download button.
* - [Boolean]: The boolean value for showing or hiding this view.
*/
private var noFilesViewItem = mutableStateOf(Triple("", "", false))
/**
* This is a Pair which is responsible for showing and hiding the "Pull to refresh"
* animation.
*
* A [Pair] containing:
* - [Boolean]: The first boolean triggers/hide the "pull to refresh" animation.
* - [Boolean]: The second boolean enable/disable the "pull to refresh".
*/
private var swipeRefreshItem = mutableStateOf(Pair(false, true))
private var scanningProgressItem = mutableStateOf(Pair(false, ZERO))
val libraryScreenState = mutableStateOf(
LocalLibraryScreenState(
fileSelectListState = FileSelectListState(emptyList()),
snackBarHostState = SnackbarHostState(),
swipeRefreshItem = Pair(false, true),
scanningProgressItem = Pair(false, ZERO),
noFilesViewItem = Triple("", "", false),
actionMenuItems = listOf(),
bottomNavigationHeight = ZERO
)
)
private val zimManageViewModel by lazy {
requireActivity().viewModel<ZimManageViewModel>(viewModelFactory)
@ -174,10 +153,12 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
val isGranted = permissionResult.values.all { it }
val isPermanentlyDenied = readStorageHasBeenPermanentlyDenied(isGranted)
permissionDeniedLayoutShowing = isPermanentlyDenied
noFilesViewItem.value = Triple(
requireActivity().resources.getString(string.grant_read_storage_permission),
requireActivity().resources.getString(string.go_to_settings_label),
isPermanentlyDenied
updateLibraryScreenState(
noFilesViewItem = Triple(
requireActivity().resources.getString(string.grant_read_storage_permission),
requireActivity().resources.getString(string.go_to_settings_label),
isPermanentlyDenied
)
)
}
@ -195,20 +176,18 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
val composeView = ComposeView(requireContext()).apply {
setContent {
snackBarHostState = remember { SnackbarHostState() }
updateLibraryScreenState(
bottomNavigationHeight = getBottomNavigationHeight(),
actionMenuItems = actionMenuItems()
)
LocalLibraryScreen(
state = fileSelectListState.value,
snackBarHostState = snackBarHostState,
state = libraryScreenState.value,
fabButtonClick = { filePickerButtonClick() },
actionMenuItems = actionMenuItems(),
onClick = { onBookItemClick(it) },
onLongClick = { onBookItemLongClick(it) },
onMultiSelect = { offerAction(RequestSelect(it)) },
onRefresh = { onSwipeRefresh() },
swipeRefreshItem = swipeRefreshItem.value,
noFilesViewItem = noFilesViewItem.value,
onDownloadButtonClick = { downloadBookButtonClick() },
scanningProgressItem = scanningProgressItem.value
) {
NavigationIcon(
iconItem = IconItem.Vector(Icons.Filled.Menu),
@ -260,6 +239,28 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
}
}
private fun updateLibraryScreenState(
fileSelectListState: FileSelectListState? = null,
snackBarHostState: SnackbarHostState? = null,
swipeRefreshItem: Pair<Boolean, Boolean>? = null,
scanningProgressItem: Pair<Boolean, Int>? = null,
noFilesViewItem: Triple<String, String, Boolean>? = null,
actionMenuItems: List<ActionMenuItem>? = null,
bottomNavigationHeight: Int? = null
) {
libraryScreenState.value = libraryScreenState.value.copy(
fileSelectListState = fileSelectListState ?: libraryScreenState.value.fileSelectListState,
snackBarHostState = snackBarHostState ?: libraryScreenState.value.snackBarHostState,
swipeRefreshItem = swipeRefreshItem ?: libraryScreenState.value.swipeRefreshItem,
scanningProgressItem = scanningProgressItem
?: libraryScreenState.value.scanningProgressItem,
noFilesViewItem = noFilesViewItem ?: libraryScreenState.value.noFilesViewItem,
actionMenuItems = actionMenuItems ?: libraryScreenState.value.actionMenuItems,
bottomNavigationHeight = bottomNavigationHeight
?: libraryScreenState.value.bottomNavigationHeight
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
copyMoveFileHandler?.apply {
@ -270,38 +271,20 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
.also {
coreMainActivity.navHostContainer
.setBottomMarginToFragmentContainerView(0)
getBottomNavigationView()?.let { bottomNavigationView ->
setBottomMarginToSwipeRefreshLayout(bottomNavigationView.measuredHeight)
}
}
disposable.add(sideEffects())
disposable.add(fileSelectActions())
zimManageViewModel.deviceListScanningProgress.observe(viewLifecycleOwner) {
// hide this progress bar when scanning is complete.
scanningProgressItem.value = Pair(it != MAX_PROGRESS, it)
// enable if the previous scanning is completes.
swipeRefreshItem.value = Pair(false, it == MAX_PROGRESS)
updateLibraryScreenState(
// hide this progress bar when scanning is complete.
scanningProgressItem = Pair(it != MAX_PROGRESS, it),
// enable if the previous scanning is completes.
swipeRefreshItem = Pair(false, it == MAX_PROGRESS)
)
}
if (savedInstanceState != null && savedInstanceState.getBoolean(WAS_IN_ACTION_MODE)) {
zimManageViewModel.fileSelectActions.offer(FileSelectActions.RestartActionMode)
}
fragmentDestinationLibraryBinding?.zimfilelist?.addOnScrollListener(
SimpleRecyclerViewScrollListener { _, newState ->
when (newState) {
SCROLL_DOWN -> {
setBottomMarginToSwipeRefreshLayout(0)
}
SCROLL_UP -> {
getBottomNavigationView()?.let {
setBottomMarginToSwipeRefreshLayout(it.measuredHeight)
}
}
}
}
)
showCopyMoveDialogForOpenedZimFileFromStorage()
}
@ -325,22 +308,24 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
private fun onSwipeRefresh() {
if (permissionDeniedLayoutShowing) {
// When permission denied layout is showing hide the "Swipe refresh".
swipeRefreshItem.value = false to true
updateLibraryScreenState(swipeRefreshItem = false to true)
} else {
if (!requireActivity().isManageExternalStoragePermissionGranted(sharedPreferenceUtil)) {
showManageExternalStoragePermissionDialog()
// Set loading to false since the dialog is currently being displayed.
// If the user clicks on "No" in the permission dialog,
// the loading icon remains visible infinitely.
swipeRefreshItem.value = false to true
updateLibraryScreenState(swipeRefreshItem = false to true)
} else {
// hide the swipe refreshing because now we are showing the ContentLoadingProgressBar
// to show the progress of how many files are scanned.
// disable the swipe refresh layout until the ongoing scanning will not complete
// to avoid multiple scanning.
swipeRefreshItem.value = false to false
// Show the progress Bar.
scanningProgressItem.value = true to ZERO
updateLibraryScreenState(
swipeRefreshItem = false to false,
// Show the progress Bar.
scanningProgressItem = true to ZERO
)
requestFileSystemCheck()
}
}
@ -360,13 +345,7 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
private fun getBottomNavigationView() =
requireActivity().findViewById<BottomNavigationView>(R.id.bottom_nav_view)
private fun setBottomMarginToSwipeRefreshLayout(marginBottom: Int) {
fragmentDestinationLibraryBinding?.zimSwiperefresh?.apply {
val params = layoutParams as CoordinatorLayout.LayoutParams?
params?.bottomMargin = marginBottom
requestLayout()
}
}
private fun getBottomNavigationHeight() = getBottomNavigationView().measuredHeight
private fun filePickerButtonClick() {
if (!requireActivity().isManageExternalStoragePermissionGranted(sharedPreferenceUtil)) {
@ -510,10 +489,12 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
) {
checkPermissions()
} else if (!permissionDeniedLayoutShowing) {
noFilesViewItem.value = Triple(
requireActivity().resources.getString(string.no_files_here),
requireActivity().resources.getString(string.download_books),
false
updateLibraryScreenState(
noFilesViewItem = Triple(
requireActivity().resources.getString(string.no_files_here),
requireActivity().resources.getString(string.download_books),
false
)
)
}
}
@ -540,7 +521,11 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
val effectResult = it.invokeWith(requireActivity() as AppCompatActivity)
if (effectResult is ActionMode) {
actionMode = effectResult
fileSelectListState.value.selectedBooks.size.let(::setActionModeTitle)
libraryScreenState
.value
.fileSelectListState
.selectedBooks
.size.let(::setActionModeTitle)
}
},
Throwable::printStackTrace
@ -568,20 +553,21 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
// Force recomposition by first setting an empty list before assigning the updated list.
// This is necessary because modifying an object's property doesn't trigger recomposition,
// as Compose still considers the list unchanged.
fileSelectListState.value = FileSelectListState(emptyList())
fileSelectListState.value = state
updateLibraryScreenState(
fileSelectListState = state,
noFilesViewItem = Triple(
requireActivity().resources.getString(string.no_files_here),
requireActivity().resources.getString(string.download_books),
// If here are no items available then show the "No files here" text, and "Download books"
// button so that user can go to "Online library" screen by clicking this button.
state.bookOnDiskListItems.isEmpty()
)
)
if (state.bookOnDiskListItems.none(BooksOnDiskListItem::isSelected)) {
actionMode?.finish()
actionMode = null
}
setActionModeTitle(state.selectedBooks.size)
noFilesViewItem.value = Triple(
requireActivity().resources.getString(string.no_files_here),
requireActivity().resources.getString(string.download_books),
// If here are no items available then show the "No files here" text, and "Download books"
// button so that user can go to "Online library" screen by clicking this button.
state.bookOnDiskListItems.isEmpty()
)
}
private fun setActionModeTitle(selectedBookCount: Int) {
@ -679,7 +665,7 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
}
private fun showStorageSelectionSnackBar(message: String) {
snackBarHostState.snack(
libraryScreenState.value.snackBarHostState.snack(
message = message,
actionLabel = getString(string.download_change_storage),
lifecycleScope = lifecycleScope,

View File

@ -26,35 +26,42 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.kiwix.kiwixmobile.R.string
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.downloader.downloadManager.ZERO
import org.kiwix.kiwixmobile.core.ui.components.ContentLoadingProgressBar
import org.kiwix.kiwixmobile.core.ui.components.KiwixAppBar
import org.kiwix.kiwixmobile.core.ui.components.KiwixButton
import org.kiwix.kiwixmobile.core.ui.components.KiwixSnackbarHost
import org.kiwix.kiwixmobile.core.ui.components.ProgressBarStyle
import org.kiwix.kiwixmobile.core.ui.components.SwipeRefreshLayout
import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem
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.EIGHT_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FAB_ICON_BOTTOM_MARGIN
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TEN_DP
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.adapter.BooksOnDiskListItem
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.adapter.BooksOnDiskListItem.BookOnDisk
import org.kiwix.kiwixmobile.ui.BookItem
@ -62,54 +69,65 @@ import org.kiwix.kiwixmobile.ui.ZimFilesLanguageHeader
import org.kiwix.kiwixmobile.zimManager.fileselectView.FileSelectListState
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("ComposableLambdaParameterNaming", "LongParameterList")
@Suppress("ComposableLambdaParameterNaming")
@Composable
fun LocalLibraryScreen(
state: FileSelectListState,
snackBarHostState: SnackbarHostState,
swipeRefreshItem: Pair<Boolean, Boolean>,
state: LocalLibraryScreenState,
onRefresh: () -> Unit,
scanningProgressItem: Pair<Boolean, Int>,
noFilesViewItem: Triple<String, String, Boolean>,
onDownloadButtonClick: () -> Unit,
fabButtonClick: () -> Unit,
actionMenuItems: List<ActionMenuItem>,
onClick: ((BookOnDisk) -> Unit)? = null,
onLongClick: ((BookOnDisk) -> Unit)? = null,
onMultiSelect: ((BookOnDisk) -> Unit)? = null,
navigationIcon: @Composable () -> Unit
) {
val lazyListState = rememberLazyListState()
val bottomNavHeightInDp = with(LocalDensity.current) { state.bottomNavigationHeight.toDp() }
val bottomNavHeight = remember { mutableStateOf(bottomNavHeightInDp) }
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState.firstVisibleItemScrollOffset }
.collect { scrollOffset ->
bottomNavHeight.value = if (scrollOffset > 0) ZERO.dp else bottomNavHeightInDp
}
}
KiwixTheme {
Scaffold(
snackbarHost = { KiwixSnackbarHost(snackbarHostState = snackBarHostState) },
topBar = { KiwixAppBar(R.string.library, navigationIcon, actionMenuItems) },
snackbarHost = { KiwixSnackbarHost(snackbarHostState = state.snackBarHostState) },
topBar = { KiwixAppBar(R.string.library, navigationIcon, state.actionMenuItems) },
modifier = Modifier.systemBarsPadding()
) { contentPadding ->
SwipeRefreshLayout(
isRefreshing = swipeRefreshItem.first,
isEnabled = swipeRefreshItem.second,
isRefreshing = state.swipeRefreshItem.first,
isEnabled = state.swipeRefreshItem.second,
onRefresh = onRefresh,
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
.padding(bottom = bottomNavHeight.value)
) {
if (scanningProgressItem.first) {
if (state.scanningProgressItem.first) {
ContentLoadingProgressBar(
progressBarStyle = ProgressBarStyle.HORIZONTAL,
progress = scanningProgressItem.second
progress = state.scanningProgressItem.second
)
}
if (noFilesViewItem.third) {
NoFilesView(noFilesViewItem, onDownloadButtonClick)
if (state.noFilesViewItem.third) {
NoFilesView(state.noFilesViewItem, onDownloadButtonClick)
} else {
BookItemList(state, onClick, onLongClick, onMultiSelect)
BookItemList(
state.fileSelectListState,
onClick,
onLongClick,
onMultiSelect,
lazyListState
)
}
SelectFileButton(
fabButtonClick,
Modifier
.align(Alignment.BottomEnd)
.padding(end = SIXTEEN_DP, bottom = FAB_ICON_BOTTOM_MARGIN)
.padding(end = SIXTEEN_DP, bottom = TEN_DP)
)
}
}
@ -122,8 +140,9 @@ private fun BookItemList(
onClick: ((BookOnDisk) -> Unit)? = null,
onLongClick: ((BookOnDisk) -> Unit)? = null,
onMultiSelect: ((BookOnDisk) -> Unit)? = null,
lazyListState: LazyListState,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(state.bookOnDiskListItems) { index, bookItem ->
when (bookItem) {
is BooksOnDiskListItem.LanguageItem -> {
@ -179,32 +198,3 @@ fun NoFilesView(
KiwixButton(noFilesViewItem.second, onDownloadButtonClick)
}
}
// @Preview
// @Preview(name = "NightMode", uiMode = Configuration.UI_MODE_NIGHT_YES)
// @Composable
// fun PreviewLocalLibrary() {
// LocalLibraryScreen(
// state = FileSelectListState(listOf(), SelectionMode.NORMAL),
// snackBarHostState = SnackbarHostState(),
// actionMenuItems = listOf(
// ActionMenuItem(
// IconItem.Drawable(org.kiwix.kiwixmobile.R.drawable.ic_baseline_mobile_screen_share_24px),
// R.string.get_content_from_nearby_device,
// { },
// isEnabled = true,
// testingTag = DELETE_MENU_BUTTON_TESTING_TAG
// )
// ),
// onRefresh = {},
// fabButtonClick = {},
// swipeRefreshItem = false to true,
// noFilesViewItem = Triple(
// stringResource(R.string.no_files_here),
// stringResource(R.string.download_books),
// {}
// )
// ) {
// NavigationIcon(IconItem.Vector(Icons.Filled.Menu), {})
// }
// }

View File

@ -0,0 +1,73 @@
/*
* 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.nav.destination.library.local
import androidx.compose.material3.SnackbarHostState
import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem
import org.kiwix.kiwixmobile.zimManager.fileselectView.FileSelectListState
/**
* Represents the UI state for the Local Library Screen.
*
* This data class encapsulates all UI-related states in a single object,
* reducing complexity in the Fragment.
*/
data class LocalLibraryScreenState(
/**
* Manages the file selection list state.
*/
val fileSelectListState: FileSelectListState,
/**
* Handles snack bar messages and displays.
*/
val snackBarHostState: SnackbarHostState,
/**
* Controls the visibility and behavior of the "Pull to refresh" animation.
*
* A [Pair] containing:
* - [Boolean]: The first boolean triggers/hides the "pull to refresh" animation.
* - [Boolean]: The second boolean enables/disables the "pull to refresh" gesture.
*/
val swipeRefreshItem: Pair<Boolean, Boolean>,
/**
* Represents the scanning progress state.
*
* A [Pair] containing:
* - [Boolean]: Whether scanning is in progress.
* - [Int]: The progress percentage of the scan.
*/
val scanningProgressItem: Pair<Boolean, Int>,
/**
* Controls the visibility of the "No files here" message and the "Download books" button.
*
* A [Triple] containing:
* - [String]: The title text displayed when no files are available.
* - [String]: The label for the download button.
* - [Boolean]: Whether to show or hide this view.
*/
val noFilesViewItem: Triple<String, String, Boolean>,
/**
* Represents a list of action menu items available in the screen's top app bar.
*/
val actionMenuItems: List<ActionMenuItem>,
/**
* Stores the height of the bottom navigation bar in pixels.
*/
val bottomNavigationHeight: Int
)