Last Commit Merged: b4bb855675
This commit is contained in:
LuftVerbot 2023-10-05 14:16:41 +02:00
parent 2ebf477bd1
commit f705e19182
44 changed files with 661 additions and 681 deletions

View file

@ -37,4 +37,4 @@ jobs:
- name: Build app and run unit tests
uses: gradle/gradle-command-action@v2
with:
arguments: lintKotlin assembleStandardRelease testStandardReleaseUnitTest
arguments: lintKotlin assembleStandardRelease testReleaseUnitTest

View file

@ -42,7 +42,7 @@ jobs:
- name: Build app and run unit tests
uses: gradle/gradle-command-action@v2
with:
arguments: lintKotlin assembleStandardRelease testStandardReleaseUnitTest
arguments: lintKotlin assembleStandardRelease testReleaseUnitTest
# Sign APK and create release for tags

View file

@ -218,7 +218,6 @@ dependencies {
// Disk
implementation(libs.disklrucache)
implementation(libs.unifile)
implementation(libs.compress)
implementation(libs.junrar)
// Preferences

View file

@ -74,7 +74,4 @@
##---------------End: proguard configuration for kotlinx.serialization ----------
# XmlUtil
-keep public enum nl.adaptivity.xmlutil.EventType { *; }
# org.apache.commons:commons-compress
-keep,allowoptimization class org.apache.commons.compress.archivers.zip.**
-keep public enum nl.adaptivity.xmlutil.EventType { *; }

View file

@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.browse.manga.components.BrowseSourceLoadingItem
import eu.kanade.presentation.library.CommonEntryItemDefaults
@ -41,10 +40,7 @@ fun BrowseAnimeSourceComfortableGrid(
}
}
items(
count = animeList.itemCount,
key = animeList.itemKey { it.value.id },
) { index ->
items(count = animeList.itemCount) { index ->
val anime by animeList[index]?.collectAsState() ?: return@items
BrowseAnimeSourceComfortableGridItem(
anime = anime,

View file

@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.browse.manga.components.BrowseSourceLoadingItem
import eu.kanade.presentation.library.CommonEntryItemDefaults
@ -41,10 +40,7 @@ fun BrowseAnimeSourceCompactGrid(
}
}
items(
count = animeList.itemCount,
key = animeList.itemKey { it.value.id },
) { index ->
items(count = animeList.itemCount) { index ->
val anime by animeList[index]?.collectAsState() ?: return@items
BrowseAnimeSourceCompactGridItem(
anime = anime,

View file

@ -7,7 +7,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import androidx.paging.compose.items
import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.browse.manga.components.BrowseSourceLoadingItem
@ -35,10 +34,7 @@ fun BrowseAnimeSourceList(
}
}
items(
count = animeList.itemCount,
key = animeList.itemKey { it.value.id },
) { index ->
items(count = animeList.itemCount) { index ->
val anime by animeList[index]?.collectAsState() ?: return@items
BrowseAnimeSourceListItem(
anime = anime,

View file

@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.library.CommonEntryItemDefaults
import eu.kanade.presentation.library.EntryComfortableGridItem
@ -40,10 +39,7 @@ fun BrowseMangaSourceComfortableGrid(
}
}
items(
count = mangaList.itemCount,
key = mangaList.itemKey { it.value.id },
) { index ->
items(count = mangaList.itemCount) { index ->
val manga by mangaList[index]?.collectAsState() ?: return@items
BrowseMangaSourceComfortableGridItem(
manga = manga,

View file

@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.library.CommonEntryItemDefaults
import eu.kanade.presentation.library.EntryCompactGridItem
@ -40,10 +39,7 @@ fun BrowseMangaSourceCompactGrid(
}
}
items(
count = mangaList.itemCount,
key = mangaList.itemKey { it.value.id },
) { index ->
items(count = mangaList.itemCount) { index ->
val manga by mangaList[index]?.collectAsState() ?: return@items
BrowseMangaSourceCompactGridItem(
manga = manga,

View file

@ -7,7 +7,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import androidx.paging.compose.items
import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.library.CommonEntryItemDefaults
@ -34,10 +33,7 @@ fun BrowseMangaSourceList(
}
}
items(
count = mangaList.itemCount,
key = mangaList.itemKey { it.value.id },
) { index ->
items(count = mangaList.itemCount) { index ->
val manga by mangaList[index]?.collectAsState() ?: return@items
BrowseMangaSourceListItem(
manga = manga,

View file

@ -16,13 +16,15 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import cafe.adriel.voyager.core.annotation.InternalVoyagerApi
import cafe.adriel.voyager.core.lifecycle.DisposableEffectIgnoringConfiguration
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.ScreenTransition
import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.isTabletUi
import tachiyomi.presentation.core.components.AdaptiveSheet as AdaptiveSheetImpl
@OptIn(InternalVoyagerApi::class)
@Composable
fun NavigatorAdaptiveSheet(
screen: Screen,

View file

@ -20,6 +20,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltipBox
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@ -203,21 +204,36 @@ fun AppBarActions(
var showMenu by remember { mutableStateOf(false) }
actions.filterIsInstance<AppBar.Action>().map {
IconButton(
onClick = it.onClick,
enabled = it.enabled,
PlainTooltipBox(
tooltip = { Text(it.title) },
) {
Icon(
imageVector = it.icon,
contentDescription = it.title,
)
IconButton(
onClick = it.onClick,
enabled = it.enabled,
modifier = Modifier.tooltipAnchor(),
) {
Icon(
imageVector = it.icon,
contentDescription = it.title,
)
}
}
}
val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>()
if (overflowActions.isNotEmpty()) {
IconButton(onClick = { showMenu = !showMenu }) {
Icon(Icons.Outlined.MoreVert, contentDescription = stringResource(R.string.abc_action_menu_overflow_description))
PlainTooltipBox(
tooltip = { Text(stringResource(R.string.abc_action_menu_overflow_description)) },
) {
IconButton(
onClick = { showMenu = !showMenu },
modifier = Modifier.tooltipAnchor(),
) {
Icon(
Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.abc_action_menu_overflow_description),
)
}
}
DropdownMenu(
@ -327,17 +343,35 @@ fun SearchToolbar(
if (!searchEnabled) {
// Don't show search action
} else if (searchQuery == null) {
IconButton(onClick) {
Icon(Icons.Outlined.Search, contentDescription = stringResource(R.string.action_search))
PlainTooltipBox(
tooltip = { Text(stringResource(R.string.action_search)) },
) {
IconButton(
onClick = onClick,
modifier = Modifier.tooltipAnchor(),
) {
Icon(
Icons.Outlined.Search,
contentDescription = stringResource(R.string.action_search),
)
}
}
} else if (searchQuery.isNotEmpty()) {
IconButton(
onClick = {
onClick()
focusRequester.requestFocus()
},
PlainTooltipBox(
tooltip = { Text(stringResource(R.string.action_reset)) },
) {
Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.action_reset))
IconButton(
onClick = {
onClick()
focusRequester.requestFocus()
},
modifier = Modifier.tooltipAnchor(),
) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.action_reset),
)
}
}
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Icon
@ -31,7 +32,6 @@ import kotlinx.coroutines.launch
import tachiyomi.presentation.core.components.HorizontalPager
import tachiyomi.presentation.core.components.material.Divider
import tachiyomi.presentation.core.components.material.TabIndicator
import tachiyomi.presentation.core.components.rememberPagerState
object TabbedDialogPaddings {
val Horizontal = 24.dp
@ -84,7 +84,7 @@ fun TabbedDialog(
HorizontalPager(
modifier = Modifier.animateContentSize(),
count = tabTitles.size,
pageCount = tabTitles.size,
state = pagerState,
verticalAlignment = Alignment.Top,
) { page ->

View file

@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.SnackbarHost
@ -25,11 +27,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import tachiyomi.presentation.core.components.HorizontalPager
import tachiyomi.presentation.core.components.PagerState
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.TabIndicator
import tachiyomi.presentation.core.components.material.TabText
import tachiyomi.presentation.core.components.rememberPagerState
@Composable
fun TabbedScreen(
@ -105,7 +105,7 @@ fun TabbedScreen(
}
HorizontalPager(
count = tabs.size,
pageCount = tabs.size,
modifier = Modifier.fillMaxSize(),
state = state,
verticalAlignment = Alignment.Top,

View file

@ -26,6 +26,8 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.EntryDownloadDropdownMenu
import eu.kanade.presentation.components.OverflowMenu
import eu.kanade.tachiyomi.R
@ -77,18 +79,20 @@ fun EntryToolbar(
},
actions = {
if (isActionMode) {
IconButton(onClick = onSelectAll) {
Icon(
imageVector = Icons.Outlined.SelectAll,
contentDescription = stringResource(R.string.action_select_all),
)
}
IconButton(onClick = onInvertSelection) {
Icon(
imageVector = Icons.Outlined.FlipToBack,
contentDescription = stringResource(R.string.action_select_inverse),
)
}
AppBarActions(
listOf(
AppBar.Action(
title = stringResource(R.string.action_select_all),
icon = Icons.Outlined.SelectAll,
onClick = onSelectAll,
),
AppBar.Action(
title = stringResource(R.string.action_select_inverse),
icon = Icons.Outlined.FlipToBack,
onClick = onInvertSelection,
),
),
)
} else {
if (onClickDownload != null) {
val (downloadExpanded, onDownloadExpanded) = remember { mutableStateOf(false) }

View file

@ -49,9 +49,9 @@ import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import eu.kanade.core.util.asFlow
import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadManager
@ -81,7 +81,7 @@ class EpisodeOptionsDialogScreen(
private val episodeId: Long,
private val animeId: Long,
private val sourceId: Long,
) : Screen() {
) : Screen {
@Composable
override fun Content() {

View file

@ -1,6 +1,7 @@
package eu.kanade.presentation.library
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
@ -8,7 +9,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.category.visualName
import tachiyomi.domain.category.model.Category
import tachiyomi.presentation.core.components.PagerState
import tachiyomi.presentation.core.components.material.Divider
import tachiyomi.presentation.core.components.material.TabIndicator
import tachiyomi.presentation.core.components.material.TabText

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.OverflowMenu
import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.tachiyomi.R
@ -145,12 +146,20 @@ fun LibrarySelectionToolbar(
AppBar(
titleContent = { Text(text = "$selectedCount") },
actions = {
IconButton(onClick = onClickSelectAll) {
Icon(Icons.Outlined.SelectAll, contentDescription = stringResource(R.string.action_select_all))
}
IconButton(onClick = onClickInvertSelection) {
Icon(Icons.Outlined.FlipToBack, contentDescription = stringResource(R.string.action_select_inverse))
}
AppBarActions(
listOf(
AppBar.Action(
title = stringResource(R.string.action_select_all),
icon = Icons.Outlined.SelectAll,
onClick = onClickSelectAll,
),
AppBar.Action(
title = stringResource(R.string.action_select_inverse),
icon = Icons.Outlined.FlipToBack,
onClick = onClickInvertSelection,
),
),
)
},
isActionMode = true,
onCancelActionMode = onClickUnselectAll,

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -23,7 +24,6 @@ import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.anime.LibraryAnime
import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.presentation.core.components.material.PullRefresh
import tachiyomi.presentation.core.components.rememberPagerState
import kotlin.time.Duration.Companion.seconds
@Composable
@ -61,8 +61,10 @@ fun AnimeLibraryContent(
var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) }
if (showPageTabs && categories.size > 1) {
if (categories.size <= pagerState.currentPage) {
pagerState.currentPage = categories.size - 1
LaunchedEffect(categories) {
if (categories.size <= pagerState.currentPage) {
pagerState.scrollToPage(categories.size - 1)
}
}
LibraryTabs(
categories = categories,

View file

@ -3,6 +3,7 @@ package eu.kanade.presentation.library.anime
import android.content.res.Configuration
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -16,7 +17,6 @@ import eu.kanade.tachiyomi.ui.library.anime.AnimeLibraryItem
import tachiyomi.domain.library.anime.LibraryAnime
import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.presentation.core.components.HorizontalPager
import tachiyomi.presentation.core.components.PagerState
@Composable
fun AnimeLibraryPager(
@ -35,7 +35,7 @@ fun AnimeLibraryPager(
onClickContinueWatching: ((LibraryAnime) -> Unit)?,
) {
HorizontalPager(
count = pageCount,
pageCount = pageCount,
modifier = Modifier.fillMaxSize(),
state = state,
verticalAlignment = Alignment.Top,

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -23,7 +24,6 @@ import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.manga.LibraryManga
import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.presentation.core.components.material.PullRefresh
import tachiyomi.presentation.core.components.rememberPagerState
import kotlin.time.Duration.Companion.seconds
@Composable
@ -61,8 +61,10 @@ fun MangaLibraryContent(
var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) }
if (showPageTabs && categories.size > 1) {
if (categories.size <= pagerState.currentPage) {
pagerState.currentPage = categories.size - 1
LaunchedEffect(categories) {
if (categories.size <= pagerState.currentPage) {
pagerState.scrollToPage(categories.size - 1)
}
}
LibraryTabs(
categories = categories,

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
@ -23,7 +24,6 @@ import eu.kanade.tachiyomi.ui.library.manga.MangaLibraryItem
import tachiyomi.domain.library.manga.LibraryManga
import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.presentation.core.components.HorizontalPager
import tachiyomi.presentation.core.components.PagerState
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.util.plus
@ -44,7 +44,7 @@ fun MangaLibraryPager(
onClickContinueReading: ((LibraryManga) -> Unit)?,
) {
HorizontalPager(
count = pageCount,
pageCount = pageCount,
modifier = Modifier.fillMaxSize(),
state = state,
verticalAlignment = Alignment.Top,

View file

@ -18,6 +18,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
@ -28,8 +29,11 @@ import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.ioCoroutineScope
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.flow.SharingStarted
@ -63,13 +67,17 @@ object WorkerInfoScreen : Screen() {
}
},
actions = {
IconButton(
onClick = {
context.copyToClipboard(title, enqueued + finished + running)
},
) {
Icon(imageVector = Icons.Default.ContentCopy, contentDescription = null)
}
AppBarActions(
listOf(
AppBar.Action(
title = stringResource(R.string.action_copy_to_clipboard),
icon = Icons.Default.ContentCopy,
onClick = {
context.copyToClipboard(title, enqueued + finished + running)
},
),
),
)
},
scrollBehavior = it,
)

View file

@ -35,6 +35,7 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.util.isTabletUi
import eu.kanade.tachiyomi.R
import kotlin.math.roundToInt
@Composable
fun ChapterNavigator(
@ -107,7 +108,7 @@ fun ChapterNavigator(
valueRange = 1f..totalPages.toFloat(),
steps = totalPages - 2,
onValueChange = {
onSliderValueChange(it.toInt() - 1)
onSliderValueChange(it.roundToInt() - 1)
},
interactionSource = interactionSource,
)

View file

@ -2,60 +2,59 @@ package eu.kanade.presentation.reader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.OfflinePin
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.common.io.Files.append
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.manga.Chapter
import eu.kanade.tachiyomi.data.database.models.manga.ChapterImpl
import eu.kanade.tachiyomi.data.database.models.manga.toDomainChapter
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import tachiyomi.domain.entries.manga.model.Manga
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import tachiyomi.domain.items.service.calculateChapterGap
import tachiyomi.presentation.core.components.material.SecondaryItemAlpha
import tachiyomi.presentation.core.util.ThemePreviews
import tachiyomi.presentation.core.util.secondaryItemAlpha
@Composable
fun ChapterTransition(
transition: ChapterTransition,
downloadManager: MangaDownloadManager,
manga: Manga?,
currChapterDownloaded: Boolean,
goingToChapterDownloaded: Boolean,
) {
manga ?: return
val currChapter = transition.from.chapter
val currChapterDownloaded = transition.from.pageLoader?.isLocal == true
val goingToChapter = transition.to?.chapter
val goingToChapterDownloaded = if (goingToChapter != null) {
downloadManager.isChapterDownloaded(
goingToChapter.name,
goingToChapter.scanlator,
manga.title,
manga.source,
skipCache = true,
)
} else {
false
}
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
when (transition) {
@ -90,80 +89,289 @@ fun ChapterTransition(
@Composable
private fun TransitionText(
topLabel: String,
topChapter: Chapter? = null,
topChapter: Chapter?,
topChapterDownloaded: Boolean,
bottomLabel: String,
bottomChapter: Chapter? = null,
bottomChapter: Chapter?,
bottomChapterDownloaded: Boolean,
fallbackLabel: String,
chapterGap: Int,
) {
val hasTopChapter = topChapter != null
val hasBottomChapter = bottomChapter != null
Column(
modifier = Modifier
.widthIn(max = 460.dp)
.fillMaxWidth(),
) {
if (topChapter != null) {
ChapterText(
header = topLabel,
name = topChapter.name,
scanlator = topChapter.scanlator,
downloaded = topChapterDownloaded,
)
Column {
Text(
text = if (hasTopChapter) topLabel else fallbackLabel,
fontWeight = FontWeight.Bold,
textAlign = if (hasTopChapter) TextAlign.Start else TextAlign.Center,
)
topChapter?.let { ChapterText(chapter = it, downloaded = topChapterDownloaded) }
Spacer(Modifier.height(16.dp))
if (chapterGap > 0) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Warning,
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
)
Text(text = pluralStringResource(R.plurals.missing_chapters_warning, count = chapterGap, chapterGap))
}
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(VerticalSpacerSize))
} else {
NoChapterNotification(
text = fallbackLabel,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
Text(
text = if (hasBottomChapter) bottomLabel else fallbackLabel,
fontWeight = FontWeight.Bold,
textAlign = if (hasBottomChapter) TextAlign.Start else TextAlign.Center,
)
bottomChapter?.let { ChapterText(chapter = it, downloaded = bottomChapterDownloaded) }
if (bottomChapter != null) {
if (chapterGap > 0) {
ChapterGapWarning(
gapCount = chapterGap,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
Spacer(Modifier.height(VerticalSpacerSize))
ChapterText(
header = bottomLabel,
name = bottomChapter.name,
scanlator = bottomChapter.scanlator,
downloaded = bottomChapterDownloaded,
)
} else {
NoChapterNotification(
text = fallbackLabel,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
}
@Composable
private fun ColumnScope.ChapterText(
chapter: Chapter,
downloaded: Boolean,
private fun NoChapterNotification(
text: String,
modifier: Modifier = Modifier,
) {
FlowRow(
verticalAlignment = Alignment.CenterVertically,
OutlinedCard(
modifier = modifier,
colors = CardColor,
) {
if (downloaded) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.OfflinePin,
contentDescription = stringResource(R.string.label_downloaded),
imageVector = Icons.Outlined.Info,
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
}
Text(chapter.name)
}
chapter.scanlator?.let {
ProvideTextStyle(
MaterialTheme.typography.bodyMedium.copy(
color = LocalContentColor.current.copy(alpha = SecondaryItemAlpha),
),
) {
Text(it)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
private fun ChapterGapWarning(
gapCount: Int,
modifier: Modifier = Modifier,
) {
OutlinedCard(
modifier = modifier,
colors = CardColor,
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Warning,
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
)
Text(
text = pluralStringResource(R.plurals.missing_chapters_warning, count = gapCount, gapCount),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
private fun ChapterHeaderText(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
modifier = modifier,
style = MaterialTheme.typography.titleMedium,
)
}
@Composable
private fun ChapterText(
header: String,
name: String,
scanlator: String?,
downloaded: Boolean,
) {
Column {
ChapterHeaderText(
text = header,
modifier = Modifier.padding(bottom = 4.dp),
)
Text(
text = buildAnnotatedString {
if (downloaded) {
appendInlineContent(DownloadedIconContentId)
append(' ')
}
append(name)
},
fontSize = 20.sp,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleLarge,
inlineContent = mapOf(
DownloadedIconContentId to InlineTextContent(
Placeholder(
width = 22.sp,
height = 22.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
),
) {
Icon(
imageVector = Icons.Outlined.OfflinePin,
contentDescription = stringResource(R.string.label_downloaded),
)
},
),
)
scanlator?.let {
Text(
text = it,
modifier = Modifier
.secondaryItemAlpha()
.padding(top = 2.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
private val CardColor: CardColors
@Composable
get() = CardDefaults.outlinedCardColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
)
private val VerticalSpacerSize = 24.dp
private const val DownloadedIconContentId = "downloaded"
private fun previewChapter(name: String, scanlator: String, chapterNumber: Float) = ChapterImpl().apply {
this.name = name
this.scanlator = scanlator
this.chapter_number = chapterNumber
this.id = 0
this.manga_id = 0
this.url = ""
}
private val FakeChapter = previewChapter(
name = "Vol.1, Ch.1 - Fake Chapter Title",
scanlator = "Scanlator Name",
chapterNumber = 1f,
)
private val FakeGapChapter = previewChapter(
name = "Vol.5, Ch.44 - Fake Gap Chapter Title",
scanlator = "Scanlator Name",
chapterNumber = 44f,
)
private val FakeChapterLongTitle = previewChapter(
name = "Vol.1, Ch.0 - The Mundane Musings of a Metafictional Manga: A Chapter About a Chapter, Featuring" +
" an Absurdly Long Title and a Surprisingly Normal Day in the Lives of Our Heroes, as They Grapple with the " +
"Daily Challenges of Existence, from Paying Rent to Finding Love, All While Navigating the Strange World of " +
"Fictional Realities and Reality-Bending Fiction, Where the Fourth Wall is Always in Danger of Being Broken " +
"and the Line Between Author and Character is Forever Blurred.",
scanlator = "Long Long Funny Scanlator Sniper Group Name Reborn",
chapterNumber = 1f,
)
@ThemePreviews
@Composable
private fun TransitionTextPreview() {
TachiyomiTheme {
Surface(modifier = Modifier.padding(48.dp)) {
ChapterTransition(
transition = ChapterTransition.Next(ReaderChapter(FakeChapter), ReaderChapter(FakeChapter)),
currChapterDownloaded = false,
goingToChapterDownloaded = true,
)
}
}
}
@ThemePreviews
@Composable
private fun TransitionTextLongTitlePreview() {
TachiyomiTheme {
Surface(modifier = Modifier.padding(48.dp)) {
ChapterTransition(
transition = ChapterTransition.Next(ReaderChapter(FakeChapterLongTitle), ReaderChapter(FakeChapter)),
currChapterDownloaded = true,
goingToChapterDownloaded = true,
)
}
}
}
@ThemePreviews
@Composable
private fun TransitionTextWithGapPreview() {
TachiyomiTheme {
Surface(modifier = Modifier.padding(48.dp)) {
ChapterTransition(
transition = ChapterTransition.Next(ReaderChapter(FakeChapter), ReaderChapter(FakeGapChapter)),
currChapterDownloaded = true,
goingToChapterDownloaded = false,
)
}
}
}
@ThemePreviews
@Composable
private fun TransitionTextNoNextPreview() {
TachiyomiTheme {
Surface(modifier = Modifier.padding(48.dp)) {
ChapterTransition(
transition = ChapterTransition.Next(ReaderChapter(FakeChapter), null),
currChapterDownloaded = true,
goingToChapterDownloaded = false,
)
}
}
}
@ThemePreviews
@Composable
private fun TransitionTextNoPreviousPreview() {
TachiyomiTheme {
Surface(modifier = Modifier.padding(48.dp)) {
ChapterTransition(
transition = ChapterTransition.Prev(ReaderChapter(FakeChapter), null),
currChapterDownloaded = true,
goingToChapterDownloaded = false,
)
}
}
}

View file

@ -31,8 +31,6 @@ abstract class Tab : cafe.adriel.voyager.navigator.tab.Tab {
open suspend fun onReselect(navigator: Navigator) {}
}
// TODO: this prevents crashes in nested navigators with transitions not being disposed
// properly. Go back to using vanilla Voyager Screens once fixed upstream.
abstract class Screen : Screen {
override val key: ScreenKey = uniqueScreenKey

View file

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.backup
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import androidx.work.BackoffPolicy
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
@ -22,6 +23,8 @@ import tachiyomi.domain.backup.service.BackupPreferences
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.minutes
import kotlin.time.toJavaDuration
class BackupCreateJob(private val context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) {
@ -29,12 +32,14 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
private val notifier = BackupNotifier(context)
override suspend fun doWork(): Result {
val isAutoBackup = inputData.getBoolean(IS_AUTO_BACKUP_KEY, true)
if (isAutoBackup && BackupRestoreJob.isRunning(context)) return Result.retry()
val backupPreferences = Injekt.get<BackupPreferences>()
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
?: backupPreferences.backupsDirectory().get().toUri()
val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupConst.BACKUP_ALL)
val isAutoBackup = inputData.getBoolean(IS_AUTO_BACKUP_KEY, true)
try {
setForeground(getForegroundInfo())
} catch (e: IllegalStateException) {
@ -79,6 +84,7 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
10,
TimeUnit.MINUTES,
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10.minutes.toJavaDuration())
.addTag(TAG_AUTO)
.setInputData(
workDataOf(

View file

@ -111,7 +111,6 @@ class BackupManager(
* @param uri path of Uri
* @param isAutoBackup backup called from scheduled backup job
*/
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean): String {
if (!context.hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
throw IllegalStateException(context.getString(R.string.missing_storage_permission))
@ -446,10 +445,10 @@ class BackupManager(
}
internal suspend fun restoreExistingManga(manga: Manga, dbManga: Mangas): Manga {
var manga = manga.copy(id = dbManga._id)
manga = manga.copyFrom(dbManga)
updateManga(manga)
return manga
var updatedManga = manga.copy(id = dbManga._id)
updatedManga = updatedManga.copyFrom(dbManga)
updateManga(updatedManga)
return updatedManga
}
/**
@ -466,10 +465,10 @@ class BackupManager(
}
internal suspend fun restoreExistingAnime(anime: Anime, dbAnime: Animes): Anime {
var anime = anime.copy(id = dbAnime._id)
anime = anime.copyFrom(dbAnime)
updateAnime(anime)
return anime
var updatedAnime = anime.copy(id = dbAnime._id)
updatedAnime = updatedAnime.copyFrom(dbAnime)
updateAnime(updatedAnime)
return updatedAnime
}
/**
@ -582,7 +581,7 @@ class BackupManager(
dbCategories.firstOrNull { dbCategory ->
dbCategory.name == backupCategory.name
}?.let { dbCategory ->
mangaCategoriesToUpdate.add(Pair(manga.id!!, dbCategory.id))
mangaCategoriesToUpdate.add(Pair(manga.id, dbCategory.id))
}
}
}
@ -590,7 +589,7 @@ class BackupManager(
// Update database
if (mangaCategoriesToUpdate.isNotEmpty()) {
mangaHandler.await(true) {
mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id!!)
mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id)
mangaCategoriesToUpdate.forEach { (mangaId, categoryId) ->
mangas_categoriesQueries.insert(mangaId, categoryId)
}
@ -615,7 +614,7 @@ class BackupManager(
dbCategories.firstOrNull { dbCategory ->
dbCategory.name == backupCategory.name
}?.let { dbCategory ->
animeCategoriesToUpdate.add(Pair(anime.id!!, dbCategory.id))
animeCategoriesToUpdate.add(Pair(anime.id, dbCategory.id))
}
}
}
@ -623,7 +622,7 @@ class BackupManager(
// Update database
if (animeCategoriesToUpdate.isNotEmpty()) {
animeHandler.await(true) {
animes_categoriesQueries.deleteAnimeCategoryByAnimeId(anime.id!!)
animes_categoriesQueries.deleteAnimeCategoryByAnimeId(anime.id)
animeCategoriesToUpdate.forEach { (animeId, categoryId) ->
animes_categoriesQueries.insert(animeId, categoryId)
}
@ -730,37 +729,38 @@ class BackupManager(
* @param tracks the track list to restore.
*/
internal suspend fun restoreTracking(manga: Manga, tracks: List<tachiyomi.domain.track.manga.model.MangaTrack>) {
// Fix foreign keys with the current manga id
val tracks = tracks.map { it.copy(mangaId = manga.id!!) }
// Get tracks from database
val dbTracks = mangaHandler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id!!) }
val dbTracks = mangaHandler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id) }
val toUpdate = mutableListOf<Manga_sync>()
val toInsert = mutableListOf<tachiyomi.domain.track.manga.model.MangaTrack>()
tracks.forEach { track ->
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.syncId == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
var temp = dbTrack
if (track.remoteId != dbTrack.remote_id) {
temp = temp.copy(remote_id = track.remoteId)
tracks
// Fix foreign keys with the current manga id
.map { it.copy(mangaId = manga.id) }
.forEach { track ->
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.syncId == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
var temp = dbTrack
if (track.remoteId != dbTrack.remote_id) {
temp = temp.copy(remote_id = track.remoteId)
}
if (track.libraryId != dbTrack.library_id) {
temp = temp.copy(library_id = track.libraryId)
}
temp = temp.copy(last_chapter_read = max(dbTrack.last_chapter_read, track.lastChapterRead))
isInDatabase = true
toUpdate.add(temp)
break
}
if (track.libraryId != dbTrack.library_id) {
temp = temp.copy(library_id = track.libraryId)
}
temp = temp.copy(last_chapter_read = max(dbTrack.last_chapter_read, track.lastChapterRead))
isInDatabase = true
toUpdate.add(temp)
break
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
toInsert.add(track.copy(id = 0))
}
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
toInsert.add(track.copy(id = 0))
}
}
// Update database
if (toUpdate.isNotEmpty()) {
mangaHandler.await(true) {
@ -812,37 +812,38 @@ class BackupManager(
* @param tracks the track list to restore.
*/
internal suspend fun restoreAnimeTracking(anime: Anime, tracks: List<tachiyomi.domain.track.anime.model.AnimeTrack>) {
// Fix foreign keys with the current anime id
val tracks = tracks.map { it.copy(animeId = anime.id!!) }
// Get tracks from database
val dbTracks = animeHandler.awaitList { anime_syncQueries.getTracksByAnimeId(anime.id!!) }
val dbTracks = animeHandler.awaitList { anime_syncQueries.getTracksByAnimeId(anime.id) }
val toUpdate = mutableListOf<Anime_sync>()
val toInsert = mutableListOf<tachiyomi.domain.track.anime.model.AnimeTrack>()
tracks.forEach { track ->
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.syncId == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
var temp = dbTrack
if (track.remoteId != dbTrack.remote_id) {
temp = temp.copy(remote_id = track.remoteId)
tracks
// Fix foreign keys with the current manga id
.map { it.copy(animeId = anime.id) }
.forEach { track ->
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.syncId == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
var temp = dbTrack
if (track.remoteId != dbTrack.remote_id) {
temp = temp.copy(remote_id = track.remoteId)
}
if (track.libraryId != dbTrack.library_id) {
temp = temp.copy(library_id = track.libraryId)
}
temp = temp.copy(last_episode_seen = max(dbTrack.last_episode_seen, track.lastEpisodeSeen))
isInDatabase = true
toUpdate.add(temp)
break
}
if (track.libraryId != dbTrack.library_id) {
temp = temp.copy(library_id = track.libraryId)
}
temp = temp.copy(last_episode_seen = max(dbTrack.last_episode_seen, track.lastEpisodeSeen))
isInDatabase = true
toUpdate.add(temp)
break
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
toInsert.add(track.copy(id = 0))
}
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
toInsert.add(track.copy(id = 0))
}
}
// Update database
if (toUpdate.isNotEmpty()) {
animeHandler.await(true) {
@ -891,22 +892,22 @@ class BackupManager(
val dbChapters = mangaHandler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id!!) }
val processed = chapters.map { chapter ->
var chapter = chapter
val dbChapter = dbChapters.find { it.url == chapter.url }
var updatedChapter = chapter
val dbChapter = dbChapters.find { it.url == updatedChapter.url }
if (dbChapter != null) {
chapter = chapter.copy(id = dbChapter._id)
chapter = chapter.copyFrom(dbChapter)
if (dbChapter.read && !chapter.read) {
chapter = chapter.copy(read = dbChapter.read, lastPageRead = dbChapter.last_page_read)
updatedChapter = updatedChapter.copy(id = dbChapter._id)
updatedChapter = updatedChapter.copyFrom(dbChapter)
if (dbChapter.read && !updatedChapter.read) {
updatedChapter = updatedChapter.copy(read = true, lastPageRead = dbChapter.last_page_read)
} else if (chapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) {
chapter = chapter.copy(lastPageRead = dbChapter.last_page_read)
updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read)
}
if (!chapter.bookmark && dbChapter.bookmark) {
chapter = chapter.copy(bookmark = dbChapter.bookmark)
if (!updatedChapter.bookmark && dbChapter.bookmark) {
updatedChapter = updatedChapter.copy(bookmark = true)
}
}
chapter.copy(mangaId = manga.id ?: -1)
updatedChapter.copy(mangaId = manga.id ?: -1)
}
val newChapters = processed.groupBy { it.id > 0 }
@ -918,22 +919,22 @@ class BackupManager(
val dbEpisodes = animeHandler.awaitList { episodesQueries.getEpisodesByAnimeId(anime.id!!) }
val processed = episodes.map { episode ->
var episode = episode
val dbEpisode = dbEpisodes.find { it.url == episode.url }
var updatedEpisode = episode
val dbEpisode = dbEpisodes.find { it.url == updatedEpisode.url }
if (dbEpisode != null) {
episode = episode.copy(id = dbEpisode._id)
episode = episode.copyFrom(dbEpisode)
if (dbEpisode.seen && !episode.seen) {
episode = episode.copy(seen = dbEpisode.seen, lastSecondSeen = dbEpisode.last_second_seen)
} else if (episode.lastSecondSeen == 0L && dbEpisode.last_second_seen != 0L) {
episode = episode.copy(lastSecondSeen = dbEpisode.last_second_seen)
updatedEpisode = updatedEpisode.copy(id = dbEpisode._id)
updatedEpisode = updatedEpisode.copyFrom(dbEpisode)
if (dbEpisode.seen && !updatedEpisode.seen) {
updatedEpisode = updatedEpisode.copy(seen = true, lastSecondSeen = dbEpisode.last_second_seen)
} else if (updatedEpisode.lastSecondSeen == 0L && dbEpisode.last_second_seen != 0L) {
updatedEpisode = updatedEpisode.copy(lastSecondSeen = dbEpisode.last_second_seen)
}
if (!episode.bookmark && dbEpisode.bookmark) {
episode = episode.copy(bookmark = dbEpisode.bookmark)
if (!updatedEpisode.bookmark && dbEpisode.bookmark) {
updatedEpisode = updatedEpisode.copy(bookmark = true)
}
}
episode.copy(animeId = anime.id ?: -1)
updatedEpisode.copy(animeId = anime.id ?: -1)
}
val newEpisodes = processed.groupBy { it.id > 0 }
@ -1126,55 +1127,6 @@ class BackupManager(
}
}
/**
* Updates a list of chapters
*/
private suspend fun updateChapters(chapters: List<tachiyomi.domain.items.chapter.model.Chapter>) {
mangaHandler.await(true) {
chapters.forEach { chapter ->
chaptersQueries.update(
chapter.mangaId,
chapter.url,
chapter.name,
chapter.scanlator,
chapter.read.toLong(),
chapter.bookmark.toLong(),
chapter.lastPageRead,
chapter.chapterNumber.toDouble(),
chapter.sourceOrder,
chapter.dateFetch,
chapter.dateUpload,
chapter.id,
)
}
}
}
/**
* Updates a list of episodes
*/
private suspend fun updateEpisodes(episodes: List<tachiyomi.domain.items.episode.model.Episode>) {
animeHandler.await(true) {
episodes.forEach { episode ->
episodesQueries.update(
episode.animeId,
episode.url,
episode.name,
episode.scanlator,
episode.seen.toLong(),
episode.bookmark.toLong(),
episode.lastSecondSeen,
episode.totalSeconds,
episode.episodeNumber.toDouble(),
episode.sourceOrder,
episode.dateFetch,
episode.dateUpload,
episode.id,
)
}
}
}
/**
* Updates a list of chapters with known database ids
*/

View file

@ -28,6 +28,7 @@ data class BackupAnimeTracking(
@ProtoNumber(11) var finishedWatchingDate: Long = 0,
@ProtoNumber(100) var mediaId: Long = 0,
) {
@Suppress("DEPRECATION")
fun getTrackingImpl(): AnimeTrack {
return AnimeTrack(
id = -1,

View file

@ -29,6 +29,7 @@ data class BackupTracking(
@ProtoNumber(100) var mediaId: Long = 0,
) {
@Suppress("DEPRECATION")
fun getTrackingImpl(): MangaTrack {
return MangaTrack(
id = -1,

View file

@ -118,7 +118,6 @@ class AnimeCoverScreenModel(
fun editCover(context: Context, data: Uri) {
val anime = state.value ?: return
coroutineScope.launchIO {
@Suppress("BlockingMethodInNonBlockingContext")
context.contentResolver.openInputStream(data)?.use {
try {
anime.editCover(Injekt.get(), it, updateAnime, coverCache)

View file

@ -628,8 +628,8 @@ class AnimeInfoScreenModel(
downloadEpisodes(episodes, false, video)
}
if (!isFavorited && !successState.hasPromptedToAddBefore) {
updateSuccessState { successState ->
successState.copy(hasPromptedToAddBefore = true)
updateSuccessState { state ->
state.copy(hasPromptedToAddBefore = true)
}
coroutineScope.launch {
val result = snackbarHostState.showSnackbar(

View file

@ -118,7 +118,6 @@ class MangaCoverScreenModel(
fun editCover(context: Context, data: Uri) {
val manga = state.value ?: return
coroutineScope.launchIO {
@Suppress("BlockingMethodInNonBlockingContext")
context.contentResolver.openInputStream(data)?.use {
try {
manga.editCover(Injekt.get(), it, updateManga, coverCache)

View file

@ -622,8 +622,8 @@ class MangaInfoScreenModel(
downloadChapters(chapters)
}
if (!isFavorited && !successState.hasPromptedToAddBefore) {
updateSuccessState { successState ->
successState.copy(hasPromptedToAddBefore = true)
updateSuccessState { state ->
state.copy(hasPromptedToAddBefore = true)
}
coroutineScope.launch {
val result = snackbarHostState.showSnackbar(

View file

@ -1,10 +1,11 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import com.github.junrar.Archive
import com.github.junrar.rarfile.FileHeader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import uy.kohesive.injekt.injectLazy
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import tachiyomi.core.util.system.ImageUtil
import java.io.File
import java.io.InputStream
import java.io.PipedInputStream
@ -15,36 +16,30 @@ import java.io.PipedOutputStream
*/
internal class RarPageLoader(file: File) : PageLoader() {
private val context: Application by injectLazy()
private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also {
it.deleteRecursively()
it.mkdirs()
}
init {
Archive(file).use { rar ->
rar.fileHeaders.asSequence()
.filterNot { it.isDirectory }
.forEach { header ->
val pageOutputStream = File(tmpDir, header.fileName.substringAfterLast("/"))
.also { it.createNewFile() }
.outputStream()
getStream(rar, header).use {
it.copyTo(pageOutputStream)
}
}
}
}
private val rar = Archive(file)
override var isLocal: Boolean = true
override suspend fun getPages(): List<ReaderPage> {
return DirectoryPageLoader(tmpDir).getPages()
return rar.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, header ->
ReaderPage(i).apply {
stream = { getStream(rar, header) }
status = Page.State.READY
}
}
.toList()
}
override suspend fun loadPage(page: ReaderPage) {
check(!isRecycled)
}
override fun recycle() {
super.recycle()
tmpDir.deleteRecursively()
rar.close()
}
/**

View file

@ -1,52 +1,46 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import android.os.Build
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import org.apache.commons.compress.archivers.zip.ZipFile
import org.apache.commons.compress.utils.SeekableInMemoryByteChannel
import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayOutputStream
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import tachiyomi.core.util.system.ImageUtil
import java.io.File
import java.io.FileInputStream
import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile
/**
* Loader used to load a chapter from a .zip or .cbz file.
*/
internal class ZipPageLoader(file: File) : PageLoader() {
private val context: Application by injectLazy()
private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also {
it.deleteRecursively()
it.mkdirs()
}
init {
ByteArrayOutputStream().use { byteArrayOutputStream ->
FileInputStream(file).use { it.copyTo(byteArrayOutputStream) }
ZipFile(SeekableInMemoryByteChannel(byteArrayOutputStream.toByteArray())).use { zip ->
zip.entries.asSequence()
.filterNot { it.isDirectory }
.forEach { entry ->
File(tmpDir, entry.name.substringAfterLast("/"))
.also { it.createNewFile() }
.outputStream().use { pageOutputStream ->
zip.getInputStream(entry).copyTo(pageOutputStream)
pageOutputStream.flush()
}
}
}
}
private val zip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
ZipFile(file, StandardCharsets.ISO_8859_1)
} else {
ZipFile(file)
}
override var isLocal: Boolean = true
override suspend fun getPages(): List<ReaderPage> {
return DirectoryPageLoader(tmpDir).getPages()
return zip.entries().asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->
ReaderPage(i).apply {
stream = { zip.getInputStream(entry) }
status = Page.State.READY
}
}
.toList()
}
override suspend fun loadPage(page: ReaderPage) {
check(!isRecycled)
}
override fun recycle() {
super.recycle()
tmpDir.deleteRecursively()
zip.close()
}
}

View file

@ -2,35 +2,70 @@ package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.compose.ui.platform.ComposeView
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView
import eu.kanade.presentation.reader.ChapterTransition
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.util.view.setComposeContent
import tachiyomi.domain.entries.manga.model.Manga
class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
FrameLayout(context, attrs) {
AbstractComposeView(context, attrs) {
private var data: Data? by mutableStateOf(null)
init {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
fun bind(transition: ChapterTransition, downloadManager: MangaDownloadManager, manga: Manga?) {
manga ?: return
data = if (manga != null) {
Data(
transition = transition,
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
goingToChapterDownloaded = transition.to?.chapter?.let { goingToChapter ->
downloadManager.isChapterDownloaded(
chapterName = goingToChapter.name,
chapterScanlator = goingToChapter.scanlator,
mangaTitle = manga.title,
sourceId = manga.source,
skipCache = true,
)
} ?: false,
)
} else {
null
}
}
removeAllViews()
val transitionView = ComposeView(context).apply {
setComposeContent {
ChapterTransition(
transition = transition,
downloadManager = downloadManager,
manga = manga,
)
@Composable
override fun Content() {
data?.let {
TachiyomiTheme {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
ChapterTransition(
transition = it.transition,
currChapterDownloaded = it.currChapterDownloaded,
goingToChapterDownloaded = it.goingToChapterDownloaded,
)
}
}
}
addView(transitionView)
}
private data class Data(
val transition: ChapterTransition,
val currChapterDownloaded: Boolean,
val goingToChapterDownloaded: Boolean,
)
}

View file

@ -8,8 +8,8 @@ import eu.kanade.tachiyomi.animesource.model.Video
@Suppress("OverridingDeprecatedMember")
class StubAnimeSource(
override val id: Long,
override val name: String,
override val lang: String,
override val name: String,
) : AnimeSource {
val isInvalid: Boolean = name.isBlank() || lang.isBlank()

View file

@ -9,8 +9,8 @@ import rx.Observable
@Suppress("OverridingDeprecatedMember")
class StubMangaSource(
override val id: Long,
override val name: String,
override val lang: String,
override val name: String,
) : MangaSource {
val isInvalid: Boolean = name.isBlank() || lang.isBlank()

View file

@ -10,7 +10,7 @@ appcompat = "androidx.appcompat:appcompat:1.6.1"
biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4"
coordinatorlayout = "androidx.coordinatorlayout:coordinatorlayout:1.2.0"
corektx = "androidx.core:core-ktx:1.11.0-alpha03"
corektx = "androidx.core:core-ktx:1.11.0-alpha04"
splashscreen = "androidx.core:core-splashscreen:1.0.0-alpha02"
recyclerview = "androidx.recyclerview:recyclerview:1.3.0"
viewpager = "androidx.viewpager:viewpager:1.1.0-alpha01"

View file

@ -1,5 +1,5 @@
[versions]
compiler = "1.4.4"
compiler = "1.4.7"
compose-bom = "2023.03.00"
accompanist = "0.30.1"

View file

@ -1,5 +1,5 @@
[versions]
kotlin_version = "1.8.10"
kotlin_version = "1.8.21"
serialization_version = "1.5.0"
xml_serialization_version = "0.85.0"
@ -7,7 +7,7 @@ xml_serialization_version = "0.85.0"
reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin_version" }
gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin_version" }
coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version = "1.6.4" }
coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version = "1.7.1" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android" }
coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava" }

View file

@ -6,7 +6,7 @@ shizuku_version = "12.2.0"
sqlite = "2.3.1"
sqldelight = "1.5.5"
leakcanary = "2.10"
voyager = "1.0.0-rc07"
voyager = "1.0.0-rc06"
richtext = "0.16.0"
[libraries]
@ -31,7 +31,6 @@ jsoup = "org.jsoup:jsoup:1.16.1"
disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:17bec43"
compress = "org.apache.commons:commons-compress:1.23.0"
junrar = "com.github.junrar:junrar:7.5.4"
sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" }
@ -54,14 +53,14 @@ natural-comparator = "com.github.gpanther:java-nat-sort:natural-comparator-1.1"
richtext-commonmark = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtext" }
richtext-m3 = { module = "com.halilibo.compose-richtext:richtext-ui-material3", version.ref = "richtext" }
material = "com.google.android.material:material:1.8.0"
material = "com.google.android.material:material:1.9.0"
flexible-adapter-core = "com.github.arkon.FlexibleAdapter:flexible-adapter:c8013533"
flexible-adapter-ui = "com.github.arkon.FlexibleAdapter:flexible-adapter-ui:c8013533"
photoview = "com.github.chrisbanes:PhotoView:2.3.0"
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
compose-cascade = "me.saket.cascade:cascade-compose:2.0.0-rc02"
compose-materialmotion = "io.github.fornewid:material-motion-compose-core:0.12.1"
compose-materialmotion = "io.github.fornewid:material-motion-compose-core:0.12.3"
compose-simpleicons = "br.com.devsrsouza.compose.icons.android:simple-icons:1.0.0"
logcat = "com.squareup.logcat:logcat:0.1"
@ -84,11 +83,11 @@ sqldelight-android-paging = { module = "com.squareup.sqldelight:android-paging3-
sqldelight-gradle = { module = "com.squareup.sqldelight:gradle-plugin", version.ref = "sqldelight" }
junit = "org.junit.jupiter:junit-jupiter:5.9.3"
kotest-assertions = "io.kotest:kotest-assertions-core:5.6.1"
kotest-assertions = "io.kotest:kotest-assertions-core:5.6.2"
voyager-navigator = { module = "ca.gosyer:voyager-navigator", version.ref = "voyager" }
voyager-tab-navigator = { module = "ca.gosyer:voyager-tab-navigator", version.ref = "voyager" }
voyager-transitions = { module = "ca.gosyer:voyager-transitions", version.ref = "voyager" }
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" }
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
kotlinter = "org.jmailen.gradle:kotlinter-gradle:3.13.0"

View file

@ -1,300 +1,59 @@
package tachiyomi.presentation.core.components
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerSnapDistance
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMaxBy
import androidx.compose.ui.util.fastSumBy
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlin.math.abs
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Horizontal Pager with custom SnapFlingBehavior for a more natural swipe feeling
*/
@Composable
fun HorizontalPager(
count: Int,
pageCount: Int,
modifier: Modifier = Modifier,
state: PagerState = rememberPagerState(),
key: ((page: Int) -> Any)? = null,
contentPadding: PaddingValues = PaddingValues(),
contentPadding: PaddingValues = PaddingValues(0.dp),
pageSize: PageSize = PageSize.Fill,
beyondBoundsPageCount: Int = 0,
pageSpacing: Dp = 0.dp,
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
userScrollEnabled: Boolean = true,
content: @Composable BoxScope.(page: Int) -> Unit,
reverseLayout: Boolean = false,
key: ((index: Int) -> Any)? = null,
pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection(
Orientation.Horizontal,
),
pageContent: @Composable (page: Int) -> Unit,
) {
Pager(
count = count,
androidx.compose.foundation.pager.HorizontalPager(
pageCount = pageCount,
modifier = modifier,
state = state,
isVertical = false,
key = key,
contentPadding = contentPadding,
pageSize = pageSize,
beyondBoundsPageCount = beyondBoundsPageCount,
pageSpacing = pageSpacing,
verticalAlignment = verticalAlignment,
flingBehavior = PagerDefaults.flingBehavior(
state = state,
pagerSnapDistance = PagerSnapDistance.atMost(0),
),
userScrollEnabled = userScrollEnabled,
content = content,
reverseLayout = reverseLayout,
key = key,
pageNestedScrollConnection = pageNestedScrollConnection,
pageContent = pageContent,
)
}
@Composable
private fun Pager(
count: Int,
modifier: Modifier,
state: PagerState,
isVertical: Boolean,
key: ((page: Int) -> Any)?,
contentPadding: PaddingValues,
userScrollEnabled: Boolean,
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
content: @Composable BoxScope.(page: Int) -> Unit,
) {
LaunchedEffect(count) {
state.currentPage = minOf(count - 1, state.currentPage).coerceAtLeast(0)
}
LaunchedEffect(state) {
snapshotFlow { state.mostVisiblePageLayoutInfo?.index }
.distinctUntilChanged()
.collect { state.updateCurrentPageBasedOnLazyListState() }
}
if (isVertical) {
LazyColumn(
modifier = modifier,
state = state.lazyListState,
contentPadding = contentPadding,
horizontalAlignment = horizontalAlignment,
verticalArrangement = Arrangement.aligned(verticalAlignment),
userScrollEnabled = userScrollEnabled,
flingBehavior = rememberLazyListSnapFlingBehavior(lazyListState = state.lazyListState),
) {
items(
count = count,
key = key,
) { page ->
Box(
modifier = Modifier
.fillParentMaxHeight()
.wrapContentSize(),
) {
content(this, page)
}
}
}
} else {
LazyRow(
modifier = modifier,
state = state.lazyListState,
contentPadding = contentPadding,
verticalAlignment = verticalAlignment,
horizontalArrangement = Arrangement.aligned(horizontalAlignment),
userScrollEnabled = userScrollEnabled,
flingBehavior = rememberLazyListSnapFlingBehavior(lazyListState = state.lazyListState),
) {
items(
count = count,
key = key,
) { page ->
Box(
modifier = Modifier
.fillParentMaxWidth()
.wrapContentSize(),
) {
content(this, page)
}
}
}
}
}
@Composable
fun rememberPagerState(
initialPage: Int = 0,
) = rememberSaveable(saver = PagerState.Saver) {
PagerState(currentPage = initialPage)
}
@Stable
class PagerState(
currentPage: Int = 0,
) {
init { check(currentPage >= 0) { "currentPage cannot be less than zero" } }
val lazyListState = LazyListState(firstVisibleItemIndex = currentPage)
private val pageSize: Int
get() = visiblePages.firstOrNull()?.size ?: 0
private var _currentPage by mutableStateOf(currentPage)
private val layoutInfo: LazyListLayoutInfo
get() = lazyListState.layoutInfo
private val visiblePages: List<LazyListItemInfo>
get() = layoutInfo.visibleItemsInfo
var currentPage: Int
get() = _currentPage
set(value) {
if (value != _currentPage) {
_currentPage = value
}
}
val mostVisiblePageLayoutInfo: LazyListItemInfo?
get() {
val layoutInfo = lazyListState.layoutInfo
return layoutInfo.visibleItemsInfo.fastMaxBy {
val start = maxOf(it.offset, 0)
val end = minOf(
it.offset + it.size,
layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding,
)
end - start
}
}
private val closestPageToSnappedPosition: LazyListItemInfo?
get() = visiblePages.fastMaxBy {
-abs(
calculateDistanceToDesiredSnapPosition(
layoutInfo,
it,
SnapAlignmentStartToStart,
),
)
}
val currentPageOffsetFraction: Float by derivedStateOf {
val currentPagePositionOffset = closestPageToSnappedPosition?.offset ?: 0
val pageUsedSpace = pageSize.toFloat()
if (pageUsedSpace == 0f) {
// Default to 0 when there's no info about the page size yet.
0f
} else {
((-currentPagePositionOffset) / (pageUsedSpace)).coerceIn(
MinPageOffset,
MaxPageOffset,
)
}
}
fun updateCurrentPageBasedOnLazyListState() {
mostVisiblePageLayoutInfo?.let {
currentPage = it.index
}
}
suspend fun animateScrollToPage(page: Int) {
lazyListState.animateScrollToItem(index = page)
}
suspend fun scrollToPage(page: Int) {
lazyListState.scrollToItem(index = page)
updateCurrentPageBasedOnLazyListState()
}
companion object {
val Saver: Saver<PagerState, *> = listSaver(
save = { listOf(it.currentPage) },
restore = { PagerState(it[0]) },
)
}
}
private const val MinPageOffset = -0.5f
private const val MaxPageOffset = 0.5f
internal val SnapAlignmentStartToStart: (layoutSize: Float, itemSize: Float) -> Float =
{ _, _ -> 0f }
// https://android.googlesource.com/platform/frameworks/support/+/refs/changes/78/2160778/35/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/LazyListSnapLayoutInfoProvider.kt
private fun lazyListSnapLayoutInfoProvider(
lazyListState: LazyListState,
positionInLayout: (layoutSize: Float, itemSize: Float) -> Float = { layoutSize, itemSize ->
layoutSize / 2f - itemSize / 2f
},
) = object : SnapLayoutInfoProvider {
private val layoutInfo: LazyListLayoutInfo
get() = lazyListState.layoutInfo
// Single page snapping is the default
override fun Density.calculateApproachOffset(initialVelocity: Float): Float = 0f
override fun Density.calculateSnappingOffsetBounds(): ClosedFloatingPointRange<Float> {
var lowerBoundOffset = Float.NEGATIVE_INFINITY
var upperBoundOffset = Float.POSITIVE_INFINITY
layoutInfo.visibleItemsInfo.fastForEach { item ->
val offset =
calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout)
// Find item that is closest to the center
if (offset <= 0 && offset > lowerBoundOffset) {
lowerBoundOffset = offset
}
// Find item that is closest to center, but after it
if (offset >= 0 && offset < upperBoundOffset) {
upperBoundOffset = offset
}
}
return lowerBoundOffset.rangeTo(upperBoundOffset)
}
override fun Density.calculateSnapStepSize(): Float = with(layoutInfo) {
if (visibleItemsInfo.isNotEmpty()) {
visibleItemsInfo.fastSumBy { it.size } / visibleItemsInfo.size.toFloat()
} else {
0f
}
}
}
@Composable
private fun rememberLazyListSnapFlingBehavior(lazyListState: LazyListState): FlingBehavior {
val snappingLayout = remember(lazyListState) { lazyListSnapLayoutInfoProvider(lazyListState) }
return rememberSnapFlingBehavior(snappingLayout)
}
private fun calculateDistanceToDesiredSnapPosition(
layoutInfo: LazyListLayoutInfo,
item: LazyListItemInfo,
positionInLayout: (layoutSize: Float, itemSize: Float) -> Float,
): Float {
val containerSize =
with(layoutInfo) { singleAxisViewportSize - beforeContentPadding - afterContentPadding }
val desiredDistance =
positionInLayout(containerSize.toFloat(), item.size.toFloat())
val itemCurrentPosition = item.offset
return itemCurrentPosition - desiredDistance
}
private val LazyListLayoutInfo.singleAxisViewportSize: Int
get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width