mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	Full Compose MangaController (#8452)
* Full Compose MangaController * unique key * Use StateScreenModel * dismiss * rebase fix * toShareIntent
This commit is contained in:
		| @@ -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) | ||||
|   | ||||
| @@ -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<Float>(durationMillis = SheetAnimationDuration) | ||||
| private const val ScrimAnimationDuration = 350 | ||||
| private val ScrimAnimationSpec = tween<Float>(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 <T> SwipeableState<T>.preUpPostDownNestedScrollConnection( | ||||
|     enabled: Boolean = true, | ||||
|     anchor: Map<Float, T>, | ||||
| ) = 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 | ||||
| } | ||||
| @@ -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 | ||||
| @@ -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), | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -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 = {}, | ||||
|             ) {} | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<TrackItem>, | ||||
|     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 | ||||
|                 }, | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<Int, String>, | ||||
|     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<Int>, | ||||
|     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<String>, | ||||
|     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)) | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
| @@ -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<List<TrackSearch>>?, | ||||
|     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, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -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(), | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<Router?> = staticCompositionLocalOf | ||||
|  * For invoking back press to the parent activity | ||||
|  */ | ||||
| val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null } | ||||
|  | ||||
| val LocalNavigatorContentPadding: ProvidableCompositionLocal<PaddingValues> = compositionLocalOf { PaddingValues() } | ||||
|   | ||||
							
								
								
									
										12
									
								
								app/src/main/java/eu/kanade/presentation/util/WindowSize.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/src/main/java/eu/kanade/presentation/util/WindowSize.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<GetChapterByMangaId>().await(mangaId) | ||||
