mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	Use Compose for Library screen (#7557)
- Move Pager to Compose - Move AppBar to Compose - Use Stable interface for state - Use pills for no. of manga in category instead of (x)
This commit is contained in:
		| @@ -38,8 +38,8 @@ fun Badge( | ||||
| ) { | ||||
|     Box( | ||||
|         modifier = Modifier | ||||
|             .background(color) | ||||
|             .clip(shape), | ||||
|             .clip(shape) | ||||
|             .background(color), | ||||
|     ) { | ||||
|         Text( | ||||
|             text = text, | ||||
|   | ||||
| @@ -195,3 +195,91 @@ private fun RowScope.Button( | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun LibraryBottomActionMenu( | ||||
|     visible: Boolean, | ||||
|     modifier: Modifier = Modifier, | ||||
|     onChangeCategoryClicked: (() -> Unit)?, | ||||
|     onMarkAsReadClicked: (() -> Unit)?, | ||||
|     onMarkAsUnreadClicked: (() -> Unit)?, | ||||
|     onDownloadClicked: (() -> Unit)?, | ||||
|     onDeleteClicked: (() -> Unit)?, | ||||
| ) { | ||||
|     AnimatedVisibility( | ||||
|         visible = visible, | ||||
|         enter = expandVertically(expandFrom = Alignment.Bottom), | ||||
|         exit = shrinkVertically(shrinkTowards = Alignment.Bottom), | ||||
|     ) { | ||||
|         val scope = rememberCoroutineScope() | ||||
|         Surface( | ||||
|             modifier = modifier, | ||||
|             shape = MaterialTheme.shapes.large, | ||||
|             tonalElevation = 3.dp, | ||||
|         ) { | ||||
|             val haptic = LocalHapticFeedback.current | ||||
|             val confirm = remember { mutableStateListOf(false, false, false, false, false) } | ||||
|             var resetJob: Job? = remember { null } | ||||
|             val onLongClickItem: (Int) -> Unit = { toConfirmIndex -> | ||||
|                 haptic.performHapticFeedback(HapticFeedbackType.LongPress) | ||||
|                 (0 until 5).forEach { i -> confirm[i] = i == toConfirmIndex } | ||||
|                 resetJob?.cancel() | ||||
|                 resetJob = scope.launch { | ||||
|                     delay(1000) | ||||
|                     if (isActive) confirm[toConfirmIndex] = false | ||||
|                 } | ||||
|             } | ||||
|             Row( | ||||
|                 modifier = Modifier | ||||
|                     .navigationBarsPadding() | ||||
|                     .padding(horizontal = 8.dp, vertical = 12.dp), | ||||
|             ) { | ||||
|                 if (onChangeCategoryClicked != null) { | ||||
|                     Button( | ||||
|                         title = stringResource(R.string.action_move_category), | ||||
|                         icon = Icons.Default.BookmarkAdd, | ||||
|                         toConfirm = confirm[0], | ||||
|                         onLongClick = { onLongClickItem(0) }, | ||||
|                         onClick = onChangeCategoryClicked, | ||||
|                     ) | ||||
|                 } | ||||
|                 if (onMarkAsReadClicked != null) { | ||||
|                     Button( | ||||
|                         title = stringResource(R.string.action_mark_as_read), | ||||
|                         icon = Icons.Default.DoneAll, | ||||
|                         toConfirm = confirm[1], | ||||
|                         onLongClick = { onLongClickItem(1) }, | ||||
|                         onClick = onMarkAsReadClicked, | ||||
|                     ) | ||||
|                 } | ||||
|                 if (onMarkAsUnreadClicked != null) { | ||||
|                     Button( | ||||
|                         title = stringResource(R.string.action_mark_as_unread), | ||||
|                         icon = Icons.Default.RemoveDone, | ||||
|                         toConfirm = confirm[2], | ||||
|                         onLongClick = { onLongClickItem(2) }, | ||||
|                         onClick = onMarkAsUnreadClicked, | ||||
|                     ) | ||||
|                 } | ||||
|                 if (onDownloadClicked != null) { | ||||
|                     Button( | ||||
|                         title = stringResource(R.string.action_download), | ||||
|                         icon = Icons.Outlined.Download, | ||||
|                         toConfirm = confirm[3], | ||||
|                         onLongClick = { onLongClickItem(3) }, | ||||
|                         onClick = onDownloadClicked, | ||||
|                     ) | ||||
|                 } | ||||
|                 if (onDeleteClicked != null) { | ||||
|                     Button( | ||||
|                         title = stringResource(R.string.action_delete), | ||||
|                         icon = Icons.Outlined.Delete, | ||||
|                         toConfirm = confirm[4], | ||||
|                         onLongClick = { onLongClickItem(4) }, | ||||
|                         onClick = onDeleteClicked, | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										38
									
								
								app/src/main/java/eu/kanade/presentation/components/Pill.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								app/src/main/java/eu/kanade/presentation/components/Pill.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| package eu.kanade.presentation.components | ||||
|  | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.material3.LocalTextStyle | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.draw.clip | ||||
| import androidx.compose.ui.unit.Dp | ||||
| import androidx.compose.ui.unit.TextUnit | ||||
| import androidx.compose.ui.unit.dp | ||||
|  | ||||
| @Composable | ||||
| fun Pill( | ||||
|     text: String, | ||||
|     modifier: Modifier = Modifier, | ||||
|     color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.background, | ||||
|     contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onBackground, | ||||
|     elevation: Dp = 1.dp, | ||||
|     fontSize: TextUnit = LocalTextStyle.current.fontSize, | ||||
| ) { | ||||
|     androidx.compose.material3.Surface( | ||||
|         modifier = modifier | ||||
|             .padding(start = 4.dp) | ||||
|             .clip(RoundedCornerShape(100)), | ||||
|         color = color, | ||||
|         contentColor = contentColor, | ||||
|         tonalElevation = elevation, | ||||
|     ) { | ||||
|         Text( | ||||
|             text = text, | ||||
|             modifier = Modifier.padding(6.dp, 1.dp), | ||||
|             fontSize = fontSize, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,71 @@ | ||||
| package eu.kanade.presentation.library | ||||
|  | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.getValue | ||||
| import eu.kanade.presentation.components.LibraryBottomActionMenu | ||||
| import eu.kanade.presentation.components.Scaffold | ||||
| import eu.kanade.presentation.library.components.LibraryContent | ||||
| import eu.kanade.presentation.library.components.LibraryToolbar | ||||
| import eu.kanade.tachiyomi.ui.library.LibraryPresenter | ||||
|  | ||||
| @Composable | ||||
| fun LibraryScreen( | ||||
|     presenter: LibraryPresenter, | ||||
|     onMangaClicked: (Long) -> Unit, | ||||
|     onGlobalSearchClicked: () -> Unit, | ||||
|     onChangeCategoryClicked: () -> Unit, | ||||
|     onMarkAsReadClicked: () -> Unit, | ||||
|     onMarkAsUnreadClicked: () -> Unit, | ||||
|     onDownloadClicked: () -> Unit, | ||||
|     onDeleteClicked: () -> Unit, | ||||
|     onClickUnselectAll: () -> Unit, | ||||
|     onClickSelectAll: () -> Unit, | ||||
|     onClickInvertSelection: () -> Unit, | ||||
|     onClickFilter: () -> Unit, | ||||
|     onClickRefresh: () -> Unit, | ||||
| ) { | ||||
|     Scaffold( | ||||
|         topBar = { | ||||
|             val title by presenter.getToolbarTitle() | ||||
|             LibraryToolbar( | ||||
|                 state = presenter, | ||||
|                 title = title, | ||||
|                 onClickUnselectAll = onClickUnselectAll, | ||||
|                 onClickSelectAll = onClickSelectAll, | ||||
|                 onClickInvertSelection = onClickInvertSelection, | ||||
|                 onClickFilter = onClickFilter, | ||||
|                 onClickRefresh = onClickRefresh, | ||||
|             ) | ||||
|         }, | ||||
|         bottomBar = { | ||||
|             LibraryBottomActionMenu( | ||||
|                 visible = presenter.selectionMode, | ||||
|                 onChangeCategoryClicked = onChangeCategoryClicked, | ||||
|                 onMarkAsReadClicked = onMarkAsReadClicked, | ||||
|                 onMarkAsUnreadClicked = onMarkAsUnreadClicked, | ||||
|                 onDownloadClicked = onDownloadClicked, | ||||
|                 onDeleteClicked = onDeleteClicked, | ||||
|             ) | ||||
|         }, | ||||
|     ) { paddingValues -> | ||||
|         LibraryContent( | ||||
|             state = presenter, | ||||
|             contentPadding = paddingValues, | ||||
|             currentPage = presenter.activeCategory, | ||||
|             isLibraryEmpty = presenter.loadedManga.isEmpty(), | ||||
|             showPageTabs = presenter.tabVisibility, | ||||
|             showMangaCount = presenter.mangaCountVisibility, | ||||
|             onChangeCurrentPage = { presenter.activeCategory = it }, | ||||
|             onMangaClicked = onMangaClicked, | ||||
|             onToggleSelection = { presenter.toggleSelection(it) }, | ||||
|             onRefresh = onClickRefresh, | ||||
|             onGlobalSearchClicked = onGlobalSearchClicked, | ||||
|             getNumberOfMangaForCategory = { presenter.getMangaCountForCategory(it) }, | ||||
|             getDisplayModeForPage = { presenter.getDisplayMode(index = it) }, | ||||
|             getColumnsForOrientation = { presenter.getColumnsPreferenceForCurrentOrientation(it) }, | ||||
|             getLibraryForPage = { presenter.getMangaForCategory(page = it) }, | ||||
|             isIncognitoMode = presenter.isIncognitoMode, | ||||
|             isDownloadOnly = presenter.isDownloadOnly, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,32 @@ | ||||
| package eu.kanade.presentation.library | ||||
|  | ||||
| import androidx.compose.runtime.Stable | ||||
| import androidx.compose.runtime.derivedStateOf | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.setValue | ||||
| import eu.kanade.domain.category.model.Category | ||||
| import eu.kanade.tachiyomi.data.database.models.LibraryManga | ||||
|  | ||||
| @Stable | ||||
| interface LibraryState { | ||||
|     val isLoading: Boolean | ||||
|     val categories: List<Category> | ||||
|     var searchQuery: String? | ||||
|     val selection: List<LibraryManga> | ||||
|     val selectionMode: Boolean | ||||
|     var hasActiveFilters: Boolean | ||||
| } | ||||
|  | ||||
| fun LibraryState(): LibraryState { | ||||
|     return LibraryStateImpl() | ||||
| } | ||||
|  | ||||
| class LibraryStateImpl : LibraryState { | ||||
|     override var isLoading: Boolean by mutableStateOf(true) | ||||
|     override var categories: List<Category> by mutableStateOf(emptyList()) | ||||
|     override var searchQuery: String? by mutableStateOf(null) | ||||
|     override var selection: List<LibraryManga> by mutableStateOf(emptyList()) | ||||
|     override val selectionMode: Boolean by derivedStateOf { selection.isNotEmpty() } | ||||
|     override var hasActiveFilters: Boolean by mutableStateOf(false) | ||||
| } | ||||
| @@ -3,14 +3,19 @@ package eu.kanade.presentation.library.components | ||||
| import androidx.compose.foundation.combinedClickable | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.lazy.grid.GridItemSpan | ||||
| import androidx.compose.foundation.lazy.grid.items | ||||
| import androidx.compose.material3.LocalTextStyle | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.text.font.FontWeight | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.zIndex | ||||
| import eu.kanade.domain.manga.model.MangaCover | ||||
| import eu.kanade.presentation.components.TextButton | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.LibraryManga | ||||
| import eu.kanade.tachiyomi.ui.library.LibraryItem | ||||
|  | ||||
| @@ -21,10 +26,22 @@ fun LibraryComfortableGrid( | ||||
|     selection: List<LibraryManga>, | ||||
|     onClick: (LibraryManga) -> Unit, | ||||
|     onLongClick: (LibraryManga) -> Unit, | ||||
|     searchQuery: String?, | ||||
|     onGlobalSearchClicked: () -> Unit, | ||||
| ) { | ||||
|     LazyLibraryGrid( | ||||
|         columns = columns, | ||||
|     ) { | ||||
|         item(span = { GridItemSpan(maxLineSpan) }) { | ||||
|             if (searchQuery.isNullOrEmpty().not()) { | ||||
|                 TextButton(onClick = onGlobalSearchClicked) { | ||||
|                     Text( | ||||
|                         text = stringResource(R.string.action_global_search_query, searchQuery!!), | ||||
|                         modifier = Modifier.zIndex(99f), | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         items( | ||||
|             items = items, | ||||
|             key = { | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.fillMaxHeight | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.lazy.grid.GridItemSpan | ||||
| import androidx.compose.foundation.lazy.grid.items | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.material3.LocalTextStyle | ||||
| @@ -17,8 +18,12 @@ import androidx.compose.ui.draw.clip | ||||
| import androidx.compose.ui.graphics.Brush | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.graphics.Shadow | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.text.font.FontWeight | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.zIndex | ||||
| import eu.kanade.presentation.components.TextButton | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.LibraryManga | ||||
| import eu.kanade.tachiyomi.ui.library.LibraryItem | ||||
|  | ||||
| @@ -29,10 +34,23 @@ fun LibraryCompactGrid( | ||||
|     selection: List<LibraryManga>, | ||||
|     onClick: (LibraryManga) -> Unit, | ||||
|     onLongClick: (LibraryManga) -> Unit, | ||||
|     searchQuery: String?, | ||||
|     onGlobalSearchClicked: () -> Unit, | ||||
| ) { | ||||
|     LazyLibraryGrid( | ||||
|         columns = columns, | ||||
|     ) { | ||||
|         item(span = { GridItemSpan(maxLineSpan) }) { | ||||
|             if (searchQuery.isNullOrEmpty().not()) { | ||||
|                 TextButton(onClick = onGlobalSearchClicked) { | ||||
|                     Text( | ||||
|                         text = stringResource(R.string.action_global_search_query, searchQuery!!), | ||||
|                         modifier = Modifier.zIndex(99f), | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         items( | ||||
|             items = items, | ||||
|             key = { | ||||
|   | ||||
| @@ -0,0 +1,126 @@ | ||||
| package eu.kanade.presentation.library.components | ||||
|  | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.PaddingValues | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.compose.runtime.State | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.input.nestedscroll.nestedScroll | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.platform.rememberNestedScrollInteropConnection | ||||
| import com.google.accompanist.pager.rememberPagerState | ||||
| import com.google.accompanist.swiperefresh.SwipeRefresh | ||||
| import com.google.accompanist.swiperefresh.rememberSwipeRefreshState | ||||
| import eu.kanade.core.prefs.PreferenceMutableState | ||||
| import eu.kanade.presentation.components.EmptyScreen | ||||
| import eu.kanade.presentation.components.LoadingScreen | ||||
| import eu.kanade.presentation.components.SwipeRefreshIndicator | ||||
| import eu.kanade.presentation.library.LibraryState | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.LibraryManga | ||||
| import eu.kanade.tachiyomi.ui.library.LibraryItem | ||||
| import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting | ||||
| import eu.kanade.tachiyomi.util.system.openInBrowser | ||||
| import eu.kanade.tachiyomi.widget.EmptyView | ||||
|  | ||||
| @Composable | ||||
| fun LibraryContent( | ||||
|     state: LibraryState, | ||||
|     contentPadding: PaddingValues, | ||||
|     currentPage: Int, | ||||
|     isLibraryEmpty: Boolean, | ||||
|     isDownloadOnly: Boolean, | ||||
|     isIncognitoMode: Boolean, | ||||
|     showPageTabs: Boolean, | ||||
|     showMangaCount: Boolean, | ||||
|     onChangeCurrentPage: (Int) -> Unit, | ||||
|     onMangaClicked: (Long) -> Unit, | ||||
|     onToggleSelection: (LibraryManga) -> Unit, | ||||
|     onRefresh: () -> Unit, | ||||
|     onGlobalSearchClicked: () -> Unit, | ||||
|     getNumberOfMangaForCategory: @Composable (Long) -> State<Int?>, | ||||
|     getDisplayModeForPage: @Composable (Int) -> State<DisplayModeSetting>, | ||||
|     getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>, | ||||
|     getLibraryForPage: @Composable (Int) -> State<List<LibraryItem>>, | ||||
| ) { | ||||
|     val nestedScrollInterop = rememberNestedScrollInteropConnection() | ||||
|  | ||||
|     val pagerState = rememberPagerState(currentPage) | ||||
|  | ||||
|     val categories = state.categories | ||||
|  | ||||
|     if (categories.isEmpty()) { | ||||
|         LoadingScreen() | ||||
|         return | ||||
|     } | ||||
|  | ||||
|     Column( | ||||
|         modifier = Modifier.padding(contentPadding), | ||||
|     ) { | ||||
|         if (showPageTabs && categories.size > 1) { | ||||
|             LibraryTabs( | ||||
|                 state = pagerState, | ||||
|                 categories = state.categories, | ||||
|                 showMangaCount = showMangaCount, | ||||
|                 getNumberOfMangaForCategory = getNumberOfMangaForCategory, | ||||
|                 isDownloadOnly = isDownloadOnly, | ||||
|                 isIncognitoMode = isIncognitoMode, | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         val onClickManga = { manga: LibraryManga -> | ||||
|             if (state.selectionMode.not()) { | ||||
|                 onMangaClicked(manga.id!!) | ||||
|             } else { | ||||
|                 onToggleSelection(manga) | ||||
|             } | ||||
|         } | ||||
|         val onLongClickManga = { manga: LibraryManga -> | ||||
|             onToggleSelection(manga) | ||||
|         } | ||||
|  | ||||
|         SwipeRefresh( | ||||
|             state = rememberSwipeRefreshState(isRefreshing = false), | ||||
|             modifier = Modifier.nestedScroll(nestedScrollInterop), | ||||
|             onRefresh = onRefresh, | ||||
|             indicator = { s, trigger -> | ||||
|                 SwipeRefreshIndicator( | ||||
|                     state = s, | ||||
|                     refreshTriggerDistance = trigger, | ||||
|                 ) | ||||
|             }, | ||||
|         ) { | ||||
|             if (state.searchQuery.isNullOrEmpty() && isLibraryEmpty) { | ||||
|                 val context = LocalContext.current | ||||
|                 EmptyScreen( | ||||
|                     R.string.information_empty_library, | ||||
|                     listOf( | ||||
|                         EmptyView.Action(R.string.getting_started_guide, R.drawable.ic_help_24dp) { | ||||
|                             context.openInBrowser("https://tachiyomi.org/help/guides/getting-started") | ||||
|                         }, | ||||
|                     ), | ||||
|                 ) | ||||
|                 return@SwipeRefresh | ||||
|             } | ||||
|  | ||||
|             LibraryPager( | ||||
|                 state = pagerState, | ||||
|                 pageCount = categories.size, | ||||
|                 selectedManga = state.selection, | ||||
|                 getDisplayModeForPage = getDisplayModeForPage, | ||||
|                 getColumnsForOrientation = getColumnsForOrientation, | ||||
|                 getLibraryForPage = getLibraryForPage, | ||||
|                 onClickManga = onClickManga, | ||||
|                 onLongClickManga = onLongClickManga, | ||||
|                 onGlobalSearchClicked = onGlobalSearchClicked, | ||||
|                 searchQuery = state.searchQuery, | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         LaunchedEffect(pagerState.currentPage) { | ||||
|             onChangeCurrentPage(pagerState.currentPage) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,9 +1,15 @@ | ||||
| package eu.kanade.presentation.library.components | ||||
|  | ||||
| import androidx.compose.foundation.combinedClickable | ||||
| import androidx.compose.foundation.lazy.grid.GridItemSpan | ||||
| import androidx.compose.foundation.lazy.grid.items | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.zIndex | ||||
| import eu.kanade.presentation.components.TextButton | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.LibraryManga | ||||
| import eu.kanade.tachiyomi.ui.library.LibraryItem | ||||
|  | ||||
| @@ -14,10 +20,22 @@ fun LibraryCoverOnlyGrid( | ||||
|     selection: List<LibraryManga>, | ||||
|     onClick: (LibraryManga) -> Unit, | ||||
|     onLongClick: (LibraryManga) -> Unit, | ||||
|     searchQuery: String?, | ||||
|     onGlobalSearchClicked: () -> Unit, | ||||
| ) { | ||||
|     LazyLibraryGrid( | ||||
|         columns = columns, | ||||
|     ) { | ||||
|         item(span = { GridItemSpan(maxLineSpan) }) { | ||||
|             if (searchQuery.isNullOrEmpty().not()) { | ||||
|                 TextButton(onClick = onGlobalSearchClicked) { | ||||
|                     Text( | ||||
|                         text = stringResource(R.string.action_global_search_query, searchQuery!!), | ||||
|                         modifier = Modifier.zIndex(99f), | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         items( | ||||
|             items = items, | ||||
|             key = { | ||||
|   | ||||
| @@ -17,9 +17,11 @@ import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.zIndex | ||||
| import eu.kanade.domain.manga.model.MangaCover | ||||
| import eu.kanade.presentation.components.Badge | ||||
| import eu.kanade.presentation.components.BadgeGroup | ||||
| import eu.kanade.presentation.components.TextButton | ||||
| import eu.kanade.presentation.util.horizontalPadding | ||||
| import eu.kanade.presentation.util.selectedBackground | ||||
| import eu.kanade.presentation.util.verticalPadding | ||||
| @@ -33,10 +35,23 @@ fun LibraryList( | ||||
|     selection: List<LibraryManga>, | ||||
|     onClick: (LibraryManga) -> Unit, | ||||
|     onLongClick: (LibraryManga) -> Unit, | ||||
|     searchQuery: String?, | ||||
|     onGlobalSearchClicked: () -> Unit, | ||||
| ) { | ||||
|     LazyColumn( | ||||
|         contentPadding = WindowInsets.navigationBars.asPaddingValues(), | ||||
|     ) { | ||||
|         item { | ||||
|             if (searchQuery.isNullOrEmpty().not()) { | ||||
|                 TextButton(onClick = onGlobalSearchClicked) { | ||||
|                     Text( | ||||
|                         text = stringResource(R.string.action_global_search_query, searchQuery!!), | ||||
|                         modifier = Modifier.zIndex(99f), | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         items( | ||||
|             items = items, | ||||
|             key = { | ||||
|   | ||||
| @@ -0,0 +1,96 @@ | ||||
| package eu.kanade.presentation.library.components | ||||
|  | ||||
| import android.content.res.Configuration | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.State | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.platform.LocalConfiguration | ||||
| import com.google.accompanist.pager.HorizontalPager | ||||
| import com.google.accompanist.pager.PagerState | ||||
| import eu.kanade.core.prefs.PreferenceMutableState | ||||
| import eu.kanade.tachiyomi.data.database.models.LibraryManga | ||||
| import eu.kanade.tachiyomi.ui.library.LibraryItem | ||||
| import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting | ||||
|  | ||||
| @Composable | ||||
| fun LibraryPager( | ||||
|     state: PagerState, | ||||
|     pageCount: Int, | ||||
|     selectedManga: List<LibraryManga>, | ||||
|     searchQuery: String?, | ||||
|     onGlobalSearchClicked: () -> Unit, | ||||
|     getDisplayModeForPage: @Composable (Int) -> State<DisplayModeSetting>, | ||||
|     getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>, | ||||
|     getLibraryForPage: @Composable (Int) -> State<List<LibraryItem>>, | ||||
|     onClickManga: (LibraryManga) -> Unit, | ||||
|     onLongClickManga: (LibraryManga) -> Unit, | ||||
| ) { | ||||
|     HorizontalPager( | ||||
|         count = pageCount, | ||||
|         modifier = Modifier.fillMaxSize(), | ||||
|         state = state, | ||||
|         verticalAlignment = Alignment.Top, | ||||
|     ) { page -> | ||||
|         val library by getLibraryForPage(page) | ||||
|         val displayMode by getDisplayModeForPage(page) | ||||
|         val columns by if (displayMode != DisplayModeSetting.LIST) { | ||||
|             val configuration = LocalConfiguration.current | ||||
|             val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE | ||||
|  | ||||
|             remember(isLandscape) { getColumnsForOrientation(isLandscape) } | ||||
|         } else { | ||||
|             remember { mutableStateOf(0) } | ||||
|         } | ||||
|  | ||||
|         when (displayMode) { | ||||
|             DisplayModeSetting.LIST -> { | ||||
|                 LibraryList( | ||||
|                     items = library, | ||||
|                     selection = selectedManga, | ||||
|                     onClick = onClickManga, | ||||
|                     onLongClick = onLongClickManga, | ||||
|                     searchQuery = searchQuery, | ||||
|                     onGlobalSearchClicked = onGlobalSearchClicked, | ||||
|                 ) | ||||
|             } | ||||
|             DisplayModeSetting.COMPACT_GRID -> { | ||||
|                 LibraryCompactGrid( | ||||
|                     items = library, | ||||
|                     columns = columns, | ||||
|                     selection = selectedManga, | ||||
|                     onClick = onClickManga, | ||||
|                     onLongClick = onLongClickManga, | ||||
|                     searchQuery = searchQuery, | ||||
|                     onGlobalSearchClicked = onGlobalSearchClicked, | ||||
|                 ) | ||||
|             } | ||||
|             DisplayModeSetting.COMFORTABLE_GRID -> { | ||||
|                 LibraryComfortableGrid( | ||||
|                     items = library, | ||||
|                     columns = columns, | ||||
|                     selection = selectedManga, | ||||
|                     onClick = onClickManga, | ||||
|                     onLongClick = onLongClickManga, | ||||
|                     searchQuery = searchQuery, | ||||
|                     onGlobalSearchClicked = onGlobalSearchClicked, | ||||
|                 ) | ||||
|             } | ||||
|             DisplayModeSetting.COVER_ONLY_GRID -> { | ||||
|                 LibraryCoverOnlyGrid( | ||||
|                     items = library, | ||||
|                     columns = columns, | ||||
|                     selection = selectedManga, | ||||
|                     onClick = onClickManga, | ||||
|                     onLongClick = onLongClickManga, | ||||
|                     searchQuery = searchQuery, | ||||
|                     onGlobalSearchClicked = onGlobalSearchClicked, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,77 @@ | ||||
| package eu.kanade.presentation.library.components | ||||
|  | ||||
| import androidx.compose.foundation.isSystemInDarkTheme | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.ScrollableTabRow | ||||
| import androidx.compose.material3.Tab | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.State | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.rememberCoroutineScope | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.unit.sp | ||||
| import com.google.accompanist.pager.PagerState | ||||
| import eu.kanade.domain.category.model.Category | ||||
| import eu.kanade.presentation.components.DownloadedOnlyModeBanner | ||||
| import eu.kanade.presentation.components.IncognitoModeBanner | ||||
| import eu.kanade.presentation.components.Pill | ||||
| import kotlinx.coroutines.launch | ||||
|  | ||||
| @Composable | ||||
| fun LibraryTabs( | ||||
|     state: PagerState, | ||||
|     categories: List<Category>, | ||||
|     showMangaCount: Boolean, | ||||
|     isDownloadOnly: Boolean, | ||||
|     isIncognitoMode: Boolean, | ||||
|     getNumberOfMangaForCategory: @Composable (Long) -> State<Int?>, | ||||
| ) { | ||||
|     val scope = rememberCoroutineScope() | ||||
|  | ||||
|     val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f | ||||
|  | ||||
|     Column { | ||||
|         ScrollableTabRow( | ||||
|             selectedTabIndex = state.currentPage, | ||||
|             edgePadding = 0.dp, | ||||
|         ) { | ||||
|             categories.forEachIndexed { index, category -> | ||||
|                 val count by if (showMangaCount) { | ||||
|                     getNumberOfMangaForCategory(category.id) | ||||
|                 } else { | ||||
|                     remember { mutableStateOf<Int?>(null) } | ||||
|                 } | ||||
|                 Tab( | ||||
|                     selected = state.currentPage == index, | ||||
|                     onClick = { scope.launch { state.animateScrollToPage(index) } }, | ||||
|                     text = { | ||||
|                         Row( | ||||
|                             verticalAlignment = Alignment.CenterVertically, | ||||
|                         ) { | ||||
|                             Text(text = category.name) | ||||
|                             if (count != null) { | ||||
|                                 Pill( | ||||
|                                     text = "$count", | ||||
|                                     color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha), | ||||
|                                     fontSize = 10.sp, | ||||
|                                 ) | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|         if (isDownloadOnly) { | ||||
|             DownloadedOnlyModeBanner() | ||||
|         } | ||||
|         if (isIncognitoMode) { | ||||
|             IncognitoModeBanner() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,188 @@ | ||||
| package eu.kanade.presentation.library.components | ||||
|  | ||||
| import androidx.compose.foundation.isSystemInDarkTheme | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.statusBarsPadding | ||||
| import androidx.compose.foundation.text.BasicTextField | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.outlined.ArrowBack | ||||
| import androidx.compose.material.icons.outlined.Close | ||||
| import androidx.compose.material.icons.outlined.FilterList | ||||
| import androidx.compose.material.icons.outlined.FlipToBack | ||||
| import androidx.compose.material.icons.outlined.Refresh | ||||
| import androidx.compose.material.icons.outlined.Search | ||||
| import androidx.compose.material.icons.outlined.SelectAll | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.IconButton | ||||
| import androidx.compose.material3.LocalContentColor | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.SmallTopAppBar | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TopAppBarDefaults | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.draw.drawBehind | ||||
| import androidx.compose.ui.focus.FocusRequester | ||||
| import androidx.compose.ui.focus.focusRequester | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.graphics.SolidColor | ||||
| import androidx.compose.ui.text.style.TextOverflow | ||||
| import androidx.compose.ui.unit.sp | ||||
| import eu.kanade.presentation.components.Pill | ||||
| import eu.kanade.presentation.library.LibraryState | ||||
| import eu.kanade.presentation.theme.active | ||||
| import kotlinx.coroutines.delay | ||||
|  | ||||
| @Composable | ||||
| fun LibraryToolbar( | ||||
|     state: LibraryState, | ||||
|     title: LibraryToolbarTitle, | ||||
|     onClickUnselectAll: () -> Unit, | ||||
|     onClickSelectAll: () -> Unit, | ||||
|     onClickInvertSelection: () -> Unit, | ||||
|     onClickFilter: () -> Unit, | ||||
|     onClickRefresh: () -> Unit, | ||||
| ) = when { | ||||
|     state.searchQuery != null -> LibrarySearchToolbar( | ||||
|         searchQuery = state.searchQuery!!, | ||||
|         onChangeSearchQuery = { state.searchQuery = it }, | ||||
|         onClickCloseSearch = { state.searchQuery = null }, | ||||
|     ) | ||||
|     state.selectionMode -> LibrarySelectionToolbar( | ||||
|         state = state, | ||||
|         onClickUnselectAll = onClickUnselectAll, | ||||
|         onClickSelectAll = onClickSelectAll, | ||||
|         onClickInvertSelection = onClickInvertSelection, | ||||
|     ) | ||||
|     else -> LibraryRegularToolbar( | ||||
|         title = title, | ||||
|         hasFilters = state.hasActiveFilters, | ||||
|         onClickSearch = { state.searchQuery = "" }, | ||||
|         onClickFilter = onClickFilter, | ||||
|         onClickRefresh = onClickRefresh, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun LibraryRegularToolbar( | ||||
|     title: LibraryToolbarTitle, | ||||
|     hasFilters: Boolean, | ||||
|     onClickSearch: () -> Unit, | ||||
|     onClickFilter: () -> Unit, | ||||
|     onClickRefresh: () -> Unit, | ||||
| ) { | ||||
|     val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f | ||||
|     val filterTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current | ||||
|     SmallTopAppBar( | ||||
|         modifier = Modifier.statusBarsPadding(), | ||||
|         title = { | ||||
|             Row(verticalAlignment = Alignment.CenterVertically) { | ||||
|                 Text( | ||||
|                     text = title.text, | ||||
|                     maxLines = 1, | ||||
|                     modifier = Modifier.weight(1f, false), | ||||
|                     overflow = TextOverflow.Ellipsis, | ||||
|                 ) | ||||
|                 if (title.numberOfManga != null) { | ||||
|                     Pill( | ||||
|                         text = "${title.numberOfManga}", | ||||
|                         color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha), | ||||
|                         fontSize = 14.sp, | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         actions = { | ||||
|             IconButton(onClick = onClickSearch) { | ||||
|                 Icon(Icons.Outlined.Search, contentDescription = "search") | ||||
|             } | ||||
|             IconButton(onClick = onClickFilter) { | ||||
|                 Icon(Icons.Outlined.FilterList, contentDescription = "search", tint = filterTint) | ||||
|             } | ||||
|             IconButton(onClick = onClickRefresh) { | ||||
|                 Icon(Icons.Outlined.Refresh, contentDescription = "search") | ||||
|             } | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun LibrarySelectionToolbar( | ||||
|     state: LibraryState, | ||||
|     onClickUnselectAll: () -> Unit, | ||||
|     onClickSelectAll: () -> Unit, | ||||
|     onClickInvertSelection: () -> Unit, | ||||
| ) { | ||||
|     val backgroundColor by TopAppBarDefaults.smallTopAppBarColors().containerColor(1f) | ||||
|     SmallTopAppBar( | ||||
|         modifier = Modifier | ||||
|             .drawBehind { | ||||
|                 drawRect(backgroundColor.copy(alpha = 1f)) | ||||
|             } | ||||
|             .statusBarsPadding(), | ||||
|         navigationIcon = { | ||||
|             IconButton(onClick = onClickUnselectAll) { | ||||
|                 Icon(Icons.Outlined.Close, contentDescription = "close") | ||||
|             } | ||||
|         }, | ||||
|         title = { | ||||
|             Text(text = "${state.selection.size}") | ||||
|         }, | ||||
|         actions = { | ||||
|             IconButton(onClick = onClickSelectAll) { | ||||
|                 Icon(Icons.Outlined.SelectAll, contentDescription = "search") | ||||
|             } | ||||
|             IconButton(onClick = onClickInvertSelection) { | ||||
|                 Icon(Icons.Outlined.FlipToBack, contentDescription = "invert") | ||||
|             } | ||||
|         }, | ||||
|         colors = TopAppBarDefaults.smallTopAppBarColors( | ||||
|             containerColor = Color.Transparent, | ||||
|             scrolledContainerColor = Color.Transparent, | ||||
|         ), | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun LibrarySearchToolbar( | ||||
|     searchQuery: String, | ||||
|     onChangeSearchQuery: (String) -> Unit, | ||||
|     onClickCloseSearch: () -> Unit, | ||||
| ) { | ||||
|     val focusRequester = remember { FocusRequester.Default } | ||||
|     SmallTopAppBar( | ||||
|         modifier = Modifier.statusBarsPadding(), | ||||
|         navigationIcon = { | ||||
|             IconButton(onClick = onClickCloseSearch) { | ||||
|                 Icon(Icons.Outlined.ArrowBack, contentDescription = "back") | ||||
|             } | ||||
|         }, | ||||
|         title = { | ||||
|             BasicTextField( | ||||
|                 value = searchQuery, | ||||
|                 onValueChange = onChangeSearchQuery, | ||||
|                 modifier = Modifier | ||||
|                     .fillMaxWidth() | ||||
|                     .focusRequester(focusRequester), | ||||
|                 textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onBackground), | ||||
|                 singleLine = true, | ||||
|                 cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground), | ||||
|             ) | ||||
|             LaunchedEffect(focusRequester) { | ||||
|                 // TODO: https://issuetracker.google.com/issues/204502668 | ||||
|                 delay(100) | ||||
|                 focusRequester.requestFocus() | ||||
|             } | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| data class LibraryToolbarTitle( | ||||
|     val text: String, | ||||
|     val numberOfManga: Int? = null, | ||||
| ) | ||||
							
								
								
									
										12
									
								
								app/src/main/java/eu/kanade/presentation/theme/Color.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/src/main/java/eu/kanade/presentation/theme/Color.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| package eu.kanade.presentation.theme | ||||
|  | ||||
| import androidx.compose.foundation.isSystemInDarkTheme | ||||
| import androidx.compose.material3.ColorScheme | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.graphics.Color | ||||
|  | ||||
| val ColorScheme.active: Color | ||||
|     @Composable | ||||
|     get() { | ||||
|         return if (isSystemInDarkTheme()) Color(255, 235, 59) else Color(255, 193, 7) | ||||
|     } | ||||
| @@ -1,208 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateListOf | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.input.nestedscroll.nestedScroll | ||||
| import androidx.compose.ui.platform.ComposeView | ||||
| import androidx.compose.ui.platform.rememberNestedScrollInteropConnection | ||||
| import com.google.accompanist.swiperefresh.SwipeRefresh | ||||
| import com.google.accompanist.swiperefresh.rememberSwipeRefreshState | ||||
| import eu.kanade.domain.category.model.Category | ||||
| import eu.kanade.presentation.components.SwipeRefreshIndicator | ||||
| import eu.kanade.presentation.library.components.LibraryComfortableGrid | ||||
| import eu.kanade.presentation.library.components.LibraryCompactGrid | ||||
| import eu.kanade.presentation.library.components.LibraryCoverOnlyGrid | ||||
| import eu.kanade.presentation.library.components.LibraryList | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.LibraryManga | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateService | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.databinding.ComposeControllerBinding | ||||
| import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import eu.kanade.tachiyomi.util.view.setComposeContent | ||||
| import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| /** | ||||
|  * This adapter stores the categories from the library, used with a ViewPager. | ||||
|  * | ||||
|  * @constructor creates an instance of the adapter. | ||||
|  */ | ||||
| class LibraryAdapter( | ||||
|     private val presenter: LibraryPresenter, | ||||
|     private val onClickManga: (LibraryManga) -> Unit, | ||||
|     private val preferences: PreferencesHelper = Injekt.get(), | ||||
| ) : RecyclerViewPagerAdapter() { | ||||
|  | ||||
|     /** | ||||
|      * The categories to bind in the adapter. | ||||
|      */ | ||||
|     var categories: List<Category> = mutableStateListOf() | ||||
|         private set | ||||
|  | ||||
|     /** | ||||
|      * The number of manga in each category. | ||||
|      * List order must be the same as [categories] | ||||
|      */ | ||||
|     private var itemsPerCategory: List<Int> = emptyList() | ||||
|  | ||||
|     private var boundViews = arrayListOf<View>() | ||||
|  | ||||
|     /** | ||||
|      * Pair of category and size of category | ||||
|      */ | ||||
|     fun updateCategories(new: List<Pair<Category, Int>>) { | ||||
|         var updated = false | ||||
|  | ||||
|         val newCategories = new.map { it.first } | ||||
|         if (categories != newCategories) { | ||||
|             categories = newCategories | ||||
|             updated = true | ||||
|         } | ||||
|  | ||||
|         val newItemsPerCategory = new.map { it.second } | ||||
|         if (itemsPerCategory !== newItemsPerCategory) { | ||||
|             itemsPerCategory = newItemsPerCategory | ||||
|             updated = true | ||||
|         } | ||||
|  | ||||
|         if (updated) { | ||||
|             notifyDataSetChanged() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Creates a new view for this adapter. | ||||
|      * | ||||
|      * @return a new view. | ||||
|      */ | ||||
|     override fun inflateView(container: ViewGroup, viewType: Int): View { | ||||
|         val binding = ComposeControllerBinding.inflate(LayoutInflater.from(container.context), container, false) | ||||
|         return binding.root | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Binds a view with a position. | ||||
|      * | ||||
|      * @param view the view to bind. | ||||
|      * @param position the position in the adapter. | ||||
|      */ | ||||
|     override fun bindView(view: View, position: Int) { | ||||
|         (view as ComposeView).apply { | ||||
|             setComposeContent { | ||||
|                 val nestedScrollInterop = rememberNestedScrollInteropConnection() | ||||
|  | ||||
|                 val category = presenter.categories[position] | ||||
|                 val displayMode = presenter.getDisplayMode(index = position) | ||||
|                 val mangaList by presenter.getMangaForCategory(categoryId = category.id) | ||||
|  | ||||
|                 val onClickManga = { manga: LibraryManga -> | ||||
|                     if (presenter.hasSelection().not()) { | ||||
|                         onClickManga(manga) | ||||
|                     } else { | ||||
|                         presenter.toggleSelection(manga) | ||||
|                     } | ||||
|                 } | ||||
|                 val onLongClickManga = { manga: LibraryManga -> | ||||
|                     presenter.toggleSelection(manga) | ||||
|                 } | ||||
|  | ||||
|                 SwipeRefresh( | ||||
|                     modifier = Modifier.nestedScroll(nestedScrollInterop), | ||||
|                     state = rememberSwipeRefreshState(isRefreshing = false), | ||||
|                     onRefresh = { | ||||
|                         if (LibraryUpdateService.start(context, category)) { | ||||
|                             context.toast(R.string.updating_category) | ||||
|                         } | ||||
|                     }, | ||||
|                     indicator = { s, trigger -> | ||||
|                         SwipeRefreshIndicator( | ||||
|                             state = s, | ||||
|                             refreshTriggerDistance = trigger, | ||||
|                         ) | ||||
|                     }, | ||||
|                 ) { | ||||
|                     when (displayMode) { | ||||
|                         DisplayModeSetting.LIST -> { | ||||
|                             LibraryList( | ||||
|                                 items = mangaList, | ||||
|                                 selection = presenter.selection, | ||||
|                                 onClick = onClickManga, | ||||
|                                 onLongClick = onLongClickManga, | ||||
|                             ) | ||||
|                         } | ||||
|                         DisplayModeSetting.COMPACT_GRID -> { | ||||
|                             LibraryCompactGrid( | ||||
|                                 items = mangaList, | ||||
|                                 columns = presenter.columns, | ||||
|                                 selection = presenter.selection, | ||||
|                                 onClick = onClickManga, | ||||
|                                 onLongClick = onLongClickManga, | ||||
|                             ) | ||||
|                         } | ||||
|                         DisplayModeSetting.COMFORTABLE_GRID -> { | ||||
|                             LibraryComfortableGrid( | ||||
|                                 items = mangaList, | ||||
|                                 columns = presenter.columns, | ||||
|                                 selection = presenter.selection, | ||||
|                                 onClick = onClickManga, | ||||
|                                 onLongClick = onLongClickManga, | ||||
|                             ) | ||||
|                         } | ||||
|                         DisplayModeSetting.COVER_ONLY_GRID -> { | ||||
|                             LibraryCoverOnlyGrid( | ||||
|                                 items = mangaList, | ||||
|                                 columns = presenter.columns, | ||||
|                                 selection = presenter.selection, | ||||
|                                 onClick = onClickManga, | ||||
|                                 onLongClick = onLongClickManga, | ||||
|                             ) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         boundViews.add(view) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Recycles a view. | ||||
|      * | ||||
|      * @param view the view to recycle. | ||||
|      * @param position the position in the adapter. | ||||
|      */ | ||||
|     override fun recycleView(view: View, position: Int) { | ||||
|         boundViews.remove(view) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the number of categories. | ||||
|      * | ||||
|      * @return the number of categories or 0 if the list is null. | ||||
|      */ | ||||
|     override fun getCount(): Int { | ||||
|         return categories.size | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the title to display for a category. | ||||
|      * | ||||
|      * @param position the position of the element. | ||||
|      * @return the title to display. | ||||
|      */ | ||||
|     override fun getPageTitle(position: Int): CharSequence { | ||||
|         return if (!preferences.categoryNumberOfItems().get()) { | ||||
|             categories[position].name | ||||
|         } else { | ||||
|             categories[position].let { "${it.name} (${itemsPerCategory[position]})" } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getViewType(position: Int): Int = -1 | ||||
| } | ||||
| @@ -1,240 +1,119 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.content.res.Configuration | ||||
| 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 androidx.appcompat.view.ActionMode | ||||
| import androidx.core.view.isVisible | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import com.bluelinelabs.conductor.ControllerChangeHandler | ||||
| import com.bluelinelabs.conductor.ControllerChangeType | ||||
| import com.fredporciuncula.flow.preferences.Preference | ||||
| import com.google.android.material.tabs.TabLayout | ||||
| import com.jakewharton.rxrelay.BehaviorRelay | ||||
| import eu.kanade.domain.category.model.Category | ||||
| import eu.kanade.domain.category.model.toDbCategory | ||||
| import eu.kanade.domain.manga.model.Manga | ||||
| import eu.kanade.domain.manga.model.toDbManga | ||||
| import eu.kanade.presentation.library.LibraryScreen | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.toDomainManga | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateService | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.databinding.LibraryControllerBinding | ||||
| import eu.kanade.tachiyomi.ui.base.controller.FullComposeController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.RootController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.TabbedController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.pushController | ||||
| import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController | ||||
| import eu.kanade.tachiyomi.ui.main.MainActivity | ||||
| import eu.kanade.tachiyomi.ui.manga.MangaController | ||||
| import eu.kanade.tachiyomi.util.lang.launchIO | ||||
| import eu.kanade.tachiyomi.util.lang.launchUI | ||||
| import eu.kanade.tachiyomi.util.preference.asHotFlow | ||||
| import eu.kanade.tachiyomi.util.system.getResourceColor | ||||
| import eu.kanade.tachiyomi.util.system.openInBrowser | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import eu.kanade.tachiyomi.widget.ActionModeWithToolbar | ||||
| import eu.kanade.tachiyomi.widget.EmptyView | ||||
| import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView | ||||
| import kotlinx.coroutines.cancel | ||||
| import kotlinx.coroutines.flow.drop | ||||
| import kotlinx.coroutines.flow.launchIn | ||||
| import kotlinx.coroutines.flow.onEach | ||||
| import reactivecircus.flowbinding.android.view.clicks | ||||
| import reactivecircus.flowbinding.viewpager.pageSelections | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| class LibraryController( | ||||
|     bundle: Bundle? = null, | ||||
|     private val preferences: PreferencesHelper = Injekt.get(), | ||||
| ) : SearchableNucleusController<LibraryControllerBinding, LibraryPresenter>(bundle), | ||||
| ) : FullComposeController<LibraryPresenter>(bundle), | ||||
|     RootController, | ||||
|     TabbedController, | ||||
|     ActionModeWithToolbar.Callback, | ||||
|     ChangeMangaCategoriesDialog.Listener, | ||||
|     DeleteLibraryMangasDialog.Listener { | ||||
|  | ||||
|     /** | ||||
|      * Position of the active category. | ||||
|      */ | ||||
|     private var activeCategory: Int = preferences.lastUsedCategory().get() | ||||
|  | ||||
|     /** | ||||
|      * Action mode for selections. | ||||
|      */ | ||||
|     private var actionMode: ActionModeWithToolbar? = null | ||||
|  | ||||
|     private var mangaMap: LibraryMap = emptyMap() | ||||
|  | ||||
|     private var adapter: LibraryAdapter? = null | ||||
|  | ||||
|     /** | ||||
|      * Sheet containing filter/sort/display items. | ||||
|      */ | ||||
|     private var settingsSheet: LibrarySettingsSheet? = null | ||||
|  | ||||
|     private var tabsVisibilityRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false) | ||||
|  | ||||
|     private var mangaCountVisibilityRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false) | ||||
|  | ||||
|     private var tabsVisibilitySubscription: Subscription? = null | ||||
|  | ||||
|     private var mangaCountVisibilitySubscription: Subscription? = null | ||||
|  | ||||
|     init { | ||||
|         setHasOptionsMenu(true) | ||||
|         retainViewMode = RetainViewMode.RETAIN_DETACH | ||||
|     } | ||||
|  | ||||
|     private var currentTitle: String? = null | ||||
|         set(value) { | ||||
|             if (field != value) { | ||||
|                 field = value | ||||
|                 setTitle() | ||||
|             } | ||||
|         } | ||||
|     override fun createPresenter(): LibraryPresenter = LibraryPresenter() | ||||
|  | ||||
|     override fun getTitle(): String? { | ||||
|         return currentTitle ?: resources?.getString(R.string.label_library) | ||||
|     @Composable | ||||
|     override fun ComposeContent() { | ||||
|         val context = LocalContext.current | ||||
|         LibraryScreen( | ||||
|             presenter = presenter, | ||||
|             onMangaClicked = ::openManga, | ||||
|             onGlobalSearchClicked = { | ||||
|                 router.pushController(GlobalSearchController(presenter.query)) | ||||
|             }, | ||||
|             onChangeCategoryClicked = ::showMangaCategoriesDialog, | ||||
|             onMarkAsReadClicked = { markReadStatus(true) }, | ||||
|             onMarkAsUnreadClicked = { markReadStatus(false) }, | ||||
|             onDownloadClicked = ::downloadUnreadChapters, | ||||
|             onDeleteClicked = ::showDeleteMangaDialog, | ||||
|             onClickFilter = ::showSettingsSheet, | ||||
|             onClickRefresh = { | ||||
|                 if (LibraryUpdateService.start(context)) { | ||||
|                     context.toast(R.string.updating_library) | ||||
|                 } | ||||
|             }, | ||||
|             onClickInvertSelection = { presenter.invertSelection(presenter.activeCategory) }, | ||||
|             onClickSelectAll = { presenter.selectAll(presenter.activeCategory) }, | ||||
|             onClickUnselectAll = ::clearSelection, | ||||
|         ) | ||||
|         LaunchedEffect(presenter.selectionMode) { | ||||
|             val activity = (activity as? MainActivity) ?: return@LaunchedEffect | ||||
|             activity.showBottomNav(presenter.selectionMode.not()) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun updateTitle() { | ||||
|         val showCategoryTabs = preferences.categoryTabs().get() | ||||
|         val currentCategory = adapter?.categories?.getOrNull(binding.libraryPager.currentItem) | ||||
|  | ||||
|         var title = if (showCategoryTabs) { | ||||
|             resources?.getString(R.string.label_library) | ||||
|         } else { | ||||
|             currentCategory?.name | ||||
|     override fun handleBack(): Boolean { | ||||
|         if (presenter.selection.isNotEmpty()) { | ||||
|             presenter.clearSelection() | ||||
|             return true | ||||
|         } | ||||
|  | ||||
|         if (preferences.categoryNumberOfItems().get()) { | ||||
|             if (!showCategoryTabs || adapter?.categories?.size == 1) { | ||||
|                 title += " (${mangaMap[currentCategory?.id]?.size ?: 0})" | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         currentTitle = title | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     override fun createPresenter(): LibraryPresenter { | ||||
|         return LibraryPresenter() | ||||
|     } | ||||
|  | ||||
|     override fun createBinding(inflater: LayoutInflater) = LibraryControllerBinding.inflate(inflater) | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         adapter = LibraryAdapter( | ||||
|             presenter = presenter, | ||||
|             onClickManga = { | ||||
|                 openManga(it.id!!) | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|         getColumnsPreferenceForCurrentOrientation() | ||||
|             .asHotFlow { presenter.columns = it } | ||||
|             .launchIn(viewScope) | ||||
|  | ||||
|         binding.libraryPager.adapter = adapter | ||||
|         binding.libraryPager.pageSelections() | ||||
|             .drop(1) | ||||
|             .onEach { | ||||
|                 preferences.lastUsedCategory().set(it) | ||||
|                 activeCategory = it | ||||
|                 updateTitle() | ||||
|             } | ||||
|             .launchIn(viewScope) | ||||
|  | ||||
|         if (adapter!!.categories.isNotEmpty()) { | ||||
|             createActionModeIfNeeded() | ||||
|         } | ||||
|  | ||||
|         settingsSheet = LibrarySettingsSheet(router) { group -> | ||||
|             when (group) { | ||||
|                 is LibrarySettingsSheet.Filter.FilterGroup -> onFilterChanged() | ||||
|                 is LibrarySettingsSheet.Sort.SortGroup -> onSortChanged() | ||||
|                 is LibrarySettingsSheet.Display.DisplayGroup -> { | ||||
|                     val delay = if (preferences.categorizedDisplaySettings().get()) 125L else 0L | ||||
|  | ||||
|                     Observable.timer(delay, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) | ||||
|                         .subscribe { | ||||
|                             reattachAdapter() | ||||
|                         } | ||||
|                 } | ||||
|                 is LibrarySettingsSheet.Display.DisplayGroup -> {} | ||||
|                 is LibrarySettingsSheet.Display.BadgeGroup -> onBadgeSettingChanged() | ||||
|                 is LibrarySettingsSheet.Display.TabsGroup -> onTabsSettingsChanged() | ||||
|                 is LibrarySettingsSheet.Display.TabsGroup -> {} // onTabsSettingsChanged() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         binding.btnGlobalSearch.clicks() | ||||
|             .onEach { | ||||
|                 router.pushController(GlobalSearchController(presenter.query)) | ||||
|             } | ||||
|             .launchIn(viewScope) | ||||
|     } | ||||
|  | ||||
|     private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> { | ||||
|         return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) { | ||||
|             preferences.portraitColumns() | ||||
|         } else { | ||||
|             preferences.landscapeColumns() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { | ||||
|         super.onChangeStarted(handler, type) | ||||
|         if (type.isEnter) { | ||||
|             (activity as? MainActivity)?.binding?.tabs?.setupWithViewPager(binding.libraryPager) | ||||
|             presenter.subscribeLibrary() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         destroyActionModeIfNeeded() | ||||
|         adapter = null | ||||
|         settingsSheet?.sheetScope?.cancel() | ||||
|         settingsSheet = null | ||||
|         tabsVisibilitySubscription?.unsubscribe() | ||||
|         tabsVisibilitySubscription = null | ||||
|         super.onDestroyView(view) | ||||
|     } | ||||
|  | ||||
|     override fun configureTabs(tabs: TabLayout): Boolean { | ||||
|         with(tabs) { | ||||
|             isVisible = false | ||||
|             tabGravity = TabLayout.GRAVITY_START | ||||
|             tabMode = TabLayout.MODE_SCROLLABLE | ||||
|         } | ||||
|         tabsVisibilitySubscription?.unsubscribe() | ||||
|         tabsVisibilitySubscription = tabsVisibilityRelay.subscribe { visible -> | ||||
|             tabs.isVisible = visible | ||||
|         } | ||||
|         mangaCountVisibilitySubscription?.unsubscribe() | ||||
|         mangaCountVisibilitySubscription = mangaCountVisibilityRelay.subscribe { | ||||
|             adapter?.notifyDataSetChanged() | ||||
|         } | ||||
|  | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     override fun cleanupTabs(tabs: TabLayout) { | ||||
|         tabsVisibilitySubscription?.unsubscribe() | ||||
|         tabsVisibilitySubscription = null | ||||
|     } | ||||
|  | ||||
|     fun showSettingsSheet() { | ||||
|         if (adapter?.categories?.isNotEmpty() == true) { | ||||
|             adapter?.categories?.get(binding.libraryPager.currentItem)?.let { category -> | ||||
|         if (presenter.categories.isNotEmpty()) { | ||||
|             presenter.categories[presenter.activeCategory].let { category -> | ||||
|                 settingsSheet?.show(category.toDbCategory()) | ||||
|             } | ||||
|         } else { | ||||
| @@ -242,61 +121,6 @@ class LibraryController( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun onNextLibraryUpdate(categories: List<Category>, mangaMap: LibraryMap) { | ||||
|         val view = view ?: return | ||||
|         val adapter = adapter ?: return | ||||
|  | ||||
|         // Show empty view if needed | ||||
|         if (mangaMap.isNotEmpty()) { | ||||
|             binding.emptyView.hide() | ||||
|         } else { | ||||
|             binding.emptyView.show( | ||||
|                 R.string.information_empty_library, | ||||
|                 listOf( | ||||
|                     EmptyView.Action(R.string.getting_started_guide, R.drawable.ic_help_24dp) { | ||||
|                         activity?.openInBrowser("https://tachiyomi.org/help/guides/getting-started") | ||||
|                     }, | ||||
|                 ), | ||||
|             ) | ||||
|             (activity as? MainActivity)?.ready = true | ||||
|         } | ||||
|  | ||||
|         // Get the current active category. | ||||
|         val activeCat = if (adapter.categories.isNotEmpty()) { | ||||
|             binding.libraryPager.currentItem | ||||
|         } else { | ||||
|             activeCategory | ||||
|         } | ||||
|  | ||||
|         // Set the categories | ||||
|         adapter.updateCategories(categories.map { it to (mangaMap[it.id]?.size ?: 0) }) | ||||
|  | ||||
|         // Restore active category. | ||||
|         binding.libraryPager.setCurrentItem(activeCat, false) | ||||
|  | ||||
|         // Trigger display of tabs | ||||
|         onTabsSettingsChanged(firstLaunch = true) | ||||
|  | ||||
|         // Delay the scroll position to allow the view to be properly measured. | ||||
|         view.post { | ||||
|             if (isAttached) { | ||||
|                 (activity as? MainActivity)?.binding?.tabs?.setScrollPosition(binding.libraryPager.currentItem, 0f, true) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         presenter.loadedManga.clear() | ||||
|         mangaMap.forEach { | ||||
|             presenter.loadedManga[it.key] = it.value | ||||
|         } | ||||
|         presenter.loadedMangaFlow.value = presenter.loadedManga | ||||
|  | ||||
|         // Send the manga map to child fragments after the adapter is updated. | ||||
|         this.mangaMap = mangaMap | ||||
|  | ||||
|         // Finally update the title | ||||
|         updateTitle() | ||||
|     } | ||||
|  | ||||
|     private fun onFilterChanged() { | ||||
|         presenter.requestFilterUpdate() | ||||
|         activity?.invalidateOptionsMenu() | ||||
| @@ -306,146 +130,17 @@ class LibraryController( | ||||
|         presenter.requestBadgesUpdate() | ||||
|     } | ||||
|  | ||||
|     private fun onTabsSettingsChanged(firstLaunch: Boolean = false) { | ||||
|         if (!firstLaunch) { | ||||
|             mangaCountVisibilityRelay.call(preferences.categoryNumberOfItems().get()) | ||||
|         } | ||||
|         tabsVisibilityRelay.call(preferences.categoryTabs().get() && (adapter?.categories?.size ?: 0) > 1) | ||||
|         updateTitle() | ||||
|     } | ||||
|  | ||||
|     private fun onSortChanged() { | ||||
|         presenter.requestSortUpdate() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Reattaches the adapter to the view pager to recreate fragments | ||||
|      */ | ||||
|     private fun reattachAdapter() { | ||||
|         val adapter = adapter ?: return | ||||
|  | ||||
|         val position = binding.libraryPager.currentItem | ||||
|  | ||||
|         adapter.recycle = false | ||||
|         binding.libraryPager.adapter = adapter | ||||
|         binding.libraryPager.currentItem = position | ||||
|         adapter.recycle = true | ||||
|     } | ||||
|  | ||||
|     fun createActionModeIfNeeded() { | ||||
|         val activity = activity | ||||
|         if (actionMode == null && activity is MainActivity) { | ||||
|             actionMode = activity.startActionModeAndToolbar(this) | ||||
|             activity.showBottomNav(false) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun destroyActionModeIfNeeded() { | ||||
|         actionMode?.finish() | ||||
|     } | ||||
|  | ||||
|     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { | ||||
|         createOptionsMenu(menu, inflater, R.menu.library, R.id.action_search) | ||||
|         // Mutate the filter icon because it needs to be tinted and the resource is shared. | ||||
|         menu.findItem(R.id.action_filter).icon?.mutate() | ||||
|     } | ||||
|  | ||||
|     fun search(query: String) { | ||||
|         presenter.query = query | ||||
|     } | ||||
|  | ||||
|     private fun performSearch() { | ||||
|         if (presenter.query.isNotEmpty()) { | ||||
|             binding.btnGlobalSearch.isVisible = true | ||||
|             binding.btnGlobalSearch.text = | ||||
|                 resources?.getString(R.string.action_global_search_query, presenter.query) | ||||
|         } else { | ||||
|             binding.btnGlobalSearch.isVisible = false | ||||
|         } | ||||
|         presenter.searchQuery = query | ||||
|     } | ||||
|  | ||||
|     override fun onPrepareOptionsMenu(menu: Menu) { | ||||
|         val settingsSheet = settingsSheet ?: return | ||||
|  | ||||
|         val filterItem = menu.findItem(R.id.action_filter) | ||||
|  | ||||
|         // Tint icon if there's a filter active | ||||
|         if (settingsSheet.filters.hasActiveFilters()) { | ||||
|             val filterColor = activity!!.getResourceColor(R.attr.colorFilterActive) | ||||
|             filterItem.icon?.setTint(filterColor) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onOptionsItemSelected(item: MenuItem): Boolean { | ||||
|         when (item.itemId) { | ||||
|             R.id.action_search -> expandActionViewFromInteraction = true | ||||
|             R.id.action_filter -> showSettingsSheet() | ||||
|             R.id.action_update_library -> { | ||||
|                 activity?.let { | ||||
|                     if (LibraryUpdateService.start(it)) { | ||||
|                         it.toast(R.string.updating_library) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return super.onOptionsItemSelected(item) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Invalidates the action mode, forcing it to refresh its content. | ||||
|      */ | ||||
|     fun invalidateActionMode() { | ||||
|         actionMode?.invalidate() | ||||
|     } | ||||
|  | ||||
|     override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { | ||||
|         mode.menuInflater.inflate(R.menu.generic_selection, menu) | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     override fun onCreateActionToolbar(menuInflater: MenuInflater, menu: Menu) { | ||||
|         menuInflater.inflate(R.menu.library_selection, menu) | ||||
|     } | ||||
|  | ||||
|     override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { | ||||
|         val count = presenter.selection.size | ||||
|         if (count == 0) { | ||||
|             // Destroy action mode if there are no items selected. | ||||
|             destroyActionModeIfNeeded() | ||||
|         } else { | ||||
|             mode.title = count.toString() | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     override fun onPrepareActionToolbar(toolbar: ActionModeWithToolbar, menu: Menu) { | ||||
|         if (presenter.hasSelection().not()) return | ||||
|         toolbar.findToolbarItem(R.id.action_download_unread)?.isVisible = | ||||
|             presenter.selection.any { presenter.loadedManga.values.any { it.any { it.isLocal } } } | ||||
|     } | ||||
|  | ||||
|     override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { | ||||
|         when (item.itemId) { | ||||
|             R.id.action_move_to_category -> showMangaCategoriesDialog() | ||||
|             R.id.action_download_unread -> downloadUnreadChapters() | ||||
|             R.id.action_mark_as_read -> markReadStatus(true) | ||||
|             R.id.action_mark_as_unread -> markReadStatus(false) | ||||
|             R.id.action_delete -> showDeleteMangaDialog() | ||||
|             R.id.action_select_all -> selectAllCategoryManga() | ||||
|             R.id.action_select_inverse -> selectInverseCategoryManga() | ||||
|             else -> return false | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyActionMode(mode: ActionMode) { | ||||
|         // Clear all the manga selections and notify child views. | ||||
|         presenter.clearSelection() | ||||
|  | ||||
|         (activity as? MainActivity)?.showBottomNav(true) | ||||
|  | ||||
|         actionMode = null | ||||
|         presenter.hasActiveFilters = settingsSheet.filters.hasActiveFilters() | ||||
|     } | ||||
|  | ||||
|     private fun openManga(mangaId: Long) { | ||||
| @@ -461,7 +156,6 @@ class LibraryController( | ||||
|      */ | ||||
|     fun clearSelection() { | ||||
|         presenter.clearSelection() | ||||
|         invalidateActionMode() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -496,13 +190,13 @@ class LibraryController( | ||||
|     private fun downloadUnreadChapters() { | ||||
|         val mangas = presenter.selection.toList() | ||||
|         presenter.downloadUnreadChapters(mangas.mapNotNull { it.toDomainManga() }) | ||||
|         destroyActionModeIfNeeded() | ||||
|         presenter.clearSelection() | ||||
|     } | ||||
|  | ||||
|     private fun markReadStatus(read: Boolean) { | ||||
|         val mangas = presenter.selection.toList() | ||||
|         presenter.markReadStatus(mangas.mapNotNull { it.toDomainManga() }, read) | ||||
|         destroyActionModeIfNeeded() | ||||
|         presenter.clearSelection() | ||||
|     } | ||||
|  | ||||
|     private fun showDeleteMangaDialog() { | ||||
| @@ -512,28 +206,11 @@ class LibraryController( | ||||
|  | ||||
|     override fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) { | ||||
|         presenter.setMangaCategories(mangas, addCategories, removeCategories) | ||||
|         destroyActionModeIfNeeded() | ||||
|         presenter.clearSelection() | ||||
|     } | ||||
|  | ||||
|     override fun deleteMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) { | ||||
|         presenter.removeMangas(mangas.map { it.toDbManga() }, deleteFromLibrary, deleteChapters) | ||||
|         destroyActionModeIfNeeded() | ||||
|     } | ||||
|  | ||||
|     private fun selectAllCategoryManga() { | ||||
|         presenter.selectAll(binding.libraryPager.currentItem) | ||||
|     } | ||||
|  | ||||
|     private fun selectInverseCategoryManga() { | ||||
|         presenter.invertSelection(binding.libraryPager.currentItem) | ||||
|     } | ||||
|  | ||||
|     override fun onSearchViewQueryTextChange(newText: String?) { | ||||
|         // Ignore events if this controller isn't at the top to avoid query being reset | ||||
|         if (router.backstack.lastOrNull()?.controller == this) { | ||||
|             presenter.query = newText ?: "" | ||||
|             presenter.searchQuery = newText ?: "" | ||||
|             performSearch() | ||||
|         } | ||||
|         presenter.clearSelection() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -4,13 +4,15 @@ import android.os.Bundle | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.derivedStateOf | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateListOf | ||||
| import androidx.compose.runtime.mutableStateMapOf | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.produceState | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.setValue | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.util.fastAny | ||||
| import com.jakewharton.rxrelay.BehaviorRelay | ||||
| import eu.kanade.core.prefs.PreferenceMutableState | ||||
| import eu.kanade.core.util.asFlow | ||||
| import eu.kanade.core.util.asObservable | ||||
| import eu.kanade.data.DatabaseHandler | ||||
| import eu.kanade.domain.category.interactor.GetCategories | ||||
| @@ -25,6 +27,10 @@ import eu.kanade.domain.manga.model.Manga | ||||
| import eu.kanade.domain.manga.model.MangaUpdate | ||||
| import eu.kanade.domain.manga.model.isLocal | ||||
| import eu.kanade.domain.track.interactor.GetTracks | ||||
| import eu.kanade.presentation.library.LibraryState | ||||
| import eu.kanade.presentation.library.LibraryStateImpl | ||||
| import eu.kanade.presentation.library.components.LibraryToolbarTitle | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.cache.CoverCache | ||||
| import eu.kanade.tachiyomi.data.database.models.LibraryManga | ||||
| import eu.kanade.tachiyomi.data.database.models.toDomainManga | ||||
| @@ -39,14 +45,16 @@ import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting | ||||
| import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting | ||||
| import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting | ||||
| import eu.kanade.tachiyomi.util.lang.combineLatest | ||||
| import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed | ||||
| import eu.kanade.tachiyomi.util.lang.launchIO | ||||
| import eu.kanade.tachiyomi.util.removeCovers | ||||
| import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.flow.Flow | ||||
| import kotlinx.coroutines.flow.collectLatest | ||||
| import kotlinx.coroutines.flow.combine | ||||
| import kotlinx.coroutines.flow.map | ||||
| import kotlinx.coroutines.runBlocking | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import uy.kohesive.injekt.Injekt | ||||
| @@ -70,6 +78,7 @@ typealias LibraryMap = Map<Long, List<LibraryItem>> | ||||
|  * Presenter of [LibraryController]. | ||||
|  */ | ||||
| class LibraryPresenter( | ||||
|     private val state: LibraryStateImpl = LibraryState() as LibraryStateImpl, | ||||
|     private val handler: DatabaseHandler = Injekt.get(), | ||||
|     private val getLibraryManga: GetLibraryManga = Injekt.get(), | ||||
|     private val getTracks: GetTracks = Injekt.get(), | ||||
| @@ -83,31 +92,27 @@ class LibraryPresenter( | ||||
|     private val sourceManager: SourceManager = Injekt.get(), | ||||
|     private val downloadManager: DownloadManager = Injekt.get(), | ||||
|     private val trackManager: TrackManager = Injekt.get(), | ||||
| ) : BasePresenter<LibraryController>() { | ||||
| ) : BasePresenter<LibraryController>(), LibraryState by state { | ||||
|  | ||||
|     private val context = preferences.context | ||||
|  | ||||
|     /** | ||||
|      * Categories of the library. | ||||
|      */ | ||||
|     var categories: List<Category> = mutableStateListOf() | ||||
|     var loadedManga by mutableStateOf(emptyMap<Long, List<LibraryItem>>()) | ||||
|         private set | ||||
|  | ||||
|     var loadedManga = mutableStateMapOf<Long, List<LibraryItem>>() | ||||
|         private set | ||||
|  | ||||
|     val loadedMangaFlow = MutableStateFlow(loadedManga) | ||||
|  | ||||
|     var searchQuery by mutableStateOf(query) | ||||
|  | ||||
|     val selection: MutableList<LibraryManga> = mutableStateListOf() | ||||
|  | ||||
|     val isPerCategory by preferences.categorizedDisplaySettings().asState() | ||||
|  | ||||
|     var columns by mutableStateOf(0) | ||||
|  | ||||
|     var currentDisplayMode by preferences.libraryDisplayMode().asState() | ||||
|  | ||||
|     val tabVisibility by preferences.categoryTabs().asState() | ||||
|  | ||||
|     val mangaCountVisibility by preferences.categoryNumberOfItems().asState() | ||||
|  | ||||
|     var activeCategory: Int by preferences.lastUsedCategory().asState() | ||||
|  | ||||
|     val isDownloadOnly: Boolean by preferences.downloadedOnly().asState() | ||||
|  | ||||
|     val isIncognitoMode: Boolean by preferences.incognitoMode().asState() | ||||
|  | ||||
|     /** | ||||
|      * Relay used to apply the UI filters to the last emission of the library. | ||||
|      */ | ||||
| @@ -123,7 +128,7 @@ class LibraryPresenter( | ||||
|      */ | ||||
|     private val sortTriggerRelay = BehaviorRelay.create(Unit) | ||||
|  | ||||
|     private var librarySubscription: Subscription? = null | ||||
|     private var librarySubscription: Job? = null | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
| @@ -135,22 +140,31 @@ class LibraryPresenter( | ||||
|      * Subscribes to library if needed. | ||||
|      */ | ||||
|     fun subscribeLibrary() { | ||||
|         // TODO: Move this to a coroutine world | ||||
|         if (librarySubscription.isNullOrUnsubscribed()) { | ||||
|             librarySubscription = getLibraryObservable() | ||||
|                 .combineLatest(badgeTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> | ||||
|                     lib.apply { setBadges(mangaMap) } | ||||
|                 } | ||||
|                 .combineLatest(getFilterObservable()) { lib, tracks -> | ||||
|                     lib.copy(mangaMap = applyFilters(lib.mangaMap, tracks)) | ||||
|                 } | ||||
|                 .combineLatest(sortTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> | ||||
|                     lib.copy(mangaMap = applySort(lib.categories, lib.mangaMap)) | ||||
|                 } | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeLatestCache({ view, (categories, mangaMap) -> | ||||
|                     view.onNextLibraryUpdate(categories, mangaMap) | ||||
|                 },) | ||||
|         /** | ||||
|          * TODO: Move this to a coroutine world | ||||
|          * - Move filter and sort to getMangaForCategory and only filter and sort the current display category instead of whole library as some has 5000+ items in the library | ||||
|          * - Create new db view and new query to just fetch the current category save as needed to instance variable | ||||
|          * - Fetch badges to maps and retrive as needed instead of fetching all of them at once | ||||
|          */ | ||||
|         if (librarySubscription == null || librarySubscription!!.isCancelled) { | ||||
|             librarySubscription = presenterScope.launchIO { | ||||
|                 getLibraryObservable() | ||||
|                     .combineLatest(badgeTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> | ||||
|                         lib.apply { setBadges(mangaMap) } | ||||
|                     } | ||||
|                     .combineLatest(getFilterObservable()) { lib, tracks -> | ||||
|                         lib.copy(mangaMap = applyFilters(lib.mangaMap, tracks)) | ||||
|                     } | ||||
|                     .combineLatest(sortTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> | ||||
|                         lib.copy(mangaMap = applySort(lib.categories, lib.mangaMap)) | ||||
|                     } | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .asFlow() | ||||
|                     .collectLatest { | ||||
|                         state.isLoading = false | ||||
|                         loadedManga = it.mangaMap | ||||
|                     } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -397,7 +411,7 @@ class LibraryPresenter( | ||||
|      * @return an observable of the categories and its manga. | ||||
|      */ | ||||
|     private fun getLibraryObservable(): Observable<Library> { | ||||
|         return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable()) { dbCategories, libraryManga -> | ||||
|         return combine(getCategoriesObservable(), getLibraryMangasObservable()) { dbCategories, libraryManga -> | ||||
|             val categories = if (libraryManga.containsKey(0)) { | ||||
|                 arrayListOf(Category.default(context)) + dbCategories | ||||
|             } else { | ||||
| @@ -411,9 +425,9 @@ class LibraryPresenter( | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             this.categories = categories | ||||
|             state.categories = categories | ||||
|             Library(categories, libraryManga) | ||||
|         } | ||||
|         }.asObservable() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -421,8 +435,8 @@ class LibraryPresenter( | ||||
|      * | ||||
|      * @return an observable of the categories. | ||||
|      */ | ||||
|     private fun getCategoriesObservable(): Observable<List<Category>> { | ||||
|         return getCategories.subscribe().asObservable() | ||||
|     private fun getCategoriesObservable(): Flow<List<Category>> { | ||||
|         return getCategories.subscribe() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -431,8 +445,8 @@ class LibraryPresenter( | ||||
|      * @return an observable containing a map with the category id as key and a list of manga as the | ||||
|      * value. | ||||
|      */ | ||||
|     private fun getLibraryMangasObservable(): Observable<LibraryMap> { | ||||
|         return getLibraryManga.subscribe().asObservable() | ||||
|     private fun getLibraryMangasObservable(): Flow<LibraryMap> { | ||||
|         return getLibraryManga.subscribe() | ||||
|             .map { list -> | ||||
|                 list.map { libraryManga -> | ||||
|                     // Display mode based on user preference: take it from global library setting or category | ||||
| @@ -447,7 +461,8 @@ class LibraryPresenter( | ||||
|      * @return an observable of tracked manga. | ||||
|      */ | ||||
|     private fun getFilterObservable(): Observable<Map<Long, Map<Long, Boolean>>> { | ||||
|         return getTracksObservable().combineLatest(filterTriggerRelay.observeOn(Schedulers.io())) { tracks, _ -> tracks } | ||||
|         return filterTriggerRelay.observeOn(Schedulers.io()) | ||||
|             .combineLatest(getTracksObservable()) { _, tracks -> tracks } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -458,7 +473,7 @@ class LibraryPresenter( | ||||
|     private fun getTracksObservable(): Observable<Map<Long, Map<Long, Boolean>>> { | ||||
|         // TODO: Move this to domain/data layer | ||||
|         return getTracks.subscribe() | ||||
|             .asObservable().map { tracks -> | ||||
|             .map { tracks -> | ||||
|                 tracks | ||||
|                     .groupBy { it.mangaId } | ||||
|                     .mapValues { tracksForMangaId -> | ||||
| @@ -468,6 +483,7 @@ class LibraryPresenter( | ||||
|                         } | ||||
|                     } | ||||
|             } | ||||
|             .asObservable() | ||||
|             .observeOn(Schedulers.io()) | ||||
|     } | ||||
|  | ||||
| @@ -497,7 +513,7 @@ class LibraryPresenter( | ||||
|      */ | ||||
|     fun onOpenManga() { | ||||
|         // Avoid further db updates for the library when it's not needed | ||||
|         librarySubscription?.let { remove(it) } | ||||
|         librarySubscription?.cancel() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -610,14 +626,50 @@ class LibraryPresenter( | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     fun getMangaForCategory(categoryId: Long): androidx.compose.runtime.State<List<LibraryItem>> { | ||||
|     fun getMangaCountForCategory(categoryId: Long): androidx.compose.runtime.State<Int?> { | ||||
|         return produceState<Int?>(initialValue = null, loadedManga) { | ||||
|             value = loadedManga[categoryId]?.size | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState<Int> { | ||||
|         return (if (isLandscape) preferences.landscapeColumns() else preferences.portraitColumns()).asState() | ||||
|     } | ||||
|  | ||||
|     // TODO: This is good but should we separate title from count or get categories with count from db | ||||
|     @Composable | ||||
|     fun getToolbarTitle(): androidx.compose.runtime.State<LibraryToolbarTitle> { | ||||
|         val category = categories.getOrNull(activeCategory) | ||||
|  | ||||
|         val defaultTitle = stringResource(id = R.string.label_library) | ||||
|         val default = remember { LibraryToolbarTitle(defaultTitle) } | ||||
|  | ||||
|         return produceState(initialValue = default, category, mangaCountVisibility, tabVisibility) { | ||||
|             val title = if (tabVisibility.not()) category?.name ?: defaultTitle else defaultTitle | ||||
|  | ||||
|             value = when { | ||||
|                 category == null -> default | ||||
|                 (tabVisibility.not() && mangaCountVisibility.not()) -> LibraryToolbarTitle(title) | ||||
|                 tabVisibility.not() && mangaCountVisibility -> LibraryToolbarTitle(title, loadedManga[category.id]?.size) | ||||
|                 (tabVisibility && categories.size > 1) && mangaCountVisibility -> LibraryToolbarTitle(title) | ||||
|                 tabVisibility && mangaCountVisibility -> LibraryToolbarTitle(title, loadedManga[category.id]?.size) | ||||
|                 else -> default | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     fun getMangaForCategory(page: Int): androidx.compose.runtime.State<List<LibraryItem>> { | ||||
|         val categoryId = remember(categories) { | ||||
|             categories.getOrNull(page)?.id ?: -1 | ||||
|         } | ||||
|         val unfiltered = loadedManga[categoryId] ?: emptyList() | ||||
|  | ||||
|         return derivedStateOf { | ||||
|             val query = searchQuery | ||||
|             if (query.isNotBlank()) { | ||||
|             if (query.isNullOrBlank().not()) { | ||||
|                 unfiltered.filter { | ||||
|                     it.filter(query) | ||||
|                     it.filter(query!!) | ||||
|                 } | ||||
|             } else { | ||||
|                 unfiltered | ||||
| @@ -626,9 +678,9 @@ class LibraryPresenter( | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     fun getDisplayMode(index: Int): DisplayModeSetting { | ||||
|     fun getDisplayMode(index: Int): androidx.compose.runtime.State<DisplayModeSetting> { | ||||
|         val category = categories[index] | ||||
|         return remember { | ||||
|         return derivedStateOf { | ||||
|             if (isPerCategory.not() || category.id == 0L) { | ||||
|                 currentDisplayMode | ||||
|             } else { | ||||
| @@ -642,34 +694,30 @@ class LibraryPresenter( | ||||
|     } | ||||
|  | ||||
|     fun clearSelection() { | ||||
|         selection.clear() | ||||
|         state.selection = emptyList() | ||||
|     } | ||||
|  | ||||
|     fun toggleSelection(manga: LibraryManga) { | ||||
|         val mutableList = state.selection.toMutableList() | ||||
|         if (selection.fastAny { it.id == manga.id }) { | ||||
|             selection.remove(manga) | ||||
|             mutableList.remove(manga) | ||||
|         } else { | ||||
|             selection.add(manga) | ||||
|             mutableList.add(manga) | ||||
|         } | ||||
|         view?.invalidateActionMode() | ||||
|         view?.createActionModeIfNeeded() | ||||
|         state.selection = mutableList | ||||
|     } | ||||
|  | ||||
|     fun selectAll(index: Int) { | ||||
|         val category = categories[index] | ||||
|         val items = loadedManga[category.id] ?: emptyList() | ||||
|         selection.addAll(items.filterNot { it.manga in selection }.map { it.manga }) | ||||
|         view?.createActionModeIfNeeded() | ||||
|         view?.invalidateActionMode() | ||||
|         state.selection = state.selection.toMutableList().apply { | ||||
|             addAll(items.filterNot { it.manga in selection }.map { it.manga }) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun invertSelection(index: Int) { | ||||
|         val category = categories[index] | ||||
|         val items = (loadedManga[category.id] ?: emptyList()).map { it.manga } | ||||
|         val invert = items.filterNot { it in selection } | ||||
|         selection.removeAll(items) | ||||
|         selection.addAll(invert) | ||||
|         view?.createActionModeIfNeeded() | ||||
|         view?.invalidateActionMode() | ||||
|         state.selection = items.filterNot { it in selection } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -488,9 +488,13 @@ class MainActivity : BaseActivity() { | ||||
|             return | ||||
|         } | ||||
|         val backstackSize = router.backstackSize | ||||
|         if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) { | ||||
|         val startScreen = router.getControllerWithTag("$startScreenId") | ||||
|         if (backstackSize == 1 && startScreen == null) { | ||||
|             // Return to start screen | ||||
|             moveToStartScreen() | ||||
|             setSelectedNavItem(startScreenId) | ||||
|         } else if (startScreen != null && router.handleBack()) { | ||||
|             // Clear selection for Library screen | ||||
|         } else if (shouldHandleExitConfirmation()) { | ||||
|             // Exit confirmation (resets after 2 seconds) | ||||
|             lifecycleScope.launchUI { resetExitConfirmation() } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user