804 lines
38 KiB
Kotlin
804 lines
38 KiB
Kotlin
|
package eu.kanade.presentation.manga
|
||
|
|
||
|
import androidx.activity.compose.BackHandler
|
||
|
import androidx.compose.animation.AnimatedVisibility
|
||
|
import androidx.compose.animation.fadeIn
|
||
|
import androidx.compose.animation.fadeOut
|
||
|
import androidx.compose.animation.rememberSplineBasedDecay
|
||
|
import androidx.compose.foundation.gestures.Orientation
|
||
|
import androidx.compose.foundation.gestures.rememberScrollableState
|
||
|
import androidx.compose.foundation.gestures.scrollBy
|
||
|
import androidx.compose.foundation.gestures.scrollable
|
||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||
|
import androidx.compose.foundation.layout.Box
|
||
|
import androidx.compose.foundation.layout.PaddingValues
|
||
|
import androidx.compose.foundation.layout.Row
|
||
|
import androidx.compose.foundation.layout.WindowInsets
|
||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||
|
import androidx.compose.foundation.layout.calculateEndPadding
|
||
|
import androidx.compose.foundation.layout.calculateStartPadding
|
||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||
|
import androidx.compose.foundation.layout.navigationBars
|
||
|
import androidx.compose.foundation.layout.only
|
||
|
import androidx.compose.foundation.layout.padding
|
||
|
import androidx.compose.foundation.layout.systemBars
|
||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||
|
import androidx.compose.foundation.lazy.items
|
||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||
|
import androidx.compose.foundation.rememberScrollState
|
||
|
import androidx.compose.foundation.verticalScroll
|
||
|
import androidx.compose.material.icons.Icons
|
||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||
|
import androidx.compose.material3.Icon
|
||
|
import androidx.compose.material3.SnackbarHost
|
||
|
import androidx.compose.material3.SnackbarHostState
|
||
|
import androidx.compose.material3.Text
|
||
|
import androidx.compose.material3.rememberTopAppBarScrollState
|
||
|
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||
|
import androidx.compose.runtime.Composable
|
||
|
import androidx.compose.runtime.SideEffect
|
||
|
import androidx.compose.runtime.mutableStateOf
|
||
|
import androidx.compose.runtime.remember
|
||
|
import androidx.compose.runtime.toMutableStateList
|
||
|
import androidx.compose.ui.Alignment
|
||
|
import androidx.compose.ui.Modifier
|
||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||
|
import androidx.compose.ui.layout.onSizeChanged
|
||
|
import androidx.compose.ui.platform.LocalContext
|
||
|
import androidx.compose.ui.platform.LocalDensity
|
||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||
|
import androidx.compose.ui.res.stringResource
|
||
|
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||
|
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||
|
import eu.kanade.domain.chapter.model.Chapter
|
||
|
import eu.kanade.domain.manga.model.Manga.Companion.CHAPTER_DISPLAY_NUMBER
|
||
|
import eu.kanade.presentation.components.ExtendedFloatingActionButton
|
||
|
import eu.kanade.presentation.components.Scaffold
|
||
|
import eu.kanade.presentation.components.SwipeRefreshIndicator
|
||
|
import eu.kanade.presentation.components.VerticalFastScroller
|
||
|
import eu.kanade.presentation.manga.components.ChapterHeader
|
||
|
import eu.kanade.presentation.manga.components.MangaBottomActionMenu
|
||
|
import eu.kanade.presentation.manga.components.MangaChapterListItem
|
||
|
import eu.kanade.presentation.manga.components.MangaInfoHeader
|
||
|
import eu.kanade.presentation.manga.components.MangaSmallAppBar
|
||
|
import eu.kanade.presentation.manga.components.MangaTopAppBar
|
||
|
import eu.kanade.presentation.util.ExitUntilCollapsedScrollBehavior
|
||
|
import eu.kanade.presentation.util.isScrolledToEnd
|
||
|
import eu.kanade.presentation.util.isScrollingUp
|
||
|
import eu.kanade.presentation.util.plus
|
||
|
import eu.kanade.tachiyomi.R
|
||
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||
|
import eu.kanade.tachiyomi.source.SourceManager
|
||
|
import eu.kanade.tachiyomi.source.getNameForMangaInfo
|
||
|
import eu.kanade.tachiyomi.ui.manga.ChapterItem
|
||
|
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
|
||
|
import eu.kanade.tachiyomi.util.lang.toRelativeString
|
||
|
import kotlinx.coroutines.runBlocking
|
||
|
import java.text.DecimalFormat
|
||
|
import java.text.DecimalFormatSymbols
|
||
|
import java.util.Date
|
||
|
|
||
|
private val chapterDecimalFormat = DecimalFormat(
|
||
|
"#.###",
|
||
|
DecimalFormatSymbols()
|
||
|
.apply { decimalSeparator = '.' },
|
||
|
)
|
||
|
|
||
|
@Composable
|
||
|
fun MangaScreen(
|
||
|
state: MangaScreenState.Success,
|
||
|
snackbarHostState: SnackbarHostState,
|
||
|
windowWidthSizeClass: WindowWidthSizeClass,
|
||
|
onBackClicked: () -> Unit,
|
||
|
onChapterClicked: (Chapter) -> Unit,
|
||
|
onDownloadChapter: ((List<ChapterItem>, ChapterDownloadAction) -> Unit)?,
|
||
|
onAddToLibraryClicked: () -> Unit,
|
||
|
onWebViewClicked: (() -> Unit)?,
|
||
|
onTrackingClicked: (() -> Unit)?,
|
||
|
onTagClicked: (String) -> Unit,
|
||
|
onFilterButtonClicked: () -> Unit,
|
||
|
onRefresh: () -> Unit,
|
||
|
onContinueReading: () -> Unit,
|
||
|
onSearch: (query: String, global: Boolean) -> Unit,
|
||
|
|
||
|
// For cover dialog
|
||
|
onCoverClicked: () -> Unit,
|
||
|
|
||
|
// For top action menu
|
||
|
onShareClicked: (() -> Unit)?,
|
||
|
onDownloadActionClicked: ((DownloadAction) -> Unit)?,
|
||
|
onEditCategoryClicked: (() -> Unit)?,
|
||
|
onMigrateClicked: (() -> Unit)?,
|
||
|
|
||
|
// For bottom action menu
|
||
|
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
|
||
|
onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
|
||
|
onMarkPreviousAsReadClicked: (Chapter) -> Unit,
|
||
|
onMultiDeleteClicked: (List<Chapter>) -> Unit,
|
||
|
) {
|
||
|
if (windowWidthSizeClass == WindowWidthSizeClass.Compact) {
|
||
|
MangaScreenSmallImpl(
|
||
|
state = state,
|
||
|
snackbarHostState = snackbarHostState,
|
||
|
onBackClicked = onBackClicked,
|
||
|
onChapterClicked = onChapterClicked,
|
||
|
onDownloadChapter = onDownloadChapter,
|
||
|
onAddToLibraryClicked = onAddToLibraryClicked,
|
||
|
onWebViewClicked = onWebViewClicked,
|
||
|
onTrackingClicked = onTrackingClicked,
|
||
|
onTagClicked = onTagClicked,
|
||
|
onFilterButtonClicked = onFilterButtonClicked,
|
||
|
onRefresh = onRefresh,
|
||
|
onContinueReading = onContinueReading,
|
||
|
onSearch = onSearch,
|
||
|
onCoverClicked = onCoverClicked,
|
||
|
onShareClicked = onShareClicked,
|
||
|
onDownloadActionClicked = onDownloadActionClicked,
|
||
|
onEditCategoryClicked = onEditCategoryClicked,
|
||
|
onMigrateClicked = onMigrateClicked,
|
||
|
onMultiBookmarkClicked = onMultiBookmarkClicked,
|
||
|
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
|
||
|
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
|
||
|
onMultiDeleteClicked = onMultiDeleteClicked,
|
||
|
)
|
||
|
} else {
|
||
|
MangaScreenLargeImpl(
|
||
|
state = state,
|
||
|
windowWidthSizeClass = windowWidthSizeClass,
|
||
|
snackbarHostState = snackbarHostState,
|
||
|
onBackClicked = onBackClicked,
|
||
|
onChapterClicked = onChapterClicked,
|
||
|
onDownloadChapter = onDownloadChapter,
|
||
|
onAddToLibraryClicked = onAddToLibraryClicked,
|
||
|
onWebViewClicked = onWebViewClicked,
|
||
|
onTrackingClicked = onTrackingClicked,
|
||
|
onTagClicked = onTagClicked,
|
||
|
onFilterButtonClicked = onFilterButtonClicked,
|
||
|
onRefresh = onRefresh,
|
||
|
onContinueReading = onContinueReading,
|
||
|
onSearch = onSearch,
|
||
|
onCoverClicked = onCoverClicked,
|
||
|
onShareClicked = onShareClicked,
|
||
|
onDownloadActionClicked = onDownloadActionClicked,
|
||
|
onEditCategoryClicked = onEditCategoryClicked,
|
||
|
onMigrateClicked = onMigrateClicked,
|
||
|
onMultiBookmarkClicked = onMultiBookmarkClicked,
|
||
|
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
|
||
|
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
|
||
|
onMultiDeleteClicked = onMultiDeleteClicked,
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@Composable
|
||
|
private fun MangaScreenSmallImpl(
|
||
|
state: MangaScreenState.Success,
|
||
|
snackbarHostState: SnackbarHostState,
|
||
|
onBackClicked: () -> Unit,
|
||
|
onChapterClicked: (Chapter) -> Unit,
|
||
|
onDownloadChapter: ((List<ChapterItem>, ChapterDownloadAction) -> Unit)?,
|
||
|
onAddToLibraryClicked: () -> Unit,
|
||
|
onWebViewClicked: (() -> Unit)?,
|
||
|
onTrackingClicked: (() -> Unit)?,
|
||
|
onTagClicked: (String) -> Unit,
|
||
|
onFilterButtonClicked: () -> Unit,
|
||
|
onRefresh: () -> Unit,
|
||
|
onContinueReading: () -> Unit,
|
||
|
onSearch: (query: String, global: Boolean) -> Unit,
|
||
|
|
||
|
// For cover dialog
|
||
|
onCoverClicked: () -> Unit,
|
||
|
|
||
|
// For top action menu
|
||
|
onShareClicked: (() -> Unit)?,
|
||
|
onDownloadActionClicked: ((DownloadAction) -> Unit)?,
|
||
|
onEditCategoryClicked: (() -> Unit)?,
|
||
|
onMigrateClicked: (() -> Unit)?,
|
||
|
|
||
|
// For bottom action menu
|
||
|
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
|
||
|
onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
|
||
|
onMarkPreviousAsReadClicked: (Chapter) -> Unit,
|
||
|
onMultiDeleteClicked: (List<Chapter>) -> Unit,
|
||
|
) {
|
||
|
val context = LocalContext.current
|
||
|
val layoutDirection = LocalLayoutDirection.current
|
||
|
val haptic = LocalHapticFeedback.current
|
||
|
val decayAnimationSpec = rememberSplineBasedDecay<Float>()
|
||
|
val scrollBehavior = ExitUntilCollapsedScrollBehavior(rememberTopAppBarScrollState(), decayAnimationSpec)
|
||
|
val chapterListState = rememberLazyListState()
|
||
|
SideEffect {
|
||
|
if (chapterListState.firstVisibleItemIndex > 0 || chapterListState.firstVisibleItemScrollOffset > 0) {
|
||
|
// Should go here after a configuration change
|
||
|
// Safe to say that the app bar is fully scrolled
|
||
|
scrollBehavior.state.offset = scrollBehavior.state.offsetLimit
|
||
|
}
|
||
|
}
|
||
|
|
||
|
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
|
||
|
val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(1) }
|
||
|
SwipeRefresh(
|
||
|
state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingChapter),
|
||
|
onRefresh = onRefresh,
|
||
|
indicatorPadding = PaddingValues(
|
||
|
start = insetPadding.calculateStartPadding(layoutDirection),
|
||
|
top = with(LocalDensity.current) { topBarHeight.toDp() },
|
||
|
end = insetPadding.calculateEndPadding(layoutDirection),
|
||
|
),
|
||
|
indicator = { s, trigger ->
|
||
|
SwipeRefreshIndicator(
|
||
|
state = s,
|
||
|
refreshTriggerDistance = trigger,
|
||
|
)
|
||
|
},
|
||
|
) {
|
||
|
val chapters = remember(state) { state.processedChapters.toList() }
|
||
|
val selected = remember(chapters) { emptyList<ChapterItem>().toMutableStateList() }
|
||
|
val selectedPositions = remember(chapters) { arrayOf(-1, -1) } // first and last selected index in list
|
||
|
|
||
|
val internalOnBackPressed = {
|
||
|
if (selected.isNotEmpty()) {
|
||
|
selected.clear()
|
||
|
} else {
|
||
|
onBackClicked()
|
||
|
}
|
||
|
}
|
||
|
BackHandler(onBack = internalOnBackPressed)
|
||
|
|
||
|
Scaffold(
|
||
|
modifier = Modifier
|
||
|
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||
|
.padding(insetPadding),
|
||
|
topBar = {
|
||
|
MangaTopAppBar(
|
||
|
modifier = Modifier
|
||
|
.scrollable(
|
||
|
state = rememberScrollableState {
|
||
|
var consumed = runBlocking { chapterListState.scrollBy(-it) } * -1
|
||
|
if (consumed == 0f) {
|
||
|
// Pass scroll to app bar if we're on the top of the list
|
||
|
val newOffset =
|
||
|
(scrollBehavior.state.offset + it).coerceIn(scrollBehavior.state.offsetLimit, 0f)
|
||
|
consumed = newOffset - scrollBehavior.state.offset
|
||
|
scrollBehavior.state.offset = newOffset
|
||
|
}
|
||
|
consumed
|
||
|
},
|
||
|
orientation = Orientation.Vertical,
|
||
|
interactionSource = chapterListState.interactionSource as MutableInteractionSource,
|
||
|
),
|
||
|
title = state.manga.title,
|
||
|
author = state.manga.author,
|
||
|
artist = state.manga.artist,
|
||
|
description = state.manga.description,
|
||
|
tagsProvider = { state.manga.genre },
|
||
|
coverDataProvider = { state.manga },
|
||
|
sourceName = remember { state.source.getNameForMangaInfo() },
|
||
|
isStubSource = remember { state.source is SourceManager.StubSource },
|
||
|
favorite = state.manga.favorite,
|
||
|
status = state.manga.status,
|
||
|
trackingCount = state.trackingCount,
|
||
|
chapterCount = chapters.size,
|
||
|
chapterFiltered = state.manga.chaptersFiltered(),
|
||
|
incognitoMode = state.isIncognitoMode,
|
||
|
downloadedOnlyMode = state.isDownloadedOnlyMode,
|
||
|
fromSource = state.isFromSource,
|
||
|
onBackClicked = internalOnBackPressed,
|
||
|
onCoverClick = onCoverClicked,
|
||
|
onTagClicked = onTagClicked,
|
||
|
onAddToLibraryClicked = onAddToLibraryClicked,
|
||
|
onWebViewClicked = onWebViewClicked,
|
||
|
onTrackingClicked = onTrackingClicked,
|
||
|
onFilterButtonClicked = onFilterButtonClicked,
|
||
|
onShareClicked = onShareClicked,
|
||
|
onDownloadClicked = onDownloadActionClicked,
|
||
|
onEditCategoryClicked = onEditCategoryClicked,
|
||
|
onMigrateClicked = onMigrateClicked,
|
||
|
doGlobalSearch = onSearch,
|
||
|
scrollBehavior = scrollBehavior,
|
||
|
actionModeCounter = selected.size,
|
||
|
onSelectAll = {
|
||
|
selected.clear()
|
||
|
selected.addAll(chapters)
|
||
|
},
|
||
|
onInvertSelection = {
|
||
|
val toSelect = chapters - selected
|
||
|
selected.clear()
|
||
|
selected.addAll(toSelect)
|
||
|
},
|
||
|
onSmallAppBarHeightChanged = onTopBarHeightChanged,
|
||
|
)
|
||
|
},
|
||
|
bottomBar = {
|
||
|
MangaBottomActionMenu(
|
||
|
visible = selected.isNotEmpty(),
|
||
|
modifier = Modifier.fillMaxWidth(),
|
||
|
onBookmarkClicked = {
|
||
|
onMultiBookmarkClicked.invoke(selected.map { it.chapter }, true)
|
||
|
selected.clear()
|
||
|
}.takeIf { selected.any { !it.chapter.bookmark } },
|
||
|
onRemoveBookmarkClicked = {
|
||
|
onMultiBookmarkClicked.invoke(selected.map { it.chapter }, false)
|
||
|
selected.clear()
|
||
|
}.takeIf { selected.all { it.chapter.bookmark } },
|
||
|
onMarkAsReadClicked = {
|
||
|
onMultiMarkAsReadClicked(selected.map { it.chapter }, true)
|
||
|
selected.clear()
|
||
|
}.takeIf { selected.any { !it.chapter.read } },
|
||
|
onMarkAsUnreadClicked = {
|
||
|
onMultiMarkAsReadClicked(selected.map { it.chapter }, false)
|
||
|
selected.clear()
|
||
|
}.takeIf { selected.any { it.chapter.read } },
|
||
|
onMarkPreviousAsReadClicked = {
|
||
|
onMarkPreviousAsReadClicked(selected[0].chapter)
|
||
|
selected.clear()
|
||
|
}.takeIf { selected.size == 1 },
|
||
|
onDownloadClicked = {
|
||
|
onDownloadChapter!!(selected, ChapterDownloadAction.START)
|
||
|
selected.clear()
|
||
|
}.takeIf {
|
||
|
onDownloadChapter != null && selected.any { it.downloadState != Download.State.DOWNLOADED }
|
||
|
},
|
||
|
onDeleteClicked = {
|
||
|
onMultiDeleteClicked(selected.map { it.chapter })
|
||
|
selected.clear()
|
||
|
}.takeIf {
|
||
|
onDownloadChapter != null && selected.any { it.downloadState == Download.State.DOWNLOADED }
|
||
|
},
|
||
|
)
|
||
|
},
|
||
|
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||
|
floatingActionButton = {
|
||
|
AnimatedVisibility(
|
||
|
visible = chapters.any { !it.chapter.read } && selected.isEmpty(),
|
||
|
enter = fadeIn(),
|
||
|
exit = fadeOut(),
|
||
|
) {
|
||
|
ExtendedFloatingActionButton(
|
||
|
text = {
|
||
|
val id = if (chapters.any { it.chapter.read }) {
|
||
|
R.string.action_resume
|
||
|
} else {
|
||
|
R.string.action_start
|
||
|
}
|
||
|
Text(text = stringResource(id = id))
|
||
|
},
|
||
|
icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) },
|
||
|
onClick = onContinueReading,
|
||
|
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
|
||
|
modifier = Modifier
|
||
|
.padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()),
|
||
|
)
|
||
|
}
|
||
|
},
|
||
|
) { contentPadding ->
|
||
|
val withNavBarContentPadding = contentPadding +
|
||
|
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
|
||
|
VerticalFastScroller(
|
||
|
listState = chapterListState,
|
||
|
topContentPadding = withNavBarContentPadding.calculateTopPadding(),
|
||
|
endContentPadding = withNavBarContentPadding.calculateEndPadding(LocalLayoutDirection.current),
|
||
|
) {
|
||
|
LazyColumn(
|
||
|
modifier = Modifier.fillMaxHeight(),
|
||
|
state = chapterListState,
|
||
|
contentPadding = withNavBarContentPadding,
|
||
|
) {
|
||
|
items(items = chapters) { chapterItem ->
|
||
|
val (chapter, downloadState, downloadProgress) = chapterItem
|
||
|
val chapterTitle = remember(state.manga.displayMode, chapter.chapterNumber, chapter.name) {
|
||
|
if (state.manga.displayMode == CHAPTER_DISPLAY_NUMBER) {
|
||
|
chapterDecimalFormat.format(chapter.chapterNumber.toDouble())
|
||
|
} else {
|
||
|
chapter.name
|
||
|
}
|
||
|
}
|
||
|
val date = remember(chapter.dateUpload) {
|
||
|
chapter.dateUpload
|
||
|
.takeIf { it > 0 }
|
||
|
?.let { Date(it).toRelativeString(context, state.dateRelativeTime, state.dateFormat) }
|
||
|
}
|
||
|
val lastPageRead = remember(chapter.lastPageRead) {
|
||
|
chapter.lastPageRead.takeIf { !chapter.read && it > 0 }
|
||
|
}
|
||
|
val scanlator = remember(chapter.scanlator) { chapter.scanlator.takeIf { !it.isNullOrBlank() } }
|
||
|
|
||
|
MangaChapterListItem(
|
||
|
title = chapterTitle,
|
||
|
date = date,
|
||
|
readProgress = lastPageRead?.let { stringResource(id = R.string.chapter_progress, it + 1) },
|
||
|
scanlator = scanlator,
|
||
|
read = chapter.read,
|
||
|
bookmark = chapter.bookmark,
|
||
|
selected = selected.contains(chapterItem),
|
||
|
downloadState = downloadState,
|
||
|
downloadProgress = downloadProgress,
|
||
|
onLongClick = {
|
||
|
val dispatched = onChapterItemLongClick(
|
||
|
chapterItem = chapterItem,
|
||
|
selected = selected,
|
||
|
chapters = chapters,
|
||
|
selectedPositions = selectedPositions,
|
||
|
)
|
||
|
if (dispatched) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||
|
},
|
||
|
onClick = {
|
||
|
onChapterItemClick(
|
||
|
chapterItem = chapterItem,
|
||
|
selected = selected,
|
||
|
chapters = chapters,
|
||
|
selectedPositions = selectedPositions,
|
||
|
onChapterClicked = onChapterClicked,
|
||
|
)
|
||
|
},
|
||
|
onDownloadClick = if (onDownloadChapter != null) {
|
||
|
{ onDownloadChapter(listOf(chapterItem), it) }
|
||
|
} else null,
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@Composable
|
||
|
fun MangaScreenLargeImpl(
|
||
|
state: MangaScreenState.Success,
|
||
|
windowWidthSizeClass: WindowWidthSizeClass,
|
||
|
snackbarHostState: SnackbarHostState,
|
||
|
onBackClicked: () -> Unit,
|
||
|
onChapterClicked: (Chapter) -> Unit,
|
||
|
onDownloadChapter: ((List<ChapterItem>, ChapterDownloadAction) -> Unit)?,
|
||
|
onAddToLibraryClicked: () -> Unit,
|
||
|
onWebViewClicked: (() -> Unit)?,
|
||
|
onTrackingClicked: (() -> Unit)?,
|
||
|
onTagClicked: (String) -> Unit,
|
||
|
onFilterButtonClicked: () -> Unit,
|
||
|
onRefresh: () -> Unit,
|
||
|
onContinueReading: () -> Unit,
|
||
|
onSearch: (query: String, global: Boolean) -> Unit,
|
||
|
|
||
|
// For cover dialog
|
||
|
onCoverClicked: () -> Unit,
|
||
|
|
||
|
// For top action menu
|
||
|
onShareClicked: (() -> Unit)?,
|
||
|
onDownloadActionClicked: ((DownloadAction) -> Unit)?,
|
||
|
onEditCategoryClicked: (() -> Unit)?,
|
||
|
onMigrateClicked: (() -> Unit)?,
|
||
|
|
||
|
// For bottom action menu
|
||
|
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
|
||
|
onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
|
||
|
onMarkPreviousAsReadClicked: (Chapter) -> Unit,
|
||
|
onMultiDeleteClicked: (List<Chapter>) -> Unit,
|
||
|
) {
|
||
|
val context = LocalContext.current
|
||
|
val layoutDirection = LocalLayoutDirection.current
|
||
|
val density = LocalDensity.current
|
||
|
val haptic = LocalHapticFeedback.current
|
||
|
|
||
|
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
|
||
|
val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(0) }
|
||
|
SwipeRefresh(
|
||
|
state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingChapter),
|
||
|
onRefresh = onRefresh,
|
||
|
indicatorPadding = PaddingValues(
|
||
|
start = insetPadding.calculateStartPadding(layoutDirection),
|
||
|
top = with(density) { topBarHeight.toDp() },
|
||
|
end = insetPadding.calculateEndPadding(layoutDirection),
|
||
|
),
|
||
|
clipIndicatorToPadding = true,
|
||
|
indicator = { s, trigger ->
|
||
|
SwipeRefreshIndicator(
|
||
|
state = s,
|
||
|
refreshTriggerDistance = trigger,
|
||
|
)
|
||
|
},
|
||
|
) {
|
||
|
val chapterListState = rememberLazyListState()
|
||
|
val chapters = remember(state) { state.processedChapters.toList() }
|
||
|
val selected = remember(chapters) { emptyList<ChapterItem>().toMutableStateList() }
|
||
|
val selectedPositions = remember(chapters) { arrayOf(-1, -1) } // first and last selected index in list
|
||
|
|
||
|
val internalOnBackPressed = {
|
||
|
if (selected.isNotEmpty()) {
|
||
|
selected.clear()
|
||
|
} else {
|
||
|
onBackClicked()
|
||
|
}
|
||
|
}
|
||
|
BackHandler(onBack = internalOnBackPressed)
|
||
|
|
||
|
Scaffold(
|
||
|
modifier = Modifier.padding(insetPadding),
|
||
|
topBar = {
|
||
|
MangaSmallAppBar(
|
||
|
modifier = Modifier.onSizeChanged { onTopBarHeightChanged(it.height) },
|
||
|
title = state.manga.title,
|
||
|
titleAlphaProvider = { if (selected.isEmpty()) 0f else 1f },
|
||
|
backgroundAlphaProvider = { 1f },
|
||
|
incognitoMode = state.isIncognitoMode,
|
||
|
downloadedOnlyMode = state.isDownloadedOnlyMode,
|
||
|
onBackClicked = internalOnBackPressed,
|
||
|
onShareClicked = onShareClicked,
|
||
|
onDownloadClicked = onDownloadActionClicked,
|
||
|
onEditCategoryClicked = onEditCategoryClicked,
|
||
|
onMigrateClicked = onMigrateClicked,
|
||
|
actionModeCounter = selected.size,
|
||
|
onSelectAll = {
|
||
|
selected.clear()
|
||
|
selected.addAll(chapters)
|
||
|
},
|
||
|
onInvertSelection = {
|
||
|
val toSelect = chapters - selected
|
||
|
selected.clear()
|
||
|
selected.addAll(toSelect)
|
||
|
},
|
||
|
)
|
||
|
},
|
||
|
bottomBar = {
|
||
|
Box(
|
||
|
modifier = Modifier.fillMaxWidth(),
|
||
|
contentAlignment = Alignment.BottomEnd,
|
||
|
) {
|
||
|
MangaBottomActionMenu(
|
||
|
visible = selected.isNotEmpty(),
|
||
|
modifier = Modifier.fillMaxWidth(0.5f),
|
||
|
onBookmarkClicked = {
|
||
|
onMultiBookmarkClicked.invoke(selected.map { it.chapter }, true)
|
||
|
selected.clear()
|
||
|
}.takeIf { selected.any { !it.chapter.bookmark } },
|
||
|
onRemoveBookmarkClicked = {
|
||
|
onMultiBookmarkClicked.invoke(selected.map { it.chapter }, false)
|
||
|
selected.clear()
|
||
|
}.takeIf { selected.all { it.chapter.bookmark } },
|
||
|
onMarkAsReadClicked = {
|
||
|
onMultiMarkAsReadClicked(selected.map { it.chapter }, true)
|
||
|
selected.clear()
|
||
|
}.takeIf { selected.any { !it.chapter.read } },
|
||
|
onMarkAsUnreadClicked = {
|
||
|
onMultiMarkAsReadClicked(selected.map { it.chapter }, false)
|
||
|
selected.clear()
|
||
|
}.takeIf { selected.any { it.chapter.read } },
|
||
|
onMarkPreviousAsReadClicked = {
|
||
|
onMarkPreviousAsReadClicked(selected[0].chapter)
|
||
|
selected.clear()
|
||
|
}.takeIf { selected.size == 1 },
|
||
|
onDownloadClicked = {
|
||
|
onDownloadChapter!!(selected, ChapterDownloadAction.START)
|
||
|
selected.clear()
|
||
|
}.takeIf {
|
||
|
onDownloadChapter != null && selected.any { it.downloadState != Download.State.DOWNLOADED }
|
||
|
},
|
||
|
onDeleteClicked = {
|
||
|
onMultiDeleteClicked(selected.map { it.chapter })
|
||
|
selected.clear()
|
||
|
}.takeIf {
|
||
|
onDownloadChapter != null && selected.any { it.downloadState == Download.State.DOWNLOADED }
|
||
|
},
|
||
|
)
|
||
|
}
|
||
|
},
|
||
|
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||
|
floatingActionButton = {
|
||
|
AnimatedVisibility(
|
||
|
visible = chapters.any { !it.chapter.read } && selected.isEmpty(),
|
||
|
enter = fadeIn(),
|
||
|
exit = fadeOut(),
|
||
|
) {
|
||
|
ExtendedFloatingActionButton(
|
||
|
text = {
|
||
|
val id = if (chapters.any { it.chapter.read }) {
|
||
|
R.string.action_resume
|
||
|
} else {
|
||
|
R.string.action_start
|
||
|
}
|
||
|
Text(text = stringResource(id = id))
|
||
|
},
|
||
|
icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) },
|
||
|
onClick = onContinueReading,
|
||
|
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
|
||
|
modifier = Modifier
|
||
|
.padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()),
|
||
|
)
|
||
|
}
|
||
|
},
|
||
|
) { contentPadding ->
|
||
|
Row {
|
||
|
val withNavBarContentPadding = contentPadding +
|
||
|
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
|
||
|
MangaInfoHeader(
|
||
|
modifier = Modifier
|
||
|
.weight(1f)
|
||
|
.verticalScroll(rememberScrollState())
|
||
|
.padding(bottom = withNavBarContentPadding.calculateBottomPadding()),
|
||
|
windowWidthSizeClass = WindowWidthSizeClass.Expanded,
|
||
|
appBarPadding = contentPadding.calculateTopPadding(),
|
||
|
title = state.manga.title,
|
||
|
author = state.manga.author,
|
||
|
artist = state.manga.artist,
|
||
|
description = state.manga.description,
|
||
|
tagsProvider = { state.manga.genre },
|
||
|
sourceName = remember { state.source.getNameForMangaInfo() },
|
||
|
isStubSource = remember { state.source is SourceManager.StubSource },
|
||
|
coverDataProvider = { state.manga },
|
||
|
favorite = state.manga.favorite,
|
||
|
status = state.manga.status,
|
||
|
trackingCount = state.trackingCount,
|
||
|
fromSource = state.isFromSource,
|
||
|
onAddToLibraryClicked = onAddToLibraryClicked,
|
||
|
onWebViewClicked = onWebViewClicked,
|
||
|
onTrackingClicked = onTrackingClicked,
|
||
|
onTagClicked = onTagClicked,
|
||
|
onEditCategory = onEditCategoryClicked,
|
||
|
onCoverClick = onCoverClicked,
|
||
|
doSearch = onSearch,
|
||
|
)
|
||
|
|
||
|
val chaptersWeight = if (windowWidthSizeClass == WindowWidthSizeClass.Medium) 1f else 2f
|
||
|
VerticalFastScroller(
|
||
|
listState = chapterListState,
|
||
|
modifier = Modifier.weight(chaptersWeight),
|
||
|
topContentPadding = withNavBarContentPadding.calculateTopPadding(),
|
||
|
endContentPadding = withNavBarContentPadding.calculateEndPadding(layoutDirection),
|
||
|
) {
|
||
|
LazyColumn(
|
||
|
modifier = Modifier.fillMaxHeight(),
|
||
|
state = chapterListState,
|
||
|
contentPadding = withNavBarContentPadding,
|
||
|
) {
|
||
|
item(contentType = "header") {
|
||
|
ChapterHeader(
|
||
|
chapterCount = chapters.size,
|
||
|
isChapterFiltered = state.manga.chaptersFiltered(),
|
||
|
onFilterButtonClicked = onFilterButtonClicked,
|
||
|
)
|
||
|
}
|
||
|
|
||
|
items(items = chapters) { chapterItem ->
|
||
|
val (chapter, downloadState, downloadProgress) = chapterItem
|
||
|
val chapterTitle = remember(state.manga.displayMode, chapter.chapterNumber, chapter.name) {
|
||
|
if (state.manga.displayMode == CHAPTER_DISPLAY_NUMBER) {
|
||
|
chapterDecimalFormat.format(chapter.chapterNumber.toDouble())
|
||
|
} else {
|
||
|
chapter.name
|
||
|
}
|
||
|
}
|
||
|
val date = remember(chapter.dateUpload) {
|
||
|
chapter.dateUpload
|
||
|
.takeIf { it > 0 }
|
||
|
?.let {
|
||
|
Date(it).toRelativeString(
|
||
|
context,
|
||
|
state.dateRelativeTime,
|
||
|
state.dateFormat,
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
val lastPageRead = remember(chapter.lastPageRead) {
|
||
|
chapter.lastPageRead.takeIf { !chapter.read && it > 0 }
|
||
|
}
|
||
|
val scanlator =
|
||
|
remember(chapter.scanlator) { chapter.scanlator.takeIf { !it.isNullOrBlank() } }
|
||
|
|
||
|
MangaChapterListItem(
|
||
|
title = chapterTitle,
|
||
|
date = date,
|
||
|
readProgress = lastPageRead?.let {
|
||
|
stringResource(
|
||
|
id = R.string.chapter_progress,
|
||
|
it + 1,
|
||
|
)
|
||
|
},
|
||
|
scanlator = scanlator,
|
||
|
read = chapter.read,
|
||
|
bookmark = chapter.bookmark,
|
||
|
selected = selected.contains(chapterItem),
|
||
|
downloadState = downloadState,
|
||
|
downloadProgress = downloadProgress,
|
||
|
onLongClick = {
|
||
|
val dispatched = onChapterItemLongClick(
|
||
|
chapterItem = chapterItem,
|
||
|
selected = selected,
|
||
|
chapters = chapters,
|
||
|
selectedPositions = selectedPositions,
|
||
|
)
|
||
|
if (dispatched) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||
|
},
|
||
|
onClick = {
|
||
|
onChapterItemClick(
|
||
|
chapterItem = chapterItem,
|
||
|
selected = selected,
|
||
|
chapters = chapters,
|
||
|
selectedPositions = selectedPositions,
|
||
|
onChapterClicked = onChapterClicked,
|
||
|
)
|
||
|
},
|
||
|
onDownloadClick = if (onDownloadChapter != null) {
|
||
|
{ onDownloadChapter(listOf(chapterItem), it) }
|
||
|
} else null,
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private fun onChapterItemLongClick(
|
||
|
chapterItem: ChapterItem,
|
||
|
selected: MutableList<ChapterItem>,
|
||
|
chapters: List<ChapterItem>,
|
||
|
selectedPositions: Array<Int>,
|
||
|
): Boolean {
|
||
|
if (!selected.contains(chapterItem)) {
|
||
|
val selectedIndex = chapters.indexOf(chapterItem)
|
||
|
if (selected.isEmpty()) {
|
||
|
selected.add(chapterItem)
|
||
|
selectedPositions[0] = selectedIndex
|
||
|
selectedPositions[1] = selectedIndex
|
||
|
return true
|
||
|
}
|
||
|
|
||
|
// Try to select the items in-between when possible
|
||
|
val range: IntRange
|
||
|
if (selectedIndex < selectedPositions[0]) {
|
||
|
range = selectedIndex until selectedPositions[0]
|
||
|
selectedPositions[0] = selectedIndex
|
||
|
} else if (selectedIndex > selectedPositions[1]) {
|
||
|
range = (selectedPositions[1] + 1)..selectedIndex
|
||
|
selectedPositions[1] = selectedIndex
|
||
|
} else {
|
||
|
// Just select itself
|
||
|
range = selectedIndex..selectedIndex
|
||
|
}
|
||
|
|
||
|
range.forEach {
|
||
|
val toAdd = chapters[it]
|
||
|
if (!selected.contains(toAdd)) {
|
||
|
selected.add(toAdd)
|
||
|
}
|
||
|
}
|
||
|
return true
|
||
|
}
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
fun onChapterItemClick(
|
||
|
chapterItem: ChapterItem,
|
||
|
selected: MutableList<ChapterItem>,
|
||
|
chapters: List<ChapterItem>,
|
||
|
selectedPositions: Array<Int>,
|
||
|
onChapterClicked: (Chapter) -> Unit,
|
||
|
) {
|
||
|
val selectedIndex = chapters.indexOf(chapterItem)
|
||
|
when {
|
||
|
selected.contains(chapterItem) -> {
|
||
|
val removedIndex = chapters.indexOf(chapterItem)
|
||
|
selected.remove(chapterItem)
|
||
|
|
||
|
if (removedIndex == selectedPositions[0]) {
|
||
|
selectedPositions[0] = chapters.indexOfFirst { selected.contains(it) }
|
||
|
} else if (removedIndex == selectedPositions[1]) {
|
||
|
selectedPositions[1] = chapters.indexOfLast { selected.contains(it) }
|
||
|
}
|
||
|
}
|
||
|
selected.isNotEmpty() -> {
|
||
|
if (selectedIndex < selectedPositions[0]) {
|
||
|
selectedPositions[0] = selectedIndex
|
||
|
} else if (selectedIndex > selectedPositions[1]) {
|
||
|
selectedPositions[1] = selectedIndex
|
||
|
}
|
||
|
selected.add(chapterItem)
|
||
|
}
|
||
|
else -> onChapterClicked(chapterItem.chapter)
|
||
|
}
|
||
|
}
|