|                 val hasReadChapters = allChapters.any { it.read } | ||||
|                 bind(item, hasReadChapters) | ||||
|  | ||||
|                 val track = item.toDomainTrack(idRequired = false) ?: return@withIOContext | ||||
|  | ||||
|                 Injekt.get<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, | ||||
|                         ) | ||||
|                         setRemoteLastChapterRead(updatedTrack.toDbTrack(), latestLocalReadChapterNumber.toInt()) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if (this is EnhancedTrackService) { | ||||
|                     Injekt.get<SyncChaptersWithTrackServiceTwoWay>().await(allChapters, track, this@TrackService) | ||||
|                 } | ||||
|             } | ||||
|         } catch (e: Throwable) { | ||||
|             withUIContext { Injekt.get<Application>().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<InsertTrack>().await(it) | ||||
|                 } | ||||
|             } catch (e: Exception) { | ||||
|                 logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=$id" } | ||||
|                 withUIContext { Injekt.get<Application>().toast(e.message) } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<MangaPresenter> { | ||||
| class MangaController : BasicFullComposeController { | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) | ||||
| @@ -67,408 +14,19 @@ class MangaController : FullComposeController<MangaPresenter> { | ||||
|     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<ChapterItem>, | ||||
|         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<DomainChapter>, 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<DomainChapter>) { | ||||
|         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<TrackItem>) { | ||||
|         trackSheet.onNextTrackers(trackers) | ||||
|     } | ||||
|  | ||||
|     fun onTrackingRefreshDone() { | ||||
|     } | ||||
|  | ||||
|     fun onTrackingRefreshError(error: Throwable) { | ||||
|         logcat(LogPriority.ERROR, error) | ||||
|         activity?.toast(error.message) | ||||
|     } | ||||
|  | ||||
|     fun onTrackingSearchResults(results: List<TrackSearch>) { | ||||
|         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" | ||||
|   | ||||
| @@ -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<Manga?>(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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										329
									
								
								app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										329
									
								
								app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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<MangaController>() { | ||||
| 
 | ||||
|     private val _state: MutableStateFlow<MangaScreenState> = MutableStateFlow(MangaScreenState.Loading) | ||||
|     val state = _state.asStateFlow() | ||||
|     val snackbarHostState: SnackbarHostState = SnackbarHostState(), | ||||
| ) : StateScreenModel<MangaScreenState>(MangaScreenState.Loading) { | ||||
| 
 | ||||
|     private val successState: MangaScreenState.Success? | ||||
|         get() = state.value as? MangaScreenState.Success | ||||
| 
 | ||||
|     private var _trackList: List<TrackItem> = 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<ChapterItem>? | ||||
| @@ -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<DomainChapter>.(manga: DomainManga) -> List<ChapterItem> = { manga -> | ||||
|     init { | ||||
|         val toChapterItemsParams: List<Chapter>.(manga: Manga) -> List<ChapterItem> = { manga -> | ||||
|             val uiPreferences = Injekt.get<UiPreferences>() | ||||
|             toChapterItems( | ||||
|                 context = view?.activity ?: Injekt.get<Application>(), | ||||
|                 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<SourceManager>().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<EnhancedTrackService>() | ||||
|                     .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<Long> { | ||||
|     private suspend fun getMangaCategoryIds(manga: Manga): List<Long> { | ||||
|         return getCategories.await(manga.id) | ||||
|             .map { it.id } | ||||
|     } | ||||
| 
 | ||||
|     fun moveMangaToCategoriesAndAddToLibrary(manga: DomainManga, categories: List<Long>) { | ||||
|     fun moveMangaToCategoriesAndAddToLibrary(manga: Manga, categories: List<Long>) { | ||||
|         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<Long>) { | ||||
|         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<DomainChapter>.toChapterItems( | ||||
|     private fun List<Chapter>.toChapterItems( | ||||
|         context: Context, | ||||
|         manga: DomainManga, | ||||
|         manga: Manga, | ||||
|         dateRelativeTime: Int, | ||||
|         dateFormat: DateFormat, | ||||
|     ): List<ChapterItem> { | ||||
| @@ -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<DomainChapter> { | ||||
|     fun getUnreadChapters(): List<Chapter> { | ||||
|         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<DomainChapter> { | ||||
|     fun getUnreadChaptersSorted(): List<Chapter> { | ||||
|         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<Chapter>, | ||||
|         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<ChapterItem>, | ||||
|         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<DomainChapter>, read: Boolean) { | ||||
|         presenterScope.launchIO { | ||||
|     fun markChaptersRead(chapters: List<Chapter>, 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<DomainChapter>) { | ||||
|     private fun downloadChapters(chapters: List<Chapter>) { | ||||
|         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<DomainChapter>, bookmarked: Boolean) { | ||||
|         presenterScope.launchIO { | ||||
|     fun bookmarkChapters(chapters: List<Chapter>, 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<DomainChapter>) { | ||||
|         presenterScope.launchNonCancellable { | ||||
|     fun deleteChapters(chapters: List<Chapter>) { | ||||
|         coroutineScope.launchNonCancellable { | ||||
|             try { | ||||
|                 successState?.let { state -> | ||||
|                     downloadManager.deleteChapters( | ||||
| @@ -674,8 +728,8 @@ class MangaPresenter( | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun downloadNewChapters(chapters: List<DomainChapter>) { | ||||
|         presenterScope.launchNonCancellable { | ||||
|     private fun downloadNewChapters(chapters: List<Chapter>) { | ||||
|         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<CheckboxState<Category>>) : Dialog() | ||||
|         data class DeleteChapters(val chapters: List<DomainChapter>) : Dialog() | ||||
|         data class DuplicateManga(val manga: DomainManga, val duplicate: DomainManga) : Dialog() | ||||
|         data class ChangeCategory(val manga: Manga, val initialSelection: List<CheckboxState<Category>>) : Dialog() | ||||
|         data class DeleteChapters(val chapters: List<Chapter>) : 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<DomainChapter>) { | ||||
|         _state.update { state -> | ||||
|     fun showDeleteChapterDialog(chapters: List<Chapter>) { | ||||
|         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<ChapterItem>, | ||||
|         val trackingAvailable: Boolean = false, | ||||
|         val trackingCount: Int = 0, | ||||
|         val trackItems: List<TrackItem> = 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<ChapterItem> | ||||
|             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<ChapterItem>.applyFilters(manga: Manga): Sequence<ChapterItem> { | ||||
|             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 = '.' }, | ||||
| @@ -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<MangaScreenState.Success>() | ||||
|                 .collectLatest { | ||||
|                     manga = it.manga | ||||
|                     getTabViews().forEach { settings -> (settings as Settings).updateView() } | ||||
|                 } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDetachedFromWindow() { | ||||
|         super.onDetachedFromWindow() | ||||
|         scope.cancel() | ||||
|     } | ||||
|  | ||||
|     override fun getTabViews(): List<View> = listOf( | ||||
|         filters, | ||||
|         sort, | ||||
|         display, | ||||
|     ) | ||||
|  | ||||
|     override fun getTabTitles(): List<Int> = 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<Group>) { | ||||
|             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<Item>) : ExtendedNavigationView.Adapter(items) { | ||||
|  | ||||
|             override fun onItemClicked(item: Item) { | ||||
|                 if (item is GroupedItem) { | ||||
|                     item.group.onItemClicked(item) | ||||
|                     onGroupClicked(item.group) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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" | ||||
| @@ -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<MangaFullCoverDialog.MangaFullCoverPresenter> { | ||||
|  | ||||
|     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<MangaFullCoverDialog>() { | ||||
|  | ||||
|         private var presenterScope: CoroutineScope = MainScope() | ||||
|  | ||||
|         private val _mangaFlow = MutableStateFlow<Manga?>(null) | ||||
|         val manga = _mangaFlow.asStateFlow() | ||||
|  | ||||
|         private val imageSaver by injectLazy<ImageSaver>() | ||||
|         private val coverCache by injectLazy<CoverCache>() | ||||
|         private val updateManga by injectLazy<UpdateManga>() | ||||
|  | ||||
|         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 | ||||
|     } | ||||
| } | ||||
| @@ -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<T> : 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<Track>(KEY_ITEM_TRACK)!! | ||||
|         val service = Injekt.get<TrackManager>().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" | ||||
| @@ -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<T> : 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<Track>(KEY_ITEM_TRACK)!! | ||||
|         val service = Injekt.get<TrackManager>().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" | ||||
| @@ -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<T> : 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<Track>(KEY_ITEM_TRACK)!! | ||||
|         val service = Injekt.get<TrackManager>().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" | ||||
| @@ -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<TrackHolder>() { | ||||
|  | ||||
|     private lateinit var binding: TrackItemBinding | ||||
|  | ||||
|     var items = emptyList<TrackItem>() | ||||
|         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) | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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<UiPreferences>().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<Model.State>(State()) { | ||||
|  | ||||
|         init { | ||||
|             // Refresh data | ||||
|             coroutineScope.launch { | ||||
|                 try { | ||||
|                     val trackItems = getTracks.await(mangaId).mapToTrackItem() | ||||
|                     val insertTrack = Injekt.get<InsertTrack>() | ||||
|                     val getMangaWithChapters = Injekt.get<GetMangaWithChapters>() | ||||
|                     val syncTwoWayService = Injekt.get<SyncChaptersWithTrackServiceTwoWay>() | ||||
|                     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<Application>().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<GetManga>().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<Application>().toast(R.string.error_no_match) } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         fun unregisterTracking(serviceId: Long) { | ||||
|             coroutineScope.launchNonCancellable { deleteTrack.await(mangaId, serviceId) } | ||||
|         } | ||||
|  | ||||
|         private fun List<eu.kanade.domain.track.model.Track>.mapToTrackItem(): List<TrackItem> { | ||||
|             val dbTracks = map { it.toDbTrack() } | ||||
|             val loggedServices = Injekt.get<TrackManager>().services.filter { it.isLogged } | ||||
|             val source = Injekt.get<SourceManager>().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<TrackItem> = 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<TrackManager>().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<Model.State>(State(track.status)) { | ||||
|  | ||||
|         fun getSelections(): Map<Int, String> { | ||||
|             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<TrackManager>().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<Model.State>(State(track.last_chapter_read.toInt())) { | ||||
|  | ||||
|         fun getRange(): Iterable<Int> { | ||||
|             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<TrackManager>().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<Model.State>(State(service.displayScore(track))) { | ||||
|  | ||||
|         fun getSelections(): List<String> { | ||||
|             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<TrackManager>().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<Model.State>( | ||||
|         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<TrackManager>().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<TrackManager>().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<Model.State>(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<List<TrackSearch>>? = null, | ||||
|             val selected: TrackSearch? = 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<TrackSearchHolder>() { | ||||
|     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<TrackSearch>() | ||||
|         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<Any>) { | ||||
|         if (payloads.getOrNull(0) == UncheckPayload) { | ||||
|             holder.setUnchecked() | ||||
|         } else { | ||||
|             super.onBindViewHolder(holder, position, payloads) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         private object UncheckPayload | ||||
|     } | ||||
| } | ||||
| @@ -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<TrackManager>().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<TrackSearch>) { | ||||
|         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" | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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<TrackItem>) { | ||||
|         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" | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -1,16 +0,0 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:orientation="vertical"> | ||||
|  | ||||
|     <eu.kanade.tachiyomi.widget.MinMaxNumberPicker | ||||
|         android:id="@+id/chapters_picker" | ||||
|         android:layout_width="wrap_content" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_gravity="center" | ||||
|         app:max="9999" | ||||
|         app:min="0" /> | ||||
|  | ||||
| </LinearLayout> | ||||
| @@ -1,9 +0,0 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     android:id="@+id/track_recycler" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:clipToPadding="false" | ||||
|     android:paddingVertical="8dp" | ||||
|     tools:listitem="@layout/track_item" /> | ||||
| @@ -1,203 +0,0 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     android:id="@+id/track" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="wrap_content" | ||||
|     android:clipToPadding="false" | ||||
|     android:orientation="vertical" | ||||
|     android:paddingHorizontal="16dp" | ||||
|     android:paddingVertical="8dp"> | ||||
|  | ||||
|     <LinearLayout | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:gravity="center_vertical" | ||||
|         android:orientation="horizontal"> | ||||
|  | ||||
|         <com.google.android.material.card.MaterialCardView | ||||
|             android:id="@+id/logo_container" | ||||
|             android:layout_width="48dp" | ||||
|             android:layout_height="48dp" | ||||
|             app:cardBackgroundColor="#2E51A2" | ||||
|             app:cardElevation="0dp" | ||||
|             app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.MaterialCardView.Tracker"> | ||||
|  | ||||
|             <ImageView | ||||
|                 android:id="@+id/track_logo" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 android:importantForAccessibility="no" | ||||
|                 android:padding="4dp" | ||||
|                 tools:src="@drawable/ic_tracker_mal" /> | ||||
|  | ||||
|         </com.google.android.material.card.MaterialCardView> | ||||
|  | ||||
|         <LinearLayout | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:orientation="horizontal"> | ||||
|  | ||||
|             <Button | ||||
|                 android:id="@+id/track_set" | ||||
|                 style="?attr/borderlessButtonStyle" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_gravity="center" | ||||
|                 android:layout_marginStart="16dp" | ||||
|                 android:text="@string/add_tracking" | ||||
|                 android:visibility="gone" /> | ||||
|  | ||||
|             <TextView | ||||
|                 android:id="@+id/track_title" | ||||
|                 android:layout_width="0dp" | ||||
|                 android:layout_height="48dp" | ||||
|                 android:layout_weight="1" | ||||
|                 android:ellipsize="end" | ||||
|                 android:foreground="?attr/selectableItemBackgroundBorderless" | ||||
|                 android:gravity="center_vertical" | ||||
|                 android:maxLines="1" | ||||
|                 android:paddingHorizontal="16dp" | ||||
|                 android:textAppearance="?attr/textAppearanceTitleMedium" | ||||
|                 tools:text="Title" /> | ||||
|  | ||||
|             <ImageButton | ||||
|                 android:id="@+id/more" | ||||
|                 android:layout_width="wrap_content" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_gravity="center_vertical" | ||||
|                 android:background="?selectableItemBackgroundBorderless" | ||||
|                 android:contentDescription="@string/abc_action_menu_overflow_description" | ||||
|                 android:padding="8dp" | ||||
|                 android:src="@drawable/ic_more_vert_24" /> | ||||
|  | ||||
|         </LinearLayout> | ||||
|  | ||||
|     </LinearLayout> | ||||
|  | ||||
|     <com.google.android.material.card.MaterialCardView | ||||
|         android:id="@+id/card" | ||||
|         style="@style/Widget.Material3.CardView.Outlined" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_marginTop="8dp" | ||||
|         app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.MaterialCardView.Tracker"> | ||||
|  | ||||
|         <LinearLayout | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:orientation="vertical" | ||||
|             android:paddingHorizontal="12dp" | ||||
|             android:paddingVertical="8dp"> | ||||
|  | ||||
|             <LinearLayout | ||||
|                 android:id="@+id/middle_row" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content"> | ||||
|  | ||||
|                 <TextView | ||||
|                     android:id="@+id/track_status" | ||||
|                     android:layout_width="0dp" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:layout_weight="1" | ||||
|                     android:ellipsize="end" | ||||
|                     android:foreground="?attr/selectableItemBackgroundBorderless" | ||||
|                     android:gravity="center" | ||||
|                     android:maxLines="1" | ||||
|                     android:padding="12dp" | ||||
|                     android:textAppearance="?attr/textAppearanceBodyMedium" | ||||
|                     tools:text="Reading" /> | ||||
|  | ||||
|                 <View | ||||
|                     android:id="@+id/vert_divider_1" | ||||
|                     android:layout_width="1dp" | ||||
|                     android:layout_height="match_parent" | ||||
|                     android:background="?android:divider" /> | ||||
|  | ||||
|                 <TextView | ||||
|                     android:id="@+id/track_chapters" | ||||
|                     android:layout_width="0dp" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:layout_weight="1" | ||||
|                     android:ellipsize="end" | ||||
|                     android:foreground="?attr/selectableItemBackgroundBorderless" | ||||
|                     android:gravity="center" | ||||
|                     android:maxLines="1" | ||||
|                     android:padding="12dp" | ||||
|                     android:textAppearance="?attr/textAppearanceBodyMedium" | ||||
|                     tools:text="12/24" /> | ||||
|  | ||||
|                 <View | ||||
|                     android:id="@+id/vert_divider_2" | ||||
|                     android:layout_width="1dp" | ||||
|                     android:layout_height="match_parent" | ||||
|                     android:background="?android:divider" /> | ||||
|  | ||||
|                 <TextView | ||||
|                     android:id="@+id/track_score" | ||||
|                     android:layout_width="0dp" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:layout_weight="1" | ||||
|                     android:ellipsize="end" | ||||
|                     android:foreground="?attr/selectableItemBackgroundBorderless" | ||||
|                     android:gravity="center" | ||||
|                     android:maxLines="1" | ||||
|                     android:padding="12dp" | ||||
|                     android:textAppearance="?attr/textAppearanceBodyMedium" | ||||
|                     tools:text="10" /> | ||||
|  | ||||
|             </LinearLayout> | ||||
|  | ||||
|             <View | ||||
|                 android:id="@+id/bottom_divider" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="1dp" | ||||
|  | ||||
|                 android:background="?android:divider" /> | ||||
|  | ||||
|             <LinearLayout | ||||
|                 android:id="@+id/bottom_row" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content"> | ||||
|  | ||||
|                 <TextView | ||||
|                     android:id="@+id/track_start_date" | ||||
|                     android:layout_width="0dp" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:layout_weight="1" | ||||
|                     android:ellipsize="end" | ||||
|                     android:foreground="?attr/selectableItemBackgroundBorderless" | ||||
|                     android:gravity="center" | ||||
|                     android:maxLines="1" | ||||
|                     android:padding="12dp" | ||||
|                     android:textAppearance="?attr/textAppearanceBodyMedium" | ||||
|                     tools:text="4/16/2020" /> | ||||
|  | ||||
|                 <View | ||||
|                     android:id="@+id/vert_divider_3" | ||||
|                     android:layout_width="1dp" | ||||
|                     android:layout_height="match_parent" | ||||
|                     android:background="?android:divider" /> | ||||
|  | ||||
|                 <TextView | ||||
|                     android:id="@+id/track_finish_date" | ||||
|                     android:layout_width="0dp" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:layout_weight="1" | ||||
|                     android:ellipsize="end" | ||||
|                     android:foreground="?attr/selectableItemBackgroundBorderless" | ||||
|                     android:gravity="center" | ||||
|                     android:maxLines="1" | ||||
|                     android:padding="12dp" | ||||
|                     android:textAppearance="?attr/textAppearanceBodyMedium" | ||||
|                     tools:text="4/16/2020" /> | ||||
|  | ||||
|             </LinearLayout> | ||||
|  | ||||
|  | ||||
|         </LinearLayout> | ||||
|  | ||||
|     </com.google.android.material.card.MaterialCardView> | ||||
|  | ||||
| </LinearLayout> | ||||
| @@ -1,16 +0,0 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:orientation="vertical"> | ||||
|  | ||||
|     <eu.kanade.tachiyomi.widget.MinMaxNumberPicker | ||||
|         android:id="@+id/score_picker" | ||||
|         android:layout_width="wrap_content" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_gravity="center" | ||||
|         app:max="10" | ||||
|         app:min="0" /> | ||||
|  | ||||
| </LinearLayout> | ||||
| @@ -1,104 +0,0 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     android:id="@+id/container" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent"> | ||||
|  | ||||
|     <com.google.android.material.appbar.AppBarLayout | ||||
|         android:id="@+id/appbar" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content"> | ||||
|  | ||||
|         <com.google.android.material.appbar.MaterialToolbar | ||||
|             android:id="@+id/toolbar" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="?attr/actionBarSize" | ||||
|             android:theme="?attr/actionBarTheme" | ||||
|             app:contentInsetStartWithNavigation="0dp" | ||||
|             app:navigationIcon="@drawable/ic_close_24dp" | ||||
|             app:title="@string/add_tracking" /> | ||||
|  | ||||
|     </com.google.android.material.appbar.AppBarLayout> | ||||
|  | ||||
|     <LinearLayout | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="match_parent" | ||||
|         android:orientation="vertical" | ||||
|         app:layout_behavior="@string/appbar_scrolling_view_behavior"> | ||||
|  | ||||
|         <com.google.android.material.textfield.TextInputLayout | ||||
|             android:id="@+id/title_input" | ||||
|             style="@style/Widget.Material3.TextInputLayout.OutlinedBox.Dense" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginHorizontal="12dp" | ||||
|             android:layout_marginTop="8dp" | ||||
|             android:hint="@string/title" | ||||
|             app:endIconMode="clear_text"> | ||||
|  | ||||
|             <eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText | ||||
|                 android:id="@+id/title_input_edit_text" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:imeOptions="actionSearch" | ||||
|                 android:inputType="text" | ||||
|                 android:maxLines="1" /> | ||||
|  | ||||
|         </com.google.android.material.textfield.TextInputLayout> | ||||
|  | ||||
|         <FrameLayout | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="0dp" | ||||
|             android:layout_weight="1"> | ||||
|  | ||||
|             <com.google.android.material.progressindicator.CircularProgressIndicator | ||||
|                 android:id="@+id/progress" | ||||
|                 android:layout_width="wrap_content" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_gravity="center" | ||||
|                 android:indeterminate="true" | ||||
|                 android:visibility="gone" /> | ||||
|  | ||||
|             <TextView | ||||
|                 android:id="@+id/message" | ||||
|                 android:layout_width="wrap_content" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_gravity="center" | ||||
|                 android:padding="16dp" | ||||
|                 android:textAppearance="?attr/textAppearanceBodyMedium" | ||||
|                 android:visibility="gone" | ||||
|                 tools:text="@string/no_results_found" /> | ||||
|  | ||||
|             <eu.kanade.tachiyomi.widget.AutofitRecyclerView | ||||
|                 android:id="@+id/track_search_recyclerview" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_marginTop="8dp" | ||||
|                 android:clipToPadding="false" | ||||
|                 android:columnWidth="330dp" | ||||
|                 android:paddingHorizontal="8dp" | ||||
|                 android:paddingBottom="8dp" | ||||
|                 android:visibility="gone" | ||||
|                 tools:listitem="@layout/track_search_item" | ||||
|                 tools:visibility="visible" /> | ||||
|  | ||||
|         </FrameLayout> | ||||
|  | ||||
|         <com.google.android.material.divider.MaterialDivider | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" /> | ||||
|  | ||||
|         <Button | ||||
|             android:id="@+id/track_btn" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginHorizontal="16dp" | ||||
|             android:layout_marginVertical="8dp" | ||||
|             android:enabled="false" | ||||
|             android:text="@string/action_track" /> | ||||
|  | ||||
|     </LinearLayout> | ||||
|  | ||||
| </androidx.coordinatorlayout.widget.CoordinatorLayout> | ||||
| @@ -1,150 +0,0 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     style="@style/Widget.Material3.CardView.Outlined" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="wrap_content" | ||||
|     android:layout_margin="4dp" | ||||
|     android:checkable="true" | ||||
|     android:clickable="true" | ||||
|     android:focusable="true" | ||||
|     android:elevation="0dp" | ||||
|     app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.MaterialCardView.Tracker"> | ||||
|  | ||||
|     <androidx.constraintlayout.widget.ConstraintLayout | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_marginBottom="12dp" | ||||
|         android:orientation="horizontal"> | ||||
|  | ||||
|         <com.google.android.material.imageview.ShapeableImageView | ||||
|             android:id="@+id/track_search_cover" | ||||
|             android:layout_width="68dp" | ||||
|             android:layout_height="95dp" | ||||
|             android:layout_marginStart="12dp" | ||||
|             android:layout_marginTop="12dp" | ||||
|             android:scaleType="centerCrop" | ||||
|             app:layout_constraintStart_toStartOf="parent" | ||||
|             app:layout_constraintTop_toTopOf="parent" | ||||
|             app:shapeAppearance="@style/ShapeAppearanceOverlay.Cover" | ||||
|             tools:src="@mipmap/ic_launcher" /> | ||||
|  | ||||
|         <TextView | ||||
|             android:id="@+id/track_search_title" | ||||
|             android:layout_width="0dp" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginStart="12dp" | ||||
|             android:layout_marginTop="8dp" | ||||
|             android:layout_marginEnd="36dp" | ||||
|             android:ellipsize="end" | ||||
|             android:maxLines="2" | ||||
|             android:textAppearance="?attr/textAppearanceTitleLarge" | ||||
|             android:textSize="17sp" | ||||
|             app:layout_constraintEnd_toEndOf="parent" | ||||
|             app:layout_constraintStart_toEndOf="@id/track_search_cover" | ||||
|             app:layout_constraintTop_toTopOf="parent" | ||||
|             tools:text="@string/app_name" /> | ||||
|  | ||||
|         <TextView | ||||
|             android:id="@+id/track_search_type" | ||||
|             android:layout_width="wrap_content" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:maxLines="1" | ||||
|             android:text="@string/track_type" | ||||
|             android:textAppearance="?attr/textAppearanceTitleSmall" | ||||
|             app:layout_constraintStart_toStartOf="@+id/track_search_title" | ||||
|             app:layout_constraintTop_toBottomOf="@id/track_search_title" /> | ||||
|  | ||||
|         <TextView | ||||
|             android:id="@+id/track_search_type_result" | ||||
|             android:layout_width="0dp" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginStart="4dp" | ||||
|             android:layout_marginEnd="12dp" | ||||
|             android:maxLines="1" | ||||
|             android:textAppearance="?attr/textAppearanceBodyMedium" | ||||
|             android:textColor="?android:attr/textColorSecondary" | ||||
|             app:layout_constraintEnd_toEndOf="parent" | ||||
|             app:layout_constraintStart_toEndOf="@id/track_search_type" | ||||
|             app:layout_constraintTop_toBottomOf="@id/track_search_title" | ||||
|             tools:text="Manga" /> | ||||
|  | ||||
|         <TextView | ||||
|             android:id="@+id/track_search_start" | ||||
|             android:layout_width="wrap_content" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:maxLines="1" | ||||
|             android:text="@string/track_start_date" | ||||
|             android:textAppearance="?attr/textAppearanceTitleSmall" | ||||
|             app:layout_constraintStart_toStartOf="@+id/track_search_type" | ||||
|             app:layout_constraintTop_toBottomOf="@id/track_search_type" /> | ||||
|  | ||||
|         <TextView | ||||
|             android:id="@+id/track_search_start_result" | ||||
|             android:layout_width="0dp" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginStart="4dp" | ||||
|             android:layout_marginEnd="12dp" | ||||
|             android:ellipsize="end" | ||||
|             android:maxLines="1" | ||||
|             android:textAppearance="?attr/textAppearanceBodyMedium" | ||||
|             android:textColor="?android:attr/textColorSecondary" | ||||
|             app:layout_constraintEnd_toEndOf="parent" | ||||
|             app:layout_constraintStart_toEndOf="@id/track_search_start" | ||||
|             app:layout_constraintTop_toBottomOf="@id/track_search_type" | ||||
|             tools:text="2018-10-01" /> | ||||
|  | ||||
|         <TextView | ||||
|             android:id="@+id/track_search_status" | ||||
|             android:layout_width="wrap_content" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:ellipsize="end" | ||||
|             android:maxLines="1" | ||||
|             android:text="@string/track_status" | ||||
|             android:textAppearance="?attr/textAppearanceTitleSmall" | ||||
|             app:layout_constraintStart_toStartOf="@+id/track_search_start" | ||||
|             app:layout_constraintTop_toBottomOf="@id/track_search_start" /> | ||||
|  | ||||
|         <TextView | ||||
|             android:id="@+id/track_search_status_result" | ||||
|             android:layout_width="0dp" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginStart="4dp" | ||||
|             android:layout_marginEnd="12dp" | ||||
|             android:ellipsize="end" | ||||
|             android:maxLines="1" | ||||
|             android:textAppearance="?attr/textAppearanceBodyMedium" | ||||
|             android:textColor="?android:attr/textColorSecondary" | ||||
|             app:layout_constraintEnd_toEndOf="parent" | ||||
|             app:layout_constraintStart_toEndOf="@id/track_search_status" | ||||
|             app:layout_constraintTop_toBottomOf="@id/track_search_start" | ||||
|             tools:text="Ongoing" /> | ||||
|  | ||||
|         <TextView | ||||
|             android:id="@+id/track_search_summary" | ||||
|             android:layout_width="0dp" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginStart="12dp" | ||||
|             android:layout_marginTop="8dp" | ||||
|             android:layout_marginEnd="12dp" | ||||
|             android:ellipsize="end" | ||||
|             android:maxLines="4" | ||||
|             android:textAppearance="?attr/textAppearanceBodySmall" | ||||
|             android:textColor="?android:attr/textColorSecondary" | ||||
|             app:layout_constraintBottom_toBottomOf="parent" | ||||
|             app:layout_constraintEnd_toEndOf="parent" | ||||
|             app:layout_constraintStart_toStartOf="parent" | ||||
|             app:layout_constraintTop_toBottomOf="@id/barrier" | ||||
|             tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas condimentum et turpis ut sollicitudin. Donec tellus dolor, rhoncus a mattis eget, tempor quis augue. Fusce eleifend dignissim turpis a molestie.  Praesent tincidunt, risus sed egestas fringilla, urna orci ultrices libero, id iaculis sem lorem placerat lacus." /> | ||||
|  | ||||
|         <androidx.constraintlayout.widget.Barrier | ||||
|             android:id="@+id/barrier" | ||||
|             android:layout_width="wrap_content" | ||||
|             android:layout_height="wrap_content" | ||||
|             app:barrierDirection="bottom" | ||||
|             app:constraint_referenced_ids="track_search_start_result,track_search_title,track_search_type_result,track_search_status,track_search_cover,track_search_status_result,track_search_type,track_search_start" /> | ||||
|  | ||||
|     </androidx.constraintlayout.widget.ConstraintLayout> | ||||
|  | ||||
| </com.google.android.material.card.MaterialCardView> | ||||
| @@ -165,11 +165,6 @@ | ||||
|         <item name="cornerSize">@dimen/card_radius</item> | ||||
|     </style> | ||||
|  | ||||
|     <style name="ThemeOverlay.Tachiyomi.Dialog.Fullscreen" parent="ThemeOverlay.Material3"> | ||||
|         <item name="android:windowIsFloating">false</item> | ||||
|         <item name="android:windowAnimationStyle">@style/Animation.Tachiyomi.Dialog</item> | ||||
|     </style> | ||||
|  | ||||
|     <style name="Animation.Tachiyomi.Dialog" parent="Animation.AppCompat.Dialog"> | ||||
|         <item name="android:windowEnterAnimation">@anim/fade_in_short</item> | ||||
|         <item name="android:windowExitAnimation">@anim/fade_out_short</item> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user