From 18ccde082d5529766ad1297f9850752508805156 Mon Sep 17 00:00:00 2001 From: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> Date: Thu, 10 Nov 2022 10:31:56 +0700 Subject: [PATCH] Full Compose MangaController (#8452) * Full Compose MangaController * unique key * Use StateScreenModel * dismiss * rebase fix * toShareIntent --- app/build.gradle.kts | 5 + .../presentation/components/AdaptiveSheet.kt | 289 ++++++++ .../presentation/components/AlertDialog.kt | 93 +++ .../kanade/presentation/components/Divider.kt | 35 +- .../presentation/manga/MangaSettingsDialog.kt | 495 +++++++++++++ .../presentation/manga/TrackInfoDialogHome.kt | 335 +++++++++ .../manga/TrackInfoDialogSelector.kt | 235 +++++++ .../presentation/manga/TrackServiceSearch.kt | 315 +++++++++ .../manga/components/MangaCoverDialog.kt | 230 +++--- .../eu/kanade/presentation/util/Navigator.kt | 4 + .../eu/kanade/presentation/util/WindowSize.kt | 12 + .../data/database/models/TrackImpl.kt | 20 - .../tachiyomi/data/track/TrackService.kt | 98 +++ .../tachiyomi/ui/manga/MangaController.kt | 458 +----------- .../ui/manga/MangaCoverScreenModel.kt | 164 +++++ .../kanade/tachiyomi/ui/manga/MangaScreen.kt | 329 +++++++++ ...{MangaPresenter.kt => MangaScreenModel.kt} | 572 ++++++++------- .../ui/manga/chapter/ChaptersSettingsSheet.kt | 298 -------- .../manga/chapter/SetChapterSettingsDialog.kt | 61 -- .../ui/manga/info/MangaFullCoverDialog.kt | 240 ------- .../ui/manga/track/SetTrackChaptersDialog.kt | 71 -- .../ui/manga/track/SetTrackScoreDialog.kt | 71 -- .../ui/manga/track/SetTrackStatusDialog.kt | 60 -- .../tachiyomi/ui/manga/track/TrackAdapter.kt | 52 -- .../tachiyomi/ui/manga/track/TrackHolder.kt | 139 ---- .../ui/manga/track/TrackInfoDialog.kt | 652 ++++++++++++++++++ .../ui/manga/track/TrackSearchAdapter.kt | 55 -- .../ui/manga/track/TrackSearchDialog.kt | 194 ------ .../ui/manga/track/TrackSearchHolder.kt | 63 -- .../tachiyomi/ui/manga/track/TrackSheet.kt | 228 ------ .../widget/TachiyomiFullscreenDialog.kt | 13 - .../main/res/layout/track_chapters_dialog.xml | 16 - app/src/main/res/layout/track_controller.xml | 9 - app/src/main/res/layout/track_item.xml | 203 ------ .../main/res/layout/track_score_dialog.xml | 16 - .../main/res/layout/track_search_dialog.xml | 104 --- app/src/main/res/layout/track_search_item.xml | 150 ---- app/src/main/res/values/styles.xml | 5 - gradle/libs.versions.toml | 5 +- i18n/src/main/res/values/strings.xml | 3 + 40 files changed, 3470 insertions(+), 2927 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt create mode 100644 app/src/main/java/eu/kanade/presentation/components/AlertDialog.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/MangaSettingsDialog.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogHome.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogSelector.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/TrackServiceSearch.kt create mode 100644 app/src/main/java/eu/kanade/presentation/util/WindowSize.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaCoverScreenModel.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/manga/{MangaPresenter.kt => MangaScreenModel.kt} (70%) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetChapterSettingsDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaFullCoverDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiFullscreenDialog.kt delete mode 100644 app/src/main/res/layout/track_chapters_dialog.xml delete mode 100644 app/src/main/res/layout/track_controller.xml delete mode 100644 app/src/main/res/layout/track_item.xml delete mode 100644 app/src/main/res/layout/track_score_dialog.xml delete mode 100644 app/src/main/res/layout/track_search_dialog.xml delete mode 100644 app/src/main/res/layout/track_search_item.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc72834064..7d93f298b3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -144,6 +144,8 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 + + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -163,6 +165,8 @@ dependencies { implementation(project(":core")) implementation(project(":source-api")) + coreLibraryDesugaring(libs.desugar) + // Compose implementation(platform(compose.bom)) implementation(compose.activity) @@ -267,6 +271,7 @@ dependencies { implementation(libs.cascade) implementation(libs.numberpicker) implementation(libs.bundles.voyager) + implementation(libs.wheelpicker) // Conductor implementation(libs.bundles.conductor) diff --git a/app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt b/app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt new file mode 100644 index 0000000000..40455667c7 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt @@ -0,0 +1,289 @@ +package eu.kanade.presentation.components + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.ZeroCornerSize +import androidx.compose.material.SwipeableState +import androidx.compose.material.rememberSwipeableState +import androidx.compose.material.swipeable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.util.isTabletUi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds + +private const val SheetAnimationDuration = 500 +private val SheetAnimationSpec = tween(durationMillis = SheetAnimationDuration) +private const val ScrimAnimationDuration = 350 +private val ScrimAnimationSpec = tween(durationMillis = ScrimAnimationDuration) + +/** + * Sheet with adaptive position aligned to bottom on small screen, otherwise aligned to center + * and will not be able to dismissed with swipe gesture. + * + * Max width of the content is set to 460 dp. + */ +@Composable +fun AdaptiveSheet( + tonalElevation: Dp = 1.dp, + enableSwipeDismiss: Boolean = true, + onDismissRequest: () -> Unit, + content: @Composable (PaddingValues) -> Unit, +) { + val isTabletUi = isTabletUi() + AdaptiveSheetImpl( + isTabletUi = isTabletUi, + tonalElevation = tonalElevation, + enableSwipeDismiss = enableSwipeDismiss, + onDismissRequest = onDismissRequest, + ) { + val contentPadding = if (isTabletUi) { + PaddingValues() + } else { + WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + } + content(contentPadding) + } +} + +@Composable +fun AdaptiveSheetImpl( + isTabletUi: Boolean, + tonalElevation: Dp, + enableSwipeDismiss: Boolean, + onDismissRequest: () -> Unit, + content: @Composable () -> Unit, +) { + val scope = rememberCoroutineScope() + if (isTabletUi) { + var targetAlpha by remember { mutableStateOf(0f) } + val alpha by animateFloatAsState( + targetValue = targetAlpha, + animationSpec = ScrimAnimationSpec, + ) + val internalOnDismissRequest: () -> Unit = { + scope.launch { + targetAlpha = 0f + delay(ScrimAnimationSpec.durationMillis.milliseconds) + onDismissRequest() + } + } + BoxWithConstraints( + modifier = Modifier + .clickable( + enabled = true, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = internalOnDismissRequest, + ) + .fillMaxSize() + .alpha(alpha), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)), + ) + Surface( + modifier = Modifier + .requiredWidthIn(max = 460.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ) + .systemBarsPadding() + .padding(vertical = 16.dp), + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = tonalElevation, + content = { + BackHandler(onBack = internalOnDismissRequest) + content() + }, + ) + + LaunchedEffect(Unit) { + targetAlpha = 1f + } + } + } else { + val swipeState = rememberSwipeableState( + initialValue = 1, + animationSpec = SheetAnimationSpec, + ) + val internalOnDismissRequest: () -> Unit = { if (swipeState.currentValue == 0) scope.launch { swipeState.animateTo(1) } } + BoxWithConstraints( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = internalOnDismissRequest, + ) + .fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + val fullHeight = constraints.maxHeight.toFloat() + val anchors = mapOf(0f to 0, fullHeight to 1) + val scrimAlpha by animateFloatAsState( + targetValue = if (swipeState.targetValue == 1) 0f else 1f, + animationSpec = ScrimAnimationSpec, + ) + Box( + modifier = Modifier + .matchParentSize() + .alpha(scrimAlpha) + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)), + ) + Surface( + modifier = Modifier + .widthIn(max = 460.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ) + .nestedScroll( + remember(enableSwipeDismiss, anchors) { + swipeState.preUpPostDownNestedScrollConnection( + enabled = enableSwipeDismiss, + anchor = anchors, + ) + }, + ) + .offset { + IntOffset( + 0, + swipeState.offset.value.roundToInt(), + ) + } + .swipeable( + enabled = enableSwipeDismiss, + state = swipeState, + anchors = anchors, + orientation = Orientation.Vertical, + resistance = null, + ) + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + ), + shape = MaterialTheme.shapes.extraLarge.copy(bottomStart = ZeroCornerSize, bottomEnd = ZeroCornerSize), + tonalElevation = tonalElevation, + content = { + BackHandler(onBack = internalOnDismissRequest) + content() + }, + ) + + LaunchedEffect(swipeState) { + scope.launch { swipeState.animateTo(0) } + snapshotFlow { swipeState.currentValue } + .drop(1) + .filter { it == 1 } + .collectLatest { + delay(ScrimAnimationSpec.durationMillis.milliseconds) + onDismissRequest() + } + } + } + } +} + +/** + * Yoinked from Swipeable.kt with modifications to disable + */ +private fun SwipeableState.preUpPostDownNestedScrollConnection( + enabled: Boolean = true, + anchor: Map, +) = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (enabled && delta < 0 && source == NestedScrollSource.Drag) { + performDrag(delta).toOffset() + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + return if (enabled && source == NestedScrollSource.Drag) { + performDrag(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = Offset(available.x, available.y).toFloat() + return if (enabled && toFling < 0 && offset.value > anchor.keys.minOrNull()!!) { + performFling(velocity = toFling) + // since we go to the anchor with tween settling, consume all for the best UX + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + return if (enabled) { + performFling(velocity = Offset(available.x, available.y).toFloat()) + available + } else { + Velocity.Zero + } + } + + private fun Float.toOffset(): Offset = Offset(0f, this) + + private fun Offset.toFloat(): Float = this.y +} diff --git a/app/src/main/java/eu/kanade/presentation/components/AlertDialog.kt b/app/src/main/java/eu/kanade/presentation/components/AlertDialog.kt new file mode 100644 index 0000000000..1a67519705 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/AlertDialog.kt @@ -0,0 +1,93 @@ +package eu.kanade.presentation.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AlertDialogContent( + buttons: @Composable () -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + title: (@Composable () -> Unit)? = null, + text: @Composable (() -> Unit)? = null, +) { + Column( + modifier = modifier + .sizeIn(minWidth = MinWidth, maxWidth = MaxWidth) + .padding(DialogPadding), + ) { + icon?.let { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.secondary) { + Box( + Modifier + .padding(IconPadding) + .align(Alignment.CenterHorizontally), + ) { + icon() + } + } + } + title?.let { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + val textStyle = MaterialTheme.typography.headlineSmall + ProvideTextStyle(textStyle) { + Box( + // Align the title to the center when an icon is present. + Modifier + .padding(TitlePadding) + .align( + if (icon == null) { + Alignment.Start + } else { + Alignment.CenterHorizontally + }, + ), + ) { + title() + } + } + } + } + text?.let { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) { + val textStyle = MaterialTheme.typography.bodyMedium + ProvideTextStyle(textStyle) { + Box( + Modifier + .weight(weight = 1f, fill = false) + .padding(TextPadding) + .align(Alignment.Start), + ) { + text() + } + } + } + } + Box(modifier = Modifier.align(Alignment.End)) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) { + val textStyle = MaterialTheme.typography.labelLarge + ProvideTextStyle(value = textStyle, content = buttons) + } + } + } +} + +// Paddings for each of the dialog's parts. +private val DialogPadding = PaddingValues(all = 24.dp) +private val IconPadding = PaddingValues(bottom = 16.dp) +private val TitlePadding = PaddingValues(bottom = 16.dp) +private val TextPadding = PaddingValues(bottom = 24.dp) + +private val MinWidth = 280.dp +private val MaxWidth = 560.dp diff --git a/app/src/main/java/eu/kanade/presentation/components/Divider.kt b/app/src/main/java/eu/kanade/presentation/components/Divider.kt index d59f8e83f2..2082e73cbb 100644 --- a/app/src/main/java/eu/kanade/presentation/components/Divider.kt +++ b/app/src/main/java/eu/kanade/presentation/components/Divider.kt @@ -1,17 +1,44 @@ package eu.kanade.presentation.components -import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.DividerDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp const val DIVIDER_ALPHA = 0.2f @Composable fun Divider( modifier: Modifier = Modifier, + color: Color = DividerDefaults.color, ) { - androidx.compose.material3.Divider( - modifier = modifier, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = DIVIDER_ALPHA), + Box( + modifier + .fillMaxWidth() + .height(1.dp) + .background(color = color) + .alpha(DIVIDER_ALPHA), + ) +} + +@Composable +fun VerticalDivider( + modifier: Modifier = Modifier, + color: Color = DividerDefaults.color, +) { + Box( + modifier + .fillMaxHeight() + .width(1.dp) + .background(color = color) + .alpha(DIVIDER_ALPHA), ) } diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaSettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaSettingsDialog.kt new file mode 100644 index 0000000000..8f47dd790a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaSettingsDialog.kt @@ -0,0 +1,495 @@ +package eu.kanade.presentation.manga + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.rounded.CheckBox +import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank +import androidx.compose.material.icons.rounded.DisabledByDefault +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.model.TriStateFilter +import eu.kanade.presentation.components.AdaptiveSheet +import eu.kanade.presentation.components.Divider +import eu.kanade.presentation.components.DropdownMenu +import eu.kanade.presentation.components.HorizontalPager +import eu.kanade.presentation.components.TabIndicator +import eu.kanade.presentation.components.rememberPagerState +import eu.kanade.presentation.theme.TachiyomiTheme +import eu.kanade.tachiyomi.R +import kotlinx.coroutines.launch + +@Composable +fun ChapterSettingsDialog( + onDismissRequest: () -> Unit, + manga: Manga? = null, + onDownloadFilterChanged: (TriStateFilter) -> Unit, + onUnreadFilterChanged: (TriStateFilter) -> Unit, + onBookmarkedFilterChanged: (TriStateFilter) -> Unit, + onSortModeChanged: (Long) -> Unit, + onDisplayModeChanged: (Long) -> Unit, + onSetAsDefault: (applyToExistingManga: Boolean) -> Unit, +) { + AdaptiveSheet( + onDismissRequest = onDismissRequest, + ) { contentPadding -> + ChapterSettingsDialogImpl( + manga = manga, + contentPadding = contentPadding, + onDownloadFilterChanged = onDownloadFilterChanged, + onUnreadFilterChanged = onUnreadFilterChanged, + onBookmarkedFilterChanged = onBookmarkedFilterChanged, + onSortModeChanged = onSortModeChanged, + onDisplayModeChanged = onDisplayModeChanged, + onSetAsDefault = onSetAsDefault, + ) + } +} + +@Composable +private fun ChapterSettingsDialogImpl( + manga: Manga? = null, + contentPadding: PaddingValues = PaddingValues(), + onDownloadFilterChanged: (TriStateFilter) -> Unit, + onUnreadFilterChanged: (TriStateFilter) -> Unit, + onBookmarkedFilterChanged: (TriStateFilter) -> Unit, + onSortModeChanged: (Long) -> Unit, + onDisplayModeChanged: (Long) -> Unit, + onSetAsDefault: (applyToExistingManga: Boolean) -> Unit, +) { + val scope = rememberCoroutineScope() + val tabTitles = listOf( + stringResource(R.string.action_filter), + stringResource(R.string.action_sort), + stringResource(R.string.action_display), + ) + val pagerState = rememberPagerState() + + var showSetAsDefaultDialog by rememberSaveable { mutableStateOf(false) } + if (showSetAsDefaultDialog) { + SetAsDefaultDialog( + onDismissRequest = { showSetAsDefaultDialog = false }, + onConfirmed = onSetAsDefault, + ) + } + + Column { + Row { + TabRow( + modifier = Modifier.weight(1f), + selectedTabIndex = pagerState.currentPage, + indicator = { TabIndicator(it[pagerState.currentPage]) }, + divider = {}, + ) { + tabTitles.fastForEachIndexed { i, s -> + val selected = pagerState.currentPage == i + Tab( + selected = selected, + onClick = { scope.launch { pagerState.animateScrollToPage(i) } }, + text = { + Text( + text = s, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + }, + ) + } + } + + MoreMenu(onSetAsDefault = { showSetAsDefaultDialog = true }) + } + + Divider() + + val density = LocalDensity.current + var largestHeight by rememberSaveable { mutableStateOf(0f) } + HorizontalPager( + modifier = Modifier.heightIn(min = largestHeight.dp), + count = tabTitles.size, + state = pagerState, + verticalAlignment = Alignment.Top, + ) { page -> + Box( + modifier = Modifier.onSizeChanged { + with(density) { + val heightDp = it.height.toDp() + if (heightDp.value > largestHeight) { + largestHeight = heightDp.value + } + } + }, + ) { + when (page) { + 0 -> { + val forceDownloaded = manga?.forceDownloaded() == true + FilterPage( + contentPadding = contentPadding, + downloadFilter = if (forceDownloaded) { + TriStateFilter.ENABLED_NOT + } else { + manga?.downloadedFilter + } ?: TriStateFilter.DISABLED, + onDownloadFilterChanged = onDownloadFilterChanged.takeUnless { forceDownloaded }, + unreadFilter = manga?.unreadFilter ?: TriStateFilter.DISABLED, + onUnreadFilterChanged = onUnreadFilterChanged, + bookmarkedFilter = manga?.bookmarkedFilter ?: TriStateFilter.DISABLED, + onBookmarkedFilterChanged = onBookmarkedFilterChanged, + ) + } + 1 -> SortPage( + contentPadding = contentPadding, + sortingMode = manga?.sorting ?: 0, + sortDescending = manga?.sortDescending() ?: false, + onItemSelected = onSortModeChanged, + ) + 2 -> DisplayPage( + contentPadding = contentPadding, + displayMode = manga?.displayMode ?: 0, + onItemSelected = onDisplayModeChanged, + ) + } + } + } + } +} + +@Composable +private fun SetAsDefaultDialog( + onDismissRequest: () -> Unit, + onConfirmed: (optionalChecked: Boolean) -> Unit, +) { + var optionalChecked by rememberSaveable { mutableStateOf(false) } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(id = R.string.chapter_settings)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(text = stringResource(id = R.string.confirm_set_chapter_settings)) + + Row( + modifier = Modifier + .clickable { optionalChecked = !optionalChecked } + .padding(vertical = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = optionalChecked, + onCheckedChange = null, + ) + Text(text = stringResource(id = R.string.also_set_chapter_settings_for_library)) + } + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(id = android.R.string.cancel)) + } + }, + confirmButton = { + TextButton( + onClick = { + onConfirmed(optionalChecked) + }, + ) { + Text(text = stringResource(id = android.R.string.ok)) + } + }, + ) +} + +@Composable +private fun MoreMenu( + onSetAsDefault: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.wrapContentSize(Alignment.TopStart)) { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(id = R.string.label_more), + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.set_chapter_settings_as_default)) }, + onClick = { + onSetAsDefault() + expanded = false + }, + ) + } + } +} + +@Composable +private fun FilterPage( + contentPadding: PaddingValues, + downloadFilter: TriStateFilter, + onDownloadFilterChanged: ((TriStateFilter) -> Unit)?, + unreadFilter: TriStateFilter, + onUnreadFilterChanged: (TriStateFilter) -> Unit, + bookmarkedFilter: TriStateFilter, + onBookmarkedFilterChanged: (TriStateFilter) -> Unit, +) { + Column( + modifier = Modifier + .padding(vertical = VerticalPadding) + .padding(contentPadding) + .verticalScroll(rememberScrollState()), + ) { + FilterPageItem( + label = stringResource(id = R.string.action_filter_downloaded), + state = downloadFilter, + onClick = onDownloadFilterChanged, + ) + FilterPageItem( + label = stringResource(id = R.string.action_filter_unread), + state = unreadFilter, + onClick = onUnreadFilterChanged, + ) + FilterPageItem( + label = stringResource(id = R.string.action_filter_bookmarked), + state = bookmarkedFilter, + onClick = onBookmarkedFilterChanged, + ) + } +} + +@Composable +private fun FilterPageItem( + label: String, + state: TriStateFilter, + onClick: ((TriStateFilter) -> Unit)?, +) { + Row( + modifier = Modifier + .clickable( + enabled = onClick != null, + onClick = { + when (state) { + TriStateFilter.DISABLED -> onClick?.invoke(TriStateFilter.ENABLED_IS) + TriStateFilter.ENABLED_IS -> onClick?.invoke(TriStateFilter.ENABLED_NOT) + TriStateFilter.ENABLED_NOT -> onClick?.invoke(TriStateFilter.DISABLED) + } + }, + ) + .fillMaxWidth() + .padding(horizontal = HorizontalPadding, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + Icon( + imageVector = when (state) { + TriStateFilter.DISABLED -> Icons.Rounded.CheckBoxOutlineBlank + TriStateFilter.ENABLED_IS -> Icons.Rounded.CheckBox + TriStateFilter.ENABLED_NOT -> Icons.Rounded.DisabledByDefault + }, + contentDescription = null, + tint = if (state == TriStateFilter.DISABLED) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.primary + }, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun SortPage( + contentPadding: PaddingValues, + sortingMode: Long, + sortDescending: Boolean, + onItemSelected: (Long) -> Unit, +) { + Column( + modifier = Modifier + .padding(contentPadding) + .padding(vertical = VerticalPadding) + .verticalScroll(rememberScrollState()), + ) { + val arrowIcon = if (sortDescending) { + Icons.Default.ArrowDownward + } else { + Icons.Default.ArrowUpward + } + + SortPageItem( + label = stringResource(id = R.string.sort_by_source), + statusIcon = arrowIcon.takeIf { sortingMode == Manga.CHAPTER_SORTING_SOURCE }, + onClick = { onItemSelected(Manga.CHAPTER_SORTING_SOURCE) }, + ) + SortPageItem( + label = stringResource(id = R.string.sort_by_number), + statusIcon = arrowIcon.takeIf { sortingMode == Manga.CHAPTER_SORTING_NUMBER }, + onClick = { onItemSelected(Manga.CHAPTER_SORTING_NUMBER) }, + ) + SortPageItem( + label = stringResource(id = R.string.sort_by_upload_date), + statusIcon = arrowIcon.takeIf { sortingMode == Manga.CHAPTER_SORTING_UPLOAD_DATE }, + onClick = { onItemSelected(Manga.CHAPTER_SORTING_UPLOAD_DATE) }, + ) + } +} + +@Composable +private fun SortPageItem( + label: String, + statusIcon: ImageVector?, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .fillMaxWidth() + .padding(horizontal = HorizontalPadding, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + if (statusIcon != null) { + Icon( + imageVector = statusIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun DisplayPage( + contentPadding: PaddingValues, + displayMode: Long, + onItemSelected: (Long) -> Unit, +) { + Column( + modifier = Modifier + .padding(contentPadding) + .padding(vertical = VerticalPadding) + .verticalScroll(rememberScrollState()), + ) { + DisplayPageItem( + label = stringResource(id = R.string.show_title), + selected = displayMode == Manga.CHAPTER_DISPLAY_NAME, + onClick = { onItemSelected(Manga.CHAPTER_DISPLAY_NAME) }, + ) + DisplayPageItem( + label = stringResource(id = R.string.show_chapter_number), + selected = displayMode == Manga.CHAPTER_DISPLAY_NUMBER, + onClick = { onItemSelected(Manga.CHAPTER_DISPLAY_NUMBER) }, + ) + } +} + +@Composable +private fun DisplayPageItem( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .fillMaxWidth() + .padding(horizontal = HorizontalPadding, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + RadioButton( + selected = selected, + onClick = null, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +private val HorizontalPadding = 24.dp +private val VerticalPadding = 8.dp + +@Preview( + name = "Light", +) +@Preview( + name = "Dark", + uiMode = UI_MODE_NIGHT_YES, +) +@Composable +private fun ChapterSettingsDialogPreview() { + TachiyomiTheme { + Surface { + ChapterSettingsDialogImpl( + onDownloadFilterChanged = {}, + onUnreadFilterChanged = {}, + onBookmarkedFilterChanged = {}, + onSortModeChanged = {}, + onDisplayModeChanged = {}, + ) {} + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogHome.kt b/app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogHome.kt new file mode 100644 index 0000000000..62eda377c0 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogHome.kt @@ -0,0 +1,335 @@ +package eu.kanade.presentation.manga + +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.components.Divider +import eu.kanade.presentation.components.DropdownMenu +import eu.kanade.presentation.components.VerticalDivider +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.manga.track.TrackItem +import java.text.DateFormat + +private const val UnsetStatusTextAlpha = 0.5F + +@Composable +fun TrackInfoDialogHome( + trackItems: List, + dateFormat: DateFormat, + contentPadding: PaddingValues = PaddingValues(), + onStatusClick: (TrackItem) -> Unit, + onChapterClick: (TrackItem) -> Unit, + onScoreClick: (TrackItem) -> Unit, + onStartDateEdit: (TrackItem) -> Unit, + onEndDateEdit: (TrackItem) -> Unit, + onNewSearch: (TrackItem) -> Unit, + onOpenInBrowser: (TrackItem) -> Unit, + onRemoved: (TrackItem) -> Unit, +) { + Column( + modifier = Modifier + .animateContentSize() + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + trackItems.forEach { item -> + if (item.track != null) { + val supportsScoring = item.service.getScoreList().isNotEmpty() + val supportsReadingDates = item.service.supportsReadingDates + TrackInfoItem( + title = item.track.title, + logoRes = item.service.getLogo(), + logoColor = item.service.getLogoColor(), + status = item.service.getStatus(item.track.status), + onStatusClick = { onStatusClick(item) }, + chapters = "${item.track.last_chapter_read.toInt()}".let { + val totalChapters = item.track.total_chapters + if (totalChapters > 0) { + // Add known total chapter count + "$it / $totalChapters" + } else { + it + } + }, + onChaptersClick = { onChapterClick(item) }, + score = item.service.displayScore(item.track) + .takeIf { supportsScoring && item.track.score != 0F }, + onScoreClick = { onScoreClick(item) } + .takeIf { supportsScoring }, + startDate = remember(item.track.started_reading_date) { dateFormat.format(item.track.started_reading_date) } + .takeIf { supportsReadingDates && item.track.started_reading_date != 0L }, + onStartDateClick = { onStartDateEdit(item) } // TODO + .takeIf { supportsReadingDates }, + endDate = dateFormat.format(item.track.finished_reading_date) + .takeIf { supportsReadingDates && item.track.finished_reading_date != 0L }, + onEndDateClick = { onEndDateEdit(item) } + .takeIf { supportsReadingDates }, + onNewSearch = { onNewSearch(item) }, + onOpenInBrowser = { onOpenInBrowser(item) }, + onRemoved = { onRemoved(item) }, + ) + } else { + TrackInfoItemEmpty( + logoRes = item.service.getLogo(), + logoColor = item.service.getLogoColor(), + onNewSearch = { onNewSearch(item) }, + ) + } + } + } +} + +@Composable +private fun TrackInfoItem( + title: String, + @DrawableRes logoRes: Int, + @ColorInt logoColor: Int, + status: String, + onStatusClick: () -> Unit, + chapters: String, + onChaptersClick: () -> Unit, + score: String?, + onScoreClick: (() -> Unit)?, + startDate: String?, + onStartDateClick: (() -> Unit)?, + endDate: String?, + onEndDateClick: (() -> Unit)?, + onNewSearch: () -> Unit, + onOpenInBrowser: () -> Unit, + onRemoved: () -> Unit, +) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onOpenInBrowser) + .size(48.dp) + .background(color = Color(logoColor)) + .padding(4.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = logoRes), + contentDescription = null, + ) + } + Box( + modifier = Modifier + .height(48.dp) + .weight(1f) + .clickable(onClick = onNewSearch) + .padding(start = 16.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + } + VerticalDivider() + TrackInfoItemMenu( + onOpenInBrowser = onOpenInBrowser, + onRemoved = onRemoved, + ) + } + + Box( + modifier = Modifier + .padding(top = 12.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(8.dp) + .clip(RoundedCornerShape(6.dp)), + ) { + Column { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + TrackDetailsItem( + modifier = Modifier.weight(1f), + text = status, + onClick = onStatusClick, + ) + VerticalDivider() + TrackDetailsItem( + modifier = Modifier.weight(1f), + text = chapters, + onClick = onChaptersClick, + ) + if (onScoreClick != null) { + VerticalDivider() + TrackDetailsItem( + modifier = Modifier + .weight(1f) + .alpha(if (score == null) UnsetStatusTextAlpha else 1f), + text = score ?: stringResource(id = R.string.score), + onClick = onScoreClick, + ) + } + } + + if (onStartDateClick != null && onEndDateClick != null) { + Divider() + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + TrackDetailsItem( + modifier = Modifier + .weight(1F) + .alpha(if (startDate == null) UnsetStatusTextAlpha else 1f), + text = startDate ?: stringResource(id = R.string.track_started_reading_date), + onClick = onStartDateClick, + ) + VerticalDivider() + TrackDetailsItem( + modifier = Modifier + .weight(1F) + .alpha(if (endDate == null) UnsetStatusTextAlpha else 1f), + text = endDate ?: stringResource(id = R.string.track_finished_reading_date), + onClick = onEndDateClick, + ) + } + } + } + } + } +} + +@Composable +private fun TrackDetailsItem( + modifier: Modifier = Modifier, + text: String, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .clickable(onClick = onClick) + .padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + maxLines = 1, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun TrackInfoItemEmpty( + @DrawableRes logoRes: Int, + @ColorInt logoColor: Int, + onNewSearch: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .size(48.dp) + .background(color = Color(logoColor)) + .padding(4.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = logoRes), + contentDescription = null, + ) + } + TextButton( + onClick = onNewSearch, + modifier = Modifier + .padding(start = 16.dp) + .weight(1f), + ) { + Text(text = stringResource(id = R.string.add_tracking)) + } + } +} + +@Composable +private fun TrackInfoItemMenu( + onOpenInBrowser: () -> Unit, + onRemoved: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.wrapContentSize(Alignment.TopStart)) { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(id = R.string.label_more), + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_open_in_browser)) }, + leadingIcon = { + Icon(imageVector = Icons.Default.OpenInBrowser, contentDescription = null) + }, + onClick = { + onOpenInBrowser() + expanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_remove)) }, + leadingIcon = { + Icon(imageVector = Icons.Default.Delete, contentDescription = null) + }, + onClick = { + onRemoved() + expanded = false + }, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogSelector.kt b/app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogSelector.kt new file mode 100644 index 0000000000..2e7ccea508 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogSelector.kt @@ -0,0 +1,235 @@ +package eu.kanade.presentation.manga + +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.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.commandiron.wheel_picker_compose.WheelDatePicker +import com.commandiron.wheel_picker_compose.WheelTextPicker +import eu.kanade.presentation.components.AlertDialogContent +import eu.kanade.presentation.components.Divider +import eu.kanade.presentation.components.ScrollbarLazyColumn +import eu.kanade.presentation.util.isScrolledToEnd +import eu.kanade.presentation.util.isScrolledToStart +import eu.kanade.presentation.util.minimumTouchTargetSize +import eu.kanade.tachiyomi.R +import java.time.LocalDate +import java.time.format.TextStyle + +@Composable +fun TrackStatusSelector( + contentPadding: PaddingValues, + selection: Int, + onSelectionChange: (Int) -> Unit, + selections: Map, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + BaseSelector( + contentPadding = contentPadding, + title = stringResource(id = R.string.status), + content = { + val state = rememberLazyListState() + ScrollbarLazyColumn(state = state) { + selections.forEach { (key, value) -> + val isSelected = selection == key + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .selectable( + selected = isSelected, + onClick = { onSelectionChange(key) }, + ) + .fillMaxWidth() + .minimumTouchTargetSize(), + ) { + RadioButton( + selected = isSelected, + onClick = null, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge.merge(), + modifier = Modifier.padding(start = 24.dp), + ) + } + } + } + } + if (!state.isScrolledToStart()) Divider(modifier = Modifier.align(Alignment.TopCenter)) + if (!state.isScrolledToEnd()) Divider(modifier = Modifier.align(Alignment.BottomCenter)) + }, + onConfirm = onConfirm, + onDismissRequest = onDismissRequest, + ) +} + +@Composable +fun TrackChapterSelector( + contentPadding: PaddingValues, + selection: Int, + onSelectionChange: (Int) -> Unit, + range: Iterable, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + BaseSelector( + contentPadding = contentPadding, + title = stringResource(id = R.string.chapters), + content = { + WheelTextPicker( + modifier = Modifier.align(Alignment.Center), + texts = range.map { "$it" }, + onScrollFinished = { + onSelectionChange(it) + null + }, + startIndex = selection, + ) + }, + onConfirm = onConfirm, + onDismissRequest = onDismissRequest, + ) +} + +@Composable +fun TrackScoreSelector( + contentPadding: PaddingValues, + selection: String, + onSelectionChange: (String) -> Unit, + selections: List, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + BaseSelector( + contentPadding = contentPadding, + title = stringResource(id = R.string.score), + content = { + WheelTextPicker( + modifier = Modifier.align(Alignment.Center), + texts = selections, + onScrollFinished = { + onSelectionChange(selections[it]) + null + }, + startIndex = selections.indexOf(selection).coerceAtLeast(0), + ) + }, + onConfirm = onConfirm, + onDismissRequest = onDismissRequest, + ) +} + +@Composable +fun TrackDateSelector( + contentPadding: PaddingValues, + title: String, + selection: LocalDate, + onSelectionChange: (LocalDate) -> Unit, + onConfirm: () -> Unit, + onRemove: (() -> Unit)?, + onDismissRequest: () -> Unit, +) { + BaseSelector( + contentPadding = contentPadding, + title = title, + content = { + Row( + modifier = Modifier.align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically, + ) { + var internalSelection by remember { mutableStateOf(selection) } + Text( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + text = internalSelection.dayOfWeek + .getDisplayName(TextStyle.SHORT, java.util.Locale.getDefault()), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium, + ) + WheelDatePicker( + startDate = selection, + onScrollFinished = { + internalSelection = it + onSelectionChange(it) + }, + ) + } + }, + thirdButton = if (onRemove != null) { + { + TextButton(onClick = onRemove) { + Text(text = stringResource(id = R.string.action_remove)) + } + } + } else { + null + }, + onConfirm = onConfirm, + onDismissRequest = onDismissRequest, + ) +} + +@Composable +private fun BaseSelector( + contentPadding: PaddingValues = PaddingValues(), + title: String, + content: @Composable BoxScope.() -> Unit, + thirdButton: @Composable (RowScope.() -> Unit)? = null, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + AlertDialogContent( + modifier = Modifier.padding(contentPadding), + title = { Text(text = title) }, + text = { + Box( + modifier = Modifier.fillMaxWidth(), + content = content, + ) + }, + buttons = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + if (thirdButton != null) { + thirdButton() + Spacer(modifier = Modifier.weight(1f)) + } + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(id = android.R.string.cancel)) + } + TextButton(onClick = onConfirm) { + Text(text = stringResource(id = android.R.string.ok)) + } + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/TrackServiceSearch.kt b/app/src/main/java/eu/kanade/presentation/manga/TrackServiceSearch.kt new file mode 100644 index 0000000000..5c60785cd2 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/TrackServiceSearch.kt @@ -0,0 +1,315 @@ +package eu.kanade.presentation.manga + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.paddingFromBaseline +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.toLowerCase +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.components.Divider +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.MangaCover +import eu.kanade.presentation.components.ScrollbarLazyColumn +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.secondaryItemAlpha +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.track.model.TrackSearch + +@Composable +fun TrackServiceSearch( + contentPadding: PaddingValues = PaddingValues(), + query: TextFieldValue, + onQueryChange: (TextFieldValue) -> Unit, + onDispatchQuery: () -> Unit, + queryResult: Result>?, + selected: TrackSearch?, + onSelectedChange: (TrackSearch) -> Unit, + onConfirmSelection: () -> Unit, + onDismissRequest: () -> Unit, +) { + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + + Scaffold( + contentWindowInsets = WindowInsets( + left = contentPadding.calculateLeftPadding(LocalLayoutDirection.current), + top = contentPadding.calculateTopPadding(), + right = contentPadding.calculateRightPadding(LocalLayoutDirection.current), + bottom = contentPadding.calculateBottomPadding(), + ), + topBar = { + Column { + TopAppBar( + navigationIcon = { + IconButton(onClick = onDismissRequest) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + title = { + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + textStyle = MaterialTheme.typography.bodyLarge + .copy(color = MaterialTheme.colorScheme.onSurface), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus(); onDispatchQuery() }), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { + if (query.text.isEmpty()) { + Text( + text = stringResource(R.string.action_search_hint), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge, + ) + } + it() + }, + ) + }, + actions = { + if (query.text.isNotEmpty()) { + IconButton( + onClick = { + onQueryChange(TextFieldValue()) + focusRequester.requestFocus() + }, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + ) + Divider() + } + }, + bottomBar = { + AnimatedVisibility( + visible = selected != null, + enter = fadeIn() + slideInVertically { it / 2 }, + exit = slideOutVertically { it / 2 } + fadeOut(), + ) { + Button( + onClick = { onConfirmSelection() }, + modifier = Modifier + .padding(12.dp) + .padding(bottom = contentPadding.calculateBottomPadding()) + .fillMaxWidth(), + elevation = ButtonDefaults.elevatedButtonElevation(), + ) { + Text(text = stringResource(id = R.string.action_track)) + } + } + }, + ) { innerPadding -> + if (queryResult == null) { + LoadingScreen(modifier = Modifier.padding(innerPadding)) + } else { + val availableTracks = queryResult.getOrNull() + if (availableTracks != null) { + if (availableTracks.isEmpty()) { + EmptyScreen( + modifier = Modifier.padding(innerPadding), + textResource = R.string.no_results_found, + ) + } else { + ScrollbarLazyColumn( + contentPadding = innerPadding + PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = availableTracks, + key = { it.hashCode() }, + ) { + SearchResultItem( + title = it.title, + coverUrl = it.cover_url, + type = it.publishing_type.toLowerCase(Locale.current).capitalize(Locale.current), + startDate = it.start_date, + status = it.publishing_status.toLowerCase(Locale.current).capitalize(Locale.current), + description = it.summary.trim(), + selected = it == selected, + onClick = { onSelectedChange(it) }, + ) + } + } + } + } else { + EmptyScreen( + modifier = Modifier.padding(innerPadding), + message = queryResult.exceptionOrNull()?.message + ?: stringResource(id = R.string.unknown_error), + ) + } + } + } +} + +@Composable +private fun SearchResultItem( + title: String, + coverUrl: String, + type: String, + startDate: String, + status: String, + description: String, + selected: Boolean, + onClick: () -> Unit, +) { + val shape = RoundedCornerShape(16.dp) + val borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent + Box( + modifier = Modifier + .padding(horizontal = 12.dp) + .clip(shape) + .background(MaterialTheme.colorScheme.surface) + .border( + width = 2.dp, + color = borderColor, + shape = shape, + ) + .selectable(selected = selected, onClick = onClick) + .padding(12.dp), + ) { + if (selected) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.align(Alignment.TopEnd), + tint = MaterialTheme.colorScheme.primary, + ) + } + Column { + Row { + MangaCover.Book( + data = coverUrl, + modifier = Modifier.height(96.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = title, + modifier = Modifier.padding(end = 28.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + if (type.isNotBlank()) { + SearchResultItemDetails( + title = stringResource(id = R.string.track_type), + text = type, + ) + } + if (startDate.isNotBlank()) { + SearchResultItemDetails( + title = stringResource(id = R.string.track_start_date), + text = startDate, + ) + } + if (status.isNotBlank()) { + SearchResultItemDetails( + title = stringResource(id = R.string.track_status), + text = status, + ) + } + } + } + if (description.isNotBlank()) { + Text( + text = description, + modifier = Modifier + .paddingFromBaseline(top = 24.dp) + .secondaryItemAlpha(), + maxLines = 4, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@Composable +private fun SearchResultItemDetails( + title: String, + text: String, +) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = title, + maxLines = 1, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = text, + modifier = Modifier + .weight(1f) + .secondaryItemAlpha(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt index 88dcf66cee..0b3b9e4b5e 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt @@ -22,6 +22,8 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -34,6 +36,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.view.updatePadding import coil.imageLoader import coil.request.ImageRequest @@ -50,124 +54,134 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView fun MangaCoverDialog( coverDataProvider: () -> Manga, isCustomCover: Boolean, + snackbarHostState: SnackbarHostState, onShareClick: () -> Unit, onSaveClick: () -> Unit, onEditClick: ((EditCoverAction) -> Unit)?, onDismissRequest: () -> Unit, ) { - Scaffold( - bottomBar = { - Row( - modifier = Modifier - .fillMaxWidth() - .background(color = MaterialTheme.colorScheme.background.copy(alpha = 0.9f)) - .padding(horizontal = 4.dp, vertical = 4.dp) - .navigationBarsPadding(), - ) { - IconButton(onClick = onDismissRequest) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = stringResource(R.string.action_close), - ) - } - Spacer(modifier = Modifier.weight(1f)) - IconButton(onClick = onShareClick) { - Icon( - imageVector = Icons.Outlined.Share, - contentDescription = stringResource(R.string.action_share), - ) - } - IconButton(onClick = onSaveClick) { - Icon( - imageVector = Icons.Outlined.Save, - contentDescription = stringResource(R.string.action_save), - ) - } - if (onEditClick != null) { - Box { - var expanded by remember { mutableStateOf(false) } - IconButton( - onClick = { - if (isCustomCover) { - expanded = true - } else { - onEditClick(EditCoverAction.EDIT) - } - }, - ) { - Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = stringResource(R.string.action_edit_cover), - ) - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - offset = DpOffset(8.dp, 0.dp), - ) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_edit)) }, - onClick = { - onEditClick(EditCoverAction.EDIT) - expanded = false - }, - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_delete)) }, - onClick = { - onEditClick(EditCoverAction.DELETE) - expanded = false - }, - ) - } + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, // Doesn't work https://issuetracker.google.com/issues/246909281 + ), + ) { + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .background(color = MaterialTheme.colorScheme.background.copy(alpha = 0.9f)) + .padding(horizontal = 4.dp, vertical = 4.dp) + .navigationBarsPadding(), + ) { + IconButton(onClick = onDismissRequest) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.action_close), + ) } - } - } - }, - ) { contentPadding -> - val statusBarPaddingPx = WindowInsets.systemBars.getTop(LocalDensity.current) - val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() } - Box( - modifier = Modifier - .fillMaxSize() - .background(color = MaterialTheme.colorScheme.background) - .clickableNoIndication(onClick = onDismissRequest), - ) { - AndroidView( - factory = { - ReaderPageImageView(it).apply { - onViewClicked = onDismissRequest - clipToPadding = false - clipChildren = false + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = onShareClick) { + Icon( + imageVector = Icons.Outlined.Share, + contentDescription = stringResource(R.string.action_share), + ) } - }, - update = { view -> - val request = ImageRequest.Builder(view.context) - .data(coverDataProvider()) - .size(Size.ORIGINAL) - .target { drawable -> - // Copy bitmap in case it came from memory cache - // Because SSIV needs to thoroughly read the image - val copy = (drawable as? BitmapDrawable)?.let { - val config = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Bitmap.Config.HARDWARE - } else { - Bitmap.Config.ARGB_8888 - } - BitmapDrawable( - view.context.resources, - it.bitmap.copy(config, false), + IconButton(onClick = onSaveClick) { + Icon( + imageVector = Icons.Outlined.Save, + contentDescription = stringResource(R.string.action_save), + ) + } + if (onEditClick != null) { + Box { + var expanded by remember { mutableStateOf(false) } + IconButton( + onClick = { + if (isCustomCover) { + expanded = true + } else { + onEditClick(EditCoverAction.EDIT) + } + }, + ) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource(R.string.action_edit_cover), ) - } ?: drawable - view.setImage(copy, ReaderPageImageView.Config(zoomDuration = 500)) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset(8.dp, 0.dp), + ) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.action_edit)) }, + onClick = { + onEditClick(EditCoverAction.EDIT) + expanded = false + }, + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.action_delete)) }, + onClick = { + onEditClick(EditCoverAction.DELETE) + expanded = false + }, + ) + } } - .build() - view.context.imageLoader.enqueue(request) + } + } + }, + ) { contentPadding -> + val statusBarPaddingPx = WindowInsets.systemBars.getTop(LocalDensity.current) + val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() } + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.background) + .clickableNoIndication(onClick = onDismissRequest), + ) { + AndroidView( + factory = { + ReaderPageImageView(it).apply { + onViewClicked = onDismissRequest + clipToPadding = false + clipChildren = false + } + }, + update = { view -> + val request = ImageRequest.Builder(view.context) + .data(coverDataProvider()) + .size(Size.ORIGINAL) + .target { drawable -> + // Copy bitmap in case it came from memory cache + // Because SSIV needs to thoroughly read the image + val copy = (drawable as? BitmapDrawable)?.let { + val config = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Bitmap.Config.HARDWARE + } else { + Bitmap.Config.ARGB_8888 + } + BitmapDrawable( + view.context.resources, + it.bitmap.copy(config, false), + ) + } ?: drawable + view.setImage(copy, ReaderPageImageView.Config(zoomDuration = 500)) + } + .build() + view.context.imageLoader.enqueue(request) - view.updatePadding(top = statusBarPaddingPx, bottom = bottomPaddingPx) - }, - modifier = Modifier.fillMaxSize(), - ) + view.updatePadding(top = statusBarPaddingPx, bottom = bottomPaddingPx) + }, + modifier = Modifier.fillMaxSize(), + ) + } } } } diff --git a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt index 92fdc37f88..b69dd73c48 100644 --- a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt +++ b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt @@ -1,6 +1,8 @@ package eu.kanade.presentation.util +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf import com.bluelinelabs.conductor.Router @@ -13,3 +15,5 @@ val LocalRouter: ProvidableCompositionLocal = staticCompositionLocalOf * For invoking back press to the parent activity */ val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null } + +val LocalNavigatorContentPadding: ProvidableCompositionLocal = compositionLocalOf { PaddingValues() } diff --git a/app/src/main/java/eu/kanade/presentation/util/WindowSize.kt b/app/src/main/java/eu/kanade/presentation/util/WindowSize.kt new file mode 100644 index 0000000000..675eb2a647 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/WindowSize.kt @@ -0,0 +1,12 @@ +package eu.kanade.presentation.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalConfiguration +import eu.kanade.tachiyomi.util.system.isTabletUi + +@Composable +@ReadOnlyComposable +fun isTabletUi(): Boolean { + return LocalConfiguration.current.isTabletUi() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt index f3c0b90149..a83a5f7a7f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt @@ -27,24 +27,4 @@ class TrackImpl : Track { override var finished_reading_date: Long = 0 override var tracking_url: String = "" - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as TrackImpl - - if (manga_id != other.manga_id) return false - if (sync_id != other.sync_id) return false - if (media_id != other.media_id) return false - - return true - } - - override fun hashCode(): Int { - var result = manga_id.hashCode() - result = 31 * result + sync_id - result = 31 * result + media_id.hashCode() - return result - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt index b853da5e0f..4603ae7df1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt @@ -1,15 +1,28 @@ package eu.kanade.tachiyomi.data.track +import android.app.Application import androidx.annotation.CallSuper import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.annotation.StringRes import eu.kanade.domain.base.BasePreferences +import eu.kanade.domain.chapter.interactor.GetChapterByMangaId +import eu.kanade.domain.chapter.interactor.SyncChaptersWithTrackServiceTwoWay +import eu.kanade.domain.track.interactor.InsertTrack +import eu.kanade.domain.track.model.toDbTrack +import eu.kanade.domain.track.model.toDomainTrack import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.util.lang.withIOContext +import eu.kanade.tachiyomi.util.lang.withUIContext +import eu.kanade.tachiyomi.util.system.logcat +import eu.kanade.tachiyomi.util.system.toast +import logcat.LogPriority import okhttp3.OkHttpClient +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy abstract class TrackService(val id: Long) { @@ -78,4 +91,89 @@ abstract class TrackService(val id: Long) { fun saveCredentials(username: String, password: String) { trackPreferences.setTrackCredentials(this, username, password) } + + suspend fun registerTracking(item: Track, mangaId: Long) { + item.manga_id = mangaId + try { + withIOContext { + val allChapters = Injekt.get().await(mangaId) + val hasReadChapters = allChapters.any { it.read } + bind(item, hasReadChapters) + + val track = item.toDomainTrack(idRequired = false) ?: return@withIOContext + + Injekt.get().await(track) + + // Update chapter progress if newer chapters marked read locally + if (hasReadChapters) { + val latestLocalReadChapterNumber = allChapters + .sortedBy { it.chapterNumber } + .takeWhile { it.read } + .lastOrNull() + ?.chapterNumber?.toDouble() ?: -1.0 + + if (latestLocalReadChapterNumber > track.lastChapterRead) { + val updatedTrack = track.copy( + lastChapterRead = latestLocalReadChapterNumber, + ) + setRemoteLastChapterRead(updatedTrack.toDbTrack(), latestLocalReadChapterNumber.toInt()) + } + } + + if (this is EnhancedTrackService) { + Injekt.get().await(allChapters, track, this@TrackService) + } + } + } catch (e: Throwable) { + withUIContext { Injekt.get().toast(e.message) } + } + } + + suspend fun setRemoteStatus(track: Track, status: Int) { + track.status = status + if (track.status == getCompletionStatus() && track.total_chapters != 0) { + track.last_chapter_read = track.total_chapters.toFloat() + } + withIOContext { updateRemote(track) } + } + + suspend fun setRemoteLastChapterRead(track: Track, chapterNumber: Int) { + if (track.last_chapter_read == 0F && track.last_chapter_read < chapterNumber && track.status != getRereadingStatus()) { + track.status = getReadingStatus() + } + track.last_chapter_read = chapterNumber.toFloat() + if (track.total_chapters != 0 && track.last_chapter_read.toInt() == track.total_chapters) { + track.status = getCompletionStatus() + } + withIOContext { updateRemote(track) } + } + + suspend fun setRemoteScore(track: Track, scoreString: String) { + track.score = indexToScore(getScoreList().indexOf(scoreString)) + withIOContext { updateRemote(track) } + } + + suspend fun setRemoteStartDate(track: Track, epochMillis: Long) { + track.started_reading_date = epochMillis + withIOContext { updateRemote(track) } + } + + suspend fun setRemoteFinishDate(track: Track, epochMillis: Long) { + track.finished_reading_date = epochMillis + withIOContext { updateRemote(track) } + } + + private suspend fun updateRemote(track: Track) { + withIOContext { + try { + update(track) + track.toDomainTrack(idRequired = false)?.let { + Injekt.get().await(it) + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=$id" } + withUIContext { Injekt.get().toast(e.message) } + } + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt index 6590a90120..ac6376a9d1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt @@ -1,65 +1,12 @@ package eu.kanade.tachiyomi.ui.manga -import android.content.Intent import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalConfiguration import androidx.core.os.bundleOf -import com.bluelinelabs.conductor.ControllerChangeHandler -import com.bluelinelabs.conductor.ControllerChangeType -import eu.kanade.data.chapter.NoChaptersException -import eu.kanade.presentation.components.ChangeCategoryDialog -import eu.kanade.presentation.components.ChapterDownloadAction -import eu.kanade.presentation.components.DuplicateMangaDialog -import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.presentation.manga.DownloadAction -import eu.kanade.presentation.manga.MangaScreen -import eu.kanade.presentation.manga.components.DeleteChaptersDialog -import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.download.DownloadService -import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.network.HttpException -import eu.kanade.tachiyomi.source.isLocalOrStub -import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.base.controller.FullComposeController -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction -import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController -import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController -import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController -import eu.kanade.tachiyomi.ui.category.CategoryController -import eu.kanade.tachiyomi.ui.history.HistoryController -import eu.kanade.tachiyomi.ui.library.LibraryController -import eu.kanade.tachiyomi.ui.main.MainActivity -import eu.kanade.tachiyomi.ui.manga.MangaPresenter.Dialog -import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersSettingsSheet -import eu.kanade.tachiyomi.ui.manga.info.MangaFullCoverDialog -import eu.kanade.tachiyomi.ui.manga.track.TrackItem -import eu.kanade.tachiyomi.ui.manga.track.TrackSearchDialog -import eu.kanade.tachiyomi.ui.manga.track.TrackSheet -import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.ui.updates.UpdatesController -import eu.kanade.tachiyomi.ui.webview.WebViewActivity -import eu.kanade.tachiyomi.util.system.isTabletUi -import eu.kanade.tachiyomi.util.system.logcat -import eu.kanade.tachiyomi.util.system.toast -import kotlinx.coroutines.launch -import logcat.LogPriority -import eu.kanade.domain.chapter.model.Chapter as DomainChapter +import cafe.adriel.voyager.navigator.Navigator +import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController -class MangaController : FullComposeController { +class MangaController : BasicFullComposeController { @Suppress("unused") constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) @@ -67,408 +14,19 @@ class MangaController : FullComposeController { constructor( mangaId: Long, fromSource: Boolean = false, - ) : super(bundleOf(MANGA_EXTRA to mangaId, FROM_SOURCE_EXTRA to fromSource)) { - this.mangaId = mangaId - } + ) : super(bundleOf(MANGA_EXTRA to mangaId, FROM_SOURCE_EXTRA to fromSource)) - var mangaId: Long + val mangaId: Long + get() = args.getLong(MANGA_EXTRA) val fromSource: Boolean - get() = presenter.isFromSource - - // Sheet containing filter/sort/display items. - private lateinit var settingsSheet: ChaptersSettingsSheet - - private lateinit var trackSheet: TrackSheet - - private val snackbarHostState = SnackbarHostState() - - override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { - super.onChangeStarted(handler, type) - val actionBar = (activity as? AppCompatActivity)?.supportActionBar - if (type.isEnter) { - actionBar?.hide() - } else { - actionBar?.show() - } - } - - override fun createPresenter(): MangaPresenter { - return MangaPresenter( - mangaId = mangaId, - isFromSource = args.getBoolean(FROM_SOURCE_EXTRA, false), - ) - } + get() = args.getBoolean(FROM_SOURCE_EXTRA) @Composable override fun ComposeContent() { - val state by presenter.state.collectAsState() - - if (state is MangaScreenState.Loading) { - LoadingScreen() - return - } - - val successState = state as MangaScreenState.Success - val isHttpSource = remember { successState.source is HttpSource } - val scope = rememberCoroutineScope() - - val configuration = LocalConfiguration.current - val isTabletUi = remember { configuration.isTabletUi() } // won't survive config change - - MangaScreen( - state = successState, - snackbarHostState = snackbarHostState, - isTabletUi = isTabletUi, - onBackClicked = router::popCurrentController, - onChapterClicked = this::openChapter, - onDownloadChapter = this::onDownloadChapters.takeIf { !successState.source.isLocalOrStub() }, - onAddToLibraryClicked = this::onFavoriteClick, - onWebViewClicked = this::openMangaInWebView.takeIf { isHttpSource }, - onTrackingClicked = trackSheet::show.takeIf { successState.trackingAvailable }, - onTagClicked = this::performGenreSearch, - onFilterButtonClicked = settingsSheet::show, - onRefresh = presenter::fetchAllFromSource, - onContinueReading = this::continueReading, - onSearch = this::performSearch, - onCoverClicked = this::openCoverDialog, - onShareClicked = this::shareManga.takeIf { isHttpSource }, - onDownloadActionClicked = this::runDownloadChapterAction.takeIf { !successState.source.isLocalOrStub() }, - onEditCategoryClicked = presenter::promptChangeCategories.takeIf { successState.manga.favorite }, - onMigrateClicked = this::migrateManga.takeIf { successState.manga.favorite }, - onMultiBookmarkClicked = presenter::bookmarkChapters, - onMultiMarkAsReadClicked = presenter::markChaptersRead, - onMarkPreviousAsReadClicked = presenter::markPreviousChapterRead, - onMultiDeleteClicked = presenter::showDeleteChapterDialog, - onChapterSelected = presenter::toggleSelection, - onAllChapterSelected = presenter::toggleAllSelection, - onInvertSelection = presenter::invertSelection, - ) - - val onDismissRequest = { presenter.dismissDialog() } - when (val dialog = (state as? MangaScreenState.Success)?.dialog) { - is Dialog.ChangeCategory -> { - ChangeCategoryDialog( - initialSelection = dialog.initialSelection, - onDismissRequest = onDismissRequest, - onEditCategories = { - router.pushController(CategoryController()) - }, - onConfirm = { include, _ -> - presenter.moveMangaToCategoriesAndAddToLibrary(dialog.manga, include) - }, - ) - } - is Dialog.DeleteChapters -> { - DeleteChaptersDialog( - onDismissRequest = onDismissRequest, - onConfirm = { - presenter.toggleAllSelection(false) - deleteChapters(dialog.chapters) - }, - ) - } - is Dialog.DownloadCustomAmount -> { - DownloadCustomAmountDialog( - maxAmount = dialog.max, - onDismissRequest = onDismissRequest, - onConfirm = { amount -> - val chaptersToDownload = presenter.getUnreadChaptersSorted().take(amount) - if (chaptersToDownload.isNotEmpty()) { - scope.launch { downloadChapters(chaptersToDownload) } - } - }, - ) - } - is Dialog.DuplicateManga -> { - DuplicateMangaDialog( - onDismissRequest = onDismissRequest, - onConfirm = { - presenter.toggleFavorite( - onRemoved = {}, - onAdded = {}, - checkDuplicate = false, - ) - }, - onOpenManga = { router.pushController(MangaController(dialog.duplicate.id)) }, - duplicateFrom = presenter.getSourceOrStub(dialog.duplicate), - ) - } - null -> {} - } + Navigator(screen = MangaScreen(mangaId, fromSource)) } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedViewState: Bundle?): View { - settingsSheet = ChaptersSettingsSheet(router, presenter) - trackSheet = TrackSheet(this, (activity as MainActivity).supportFragmentManager) - return super.onCreateView(inflater, container, savedViewState) - } - - // Manga info - start - - fun onFetchMangaInfoError(error: Throwable) { - // Ignore early hints "errors" that aren't handled by OkHttp - if (error is HttpException && error.code == 103) { - return - } - activity?.toast(error.message) - } - - private fun openMangaInWebView() { - val manga = presenter.manga ?: return - val source = presenter.source as? HttpSource ?: return - - val url = try { - source.getMangaUrl(manga.toSManga()) - } catch (e: Exception) { - return - } - - val activity = activity ?: return - val intent = WebViewActivity.newIntent(activity, url, source.id, manga.title) - startActivity(intent) - } - - private fun shareManga() { - val context = view?.context ?: return - val manga = presenter.manga ?: return - val source = presenter.source as? HttpSource ?: return - try { - val url = source.getMangaUrl(manga.toSManga()) - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, url) - } - startActivity(Intent.createChooser(intent, context.getString(R.string.action_share))) - } catch (e: Exception) { - context.toast(e.message) - } - } - - private fun onFavoriteClick() { - presenter.toggleFavorite( - onRemoved = this::onFavoriteRemoved, - onAdded = { activity?.toast(R.string.manga_added_library) }, - ) - } - - private fun onFavoriteRemoved() { - val context = activity ?: return - context.toast(R.string.manga_removed_library) - viewScope.launch { - if (!presenter.hasDownloads()) return@launch - val result = snackbarHostState.showSnackbar( - message = context.getString(R.string.delete_downloads_for_manga), - actionLabel = context.getString(R.string.action_delete), - withDismissAction = true, - ) - if (result == SnackbarResult.ActionPerformed) { - presenter.deleteDownloads() - } - } - } - - /** - * Perform a search using the provided query. - * - * @param query the search query to the parent controller - */ - private fun performSearch(query: String, global: Boolean) { - if (global) { - router.pushController(GlobalSearchController(query)) - return - } - - if (router.backstackSize < 2) { - return - } - - when (val previousController = router.backstack[router.backstackSize - 2].controller) { - is LibraryController -> { - router.handleBack() - previousController.search(query) - } - is UpdatesController, - is HistoryController, - -> { - // Manually navigate to LibraryController - router.handleBack() - (router.activity as MainActivity).setSelectedNavItem(R.id.nav_library) - val controller = router.getControllerWithTag(R.id.nav_library.toString()) as LibraryController - controller.search(query) - } - is BrowseSourceController -> { - router.handleBack() - previousController.searchWithQuery(query) - } - } - } - - /** - * Performs a genre search using the provided genre name. - * - * @param genreName the search genre to the parent controller - */ - private fun performGenreSearch(genreName: String) { - if (router.backstackSize < 2) { - return - } - - val previousController = router.backstack[router.backstackSize - 2].controller - val presenterSource = presenter.source - - if (previousController is BrowseSourceController && - presenterSource is HttpSource - ) { - router.handleBack() - previousController.searchWithGenre(genreName) - } else { - performSearch(genreName, global = false) - } - } - - private fun openCoverDialog() { - val mangaId = presenter.manga?.id ?: return - router.pushController(MangaFullCoverDialog(mangaId).withFadeTransaction()) - } - - /** - * Initiates source migration for the specific manga. - */ - private fun migrateManga() { - val manga = presenter.manga ?: return - val controller = SearchController(manga) - controller.targetController = this - router.pushController(controller) - } - - // Manga info - end - - // Chapters list - start - - private fun continueReading() { - val chapter = presenter.getNextUnreadChapter() - if (chapter != null) openChapter(chapter) - } - - private fun openChapter(chapter: DomainChapter) { - activity?.run { - startActivity(ReaderActivity.newIntent(this, chapter.mangaId, chapter.id)) - } - } - - fun onFetchChaptersError(error: Throwable) { - if (error is NoChaptersException) { - activity?.toast(R.string.no_chapters_error) - } else { - activity?.toast(error.message) - } - } - - // SELECTION MODE ACTIONS - - private fun onDownloadChapters( - items: List, - action: ChapterDownloadAction, - ) { - viewScope.launch { - when (action) { - ChapterDownloadAction.START -> { - downloadChapters(items.map { it.chapter }) - if (items.any { it.downloadState == Download.State.ERROR }) { - DownloadService.start(activity!!) - } - } - ChapterDownloadAction.START_NOW -> { - downloadChapters(items.map { it.chapter }, startNow = true) - } - ChapterDownloadAction.CANCEL -> { - val chapterId = items.singleOrNull()?.chapter?.id ?: return@launch - presenter.cancelDownload(chapterId) - } - ChapterDownloadAction.DELETE -> { - deleteChapters(items.map { it.chapter }) - } - } - } - } - - private suspend fun downloadChapters(chapters: List, startNow: Boolean = false) { - if (startNow) { - val chapterId = chapters.singleOrNull()?.id ?: return - presenter.startDownloadingNow(chapterId) - } else { - presenter.downloadChapters(chapters) - } - - if (!presenter.isFavoritedManga) { - val result = snackbarHostState.showSnackbar( - message = activity!!.getString(R.string.snack_add_to_library), - actionLabel = activity!!.getString(R.string.action_add), - withDismissAction = true, - ) - if (result == SnackbarResult.ActionPerformed && !presenter.isFavoritedManga) { - onFavoriteClick() - } - } - } - - private fun deleteChapters(chapters: List) { - if (chapters.isEmpty()) return - presenter.deleteChapters(chapters) - } - - // OVERFLOW MENU DIALOGS - - private fun runDownloadChapterAction(action: DownloadAction) { - val chaptersToDownload = when (action) { - DownloadAction.NEXT_1_CHAPTER -> presenter.getUnreadChaptersSorted().take(1) - DownloadAction.NEXT_5_CHAPTERS -> presenter.getUnreadChaptersSorted().take(5) - DownloadAction.NEXT_10_CHAPTERS -> presenter.getUnreadChaptersSorted().take(10) - DownloadAction.CUSTOM -> { - presenter.showDownloadCustomDialog() - return - } - DownloadAction.UNREAD_CHAPTERS -> presenter.getUnreadChapters() - DownloadAction.ALL_CHAPTERS -> { - (presenter.state.value as? MangaScreenState.Success)?.chapters?.map { it.chapter } - } - } - if (!chaptersToDownload.isNullOrEmpty()) { - viewScope.launch { downloadChapters(chaptersToDownload) } - } - } - - // Chapters list - end - - // Tracker sheet - start - fun onNextTrackers(trackers: List) { - trackSheet.onNextTrackers(trackers) - } - - fun onTrackingRefreshDone() { - } - - fun onTrackingRefreshError(error: Throwable) { - logcat(LogPriority.ERROR, error) - activity?.toast(error.message) - } - - fun onTrackingSearchResults(results: List) { - getTrackingSearchDialog()?.onSearchResults(results) - } - - fun onTrackingSearchResultsError(error: Throwable) { - logcat(LogPriority.ERROR, error) - getTrackingSearchDialog()?.onSearchResultsError(error.message) - } - - private fun getTrackingSearchDialog(): TrackSearchDialog? { - return trackSheet.getSearchDialog() - } - - // Tracker sheet - end - companion object { const val FROM_SOURCE_EXTRA = "from_source" const val MANGA_EXTRA = "manga" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaCoverScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaCoverScreenModel.kt new file mode 100644 index 0000000000..f14d72116a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaCoverScreenModel.kt @@ -0,0 +1,164 @@ +package eu.kanade.tachiyomi.ui.manga + +import android.content.Context +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import androidx.compose.material3.SnackbarHostState +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.coroutineScope +import coil.imageLoader +import coil.request.ImageRequest +import coil.size.Size +import eu.kanade.domain.manga.interactor.GetManga +import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.manga.model.Manga +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.saver.Image +import eu.kanade.tachiyomi.data.saver.ImageSaver +import eu.kanade.tachiyomi.data.saver.Location +import eu.kanade.tachiyomi.util.editCover +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.lang.withIOContext +import eu.kanade.tachiyomi.util.lang.withUIContext +import eu.kanade.tachiyomi.util.system.logcat +import eu.kanade.tachiyomi.util.system.toShareIntent +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import logcat.LogPriority +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class MangaCoverScreenModel( + private val mangaId: Long, + private val getManga: GetManga = Injekt.get(), + private val imageSaver: ImageSaver = Injekt.get(), + private val coverCache: CoverCache = Injekt.get(), + private val updateManga: UpdateManga = Injekt.get(), + + val snackbarHostState: SnackbarHostState = SnackbarHostState(), +) : StateScreenModel(null) { + + init { + coroutineScope.launchIO { + getManga.subscribe(mangaId) + .collect { newManga -> mutableState.update { newManga } } + } + } + + fun saveCover(context: Context) { + coroutineScope.launch { + try { + saveCoverInternal(context, temp = false) + snackbarHostState.showSnackbar( + context.getString(R.string.cover_saved), + withDismissAction = true, + ) + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) + snackbarHostState.showSnackbar( + context.getString(R.string.error_saving_cover), + withDismissAction = true, + ) + } + } + } + + fun shareCover(context: Context) { + coroutineScope.launch { + try { + val uri = saveCoverInternal(context, temp = true) ?: return@launch + withUIContext { + context.startActivity(uri.toShareIntent(context)) + } + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) + snackbarHostState.showSnackbar( + context.getString(R.string.error_sharing_cover), + withDismissAction = true, + ) + } + } + } + + /** + * Save manga cover Bitmap to picture or temporary share directory. + * + * @param context The context for building and executing the ImageRequest + * @return the uri to saved file + */ + private suspend fun saveCoverInternal(context: Context, temp: Boolean): Uri? { + val manga = state.value ?: return null + val req = ImageRequest.Builder(context) + .data(manga) + .size(Size.ORIGINAL) + .build() + + return withIOContext { + val result = context.imageLoader.execute(req).drawable + + // TODO: Handle animated cover + val bitmap = (result as? BitmapDrawable)?.bitmap ?: return@withIOContext null + imageSaver.save( + Image.Cover( + bitmap = bitmap, + name = manga.title, + location = if (temp) Location.Cache else Location.Pictures.create(), + ), + ) + } + } + + /** + * Update cover with local file. + * + * @param context Context. + * @param data uri of the cover resource. + */ + fun editCover(context: Context, data: Uri) { + val manga = state.value ?: return + coroutineScope.launchIO { + @Suppress("BlockingMethodInNonBlockingContext") + context.contentResolver.openInputStream(data)?.use { + try { + manga.editCover(context, it, updateManga, coverCache) + notifyCoverUpdated(context) + } catch (e: Exception) { + notifyFailedCoverUpdate(context, e) + } + } + } + } + + fun deleteCustomCover(context: Context) { + val mangaId = state.value?.id ?: return + coroutineScope.launchIO { + try { + coverCache.deleteCustomCover(mangaId) + updateManga.awaitUpdateCoverLastModified(mangaId) + notifyCoverUpdated(context) + } catch (e: Exception) { + notifyFailedCoverUpdate(context, e) + } + } + } + + private fun notifyCoverUpdated(context: Context) { + coroutineScope.launch { + snackbarHostState.showSnackbar( + context.getString(R.string.cover_updated), + withDismissAction = true, + ) + } + } + + private fun notifyFailedCoverUpdate(context: Context, e: Throwable) { + coroutineScope.launch { + snackbarHostState.showSnackbar( + context.getString(R.string.notification_cover_update_failed), + withDismissAction = true, + ) + logcat(LogPriority.ERROR, e) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt new file mode 100644 index 0000000000..6d38c96e70 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -0,0 +1,329 @@ +package eu.kanade.tachiyomi.ui.manga + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.with +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cafe.adriel.voyager.transitions.ScreenTransition +import com.bluelinelabs.conductor.Router +import eu.kanade.domain.chapter.model.Chapter +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.model.hasCustomCover +import eu.kanade.presentation.components.AdaptiveSheet +import eu.kanade.presentation.components.ChangeCategoryDialog +import eu.kanade.presentation.components.DuplicateMangaDialog +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.manga.ChapterSettingsDialog +import eu.kanade.presentation.manga.EditCoverAction +import eu.kanade.presentation.manga.MangaScreen +import eu.kanade.presentation.manga.components.DeleteChaptersDialog +import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog +import eu.kanade.presentation.manga.components.MangaCoverDialog +import eu.kanade.presentation.util.LocalNavigatorContentPadding +import eu.kanade.presentation.util.LocalRouter +import eu.kanade.presentation.util.isTabletUi +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.isLocalOrStub +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.base.controller.pushController +import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController +import eu.kanade.tachiyomi.ui.category.CategoryController +import eu.kanade.tachiyomi.ui.history.HistoryController +import eu.kanade.tachiyomi.ui.library.LibraryController +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.manga.track.TrackInfoDialogHomeScreen +import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.ui.updates.UpdatesController +import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.util.system.toShareIntent +import eu.kanade.tachiyomi.util.system.toast + +class MangaScreen( + private val mangaId: Long, + private val fromSource: Boolean = false, +) : Screen { + + override val key = uniqueScreenKey + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val router = LocalRouter.currentOrThrow + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + val screenModel = rememberScreenModel { MangaInfoScreenModel(context, mangaId, fromSource) } + + val state by screenModel.state.collectAsState() + + if (state is MangaScreenState.Loading) { + LoadingScreen() + return + } + + val successState = state as MangaScreenState.Success + val isHttpSource = remember { successState.source is HttpSource } + + MangaScreen( + state = successState, + snackbarHostState = screenModel.snackbarHostState, + isTabletUi = isTabletUi(), + onBackClicked = router::popCurrentController, + onChapterClicked = { openChapter(context, it) }, + onDownloadChapter = screenModel::runChapterDownloadActions.takeIf { !successState.source.isLocalOrStub() }, + onAddToLibraryClicked = { + screenModel.toggleFavorite() + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onWebViewClicked = { openMangaInWebView(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource }, + onTrackingClicked = screenModel::showTrackDialog.takeIf { successState.trackingAvailable }, + onTagClicked = { performGenreSearch(router, it, screenModel.source!!) }, + onFilterButtonClicked = screenModel::showSettingsDialog, + onRefresh = screenModel::fetchAllFromSource, + onContinueReading = { continueReading(context, screenModel.getNextUnreadChapter()) }, + onSearch = { query, global -> performSearch(router, query, global) }, + onCoverClicked = screenModel::showCoverDialog, + onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource }, + onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() }, + onEditCategoryClicked = screenModel::promptChangeCategories.takeIf { successState.manga.favorite }, + onMigrateClicked = { migrateManga(router, screenModel.manga!!) }.takeIf { successState.manga.favorite }, + onMultiBookmarkClicked = screenModel::bookmarkChapters, + onMultiMarkAsReadClicked = screenModel::markChaptersRead, + onMarkPreviousAsReadClicked = screenModel::markPreviousChapterRead, + onMultiDeleteClicked = screenModel::showDeleteChapterDialog, + onChapterSelected = screenModel::toggleSelection, + onAllChapterSelected = screenModel::toggleAllSelection, + onInvertSelection = screenModel::invertSelection, + ) + + val onDismissRequest = { screenModel.dismissDialog() } + when (val dialog = (state as? MangaScreenState.Success)?.dialog) { + null -> {} + is MangaInfoScreenModel.Dialog.ChangeCategory -> { + ChangeCategoryDialog( + initialSelection = dialog.initialSelection, + onDismissRequest = onDismissRequest, + onEditCategories = { router.pushController(CategoryController()) }, + onConfirm = { include, _ -> + screenModel.moveMangaToCategoriesAndAddToLibrary(dialog.manga, include) + }, + ) + } + is MangaInfoScreenModel.Dialog.DeleteChapters -> { + DeleteChaptersDialog( + onDismissRequest = onDismissRequest, + onConfirm = { + screenModel.toggleAllSelection(false) + screenModel.deleteChapters(dialog.chapters) + }, + ) + } + is MangaInfoScreenModel.Dialog.DownloadCustomAmount -> { + DownloadCustomAmountDialog( + maxAmount = dialog.max, + onDismissRequest = onDismissRequest, + onConfirm = { amount -> + val chaptersToDownload = screenModel.getUnreadChaptersSorted().take(amount) + if (chaptersToDownload.isNotEmpty()) { + screenModel.startDownload(chapters = chaptersToDownload, startNow = false) + } + }, + ) + } + is MangaInfoScreenModel.Dialog.DuplicateManga -> DuplicateMangaDialog( + onDismissRequest = onDismissRequest, + onConfirm = { screenModel.toggleFavorite(onRemoved = {}, checkDuplicate = false) }, + onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) }, + duplicateFrom = screenModel.getSourceOrStub(dialog.duplicate), + ) + MangaInfoScreenModel.Dialog.SettingsSheet -> ChapterSettingsDialog( + onDismissRequest = onDismissRequest, + manga = successState.manga, + onDownloadFilterChanged = screenModel::setDownloadedFilter, + onUnreadFilterChanged = screenModel::setUnreadFilter, + onBookmarkedFilterChanged = screenModel::setBookmarkedFilter, + onSortModeChanged = screenModel::setSorting, + onDisplayModeChanged = screenModel::setDisplayMode, + onSetAsDefault = screenModel::setCurrentSettingsAsDefault, + ) + MangaInfoScreenModel.Dialog.TrackSheet -> { + var enableSwipeDismiss by remember { mutableStateOf(true) } + AdaptiveSheet( + enableSwipeDismiss = enableSwipeDismiss, + onDismissRequest = onDismissRequest, + ) { contentPadding -> + Navigator( + screen = TrackInfoDialogHomeScreen( + mangaId = successState.manga.id, + mangaTitle = successState.manga.title, + sourceId = successState.source.id, + ), + content = { + enableSwipeDismiss = it.lastItem is TrackInfoDialogHomeScreen + CompositionLocalProvider(LocalNavigatorContentPadding provides contentPadding) { + ScreenTransition( + navigator = it, + transition = { + fadeIn(animationSpec = tween(220, delayMillis = 90)) with + fadeOut(animationSpec = tween(90)) + }, + ) + } + }, + ) + } + } + MangaInfoScreenModel.Dialog.FullCover -> { + val sm = rememberScreenModel { MangaCoverScreenModel(successState.manga.id) } + val manga by sm.state.collectAsState() + if (manga != null) { + val getContent = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { + if (it == null) return@rememberLauncherForActivityResult + sm.editCover(context, it) + } + MangaCoverDialog( + coverDataProvider = { manga!! }, + snackbarHostState = sm.snackbarHostState, + isCustomCover = remember(manga) { manga!!.hasCustomCover() }, + onShareClick = { sm.shareCover(context) }, + onSaveClick = { sm.saveCover(context) }, + onEditClick = { + when (it) { + EditCoverAction.EDIT -> getContent.launch("image/*") + EditCoverAction.DELETE -> sm.deleteCustomCover(context) + } + }, + onDismissRequest = onDismissRequest, + ) + } else { + LoadingScreen(Modifier.systemBarsPadding()) + } + } + } + } + + private fun continueReading(context: Context, unreadChapter: Chapter?) { + if (unreadChapter != null) openChapter(context, unreadChapter) + } + + private fun openChapter(context: Context, chapter: Chapter) { + context.startActivity(ReaderActivity.newIntent(context, chapter.mangaId, chapter.id)) + } + + private fun openMangaInWebView(context: Context, manga_: Manga?, source_: Source?) { + val manga = manga_ ?: return + val source = source_ as? HttpSource ?: return + + val url = try { + source.getMangaUrl(manga.toSManga()) + } catch (e: Exception) { + return + } + + val intent = WebViewActivity.newIntent(context, url, source.id, manga.title) + context.startActivity(intent) + } + + private fun shareManga(context: Context, manga_: Manga?, source_: Source?) { + val manga = manga_ ?: return + val source = source_ as? HttpSource ?: return + try { + val uri = Uri.parse(source.getMangaUrl(manga.toSManga())) + val intent = uri.toShareIntent(context, type = "text/plain") + context.startActivity(Intent.createChooser(intent, context.getString(R.string.action_share))) + } catch (e: Exception) { + context.toast(e.message) + } + } + + /** + * Perform a search using the provided query. + * + * @param query the search query to the parent controller + */ + private fun performSearch(router: Router, query: String, global: Boolean) { + if (global) { + router.pushController(GlobalSearchController(query)) + return + } + + if (router.backstackSize < 2) { + return + } + + when (val previousController = router.backstack[router.backstackSize - 2].controller) { + is LibraryController -> { + router.handleBack() + previousController.search(query) + } + is UpdatesController, + is HistoryController, + -> { + // Manually navigate to LibraryController + router.handleBack() + (router.activity as MainActivity).setSelectedNavItem(R.id.nav_library) + val controller = router.getControllerWithTag(R.id.nav_library.toString()) as LibraryController + controller.search(query) + } + is BrowseSourceController -> { + router.handleBack() + previousController.searchWithQuery(query) + } + } + } + + /** + * Performs a genre search using the provided genre name. + * + * @param genreName the search genre to the parent controller + */ + private fun performGenreSearch(router: Router, genreName: String, source: Source) { + if (router.backstackSize < 2) { + return + } + + val previousController = router.backstack[router.backstackSize - 2].controller + + if (previousController is BrowseSourceController && + source is HttpSource + ) { + router.handleBack() + previousController.searchWithGenre(genreName) + } else { + performSearch(router, genreName, global = false) + } + } + + /** + * Initiates source migration for the specific manga. + */ + private fun migrateManga(router: Router, manga: Manga) { + val controller = SearchController(manga) + router.pushController(controller) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt similarity index 70% rename from app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt index de345ae0af..97f35ed666 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt @@ -1,11 +1,14 @@ package eu.kanade.tachiyomi.ui.manga -import android.app.Application import android.content.Context -import android.os.Bundle +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Immutable +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.coroutineScope import eu.kanade.core.prefs.CheckboxState import eu.kanade.core.prefs.mapAsCheckboxState +import eu.kanade.data.chapter.NoChaptersException import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.category.interactor.GetCategories import eu.kanade.domain.category.interactor.SetMangaCategories @@ -13,10 +16,9 @@ import eu.kanade.domain.category.model.Category import eu.kanade.domain.chapter.interactor.SetMangaDefaultChapterFlags import eu.kanade.domain.chapter.interactor.SetReadStatus import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource -import eu.kanade.domain.chapter.interactor.SyncChaptersWithTrackServiceTwoWay import eu.kanade.domain.chapter.interactor.UpdateChapter +import eu.kanade.domain.chapter.model.Chapter import eu.kanade.domain.chapter.model.ChapterUpdate -import eu.kanade.domain.chapter.model.applyFilters import eu.kanade.domain.chapter.model.toDbChapter import eu.kanade.domain.download.service.DownloadPreferences import eu.kanade.domain.library.service.LibraryPreferences @@ -24,24 +26,26 @@ import eu.kanade.domain.manga.interactor.GetDuplicateLibraryManga import eu.kanade.domain.manga.interactor.GetMangaWithChapters import eu.kanade.domain.manga.interactor.SetMangaChapterFlags import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.model.TriStateFilter +import eu.kanade.domain.manga.model.isLocal import eu.kanade.domain.manga.model.toDbManga -import eu.kanade.domain.track.interactor.DeleteTrack import eu.kanade.domain.track.interactor.GetTracks -import eu.kanade.domain.track.interactor.InsertTrack import eu.kanade.domain.track.model.toDbTrack -import eu.kanade.domain.track.model.toDomainTrack import eu.kanade.domain.ui.UiPreferences +import eu.kanade.presentation.components.ChapterDownloadAction +import eu.kanade.presentation.manga.DownloadAction import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.manga.track.TrackItem import eu.kanade.tachiyomi.util.chapter.getChapterSort import eu.kanade.tachiyomi.util.chapter.getNextUnread @@ -54,14 +58,8 @@ import eu.kanade.tachiyomi.util.preference.asHotFlow import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.shouldDownloadNewChapters import eu.kanade.tachiyomi.util.system.logcat -import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -72,8 +70,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.withContext import logcat.LogPriority import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -81,13 +77,12 @@ import java.text.DateFormat import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.Date -import eu.kanade.domain.chapter.model.Chapter as DomainChapter -import eu.kanade.domain.manga.model.Manga as DomainManga -class MangaPresenter( +class MangaInfoScreenModel( + val context: Context, val mangaId: Long, - val isFromSource: Boolean, - private val basePreferences: BasePreferences = Injekt.get(), + private val isFromSource: Boolean, + basePreferences: BasePreferences = Injekt.get(), private val downloadPreferences: DownloadPreferences = Injekt.get(), private val libraryPreferences: LibraryPreferences = Injekt.get(), private val trackManager: TrackManager = Injekt.get(), @@ -103,34 +98,23 @@ class MangaPresenter( private val updateManga: UpdateManga = Injekt.get(), private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(), private val getCategories: GetCategories = Injekt.get(), - private val deleteTrack: DeleteTrack = Injekt.get(), private val getTracks: GetTracks = Injekt.get(), private val setMangaCategories: SetMangaCategories = Injekt.get(), - private val insertTrack: InsertTrack = Injekt.get(), - private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get(), -) : BasePresenter() { - - private val _state: MutableStateFlow = MutableStateFlow(MangaScreenState.Loading) - val state = _state.asStateFlow() + val snackbarHostState: SnackbarHostState = SnackbarHostState(), +) : StateScreenModel(MangaScreenState.Loading) { private val successState: MangaScreenState.Success? get() = state.value as? MangaScreenState.Success - private var _trackList: List = emptyList() - val trackList get() = _trackList - private val loggedServices by lazy { trackManager.services.filter { it.isLogged } } - private var searchTrackerJob: Job? = null - private var refreshTrackersJob: Job? = null - - val manga: DomainManga? + val manga: Manga? get() = successState?.manga val source: Source? get() = successState?.source - val isFavoritedManga: Boolean + private val isFavoritedManga: Boolean get() = manga?.favorite ?: false private val processedChapters: Sequence? @@ -142,7 +126,7 @@ class MangaPresenter( * Helper function to update the UI state only if it's currently in success state */ private fun updateSuccessState(func: (MangaScreenState.Success) -> MangaScreenState.Success) { - _state.update { if (it is MangaScreenState.Success) func(it) else it } + mutableState.update { if (it is MangaScreenState.Success) func(it) else it } } private var incognitoMode = false @@ -156,20 +140,18 @@ class MangaPresenter( field = value } - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - val toChapterItemsParams: List.(manga: DomainManga) -> List = { manga -> + init { + val toChapterItemsParams: List.(manga: Manga) -> List = { manga -> val uiPreferences = Injekt.get() toChapterItems( - context = view?.activity ?: Injekt.get(), + context = context, manga = manga, dateRelativeTime = uiPreferences.relativeTime().get(), dateFormat = UiPreferences.dateFormat(uiPreferences.dateFormat().get()), ) } - presenterScope.launchIO { + coroutineScope.launchIO { combine( getMangaAndChapters.subscribe(mangaId).distinctUntilChanged(), downloadCache.changes, @@ -187,7 +169,7 @@ class MangaPresenter( observeDownloads() - presenterScope.launchIO { + coroutineScope.launchIO { val manga = getMangaAndChapters.awaitManga(mangaId) val chapters = getMangaAndChapters.awaitChapters(mangaId) .toChapterItemsParams(manga) @@ -200,12 +182,11 @@ class MangaPresenter( val needRefreshChapter = chapters.isEmpty() // Show what we have earlier - _state.update { + mutableState.update { MangaScreenState.Success( manga = manga, source = Injekt.get().getOrStub(manga.source), isFromSource = isFromSource, - trackingAvailable = trackManager.hasLoggedServices(), chapters = chapters, isRefreshingData = needRefreshInfo || needRefreshChapter, isIncognitoMode = incognitoMode, @@ -216,10 +197,9 @@ class MangaPresenter( // Start observe tracking since it only needs mangaId observeTrackers() - observeTrackingCount() // Fetch info-chapters when needed - if (presenterScope.isActive) { + if (coroutineScope.isActive) { val fetchFromSourceTasks = listOf( async { if (needRefreshInfo) fetchMangaFromSource() }, async { if (needRefreshChapter) fetchChaptersFromSource() }, @@ -233,15 +213,15 @@ class MangaPresenter( basePreferences.incognitoMode() .asHotFlow { incognitoMode = it } - .launchIn(presenterScope) + .launchIn(coroutineScope) basePreferences.downloadedOnly() .asHotFlow { downloadedOnlyMode = it } - .launchIn(presenterScope) + .launchIn(coroutineScope) } fun fetchAllFromSource(manualFetch: Boolean = true) { - presenterScope.launch { + coroutineScope.launch { updateSuccessState { it.copy(isRefreshingData = true) } val fetchFromSourceTasks = listOf( async { fetchMangaFromSource(manualFetch) }, @@ -265,21 +245,44 @@ class MangaPresenter( updateManga.awaitUpdateFromSource(it.manga, networkManga, manualFetch) } } catch (e: Throwable) { - withUIContext { view?.onFetchMangaInfoError(e) } + withUIContext { + // Ignore early hints "errors" that aren't handled by OkHttp + if (e !is HttpException || e.code != 103) { + snackbarHostState.showSnackbar(message = "${e.message}") + logcat(LogPriority.ERROR, e) + } + } } } } + fun toggleFavorite() { + toggleFavorite( + onRemoved = { + coroutineScope.launch { + if (!hasDownloads()) return@launch + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.delete_downloads_for_manga), + actionLabel = context.getString(R.string.action_delete), + withDismissAction = true, + ) + if (result == SnackbarResult.ActionPerformed) { + deleteDownloads() + } + } + }, + ) + } + /** * Update favorite status of manga, (removes / adds) manga (to / from) library. */ fun toggleFavorite( onRemoved: () -> Unit, - onAdded: () -> Unit, checkDuplicate: Boolean = true, ) { val state = successState ?: return - presenterScope.launchIO { + coroutineScope.launchIO { val manga = state.manga if (isFavoritedManga) { @@ -298,7 +301,7 @@ class MangaPresenter( val duplicate = getDuplicateLibraryManga.await(manga.title, manga.source) if (duplicate != null) { - _state.update { state -> + mutableState.update { state -> when (state) { MangaScreenState.Loading -> state is MangaScreenState.Success -> state.copy(dialog = Dialog.DuplicateManga(manga, duplicate)) @@ -318,7 +321,6 @@ class MangaPresenter( val result = updateManga.awaitUpdateFavorite(manga.id, true) if (!result) return@launchIO moveMangaToCategory(defaultCategory) - withUIContext { onAdded() } } // Automatic 'Default' or no categories @@ -326,7 +328,6 @@ class MangaPresenter( val result = updateManga.awaitUpdateFavorite(manga.id, true) if (!result) return@launchIO moveMangaToCategory(null) - withUIContext { onAdded() } } // Choose a category @@ -335,7 +336,7 @@ class MangaPresenter( // Finally match with enhanced tracking when available val source = state.source - trackList + state.trackItems .map { it.service } .filterIsInstance() .filter { it.accept(source) } @@ -343,7 +344,7 @@ class MangaPresenter( launchIO { try { service.match(manga.toDbManga())?.let { track -> - registerTracking(track, service as TrackService) + (service as TrackService).registerTracking(track, mangaId) } } catch (e: Exception) { logcat(LogPriority.WARN, e) { @@ -359,10 +360,10 @@ class MangaPresenter( fun promptChangeCategories() { val state = successState ?: return val manga = state.manga - presenterScope.launch { + coroutineScope.launch { val categories = getCategories() val selection = getMangaCategoryIds(manga) - _state.update { state -> + mutableState.update { state -> when (state) { MangaScreenState.Loading -> state is MangaScreenState.Success -> state.copy( @@ -387,7 +388,7 @@ class MangaPresenter( /** * Deletes all the downloads for the manga. */ - fun deleteDownloads() { + private fun deleteDownloads() { val state = successState ?: return downloadManager.deleteManga(state.manga, state.source) } @@ -407,15 +408,15 @@ class MangaPresenter( * @param manga the manga to get categories from. * @return Array of category ids the manga is in, if none returns default id */ - private suspend fun getMangaCategoryIds(manga: DomainManga): List { + private suspend fun getMangaCategoryIds(manga: Manga): List { return getCategories.await(manga.id) .map { it.id } } - fun moveMangaToCategoriesAndAddToLibrary(manga: DomainManga, categories: List) { + fun moveMangaToCategoriesAndAddToLibrary(manga: Manga, categories: List) { moveMangaToCategory(categories) if (!manga.favorite) { - presenterScope.launchIO { + coroutineScope.launchIO { updateManga.awaitUpdateFavorite(manga.id, true) } } @@ -432,7 +433,7 @@ class MangaPresenter( } private fun moveMangaToCategory(categoryIds: List) { - presenterScope.launchIO { + coroutineScope.launchIO { setMangaCategories.await(mangaId, categoryIds) } } @@ -446,28 +447,12 @@ class MangaPresenter( moveMangaToCategories(listOfNotNull(category)) } - private fun observeTrackingCount() { - val manga = successState?.manga ?: return - - presenterScope.launchIO { - getTracks.subscribe(manga.id) - .catch { logcat(LogPriority.ERROR, it) } - .map { tracks -> - val loggedServicesId = loggedServices.map { it.id } - tracks.filter { it.syncId in loggedServicesId }.size - } - .collectLatest { trackingCount -> - updateSuccessState { it.copy(trackingCount = trackingCount) } - } - } - } - // Manga info - end // Chapters list - start private fun observeDownloads() { - presenterScope.launchIO { + coroutineScope.launchIO { downloadManager.queue.statusFlow() .filter { it.manga.id == successState?.manga?.id } .catch { error -> logcat(LogPriority.ERROR, error) } @@ -478,7 +463,7 @@ class MangaPresenter( } } - presenterScope.launchIO { + coroutineScope.launchIO { downloadManager.queue.progressFlow() .filter { it.manga.id == successState?.manga?.id } .catch { error -> logcat(LogPriority.ERROR, error) } @@ -504,9 +489,9 @@ class MangaPresenter( } } - private fun List.toChapterItems( + private fun List.toChapterItems( context: Context, - manga: DomainManga, + manga: Manga, dateRelativeTime: Int, dateFormat: DateFormat, ): List { @@ -522,7 +507,7 @@ class MangaPresenter( chapter = chapter, downloadState = downloadState, downloadProgress = activeDownload?.progress ?: 0, - chapterTitleString = if (manga.displayMode == DomainManga.CHAPTER_DISPLAY_NUMBER) { + chapterTitleString = if (manga.displayMode == Manga.CHAPTER_DISPLAY_NUMBER) { context.getString( R.string.display_mode_chapter, chapterDecimalFormat.format(chapter.chapterNumber.toDouble()), @@ -569,7 +554,14 @@ class MangaPresenter( } } } catch (e: Throwable) { - withUIContext { view?.onFetchChaptersError(e) } + withUIContext { + if (e is NoChaptersException) { + snackbarHostState.showSnackbar(message = context.getString(R.string.no_chapters_error)) + } else { + snackbarHostState.showSnackbar(message = "${e.message}") + logcat(LogPriority.ERROR, e) + } + } } } } @@ -577,12 +569,12 @@ class MangaPresenter( /** * Returns the next unread chapter or null if everything is read. */ - fun getNextUnreadChapter(): DomainChapter? { + fun getNextUnreadChapter(): Chapter? { val successState = successState ?: return null return successState.chapters.getNextUnread(successState.manga) } - fun getUnreadChapters(): List { + fun getUnreadChapters(): List { return successState?.processedChapters ?.filter { (chapter, dlStatus) -> !chapter.read && dlStatus == Download.State.NOT_DOWNLOADED } ?.map { it.chapter } @@ -590,14 +582,76 @@ class MangaPresenter( ?: emptyList() } - fun getUnreadChaptersSorted(): List { + fun getUnreadChaptersSorted(): List { val manga = successState?.manga ?: return emptyList() val chapters = getUnreadChapters().sortedWith(getChapterSort(manga)) return if (manga.sortDescending()) chapters.reversed() else chapters } - fun startDownloadingNow(chapterId: Long) { - downloadManager.startDownloadNow(chapterId) + fun startDownload( + chapters: List, + startNow: Boolean, + ) { + if (startNow) { + val chapterId = chapters.singleOrNull()?.id ?: return + downloadManager.startDownloadNow(chapterId) + } else { + downloadChapters(chapters) + } + if (!isFavoritedManga) { + coroutineScope.launch { + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.snack_add_to_library), + actionLabel = context.getString(R.string.action_add), + withDismissAction = true, + ) + if (result == SnackbarResult.ActionPerformed && !isFavoritedManga) { + toggleFavorite() + } + } + } + } + + fun runChapterDownloadActions( + items: List, + action: ChapterDownloadAction, + ) { + when (action) { + ChapterDownloadAction.START -> { + startDownload(items.map { it.chapter }, false) + if (items.any { it.downloadState == Download.State.ERROR }) { + DownloadService.start(context) + } + } + ChapterDownloadAction.START_NOW -> { + val chapter = items.singleOrNull()?.chapter ?: return + startDownload(listOf(chapter), true) + } + ChapterDownloadAction.CANCEL -> { + val chapterId = items.singleOrNull()?.chapter?.id ?: return + cancelDownload(chapterId) + } + ChapterDownloadAction.DELETE -> { + deleteChapters(items.map { it.chapter }) + } + } + } + + fun runDownloadAction(action: DownloadAction) { + val chaptersToDownload = when (action) { + DownloadAction.NEXT_1_CHAPTER -> getUnreadChaptersSorted().take(1) + DownloadAction.NEXT_5_CHAPTERS -> getUnreadChaptersSorted().take(5) + DownloadAction.NEXT_10_CHAPTERS -> getUnreadChaptersSorted().take(10) + DownloadAction.CUSTOM -> { + showDownloadCustomDialog() + return + } + DownloadAction.UNREAD_CHAPTERS -> getUnreadChapters() + DownloadAction.ALL_CHAPTERS -> successState?.chapters?.map { it.chapter } + } + if (!chaptersToDownload.isNullOrEmpty()) { + startDownload(chaptersToDownload, false) + } } fun cancelDownload(chapterId: Long) { @@ -606,7 +660,7 @@ class MangaPresenter( updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED }) } - fun markPreviousChapterRead(pointer: DomainChapter) { + fun markPreviousChapterRead(pointer: Chapter) { val successState = successState ?: return val chapters = processedChapters.orEmpty().map { it.chapter }.toList() val prevChapters = if (successState.manga.sortDescending()) chapters.asReversed() else chapters @@ -619,8 +673,8 @@ class MangaPresenter( * @param chapters the list of selected chapters. * @param read whether to mark chapters as read or unread. */ - fun markChaptersRead(chapters: List, read: Boolean) { - presenterScope.launchIO { + fun markChaptersRead(chapters: List, read: Boolean) { + coroutineScope.launchIO { setReadStatus.await( read = read, chapters = chapters.toTypedArray(), @@ -633,7 +687,7 @@ class MangaPresenter( * Downloads the given list of chapters with the manager. * @param chapters the list of chapters to download. */ - fun downloadChapters(chapters: List) { + private fun downloadChapters(chapters: List) { val manga = successState?.manga ?: return downloadManager.downloadChapters(manga, chapters.map { it.toDbChapter() }) toggleAllSelection(false) @@ -643,8 +697,8 @@ class MangaPresenter( * Bookmarks the given list of chapters. * @param chapters the list of chapters to bookmark. */ - fun bookmarkChapters(chapters: List, bookmarked: Boolean) { - presenterScope.launchIO { + fun bookmarkChapters(chapters: List, bookmarked: Boolean) { + coroutineScope.launchIO { chapters .filterNot { it.bookmark == bookmarked } .map { ChapterUpdate(id = it.id, bookmark = bookmarked) } @@ -658,8 +712,8 @@ class MangaPresenter( * * @param chapters the list of chapters to delete. */ - fun deleteChapters(chapters: List) { - presenterScope.launchNonCancellable { + fun deleteChapters(chapters: List) { + coroutineScope.launchNonCancellable { try { successState?.let { state -> downloadManager.deleteChapters( @@ -674,8 +728,8 @@ class MangaPresenter( } } - private fun downloadNewChapters(chapters: List) { - presenterScope.launchNonCancellable { + private fun downloadNewChapters(chapters: List) { + coroutineScope.launchNonCancellable { val manga = successState?.manga ?: return@launchNonCancellable val categories = getCategories.await(manga.id).map { it.id } if (chapters.isEmpty() || !manga.shouldDownloadNewChapters(categories, downloadPreferences)) return@launchNonCancellable @@ -687,15 +741,15 @@ class MangaPresenter( * Sets the read filter and requests an UI update. * @param state whether to display only unread chapters or all chapters. */ - fun setUnreadFilter(state: State) { + fun setUnreadFilter(state: TriStateFilter) { val manga = successState?.manga ?: return val flag = when (state) { - State.IGNORE -> DomainManga.SHOW_ALL - State.INCLUDE -> DomainManga.CHAPTER_SHOW_UNREAD - State.EXCLUDE -> DomainManga.CHAPTER_SHOW_READ + TriStateFilter.DISABLED -> Manga.SHOW_ALL + TriStateFilter.ENABLED_IS -> Manga.CHAPTER_SHOW_UNREAD + TriStateFilter.ENABLED_NOT -> Manga.CHAPTER_SHOW_READ } - presenterScope.launchNonCancellable { + coroutineScope.launchNonCancellable { setMangaChapterFlags.awaitSetUnreadFilter(manga, flag) } } @@ -704,16 +758,16 @@ class MangaPresenter( * Sets the download filter and requests an UI update. * @param state whether to display only downloaded chapters or all chapters. */ - fun setDownloadedFilter(state: State) { + fun setDownloadedFilter(state: TriStateFilter) { val manga = successState?.manga ?: return val flag = when (state) { - State.IGNORE -> DomainManga.SHOW_ALL - State.INCLUDE -> DomainManga.CHAPTER_SHOW_DOWNLOADED - State.EXCLUDE -> DomainManga.CHAPTER_SHOW_NOT_DOWNLOADED + TriStateFilter.DISABLED -> Manga.SHOW_ALL + TriStateFilter.ENABLED_IS -> Manga.CHAPTER_SHOW_DOWNLOADED + TriStateFilter.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_DOWNLOADED } - presenterScope.launchNonCancellable { + coroutineScope.launchNonCancellable { setMangaChapterFlags.awaitSetDownloadedFilter(manga, flag) } } @@ -722,16 +776,16 @@ class MangaPresenter( * Sets the bookmark filter and requests an UI update. * @param state whether to display only bookmarked chapters or all chapters. */ - fun setBookmarkedFilter(state: State) { + fun setBookmarkedFilter(state: TriStateFilter) { val manga = successState?.manga ?: return val flag = when (state) { - State.IGNORE -> DomainManga.SHOW_ALL - State.INCLUDE -> DomainManga.CHAPTER_SHOW_BOOKMARKED - State.EXCLUDE -> DomainManga.CHAPTER_SHOW_NOT_BOOKMARKED + TriStateFilter.DISABLED -> Manga.SHOW_ALL + TriStateFilter.ENABLED_IS -> Manga.CHAPTER_SHOW_BOOKMARKED + TriStateFilter.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_BOOKMARKED } - presenterScope.launchNonCancellable { + coroutineScope.launchNonCancellable { setMangaChapterFlags.awaitSetBookmarkFilter(manga, flag) } } @@ -743,7 +797,7 @@ class MangaPresenter( fun setDisplayMode(mode: Long) { val manga = successState?.manga ?: return - presenterScope.launchNonCancellable { + coroutineScope.launchNonCancellable { setMangaChapterFlags.awaitSetDisplayMode(manga, mode) } } @@ -755,11 +809,22 @@ class MangaPresenter( fun setSorting(sort: Long) { val manga = successState?.manga ?: return - presenterScope.launchNonCancellable { + coroutineScope.launchNonCancellable { setMangaChapterFlags.awaitSetSortingModeOrFlipOrder(manga, sort) } } + fun setCurrentSettingsAsDefault(applyToExisting: Boolean) { + val manga = successState?.manga ?: return + coroutineScope.launchNonCancellable { + libraryPreferences.setChapterSettingsDefault(manga) + if (applyToExisting) { + setMangaDefaultChapterFlags.awaitAll() + } + snackbarHostState.showSnackbar(message = context.getString(R.string.chapter_settings_updated)) + } + } + fun toggleSelection( item: ChapterItem, selected: Boolean, @@ -850,7 +915,7 @@ class MangaPresenter( private fun observeTrackers() { val manga = successState?.manga ?: return - presenterScope.launchIO { + coroutineScope.launchIO { getTracks.subscribe(manga.id) .catch { logcat(LogPriority.ERROR, it) } .map { tracks -> @@ -861,184 +926,31 @@ class MangaPresenter( // Show only if the service supports this manga's source .filter { (it.service as? EnhancedTrackService)?.accept(source!!) ?: true } } + .distinctUntilChanged() .collectLatest { trackItems -> - _trackList = trackItems - withContext(Dispatchers.Main) { - view?.onNextTrackers(trackItems) - } + updateSuccessState { it.copy(trackItems = trackItems) } } } } - fun refreshTrackers() { - refreshTrackersJob?.cancel() - refreshTrackersJob = presenterScope.launchNonCancellable { - supervisorScope { - try { - trackList - .map { - async { - val track = it.track ?: return@async null - - val updatedTrack = it.service.refresh(track) - - val domainTrack = updatedTrack.toDomainTrack() ?: return@async null - insertTrack.await(domainTrack) - - (it.service as? EnhancedTrackService)?.let { _ -> - val allChapters = successState?.chapters - ?.map { it.chapter } ?: emptyList() - - syncChaptersWithTrackServiceTwoWay - .await(allChapters, domainTrack, it.service) - } - } - } - .awaitAll() - - withUIContext { view?.onTrackingRefreshDone() } - } catch (e: Throwable) { - withUIContext { view?.onTrackingRefreshError(e) } - } - } - } - } - - fun trackingSearch(query: String, service: TrackService) { - searchTrackerJob?.cancel() - searchTrackerJob = presenterScope.launchIO { - try { - val results = service.search(query) - withUIContext { view?.onTrackingSearchResults(results) } - } catch (e: Throwable) { - withUIContext { view?.onTrackingSearchResultsError(e) } - } - } - } - - fun registerTracking(item: Track?, service: TrackService) { - val successState = successState ?: return - if (item != null) { - item.manga_id = successState.manga.id - presenterScope.launchNonCancellable { - try { - val allChapters = successState.chapters.map { it.chapter } - val hasReadChapters = allChapters.any { it.read } - service.bind(item, hasReadChapters) - - item.toDomainTrack(idRequired = false)?.let { track -> - insertTrack.await(track) - - // Update chapter progress if newer chapters marked read locally - if (hasReadChapters) { - val latestLocalReadChapterNumber = allChapters - .sortedBy { it.chapterNumber } - .takeWhile { it.read } - .lastOrNull() - ?.chapterNumber?.toDouble() ?: -1.0 - - if (latestLocalReadChapterNumber > track.lastChapterRead) { - val updatedTrack = track.copy( - lastChapterRead = latestLocalReadChapterNumber, - ) - setTrackerLastChapterRead(TrackItem(updatedTrack.toDbTrack(), service), latestLocalReadChapterNumber.toInt()) - } - } - - if (service is EnhancedTrackService) { - syncChaptersWithTrackServiceTwoWay.await(allChapters, track, service) - } - } - } catch (e: Throwable) { - withUIContext { view?.applicationContext?.toast(e.message) } - } - } - } else { - unregisterTracking(service) - } - } - - fun unregisterTracking(service: TrackService) { - val manga = successState?.manga ?: return - - presenterScope.launchNonCancellable { - deleteTrack.await(manga.id, service.id) - } - } - - private fun updateRemote(track: Track, service: TrackService) { - presenterScope.launchNonCancellable { - try { - service.update(track) - - track.toDomainTrack(idRequired = false)?.let { - insertTrack.await(it) - } - - withUIContext { view?.onTrackingRefreshDone() } - } catch (e: Throwable) { - withUIContext { view?.onTrackingRefreshError(e) } - - // Restart on error to set old values - observeTrackers() - } - } - } - - fun setTrackerStatus(item: TrackItem, index: Int) { - val track = item.track!! - track.status = item.service.getStatusList()[index] - if (track.status == item.service.getCompletionStatus() && track.total_chapters != 0) { - track.last_chapter_read = track.total_chapters.toFloat() - } - updateRemote(track, item.service) - } - - fun setTrackerScore(item: TrackItem, index: Int) { - val track = item.track!! - track.score = item.service.indexToScore(index) - updateRemote(track, item.service) - } - - fun setTrackerLastChapterRead(item: TrackItem, chapterNumber: Int) { - val track = item.track!! - if (track.last_chapter_read == 0F && track.last_chapter_read < chapterNumber && track.status != item.service.getRereadingStatus()) { - track.status = item.service.getReadingStatus() - } - track.last_chapter_read = chapterNumber.toFloat() - if (track.total_chapters != 0 && track.last_chapter_read.toInt() == track.total_chapters) { - track.status = item.service.getCompletionStatus() - } - updateRemote(track, item.service) - } - - fun setTrackerStartDate(item: TrackItem, date: Long) { - val track = item.track!! - track.started_reading_date = date - updateRemote(track, item.service) - } - - fun setTrackerFinishDate(item: TrackItem, date: Long) { - val track = item.track!! - track.finished_reading_date = date - updateRemote(track, item.service) - } - // Track sheet - end - fun getSourceOrStub(manga: DomainManga): Source { + fun getSourceOrStub(manga: Manga): Source { return sourceManager.getOrStub(manga.source) } sealed class Dialog { - data class ChangeCategory(val manga: DomainManga, val initialSelection: List>) : Dialog() - data class DeleteChapters(val chapters: List) : Dialog() - data class DuplicateManga(val manga: DomainManga, val duplicate: DomainManga) : Dialog() + data class ChangeCategory(val manga: Manga, val initialSelection: List>) : Dialog() + data class DeleteChapters(val chapters: List) : Dialog() + data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog() data class DownloadCustomAmount(val max: Int) : Dialog() + object SettingsSheet : Dialog() + object TrackSheet : Dialog() + object FullCover : Dialog() } fun dismissDialog() { - _state.update { state -> + mutableState.update { state -> when (state) { MangaScreenState.Loading -> state is MangaScreenState.Success -> state.copy(dialog = null) @@ -1046,9 +958,9 @@ class MangaPresenter( } } - fun showDownloadCustomDialog() { + private fun showDownloadCustomDialog() { val max = processedChapters?.count() ?: return - _state.update { state -> + mutableState.update { state -> when (state) { MangaScreenState.Loading -> state is MangaScreenState.Success -> state.copy(dialog = Dialog.DownloadCustomAmount(max)) @@ -1056,14 +968,45 @@ class MangaPresenter( } } - fun showDeleteChapterDialog(chapters: List) { - _state.update { state -> + fun showDeleteChapterDialog(chapters: List) { + mutableState.update { state -> when (state) { MangaScreenState.Loading -> state is MangaScreenState.Success -> state.copy(dialog = Dialog.DeleteChapters(chapters)) } } } + + fun showSettingsDialog() { + mutableState.update { state -> + when (state) { + MangaScreenState.Loading -> state + is MangaScreenState.Success -> state.copy(dialog = Dialog.SettingsSheet) + } + } + } + + fun showTrackDialog() { + mutableState.update { state -> + when (state) { + MangaScreenState.Loading -> state + is MangaScreenState.Success -> { + state.copy(dialog = Dialog.TrackSheet) + } + } + } + } + + fun showCoverDialog() { + mutableState.update { state -> + when (state) { + MangaScreenState.Loading -> state + is MangaScreenState.Success -> { + state.copy(dialog = Dialog.FullCover) + } + } + } + } } sealed class MangaScreenState { @@ -1072,26 +1015,65 @@ sealed class MangaScreenState { @Immutable data class Success( - val manga: DomainManga, + val manga: Manga, val source: Source, val isFromSource: Boolean, val chapters: List, - val trackingAvailable: Boolean = false, - val trackingCount: Int = 0, + val trackItems: List = emptyList(), val isRefreshingData: Boolean = false, val isIncognitoMode: Boolean = false, val isDownloadedOnlyMode: Boolean = false, - val dialog: MangaPresenter.Dialog? = null, + val dialog: MangaInfoScreenModel.Dialog? = null, ) : MangaScreenState() { val processedChapters: Sequence get() = chapters.applyFilters(manga) + + val trackingAvailable: Boolean + get() = trackItems.isNotEmpty() + + val trackingCount: Int + get() = trackItems.count { it.track != null } + + /** + * Applies the view filters to the list of chapters obtained from the database. + * @return an observable of the list of chapters filtered and sorted. + */ + private fun List.applyFilters(manga: Manga): Sequence { + val isLocalManga = manga.isLocal() + val unreadFilter = manga.unreadFilter + val downloadedFilter = manga.downloadedFilter + val bookmarkedFilter = manga.bookmarkedFilter + return asSequence() + .filter { (chapter) -> + when (unreadFilter) { + TriStateFilter.DISABLED -> true + TriStateFilter.ENABLED_IS -> !chapter.read + TriStateFilter.ENABLED_NOT -> chapter.read + } + } + .filter { (chapter) -> + when (bookmarkedFilter) { + TriStateFilter.DISABLED -> true + TriStateFilter.ENABLED_IS -> chapter.bookmark + TriStateFilter.ENABLED_NOT -> !chapter.bookmark + } + } + .filter { + when (downloadedFilter) { + TriStateFilter.DISABLED -> true + TriStateFilter.ENABLED_IS -> it.isDownloaded || isLocalManga + TriStateFilter.ENABLED_NOT -> !it.isDownloaded && !isLocalManga + } + } + .sortedWith { (chapter1), (chapter2) -> getChapterSort(manga).invoke(chapter1, chapter2) } + } } } @Immutable data class ChapterItem( - val chapter: DomainChapter, + val chapter: Chapter, val downloadState: Download.State, val downloadProgress: Int, @@ -1104,7 +1086,7 @@ data class ChapterItem( val isDownloaded = downloadState == Download.State.DOWNLOADED } -private val chapterDecimalFormat = DecimalFormat( +val chapterDecimalFormat = DecimalFormat( "#.###", DecimalFormatSymbols() .apply { decimalSeparator = '.' }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt deleted file mode 100644 index 52ec4d052f..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt +++ /dev/null @@ -1,298 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.content.Context -import android.os.Bundle -import android.util.AttributeSet -import android.view.View -import androidx.core.view.isVisible -import com.bluelinelabs.conductor.Router -import eu.kanade.domain.manga.model.Manga -import eu.kanade.domain.manga.model.toTriStateGroupState -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.manga.MangaPresenter -import eu.kanade.tachiyomi.ui.manga.MangaScreenState -import eu.kanade.tachiyomi.util.view.popupMenu -import eu.kanade.tachiyomi.widget.ExtendedNavigationView -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State -import eu.kanade.tachiyomi.widget.sheet.TabbedBottomSheetDialog -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch - -class ChaptersSettingsSheet( - private val router: Router, - private val presenter: MangaPresenter, -) : TabbedBottomSheetDialog(router.activity!!) { - - private lateinit var scope: CoroutineScope - - private var manga: Manga? = null - - private val filters = Filter(context) - private val sort = Sort(context) - private val display = Display(context) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - binding.menu.isVisible = true - binding.menu.setOnClickListener { it.post { showPopupMenu(it) } } - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - scope = MainScope() - scope.launch { - presenter.state - .filterIsInstance() - .collectLatest { - manga = it.manga - getTabViews().forEach { settings -> (settings as Settings).updateView() } - } - } - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - scope.cancel() - } - - override fun getTabViews(): List = listOf( - filters, - sort, - display, - ) - - override fun getTabTitles(): List = listOf( - R.string.action_filter, - R.string.action_sort, - R.string.action_display, - ) - - private fun showPopupMenu(view: View) { - view.popupMenu( - menuRes = R.menu.default_chapter_filter, - onMenuItemClick = { - when (itemId) { - R.id.set_as_default -> { - SetChapterSettingsDialog(presenter.manga!!).showDialog(router) - } - } - }, - ) - } - - /** - * Filters group (unread, downloaded, ...). - */ - inner class Filter @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - Settings(context, attrs) { - - private val filterGroup = FilterGroup() - - init { - setGroups(listOf(filterGroup)) - } - - /** - * Returns true if there's at least one filter from [FilterGroup] active. - */ - fun hasActiveFilters(): Boolean { - return filterGroup.items.any { it.state != State.IGNORE.value } - } - - override fun updateView() { - filterGroup.updateModels() - } - - inner class FilterGroup : Group { - - private val downloaded = Item.TriStateGroup(R.string.action_filter_downloaded, this) - private val unread = Item.TriStateGroup(R.string.action_filter_unread, this) - private val bookmarked = Item.TriStateGroup(R.string.action_filter_bookmarked, this) - - override val header: Item? = null - override val items = listOf(downloaded, unread, bookmarked) - override val footer: Item? = null - - override fun initModels() { - val manga = manga ?: return - if (manga.forceDownloaded()) { - downloaded.state = State.INCLUDE.value - downloaded.enabled = false - } else { - downloaded.state = manga.downloadedFilter.toTriStateGroupState().value - } - unread.state = manga.unreadFilter.toTriStateGroupState().value - bookmarked.state = manga.bookmarkedFilter.toTriStateGroupState().value - } - - fun updateModels() { - initModels() - adapter.notifyItemRangeChanged(0, 3) - } - - override fun onItemClicked(item: Item) { - item as Item.TriStateGroup - val newState = when (item.state) { - State.IGNORE.value -> State.INCLUDE - State.INCLUDE.value -> State.EXCLUDE - State.EXCLUDE.value -> State.IGNORE - else -> throw Exception("Unknown State") - } - when (item) { - downloaded -> presenter.setDownloadedFilter(newState) - unread -> presenter.setUnreadFilter(newState) - bookmarked -> presenter.setBookmarkedFilter(newState) - else -> {} - } - } - } - } - - /** - * Sorting group (alphabetically, by last read, ...) and ascending or descending. - */ - inner class Sort @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - Settings(context, attrs) { - - private val group = SortGroup() - - init { - setGroups(listOf(group)) - } - - override fun updateView() { - group.updateModels() - } - - inner class SortGroup : Group { - - private val source = Item.MultiSort(R.string.sort_by_source, this) - private val chapterNum = Item.MultiSort(R.string.sort_by_number, this) - private val uploadDate = Item.MultiSort(R.string.sort_by_upload_date, this) - - override val header: Item? = null - override val items = listOf(source, uploadDate, chapterNum) - override val footer: Item? = null - - override fun initModels() { - val manga = manga ?: return - val sorting = manga.sorting - val order = if (manga.sortDescending()) { - Item.MultiSort.SORT_DESC - } else { - Item.MultiSort.SORT_ASC - } - - source.state = - if (sorting == Manga.CHAPTER_SORTING_SOURCE) order else Item.MultiSort.SORT_NONE - chapterNum.state = - if (sorting == Manga.CHAPTER_SORTING_NUMBER) order else Item.MultiSort.SORT_NONE - uploadDate.state = - if (sorting == Manga.CHAPTER_SORTING_UPLOAD_DATE) order else Item.MultiSort.SORT_NONE - } - - fun updateModels() { - initModels() - adapter.notifyItemRangeChanged(0, 3) - } - - override fun onItemClicked(item: Item) { - when (item) { - source -> presenter.setSorting(Manga.CHAPTER_SORTING_SOURCE) - chapterNum -> presenter.setSorting(Manga.CHAPTER_SORTING_NUMBER) - uploadDate -> presenter.setSorting(Manga.CHAPTER_SORTING_UPLOAD_DATE) - else -> throw Exception("Unknown sorting") - } - } - } - } - - /** - * Display group, to show the library as a list or a grid. - */ - inner class Display @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - Settings(context, attrs) { - - private val group = DisplayGroup() - - init { - setGroups(listOf(group)) - } - - override fun updateView() { - group.updateModels() - } - - inner class DisplayGroup : Group { - - private val displayTitle = Item.Radio(R.string.show_title, this) - private val displayChapterNum = Item.Radio(R.string.show_chapter_number, this) - - override val header: Item? = null - override val items = listOf(displayTitle, displayChapterNum) - override val footer: Item? = null - - override fun initModels() { - val mode = manga?.displayMode ?: return - displayTitle.checked = mode == Manga.CHAPTER_DISPLAY_NAME - displayChapterNum.checked = mode == Manga.CHAPTER_DISPLAY_NUMBER - } - - fun updateModels() { - initModels() - adapter.notifyItemRangeChanged(0, 2) - } - - override fun onItemClicked(item: Item) { - item as Item.Radio - if (item.checked) return - - when (item) { - displayTitle -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NAME) - displayChapterNum -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NUMBER) - else -> throw NotImplementedError("Unknown display mode") - } - } - } - } - - open inner class Settings(context: Context, attrs: AttributeSet?) : - ExtendedNavigationView(context, attrs) { - - lateinit var adapter: Adapter - - /** - * Click listener to notify the parent fragment when an item from a group is clicked. - */ - var onGroupClicked: (Group) -> Unit = {} - - fun setGroups(groups: List) { - adapter = Adapter(groups.map { it.createItems() }.flatten()) - recycler.adapter = adapter - - groups.forEach { it.initModels() } - addView(recycler) - } - - open fun updateView() { - } - - /** - * Adapter of the recycler view. - */ - inner class Adapter(items: List) : ExtendedNavigationView.Adapter(items) { - - override fun onItemClicked(item: Item) { - if (item is GroupedItem) { - item.group.onItemClicked(item) - onGroupClicked(item.group) - } - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetChapterSettingsDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetChapterSettingsDialog.kt deleted file mode 100644 index ea89782f03..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetChapterSettingsDialog.kt +++ /dev/null @@ -1,61 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.app.Dialog -import android.os.Bundle -import androidx.core.os.bundleOf -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import eu.kanade.domain.chapter.interactor.SetMangaDefaultChapterFlags -import eu.kanade.domain.library.service.LibraryPreferences -import eu.kanade.domain.manga.model.Manga -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.util.system.getSerializableCompat -import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.widget.DialogCheckboxView -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import uy.kohesive.injekt.injectLazy - -class SetChapterSettingsDialog(bundle: Bundle? = null) : DialogController(bundle) { - - private val scope = CoroutineScope(Dispatchers.IO) - - private val libraryPreferences: LibraryPreferences by injectLazy() - private val setMangaDefaultChapterFlags: SetMangaDefaultChapterFlags by injectLazy() - - constructor(manga: Manga) : this( - bundleOf(MANGA_KEY to manga), - ) - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val view = DialogCheckboxView(activity!!).apply { - setDescription(R.string.confirm_set_chapter_settings) - setOptionDescription(R.string.also_set_chapter_settings_for_library) - } - - return MaterialAlertDialogBuilder(activity!!) - .setTitle(R.string.chapter_settings) - .setView(view) - .setPositiveButton(android.R.string.ok) { _, _ -> - libraryPreferences.setChapterSettingsDefault(args.getSerializableCompat(MANGA_KEY)!!) - if (view.isChecked()) { - scope.launch { - setMangaDefaultChapterFlags.awaitAll() - } - } - - activity?.toast(R.string.chapter_settings_updated) - } - .setNegativeButton(R.string.action_cancel, null) - .create() - } - - override fun onDestroy() { - super.onDestroy() - scope.cancel() - } -} - -private const val MANGA_KEY = "manga" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaFullCoverDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaFullCoverDialog.kt deleted file mode 100644 index 5023d47f74..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaFullCoverDialog.kt +++ /dev/null @@ -1,240 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.info - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.graphics.drawable.BitmapDrawable -import android.net.Uri -import android.os.Bundle -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember -import androidx.core.os.bundleOf -import coil.imageLoader -import coil.request.ImageRequest -import coil.size.Size -import eu.kanade.domain.manga.interactor.GetManga -import eu.kanade.domain.manga.interactor.UpdateManga -import eu.kanade.domain.manga.model.Manga -import eu.kanade.domain.manga.model.hasCustomCover -import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.presentation.manga.EditCoverAction -import eu.kanade.presentation.manga.components.MangaCoverDialog -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.cache.CoverCache -import eu.kanade.tachiyomi.data.saver.Image -import eu.kanade.tachiyomi.data.saver.ImageSaver -import eu.kanade.tachiyomi.data.saver.Location -import eu.kanade.tachiyomi.ui.base.controller.FullComposeController -import eu.kanade.tachiyomi.util.editCover -import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.withUIContext -import eu.kanade.tachiyomi.util.system.logcat -import eu.kanade.tachiyomi.util.system.toShareIntent -import eu.kanade.tachiyomi.util.system.toast -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import logcat.LogPriority -import nucleus.presenter.Presenter -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import uy.kohesive.injekt.injectLazy - -class MangaFullCoverDialog : FullComposeController { - - private val mangaId: Long - - @Suppress("unused") - constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) - - constructor( - mangaId: Long, - ) : super(bundleOf(MANGA_EXTRA to mangaId)) { - this.mangaId = mangaId - } - - override fun createPresenter() = MangaFullCoverPresenter(mangaId) - - @Composable - override fun ComposeContent() { - val manga = presenter.manga.collectAsState().value - if (manga != null) { - MangaCoverDialog( - coverDataProvider = { manga }, - isCustomCover = remember(manga) { manga.hasCustomCover() }, - onShareClick = this::shareCover, - onSaveClick = this::saveCover, - onEditClick = this::changeCover, - onDismissRequest = router::popCurrentController, - ) - } else { - LoadingScreen() - } - } - - private fun shareCover() { - val activity = activity ?: return - viewScope.launchIO { - try { - val uri = presenter.saveCover(activity, temp = true) ?: return@launchIO - withUIContext { - startActivity(uri.toShareIntent(activity)) - } - } catch (e: Throwable) { - withUIContext { - logcat(LogPriority.ERROR, e) - activity.toast(R.string.error_sharing_cover) - } - } - } - } - - private fun saveCover() { - val activity = activity ?: return - viewScope.launchIO { - try { - presenter.saveCover(activity, temp = false) - withUIContext { - activity.toast(R.string.cover_saved) - } - } catch (e: Throwable) { - withUIContext { - logcat(LogPriority.ERROR, e) - activity.toast(R.string.error_saving_cover) - } - } - } - } - - private fun changeCover(action: EditCoverAction) { - when (action) { - EditCoverAction.EDIT -> { - // This will open new Photo Picker eventually. - // See https://github.com/tachiyomiorg/tachiyomi/pull/8253#issuecomment-1285747310 - val intent = Intent(Intent.ACTION_GET_CONTENT).apply { type = "image/*" } - startActivityForResult( - Intent.createChooser(intent, resources?.getString(R.string.file_select_cover)), - REQUEST_IMAGE_OPEN, - ) - } - EditCoverAction.DELETE -> presenter.deleteCustomCover() - } - } - - private fun onSetCoverSuccess() { - activity?.toast(R.string.cover_updated) - } - - private fun onSetCoverError(error: Throwable) { - activity?.toast(R.string.notification_cover_update_failed) - logcat(LogPriority.ERROR, error) - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == REQUEST_IMAGE_OPEN) { - val dataUri = data?.data - if (dataUri == null || resultCode != Activity.RESULT_OK) return - val activity = activity ?: return - presenter.editCover(activity, dataUri) - } - } - - inner class MangaFullCoverPresenter( - private val mangaId: Long, - private val getManga: GetManga = Injekt.get(), - ) : Presenter() { - - private var presenterScope: CoroutineScope = MainScope() - - private val _mangaFlow = MutableStateFlow(null) - val manga = _mangaFlow.asStateFlow() - - private val imageSaver by injectLazy() - private val coverCache by injectLazy() - private val updateManga by injectLazy() - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - presenterScope.launchIO { - getManga.subscribe(mangaId) - .collect { _mangaFlow.value = it } - } - } - - override fun onDestroy() { - super.onDestroy() - presenterScope.cancel() - } - - /** - * Save manga cover Bitmap to picture or temporary share directory. - * - * @param context The context for building and executing the ImageRequest - * @return the uri to saved file - */ - suspend fun saveCover(context: Context, temp: Boolean): Uri? { - val manga = manga.value ?: return null - val req = ImageRequest.Builder(context) - .data(manga) - .size(Size.ORIGINAL) - .build() - val result = context.imageLoader.execute(req).drawable - - // TODO: Handle animated cover - val bitmap = (result as? BitmapDrawable)?.bitmap ?: return null - return imageSaver.save( - Image.Cover( - bitmap = bitmap, - name = manga.title, - location = if (temp) Location.Cache else Location.Pictures.create(), - ), - ) - } - - /** - * Update cover with local file. - * - * @param context Context. - * @param data uri of the cover resource. - */ - fun editCover(context: Context, data: Uri) { - val manga = manga.value ?: return - presenterScope.launchIO { - @Suppress("BlockingMethodInNonBlockingContext") - context.contentResolver.openInputStream(data)?.use { - try { - manga.editCover(context, it, updateManga, coverCache) - withUIContext { view?.onSetCoverSuccess() } - } catch (e: Exception) { - withUIContext { view?.onSetCoverError(e) } - } - } - } - } - - fun deleteCustomCover() { - val mangaId = manga.value?.id ?: return - presenterScope.launchIO { - try { - coverCache.deleteCustomCover(mangaId) - updateManga.awaitUpdateCoverLastModified(mangaId) - withUIContext { view?.onSetCoverSuccess() } - } catch (e: Exception) { - withUIContext { view?.onSetCoverError(e) } - } - } - } - } - - companion object { - private const val MANGA_EXTRA = "mangaId" - - /** - * Key to change the cover of a manga in [onActivityResult]. - */ - private const val REQUEST_IMAGE_OPEN = 101 - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt deleted file mode 100644 index 5f3938546d..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt +++ /dev/null @@ -1,71 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import androidx.core.os.bundleOf -import com.bluelinelabs.conductor.Controller -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.databinding.TrackChaptersDialogBinding -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.util.system.getSerializableCompat -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class SetTrackChaptersDialog : DialogController - where T : Controller { - - private val item: TrackItem - - private lateinit var listener: Listener - - constructor(target: T, listener: Listener, item: TrackItem) : super( - bundleOf(KEY_ITEM_TRACK to item.track), - ) { - targetController = target - this.listener = listener - this.item = item - } - - @Suppress("unused") - constructor(bundle: Bundle) : super(bundle) { - val track = bundle.getSerializableCompat(KEY_ITEM_TRACK)!! - val service = Injekt.get().getService(track.sync_id.toLong())!! - item = TrackItem(track, service) - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val pickerView = TrackChaptersDialogBinding.inflate(LayoutInflater.from(activity!!)) - val np = pickerView.chaptersPicker - - // Set initial value - np.value = item.track?.last_chapter_read?.toInt() ?: 0 - - // Enforce maximum value if tracker has total number of chapters set - if (item.track != null && item.track.total_chapters > 0) { - np.maxValue = item.track.total_chapters - } - - // Don't allow to go from 0 to 9999 - np.wrapSelectorWheel = false - - return MaterialAlertDialogBuilder(activity!!) - .setTitle(R.string.chapters) - .setView(pickerView.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - np.clearFocus() - listener.setChaptersRead(item, np.value) - } - .setNegativeButton(R.string.action_cancel, null) - .create() - } - - interface Listener { - fun setChaptersRead(item: TrackItem, chaptersRead: Int) - } -} - -private const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt deleted file mode 100644 index 0e867724a4..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt +++ /dev/null @@ -1,71 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import androidx.core.os.bundleOf -import com.bluelinelabs.conductor.Controller -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.databinding.TrackScoreDialogBinding -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.util.system.getSerializableCompat -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class SetTrackScoreDialog : DialogController - where T : Controller { - - private val item: TrackItem - - private lateinit var listener: Listener - - constructor(target: T, listener: Listener, item: TrackItem) : super( - bundleOf(KEY_ITEM_TRACK to item.track), - ) { - targetController = target - this.listener = listener - this.item = item - } - - @Suppress("unused") - constructor(bundle: Bundle) : super(bundle) { - val track = bundle.getSerializableCompat(KEY_ITEM_TRACK)!! - val service = Injekt.get().getService(track.sync_id.toLong())!! - item = TrackItem(track, service) - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val pickerView = TrackScoreDialogBinding.inflate(LayoutInflater.from(activity!!)) - val np = pickerView.scorePicker - - val scores = item.service.getScoreList().toTypedArray() - np.maxValue = scores.size - 1 - np.displayedValues = scores - - // Set initial value - val displayedScore = item.service.displayScore(item.track!!) - if (displayedScore != "-") { - val index = scores.indexOf(displayedScore) - np.value = if (index != -1) index else 0 - } - - return MaterialAlertDialogBuilder(activity!!) - .setTitle(R.string.score) - .setView(pickerView.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - np.clearFocus() - listener.setScore(item, np.value) - } - .setNegativeButton(R.string.action_cancel, null) - .create() - } - - interface Listener { - fun setScore(item: TrackItem, score: Int) - } -} - -private const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt deleted file mode 100644 index b4832e8a6c..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt +++ /dev/null @@ -1,60 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.app.Dialog -import android.os.Bundle -import androidx.core.os.bundleOf -import com.bluelinelabs.conductor.Controller -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.util.system.getSerializableCompat -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class SetTrackStatusDialog : DialogController - where T : Controller { - - private val item: TrackItem - - private lateinit var listener: Listener - - constructor(target: T, listener: Listener, item: TrackItem) : super( - bundleOf(KEY_ITEM_TRACK to item.track), - ) { - targetController = target - this.listener = listener - this.item = item - } - - @Suppress("unused") - constructor(bundle: Bundle) : super(bundle) { - val track = bundle.getSerializableCompat(KEY_ITEM_TRACK)!! - val service = Injekt.get().getService(track.sync_id.toLong())!! - item = TrackItem(track, service) - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val statusList = item.service.getStatusList() - val statusString = statusList.map { item.service.getStatus(it) } - var selectedIndex = statusList.indexOf(item.track?.status) - - return MaterialAlertDialogBuilder(activity!!) - .setTitle(R.string.status) - .setSingleChoiceItems(statusString.toTypedArray(), selectedIndex) { _, which -> - selectedIndex = which - } - .setPositiveButton(android.R.string.ok) { _, _ -> - listener.setStatus(item, selectedIndex) - } - .setNegativeButton(R.string.action_cancel, null) - .create() - } - - interface Listener { - fun setStatus(item: TrackItem, selection: Int) - } -} - -private const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt deleted file mode 100644 index 67a866e6c1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt +++ /dev/null @@ -1,52 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import eu.kanade.tachiyomi.databinding.TrackItemBinding - -class TrackAdapter(listener: OnClickListener) : RecyclerView.Adapter() { - - private lateinit var binding: TrackItemBinding - - var items = emptyList() - set(value) { - if (field !== value) { - field = value - notifyDataSetChanged() - } - } - - val rowClickListener: OnClickListener = listener - - fun getItem(index: Int): TrackItem? { - return items.getOrNull(index) - } - - override fun getItemCount(): Int { - return items.size - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackHolder { - binding = TrackItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return TrackHolder(binding, this) - } - - override fun onBindViewHolder(holder: TrackHolder, position: Int) { - holder.bind(items[position]) - } - - interface OnClickListener { - fun onOpenInBrowserClick(position: Int) - fun onSetClick(position: Int) - fun onTitleLongClick(position: Int) - fun onStatusClick(position: Int) - fun onChaptersClick(position: Int) - fun onScoreClick(position: Int) - fun onStartDateEditClick(position: Int) - fun onStartDateRemoveClick(position: Int) - fun onFinishDateEditClick(position: Int) - fun onFinishDateRemoveClick(position: Int) - fun onRemoveItemClick(position: Int) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt deleted file mode 100644 index d417a88c94..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt +++ /dev/null @@ -1,139 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.annotation.SuppressLint -import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import eu.kanade.domain.ui.UiPreferences -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.databinding.TrackItemBinding -import eu.kanade.tachiyomi.util.view.popupMenu -import uy.kohesive.injekt.injectLazy -import java.text.DateFormat - -class TrackHolder(private val binding: TrackItemBinding, adapter: TrackAdapter) : RecyclerView.ViewHolder(binding.root) { - - private val preferences: UiPreferences by injectLazy() - - private val dateFormat: DateFormat by lazy { - UiPreferences.dateFormat(preferences.dateFormat().get()) - } - - private val listener = adapter.rowClickListener - - init { - binding.trackSet.setOnClickListener { listener.onSetClick(bindingAdapterPosition) } - binding.trackTitle.setOnClickListener { listener.onSetClick(bindingAdapterPosition) } - binding.trackTitle.setOnLongClickListener { - listener.onTitleLongClick(bindingAdapterPosition) - true - } - binding.trackStatus.setOnClickListener { listener.onStatusClick(bindingAdapterPosition) } - binding.trackChapters.setOnClickListener { listener.onChaptersClick(bindingAdapterPosition) } - binding.trackScore.setOnClickListener { listener.onScoreClick(bindingAdapterPosition) } - } - - @SuppressLint("SetTextI18n") - fun bind(item: TrackItem) { - val track = item.track - binding.trackLogo.setImageResource(item.service.getLogo()) - binding.logoContainer.setCardBackgroundColor(item.service.getLogoColor()) - - binding.trackSet.isVisible = track == null - binding.trackTitle.isVisible = track != null - binding.more.isVisible = track != null - - binding.middleRow.isVisible = track != null - binding.bottomDivider.isVisible = track != null - binding.bottomRow.isVisible = track != null - - binding.card.isVisible = track != null - - if (track != null) { - val ctx = binding.trackTitle.context - - binding.trackLogo.setOnClickListener { - listener.onOpenInBrowserClick(bindingAdapterPosition) - } - binding.trackTitle.text = track.title - binding.trackChapters.text = track.last_chapter_read.toInt().toString() - if (track.total_chapters > 0) { - binding.trackChapters.text = "${binding.trackChapters.text} / ${track.total_chapters}" - } - binding.trackStatus.text = item.service.getStatus(track.status) - - val supportsScoring = item.service.getScoreList().isNotEmpty() - if (supportsScoring) { - if (track.score != 0F) { - item.service.getScoreList() - binding.trackScore.text = item.service.displayScore(track) - binding.trackScore.alpha = SET_STATUS_TEXT_ALPHA - } else { - binding.trackScore.text = ctx.getString(R.string.score) - binding.trackScore.alpha = UNSET_STATUS_TEXT_ALPHA - } - } - binding.trackScore.isVisible = supportsScoring - binding.vertDivider2.isVisible = supportsScoring - - val supportsReadingDates = item.service.supportsReadingDates - if (supportsReadingDates) { - if (track.started_reading_date != 0L) { - binding.trackStartDate.text = dateFormat.format(track.started_reading_date) - binding.trackStartDate.alpha = SET_STATUS_TEXT_ALPHA - binding.trackStartDate.setOnClickListener { - it.popupMenu(R.menu.track_item_date) { - when (itemId) { - R.id.action_edit -> listener.onStartDateEditClick(bindingAdapterPosition) - R.id.action_remove -> listener.onStartDateRemoveClick(bindingAdapterPosition) - } - } - } - } else { - binding.trackStartDate.text = ctx.getString(R.string.track_started_reading_date) - binding.trackStartDate.alpha = UNSET_STATUS_TEXT_ALPHA - binding.trackStartDate.setOnClickListener { - listener.onStartDateEditClick(bindingAdapterPosition) - } - } - if (track.finished_reading_date != 0L) { - binding.trackFinishDate.text = dateFormat.format(track.finished_reading_date) - binding.trackFinishDate.alpha = SET_STATUS_TEXT_ALPHA - binding.trackFinishDate.setOnClickListener { - it.popupMenu(R.menu.track_item_date) { - when (itemId) { - R.id.action_edit -> listener.onFinishDateEditClick(bindingAdapterPosition) - R.id.action_remove -> listener.onFinishDateRemoveClick(bindingAdapterPosition) - } - } - } - } else { - binding.trackFinishDate.text = ctx.getString(R.string.track_finished_reading_date) - binding.trackFinishDate.alpha = UNSET_STATUS_TEXT_ALPHA - binding.trackFinishDate.setOnClickListener { - listener.onFinishDateEditClick(bindingAdapterPosition) - } - } - } - binding.bottomDivider.isVisible = supportsReadingDates - binding.bottomRow.isVisible = supportsReadingDates - - binding.more.setOnClickListener { - it.popupMenu(R.menu.track_item) { - when (itemId) { - R.id.action_open_in_browser -> { - listener.onOpenInBrowserClick(bindingAdapterPosition) - } - R.id.action_remove -> { - listener.onRemoveItemClick(bindingAdapterPosition) - } - } - } - } - } - } - - companion object { - private const val SET_STATUS_TEXT_ALPHA = 1F - private const val UNSET_STATUS_TEXT_ALPHA = 0.5F - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt new file mode 100644 index 0000000000..aeb81501d5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt @@ -0,0 +1,652 @@ +package eu.kanade.tachiyomi.ui.manga.track + +import android.app.Application +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.model.ScreenModel +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 cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.domain.chapter.interactor.SyncChaptersWithTrackServiceTwoWay +import eu.kanade.domain.manga.interactor.GetManga +import eu.kanade.domain.manga.interactor.GetMangaWithChapters +import eu.kanade.domain.manga.model.toDbManga +import eu.kanade.domain.track.interactor.DeleteTrack +import eu.kanade.domain.track.interactor.GetTracks +import eu.kanade.domain.track.interactor.InsertTrack +import eu.kanade.domain.track.model.toDbTrack +import eu.kanade.domain.track.model.toDomainTrack +import eu.kanade.domain.ui.UiPreferences +import eu.kanade.presentation.components.AlertDialogContent +import eu.kanade.presentation.manga.TrackChapterSelector +import eu.kanade.presentation.manga.TrackDateSelector +import eu.kanade.presentation.manga.TrackInfoDialogHome +import eu.kanade.presentation.manga.TrackScoreSelector +import eu.kanade.presentation.manga.TrackServiceSearch +import eu.kanade.presentation.manga.TrackStatusSelector +import eu.kanade.presentation.util.LocalNavigatorContentPadding +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.EnhancedTrackService +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.util.lang.launchNonCancellable +import eu.kanade.tachiyomi.util.lang.withIOContext +import eu.kanade.tachiyomi.util.lang.withUIContext +import eu.kanade.tachiyomi.util.system.logcat +import eu.kanade.tachiyomi.util.system.openInBrowser +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import logcat.LogPriority +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset + +data class TrackInfoDialogHomeScreen( + private val mangaId: Long, + private val mangaTitle: String, + private val sourceId: Long, +) : Screen { + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val context = LocalContext.current + val sm = rememberScreenModel { Model(mangaId, sourceId) } + + val dateFormat = remember { UiPreferences.dateFormat(Injekt.get().dateFormat().get()) } + val state by sm.state.collectAsState() + + TrackInfoDialogHome( + trackItems = state.trackItems, + dateFormat = dateFormat, + contentPadding = LocalNavigatorContentPadding.current, + onStatusClick = { + navigator.push( + TrackStatusSelectorScreen( + track = it.track!!, + serviceId = it.service.id, + ), + ) + }, + onChapterClick = { + navigator.push( + TrackChapterSelectorScreen( + track = it.track!!, + serviceId = it.service.id, + ), + ) + }, + onScoreClick = { + navigator.push( + TrackScoreSelectorScreen( + track = it.track!!, + serviceId = it.service.id, + ), + ) + }, + onStartDateEdit = { + navigator.push( + TrackDateSelectorScreen( + track = it.track!!, + serviceId = it.service.id, + start = true, + ), + ) + }, + onEndDateEdit = { + navigator.push( + TrackDateSelectorScreen( + track = it.track!!, + serviceId = it.service.id, + start = false, + ), + ) + }, + onNewSearch = { + if (it.service is EnhancedTrackService) { + sm.registerEnhancedTracking(it) + } else { + navigator.push( + TrackServiceSearchScreen( + mangaId = mangaId, + initialQuery = it.track?.title ?: mangaTitle, + currentUrl = it.track?.tracking_url, + serviceId = it.service.id, + ), + ) + } + }, + onOpenInBrowser = { openTrackerInBrowser(context, it) }, + onRemoved = { sm.unregisterTracking(it.service.id) }, + ) + } + + /** + * Opens registered tracker url in browser + */ + private fun openTrackerInBrowser(context: Context, trackItem: TrackItem) { + val url = trackItem.track?.tracking_url ?: return + if (url.isNotBlank()) { + context.openInBrowser(url) + } + } + + private class Model( + private val mangaId: Long, + private val sourceId: Long, + private val getTracks: GetTracks = Injekt.get(), + private val deleteTrack: DeleteTrack = Injekt.get(), + ) : StateScreenModel(State()) { + + init { + // Refresh data + coroutineScope.launch { + try { + val trackItems = getTracks.await(mangaId).mapToTrackItem() + val insertTrack = Injekt.get() + val getMangaWithChapters = Injekt.get() + val syncTwoWayService = Injekt.get() + trackItems.forEach { + val track = it.track ?: return@forEach + val domainTrack = it.service.refresh(track).toDomainTrack() ?: return@forEach + insertTrack.await(domainTrack) + + if (it.service is EnhancedTrackService) { + val allChapters = getMangaWithChapters.awaitChapters(mangaId) + syncTwoWayService.await(allChapters, domainTrack, it.service) + } + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to refresh track data mangaId=$mangaId" } + withUIContext { Injekt.get().toast(e.message) } + } + } + + coroutineScope.launch { + getTracks.subscribe(mangaId) + .catch { logcat(LogPriority.ERROR, it) } + .distinctUntilChanged() + .map { it.mapToTrackItem() } + .collectLatest { trackItems -> mutableState.update { it.copy(trackItems = trackItems) } } + } + } + + fun registerEnhancedTracking(item: TrackItem) { + item.service as EnhancedTrackService + coroutineScope.launchNonCancellable { + val manga = Injekt.get().await(mangaId)?.toDbManga() ?: return@launchNonCancellable + try { + val matchResult = item.service.match(manga) ?: throw Exception() + item.service.registerTracking(matchResult, mangaId) + } catch (e: Exception) { + withUIContext { Injekt.get().toast(R.string.error_no_match) } + } + } + } + + fun unregisterTracking(serviceId: Long) { + coroutineScope.launchNonCancellable { deleteTrack.await(mangaId, serviceId) } + } + + private fun List.mapToTrackItem(): List { + val dbTracks = map { it.toDbTrack() } + val loggedServices = Injekt.get().services.filter { it.isLogged } + val source = Injekt.get().getOrStub(sourceId) + return loggedServices + // Map to TrackItem + .map { service -> TrackItem(dbTracks.find { it.sync_id.toLong() == service.id }, service) } + // Show only if the service supports this manga's source + .filter { (it.service as? EnhancedTrackService)?.accept(source) ?: true } + } + + data class State( + val trackItems: List = emptyList(), + ) + } +} + +private data class TrackStatusSelectorScreen( + private val track: Track, + private val serviceId: Long, +) : Screen { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val sm = rememberScreenModel { + Model( + track = track, + service = Injekt.get().getService(serviceId)!!, + ) + } + val state by sm.state.collectAsState() + TrackStatusSelector( + contentPadding = LocalNavigatorContentPadding.current, + selection = state.selection, + onSelectionChange = sm::setSelection, + selections = remember { sm.getSelections() }, + onConfirm = { sm.setStatus(); navigator.pop() }, + onDismissRequest = navigator::pop, + ) + } + + private class Model( + private val track: Track, + private val service: TrackService, + ) : StateScreenModel(State(track.status)) { + + fun getSelections(): Map { + return service.getStatusList().associateWith { service.getStatus(it) } + } + + fun setSelection(selection: Int) { + mutableState.update { it.copy(selection = selection) } + } + + fun setStatus() { + coroutineScope.launchNonCancellable { + service.setRemoteStatus(track, state.value.selection) + } + } + + data class State( + val selection: Int, + ) + } +} + +private data class TrackChapterSelectorScreen( + private val track: Track, + private val serviceId: Long, +) : Screen { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val sm = rememberScreenModel { + Model( + track = track, + service = Injekt.get().getService(serviceId)!!, + ) + } + val state by sm.state.collectAsState() + + TrackChapterSelector( + contentPadding = LocalNavigatorContentPadding.current, + selection = state.selection, + onSelectionChange = sm::setSelection, + range = remember { sm.getRange() }, + onConfirm = { sm.setChapter(); navigator.pop() }, + onDismissRequest = navigator::pop, + ) + } + + private class Model( + private val track: Track, + private val service: TrackService, + ) : StateScreenModel(State(track.last_chapter_read.toInt())) { + + fun getRange(): Iterable { + val endRange = if (track.total_chapters > 0) { + track.total_chapters + } else { + 10000 + } + return 0..endRange + } + + fun setSelection(selection: Int) { + mutableState.update { it.copy(selection = selection) } + } + + fun setChapter() { + coroutineScope.launchNonCancellable { + service.setRemoteLastChapterRead(track, state.value.selection) + } + } + + data class State( + val selection: Int, + ) + } +} + +private data class TrackScoreSelectorScreen( + private val track: Track, + private val serviceId: Long, +) : Screen { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val sm = rememberScreenModel { + Model( + track = track, + service = Injekt.get().getService(serviceId)!!, + ) + } + val state by sm.state.collectAsState() + + TrackScoreSelector( + contentPadding = LocalNavigatorContentPadding.current, + selection = state.selection, + onSelectionChange = sm::setSelection, + selections = remember { sm.getSelections() }, + onConfirm = { sm.setScore(); navigator.pop() }, + onDismissRequest = navigator::pop, + ) + } + + private class Model( + private val track: Track, + private val service: TrackService, + ) : StateScreenModel(State(service.displayScore(track))) { + + fun getSelections(): List { + return service.getScoreList() + } + + fun setSelection(selection: String) { + mutableState.update { it.copy(selection = selection) } + } + + fun setScore() { + coroutineScope.launchNonCancellable { + service.setRemoteScore(track, state.value.selection) + } + } + + data class State( + val selection: String, + ) + } +} + +private data class TrackDateSelectorScreen( + private val track: Track, + private val serviceId: Long, + private val start: Boolean, +) : Screen { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val sm = rememberScreenModel { + Model( + track = track, + service = Injekt.get().getService(serviceId)!!, + start = start, + ) + } + val state by sm.state.collectAsState() + + val canRemove = if (start) { + track.started_reading_date > 0 + } else { + track.finished_reading_date > 0 + } + TrackDateSelector( + contentPadding = LocalNavigatorContentPadding.current, + title = if (start) { + stringResource(id = R.string.track_started_reading_date) + } else { + stringResource(id = R.string.track_finished_reading_date) + }, + selection = state.selection, + onSelectionChange = sm::setSelection, + onConfirm = { sm.setDate(); navigator.pop() }, + onRemove = { sm.confirmRemoveDate(navigator) }.takeIf { canRemove }, + onDismissRequest = navigator::pop, + ) + } + + private class Model( + private val track: Track, + private val service: TrackService, + private val start: Boolean, + ) : StateScreenModel( + State( + (if (start) track.started_reading_date else track.finished_reading_date) + .takeIf { it != 0L } + ?.let { + Instant.ofEpochMilli(it) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + } + ?: LocalDate.now(), + ), + ) { + + fun setSelection(selection: LocalDate) { + mutableState.update { it.copy(selection = selection) } + } + + fun setDate() { + coroutineScope.launchNonCancellable { + val millis = state.value.selection.atStartOfDay() + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + if (start) { + service.setRemoteStartDate(track, millis) + } else { + service.setRemoteFinishDate(track, millis) + } + } + } + + fun confirmRemoveDate(navigator: Navigator) { + navigator.push(TrackDateRemoverScreen(track, service.id, start)) + } + + data class State( + val selection: LocalDate, + ) + } +} + +private data class TrackDateRemoverScreen( + private val track: Track, + private val serviceId: Long, + private val start: Boolean, +) : Screen { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val sm = rememberScreenModel { + Model( + track = track, + service = Injekt.get().getService(serviceId)!!, + start = start, + ) + } + AlertDialogContent( + modifier = Modifier.padding(LocalNavigatorContentPadding.current), + icon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + ) + }, + title = { + Text( + text = stringResource(id = R.string.track_remove_date_conf_title), + textAlign = TextAlign.Center, + ) + }, + text = { + val serviceName = stringResource(sm.getServiceNameRes()) + Text( + text = if (start) { + stringResource(id = R.string.track_remove_start_date_conf_text, serviceName) + } else { + stringResource(id = R.string.track_remove_finish_date_conf_text, serviceName) + }, + ) + }, + buttons = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = navigator::pop) { + Text(text = stringResource(id = android.R.string.cancel)) + } + FilledTonalButton( + onClick = { sm.removeDate(); navigator.popUntilRoot() }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Text(text = stringResource(id = R.string.action_remove)) + } + } + }, + ) + } + + private class Model( + private val track: Track, + private val service: TrackService, + private val start: Boolean, + ) : ScreenModel { + + fun getServiceNameRes() = service.nameRes() + + fun removeDate() { + coroutineScope.launchNonCancellable { + if (start) { + service.setRemoteStartDate(track, 0) + } else { + service.setRemoteFinishDate(track, 0) + } + } + } + } +} + +data class TrackServiceSearchScreen( + private val mangaId: Long, + private val initialQuery: String, + private val currentUrl: String?, + private val serviceId: Long, +) : Screen { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val sm = rememberScreenModel { + Model( + mangaId = mangaId, + currentUrl = currentUrl, + initialQuery = initialQuery, + service = Injekt.get().getService(serviceId)!!, + ) + } + + val state by sm.state.collectAsState() + + var textFieldValue by remember { mutableStateOf(TextFieldValue(initialQuery)) } + TrackServiceSearch( + contentPadding = LocalNavigatorContentPadding.current, + query = textFieldValue, + onQueryChange = { textFieldValue = it }, + onDispatchQuery = { sm.trackingSearch(textFieldValue.text) }, + queryResult = state.queryResult, + selected = state.selected, + onSelectedChange = sm::updateSelection, + onConfirmSelection = { sm.registerTracking(state.selected!!); navigator.pop() }, + onDismissRequest = navigator::pop, + ) + } + + private class Model( + private val mangaId: Long, + private val currentUrl: String? = null, + initialQuery: String, + private val service: TrackService, + ) : StateScreenModel(State()) { + + init { + // Run search on first launch + if (initialQuery.isNotBlank()) { + trackingSearch(initialQuery) + } + } + + fun trackingSearch(query: String) { + coroutineScope.launch { + // To show loading state + mutableState.update { it.copy(queryResult = null, selected = null) } + + val result = withIOContext { + try { + val results = service.search(query) + Result.success(results) + } catch (e: Throwable) { + Result.failure(e) + } + } + mutableState.update { oldState -> + oldState.copy( + queryResult = result, + selected = result.getOrNull()?.find { it.tracking_url == currentUrl }, + ) + } + } + } + + fun registerTracking(item: Track) { + coroutineScope.launchNonCancellable { service.registerTracking(item, mangaId) } + } + + fun updateSelection(selected: TrackSearch) { + mutableState.update { it.copy(selected = selected) } + } + + data class State( + val queryResult: Result>? = null, + val selected: TrackSearch? = null, + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt deleted file mode 100644 index e46cebcb28..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt +++ /dev/null @@ -1,55 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.databinding.TrackSearchItemBinding - -class TrackSearchAdapter( - private val currentTrackUrl: String?, - private val onSelectionChanged: (TrackSearch?) -> Unit, -) : RecyclerView.Adapter() { - var selectedItemPosition = -1 - set(value) { - if (field != value) { - val previousPosition = field - field = value - // Just notify the now-unselected item - notifyItemChanged(previousPosition, UncheckPayload) - onSelectionChanged(items.getOrNull(value)) - } - } - - var items = emptyList() - set(value) { - if (field != value) { - field = value - selectedItemPosition = value.indexOfFirst { it.tracking_url == currentTrackUrl } - notifyDataSetChanged() - } - } - - override fun getItemCount(): Int = items.size - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackSearchHolder { - val binding = TrackSearchItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return TrackSearchHolder(binding, this) - } - - override fun onBindViewHolder(holder: TrackSearchHolder, position: Int) { - holder.bind(items[position], position) - } - - override fun onBindViewHolder(holder: TrackSearchHolder, position: Int, payloads: MutableList) { - if (payloads.getOrNull(0) == UncheckPayload) { - holder.setUnchecked() - } else { - super.onBindViewHolder(holder, position, payloads) - } - } - - companion object { - private object UncheckPayload - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt deleted file mode 100644 index 6cd1bc3686..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt +++ /dev/null @@ -1,194 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.app.Dialog -import android.os.Bundle -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.View -import android.view.inputmethod.EditorInfo -import androidx.core.os.bundleOf -import androidx.core.view.WindowCompat -import androidx.core.view.isVisible -import dev.chrisbanes.insetter.applyInsetter -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.databinding.TrackSearchDialogBinding -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.util.view.hideKeyboard -import eu.kanade.tachiyomi.util.view.setNavigationBarTransparentCompat -import eu.kanade.tachiyomi.widget.TachiyomiFullscreenDialog -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import reactivecircus.flowbinding.android.widget.editorActionEvents -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class TrackSearchDialog : DialogController { - - private var binding: TrackSearchDialogBinding? = null - - private var adapter: TrackSearchAdapter? = null - - private val service: TrackService - private val currentTrackUrl: String? - - private val trackController - get() = targetController as MangaController - - private lateinit var currentlySearched: String - - constructor( - target: MangaController, - _service: TrackService, - _currentTrackUrl: String?, - ) : super(bundleOf(KEY_SERVICE to _service.id, KEY_CURRENT_URL to _currentTrackUrl)) { - targetController = target - service = _service - currentTrackUrl = _currentTrackUrl - } - - @Suppress("unused") - constructor(bundle: Bundle) : super(bundle) { - service = Injekt.get().getService(bundle.getLong(KEY_SERVICE))!! - currentTrackUrl = bundle.getString(KEY_CURRENT_URL) - } - - @Suppress("DEPRECATION") - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - binding = TrackSearchDialogBinding.inflate(LayoutInflater.from(activity!!)) - - // Toolbar stuff - binding!!.toolbar.setNavigationOnClickListener { dialog?.dismiss() } - binding!!.trackBtn.setOnClickListener { - val adapter = adapter ?: return@setOnClickListener - adapter.items.getOrNull(adapter.selectedItemPosition)?.let { - trackController.presenter.registerTracking(it, service) - dialog?.dismiss() - } - } - - // Create adapter - adapter = TrackSearchAdapter(currentTrackUrl) { which -> - binding!!.trackBtn.isEnabled = which != null - } - binding!!.trackSearchRecyclerview.adapter = adapter - - // Do an initial search based on the manga's title - if (savedViewState == null) { - currentlySearched = trackController.presenter.manga!!.title - binding!!.titleInput.editText?.append(currentlySearched) - } - search(currentlySearched) - - // Input listener - binding?.titleInput?.editText - ?.editorActionEvents { - when (it.actionId) { - EditorInfo.IME_ACTION_SEARCH -> { - true - } - else -> { - it.keyEvent?.action == KeyEvent.ACTION_DOWN && it.keyEvent?.keyCode == KeyEvent.KEYCODE_ENTER - } - } - } - ?.filter { it.view.text.isNotBlank() } - ?.onEach { - val query = it.view.text.toString() - if (query != currentlySearched) { - currentlySearched = query - search(it.view.text.toString()) - it.view.hideKeyboard() - it.view.clearFocus() - } - } - ?.launchIn(trackController.viewScope) - - // Edge to edge - binding!!.appbar.applyInsetter { - type(navigationBars = true, statusBars = true) { - padding(left = true, top = true, right = true) - } - } - binding!!.titleInput.applyInsetter { - type(navigationBars = true) { - margin(horizontal = true) - } - } - binding!!.progress.applyInsetter { - type(navigationBars = true) { - margin() - } - } - binding!!.message.applyInsetter { - type(navigationBars = true) { - margin() - } - } - binding!!.trackSearchRecyclerview.applyInsetter { - type(navigationBars = true) { - padding(vertical = true) - margin(horizontal = true) - } - } - binding!!.trackBtn.applyInsetter { - type(navigationBars = true) { - margin() - } - } - - return TachiyomiFullscreenDialog(activity!!, binding!!.root) - } - - override fun onAttach(view: View) { - super.onAttach(view) - dialog?.window?.let { window -> - window.setNavigationBarTransparentCompat(window.context) - WindowCompat.setDecorFitsSystemWindows(window, false) - } - } - - override fun onDestroyView(view: View) { - super.onDestroyView(view) - binding = null - adapter = null - } - - private fun search(query: String) { - val binding = binding ?: return - binding.progress.isVisible = true - binding.trackSearchRecyclerview.isVisible = false - binding.message.isVisible = false - trackController.presenter.trackingSearch(query, service) - } - - fun onSearchResults(results: List) { - val binding = binding ?: return - binding.progress.isVisible = false - - val emptyResult = results.isEmpty() - adapter?.items = results - binding.trackSearchRecyclerview.isVisible = !emptyResult - binding.trackSearchRecyclerview.scrollToPosition(0) - binding.message.isVisible = emptyResult - if (emptyResult) { - binding.message.text = binding.message.context.getString(R.string.no_results_found) - } - } - - fun onSearchResultsError(message: String?) { - val binding = binding ?: return - binding.progress.isVisible = false - binding.trackSearchRecyclerview.isVisible = false - binding.message.isVisible = true - binding.message.text = message ?: binding.message.context.getString(R.string.unknown_error) - adapter?.items = emptyList() - } -} - -private const val KEY_SERVICE = "service_id" -private const val KEY_CURRENT_URL = "current_url" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchHolder.kt deleted file mode 100644 index cfd9807f62..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchHolder.kt +++ /dev/null @@ -1,63 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import coil.dispose -import coil.load -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.databinding.TrackSearchItemBinding -import java.util.Locale - -class TrackSearchHolder( - private val binding: TrackSearchItemBinding, - private val adapter: TrackSearchAdapter, -) : RecyclerView.ViewHolder(binding.root) { - fun bind(track: TrackSearch, position: Int) { - binding.root.isChecked = position == adapter.selectedItemPosition - binding.root.setOnClickListener { - adapter.selectedItemPosition = position - binding.root.isChecked = true - } - - binding.trackSearchTitle.text = track.title - binding.trackSearchCover.dispose() - if (track.cover_url.isNotEmpty()) { - binding.trackSearchCover.load(track.cover_url) - } - - val hasStatus = track.publishing_status.isNotBlank() - binding.trackSearchStatus.isVisible = hasStatus - binding.trackSearchStatusResult.isVisible = hasStatus - if (hasStatus) { - binding.trackSearchStatusResult.text = track.publishing_status.lowercase().replaceFirstChar { - it.titlecase(Locale.getDefault()) - } - } - - val hasType = track.publishing_type.isNotBlank() - binding.trackSearchType.isVisible = hasType - binding.trackSearchTypeResult.isVisible = hasType - if (hasType) { - binding.trackSearchTypeResult.text = track.publishing_type.lowercase().replaceFirstChar { - it.titlecase(Locale.getDefault()) - } - } - - val hasStartDate = track.start_date.isNotBlank() - binding.trackSearchStart.isVisible = hasStartDate - binding.trackSearchStartResult.isVisible = hasStartDate - if (hasStartDate) { - binding.trackSearchStartResult.text = track.start_date - } - - val hasSummary = track.summary.isNotBlank() - binding.trackSearchSummary.isVisible = hasSummary - if (hasSummary) { - binding.trackSearchSummary.text = track.summary - } - } - - fun setUnchecked() { - binding.root.isChecked = false - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt deleted file mode 100644 index 0d8cbeac5a..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt +++ /dev/null @@ -1,228 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import androidx.fragment.app.FragmentManager -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.datepicker.CalendarConstraints -import com.google.android.material.datepicker.DateValidatorPointBackward -import com.google.android.material.datepicker.DateValidatorPointForward -import com.google.android.material.datepicker.MaterialDatePicker -import eu.kanade.domain.manga.model.toDbManga -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.track.EnhancedTrackService -import eu.kanade.tachiyomi.databinding.TrackControllerBinding -import eu.kanade.tachiyomi.ui.base.controller.openInBrowser -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.toLocalCalendar -import eu.kanade.tachiyomi.util.lang.toUtcCalendar -import eu.kanade.tachiyomi.util.lang.withUIContext -import eu.kanade.tachiyomi.util.system.copyToClipboard -import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog - -class TrackSheet( - val controller: MangaController, - private val fragmentManager: FragmentManager, -) : BaseBottomSheetDialog(controller.activity!!), - TrackAdapter.OnClickListener, - SetTrackStatusDialog.Listener, - SetTrackChaptersDialog.Listener, - SetTrackScoreDialog.Listener { - - private lateinit var binding: TrackControllerBinding - - private lateinit var adapter: TrackAdapter - - override fun createView(inflater: LayoutInflater): View { - binding = TrackControllerBinding.inflate(layoutInflater) - return binding.root - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - adapter = TrackAdapter(this) - binding.trackRecycler.layoutManager = LinearLayoutManager(context) - binding.trackRecycler.adapter = adapter - - adapter.items = controller.presenter.trackList - } - - override fun show() { - super.show() - controller.presenter.refreshTrackers() - behavior.state = BottomSheetBehavior.STATE_COLLAPSED - } - - fun onNextTrackers(trackers: List) { - if (this::adapter.isInitialized) { - adapter.items = trackers - adapter.notifyDataSetChanged() - } - } - - override fun onOpenInBrowserClick(position: Int) { - val track = adapter.getItem(position)?.track ?: return - - if (track.tracking_url.isNotBlank()) { - controller.openInBrowser(track.tracking_url) - } - } - - override fun onSetClick(position: Int) { - val item = adapter.getItem(position) ?: return - val manga = controller.presenter.manga?.toDbManga() ?: return - val source = controller.presenter.source ?: return - - if (item.service is EnhancedTrackService) { - if (item.track != null) { - controller.presenter.unregisterTracking(item.service) - return - } - - if (!item.service.accept(source)) { - controller.presenter.view?.applicationContext?.toast(R.string.source_unsupported) - return - } - - launchIO { - try { - item.service.match(manga)?.let { track -> - controller.presenter.registerTracking(track, item.service) - } - ?: withUIContext { controller.presenter.view?.applicationContext?.toast(R.string.error_no_match) } - } catch (e: Exception) { - withUIContext { controller.presenter.view?.applicationContext?.toast(R.string.error_no_match) } - } - } - } else { - TrackSearchDialog(controller, item.service, item.track?.tracking_url) - .showDialog(controller.router, TAG_SEARCH_CONTROLLER) - } - } - - override fun onTitleLongClick(position: Int) { - adapter.getItem(position)?.track?.title?.let { - controller.activity?.copyToClipboard(it, it) - } - } - - override fun onStatusClick(position: Int) { - val item = adapter.getItem(position) ?: return - if (item.track == null) return - - SetTrackStatusDialog(controller, this, item).showDialog(controller.router) - } - - override fun onChaptersClick(position: Int) { - val item = adapter.getItem(position) ?: return - if (item.track == null) return - - SetTrackChaptersDialog(controller, this, item).showDialog(controller.router) - } - - override fun onScoreClick(position: Int) { - val item = adapter.getItem(position) ?: return - if (item.track == null || item.service.getScoreList().isEmpty()) return - - SetTrackScoreDialog(controller, this, item).showDialog(controller.router) - } - - override fun onStartDateEditClick(position: Int) { - val item = adapter.getItem(position) ?: return - if (item.track == null) return - - val selection = item.track.started_reading_date.toUtcCalendar()?.timeInMillis - ?: MaterialDatePicker.todayInUtcMilliseconds() - - // No time travellers allowed - val constraints = CalendarConstraints.Builder().apply { - val finishedMillis = item.track.finished_reading_date.toUtcCalendar()?.timeInMillis - if (finishedMillis != null) { - setValidator(DateValidatorPointBackward.before(finishedMillis)) - } - }.build() - - val picker = MaterialDatePicker.Builder.datePicker() - .setTitleText(R.string.track_started_reading_date) - .setSelection(selection) - .setCalendarConstraints(constraints) - .build() - picker.addOnPositiveButtonClickListener { utcMillis -> - val result = utcMillis.toLocalCalendar()?.timeInMillis - if (result != null) { - controller.presenter.setTrackerStartDate(item, result) - } - } - picker.show(fragmentManager, null) - } - - override fun onFinishDateEditClick(position: Int) { - val item = adapter.getItem(position) ?: return - if (item.track == null) return - - val selection = item.track.finished_reading_date.toUtcCalendar()?.timeInMillis - ?: MaterialDatePicker.todayInUtcMilliseconds() - - // No time travellers allowed - val constraints = CalendarConstraints.Builder().apply { - val startMillis = item.track.started_reading_date.toUtcCalendar()?.timeInMillis - if (startMillis != null) { - setValidator(DateValidatorPointForward.from(startMillis)) - } - }.build() - - val picker = MaterialDatePicker.Builder.datePicker() - .setTitleText(R.string.track_finished_reading_date) - .setSelection(selection) - .setCalendarConstraints(constraints) - .build() - picker.addOnPositiveButtonClickListener { utcMillis -> - val result = utcMillis.toLocalCalendar()?.timeInMillis - if (result != null) { - controller.presenter.setTrackerFinishDate(item, result) - } - } - picker.show(fragmentManager, null) - } - - override fun onStartDateRemoveClick(position: Int) { - val item = adapter.getItem(position) ?: return - if (item.track == null) return - controller.presenter.setTrackerStartDate(item, 0) - } - - override fun onFinishDateRemoveClick(position: Int) { - val item = adapter.getItem(position) ?: return - if (item.track == null) return - controller.presenter.setTrackerFinishDate(item, 0) - } - - override fun onRemoveItemClick(position: Int) { - val item = adapter.getItem(position) ?: return - if (item.track == null) return - controller.presenter.unregisterTracking(item.service) - } - - override fun setStatus(item: TrackItem, selection: Int) { - controller.presenter.setTrackerStatus(item, selection) - } - - override fun setChaptersRead(item: TrackItem, chaptersRead: Int) { - controller.presenter.setTrackerLastChapterRead(item, chaptersRead) - } - - override fun setScore(item: TrackItem, score: Int) { - controller.presenter.setTrackerScore(item, score) - } - - fun getSearchDialog(): TrackSearchDialog? { - return controller.router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog - } -} - -private const val TAG_SEARCH_CONTROLLER = "track_search_controller" diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiFullscreenDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiFullscreenDialog.kt deleted file mode 100644 index 1dea274e7f..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiFullscreenDialog.kt +++ /dev/null @@ -1,13 +0,0 @@ -package eu.kanade.tachiyomi.widget - -import android.content.Context -import android.view.View -import androidx.appcompat.app.AppCompatDialog -import eu.kanade.tachiyomi.R - -class TachiyomiFullscreenDialog(context: Context, view: View) : AppCompatDialog(context, R.style.ThemeOverlay_Tachiyomi_Dialog_Fullscreen) { - - init { - setContentView(view) - } -} diff --git a/app/src/main/res/layout/track_chapters_dialog.xml b/app/src/main/res/layout/track_chapters_dialog.xml deleted file mode 100644 index d09123d6ee..0000000000 --- a/app/src/main/res/layout/track_chapters_dialog.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/track_controller.xml b/app/src/main/res/layout/track_controller.xml deleted file mode 100644 index 47b67ee7b0..0000000000 --- a/app/src/main/res/layout/track_controller.xml +++ /dev/null @@ -1,9 +0,0 @@ - - diff --git a/app/src/main/res/layout/track_item.xml b/app/src/main/res/layout/track_item.xml deleted file mode 100644 index df6e482d3b..0000000000 --- a/app/src/main/res/layout/track_item.xml +++ /dev/null @@ -1,203 +0,0 @@ - - - - - - - - - - - - - -