Full Compose MangaController (#8452)
* Full Compose MangaController * unique key * Use StateScreenModel * dismiss * rebase fix * toShareIntent
This commit is contained in:
parent
21bc0f1952
commit
18ccde082d
40 changed files with 3470 additions and 2927 deletions
|
@ -144,6 +144,8 @@ android {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
|
@ -163,6 +165,8 @@ dependencies {
|
||||||
implementation(project(":core"))
|
implementation(project(":core"))
|
||||||
implementation(project(":source-api"))
|
implementation(project(":source-api"))
|
||||||
|
|
||||||
|
coreLibraryDesugaring(libs.desugar)
|
||||||
|
|
||||||
// Compose
|
// Compose
|
||||||
implementation(platform(compose.bom))
|
implementation(platform(compose.bom))
|
||||||
implementation(compose.activity)
|
implementation(compose.activity)
|
||||||
|
@ -267,6 +271,7 @@ dependencies {
|
||||||
implementation(libs.cascade)
|
implementation(libs.cascade)
|
||||||
implementation(libs.numberpicker)
|
implementation(libs.numberpicker)
|
||||||
implementation(libs.bundles.voyager)
|
implementation(libs.bundles.voyager)
|
||||||
|
implementation(libs.wheelpicker)
|
||||||
|
|
||||||
// Conductor
|
// Conductor
|
||||||
implementation(libs.bundles.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
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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
|
const val DIVIDER_ALPHA = 0.2f
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Divider(
|
fun Divider(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
color: Color = DividerDefaults.color,
|
||||||
) {
|
) {
|
||||||
androidx.compose.material3.Divider(
|
Box(
|
||||||
modifier = modifier,
|
modifier
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = DIVIDER_ALPHA),
|
.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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import coil.imageLoader
|
import coil.imageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
|
@ -50,124 +54,134 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
||||||
fun MangaCoverDialog(
|
fun MangaCoverDialog(
|
||||||
coverDataProvider: () -> Manga,
|
coverDataProvider: () -> Manga,
|
||||||
isCustomCover: Boolean,
|
isCustomCover: Boolean,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
onShareClick: () -> Unit,
|
onShareClick: () -> Unit,
|
||||||
onSaveClick: () -> Unit,
|
onSaveClick: () -> Unit,
|
||||||
onEditClick: ((EditCoverAction) -> Unit)?,
|
onEditClick: ((EditCoverAction) -> Unit)?,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Dialog(
|
||||||
bottomBar = {
|
onDismissRequest = onDismissRequest,
|
||||||
Row(
|
properties = DialogProperties(
|
||||||
modifier = Modifier
|
usePlatformDefaultWidth = false,
|
||||||
.fillMaxWidth()
|
decorFitsSystemWindows = false, // Doesn't work https://issuetracker.google.com/issues/246909281
|
||||||
.background(color = MaterialTheme.colorScheme.background.copy(alpha = 0.9f))
|
),
|
||||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
) {
|
||||||
.navigationBarsPadding(),
|
Scaffold(
|
||||||
) {
|
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||||
IconButton(onClick = onDismissRequest) {
|
bottomBar = {
|
||||||
Icon(
|
Row(
|
||||||
imageVector = Icons.Outlined.Close,
|
modifier = Modifier
|
||||||
contentDescription = stringResource(R.string.action_close),
|
.fillMaxWidth()
|
||||||
)
|
.background(color = MaterialTheme.colorScheme.background.copy(alpha = 0.9f))
|
||||||
}
|
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
.navigationBarsPadding(),
|
||||||
IconButton(onClick = onShareClick) {
|
) {
|
||||||
Icon(
|
IconButton(onClick = onDismissRequest) {
|
||||||
imageVector = Icons.Outlined.Share,
|
Icon(
|
||||||
contentDescription = stringResource(R.string.action_share),
|
imageVector = Icons.Outlined.Close,
|
||||||
)
|
contentDescription = stringResource(R.string.action_close),
|
||||||
}
|
)
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
}
|
IconButton(onClick = onShareClick) {
|
||||||
},
|
Icon(
|
||||||
) { contentPadding ->
|
imageVector = Icons.Outlined.Share,
|
||||||
val statusBarPaddingPx = WindowInsets.systemBars.getTop(LocalDensity.current)
|
contentDescription = stringResource(R.string.action_share),
|
||||||
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
|
|
||||||
}
|
}
|
||||||
},
|
IconButton(onClick = onSaveClick) {
|
||||||
update = { view ->
|
Icon(
|
||||||
val request = ImageRequest.Builder(view.context)
|
imageVector = Icons.Outlined.Save,
|
||||||
.data(coverDataProvider())
|
contentDescription = stringResource(R.string.action_save),
|
||||||
.size(Size.ORIGINAL)
|
)
|
||||||
.target { drawable ->
|
}
|
||||||
// Copy bitmap in case it came from memory cache
|
if (onEditClick != null) {
|
||||||
// Because SSIV needs to thoroughly read the image
|
Box {
|
||||||
val copy = (drawable as? BitmapDrawable)?.let {
|
var expanded by remember { mutableStateOf(false) }
|
||||||
val config = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
IconButton(
|
||||||
Bitmap.Config.HARDWARE
|
onClick = {
|
||||||
} else {
|
if (isCustomCover) {
|
||||||
Bitmap.Config.ARGB_8888
|
expanded = true
|
||||||
}
|
} else {
|
||||||
BitmapDrawable(
|
onEditClick(EditCoverAction.EDIT)
|
||||||
view.context.resources,
|
}
|
||||||
it.bitmap.copy(config, false),
|
},
|
||||||
|
) {
|
||||||
|
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)
|
view.updatePadding(top = statusBarPaddingPx, bottom = bottomPaddingPx)
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package eu.kanade.presentation.util
|
package eu.kanade.presentation.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import com.bluelinelabs.conductor.Router
|
import com.bluelinelabs.conductor.Router
|
||||||
|
|
||||||
|
@ -13,3 +15,5 @@ val LocalRouter: ProvidableCompositionLocal<Router?> = staticCompositionLocalOf
|
||||||
* For invoking back press to the parent activity
|
* For invoking back press to the parent activity
|
||||||
*/
|
*/
|
||||||
val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null }
|
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 finished_reading_date: Long = 0
|
||||||
|
|
||||||
override var tracking_url: String = ""
|
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
|
package eu.kanade.tachiyomi.data.track
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import eu.kanade.domain.base.BasePreferences
|
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.domain.track.service.TrackPreferences
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
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 okhttp3.OkHttpClient
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
abstract class TrackService(val id: Long) {
|
abstract class TrackService(val id: Long) {
|
||||||
|
@ -78,4 +91,89 @@ abstract class TrackService(val id: Long) {
|
||||||
fun saveCredentials(username: String, password: String) {
|
fun saveCredentials(username: String, password: String) {
|
||||||
trackPreferences.setTrackCredentials(this, username, password)
|
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
|
package eu.kanade.tachiyomi.ui.manga
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
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.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 androidx.core.os.bundleOf
|
||||||
import com.bluelinelabs.conductor.ControllerChangeHandler
|
import cafe.adriel.voyager.navigator.Navigator
|
||||||
import com.bluelinelabs.conductor.ControllerChangeType
|
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
|
||||||
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
|
|
||||||
|
|
||||||
class MangaController : FullComposeController<MangaPresenter> {
|
class MangaController : BasicFullComposeController {
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA))
|
constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA))
|
||||||
|
@ -67,408 +14,19 @@ class MangaController : FullComposeController<MangaPresenter> {
|
||||||
constructor(
|
constructor(
|
||||||
mangaId: Long,
|
mangaId: Long,
|
||||||
fromSource: Boolean = false,
|
fromSource: Boolean = false,
|
||||||
) : super(bundleOf(MANGA_EXTRA to mangaId, FROM_SOURCE_EXTRA to fromSource)) {
|
) : super(bundleOf(MANGA_EXTRA to mangaId, FROM_SOURCE_EXTRA to fromSource))
|
||||||
this.mangaId = mangaId
|
|
||||||
}
|
|
||||||
|
|
||||||
var mangaId: Long
|
val mangaId: Long
|
||||||
|
get() = args.getLong(MANGA_EXTRA)
|
||||||
|
|
||||||
val fromSource: Boolean
|
val fromSource: Boolean
|
||||||
get() = presenter.isFromSource
|
get() = args.getBoolean(FROM_SOURCE_EXTRA)
|
||||||
|
|
||||||
// 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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun ComposeContent() {
|
override fun ComposeContent() {
|
||||||
val state by presenter.state.collectAsState()
|
Navigator(screen = MangaScreen(mangaId, fromSource))
|
||||||
|
|
||||||
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 -> {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
companion object {
|
||||||
const val FROM_SOURCE_EXTRA = "from_source"
|
const val FROM_SOURCE_EXTRA = "from_source"
|
||||||
const val MANGA_EXTRA = "manga"
|
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
|
package eu.kanade.tachiyomi.ui.manga
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.runtime.Immutable
|
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.CheckboxState
|
||||||
import eu.kanade.core.prefs.mapAsCheckboxState
|
import eu.kanade.core.prefs.mapAsCheckboxState
|
||||||
|
import eu.kanade.data.chapter.NoChaptersException
|
||||||
import eu.kanade.domain.base.BasePreferences
|
import eu.kanade.domain.base.BasePreferences
|
||||||
import eu.kanade.domain.category.interactor.GetCategories
|
import eu.kanade.domain.category.interactor.GetCategories
|
||||||
import eu.kanade.domain.category.interactor.SetMangaCategories
|
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.SetMangaDefaultChapterFlags
|
||||||
import eu.kanade.domain.chapter.interactor.SetReadStatus
|
import eu.kanade.domain.chapter.interactor.SetReadStatus
|
||||||
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
|
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.interactor.UpdateChapter
|
||||||
|
import eu.kanade.domain.chapter.model.Chapter
|
||||||
import eu.kanade.domain.chapter.model.ChapterUpdate
|
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.chapter.model.toDbChapter
|
||||||
import eu.kanade.domain.download.service.DownloadPreferences
|
import eu.kanade.domain.download.service.DownloadPreferences
|
||||||
import eu.kanade.domain.library.service.LibraryPreferences
|
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.GetMangaWithChapters
|
||||||
import eu.kanade.domain.manga.interactor.SetMangaChapterFlags
|
import eu.kanade.domain.manga.interactor.SetMangaChapterFlags
|
||||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
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.manga.model.toDbManga
|
||||||
import eu.kanade.domain.track.interactor.DeleteTrack
|
|
||||||
import eu.kanade.domain.track.interactor.GetTracks
|
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.toDbTrack
|
||||||
import eu.kanade.domain.track.model.toDomainTrack
|
|
||||||
import eu.kanade.domain.ui.UiPreferences
|
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.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadCache
|
import eu.kanade.tachiyomi.data.download.DownloadCache
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
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.download.model.Download
|
||||||
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
|
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
|
||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
|
import eu.kanade.tachiyomi.network.HttpException
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
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.ui.manga.track.TrackItem
|
||||||
import eu.kanade.tachiyomi.util.chapter.getChapterSort
|
import eu.kanade.tachiyomi.util.chapter.getChapterSort
|
||||||
import eu.kanade.tachiyomi.util.chapter.getNextUnread
|
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.removeCovers
|
||||||
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
|
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
|
||||||
import eu.kanade.tachiyomi.util.system.logcat
|
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.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
@ -72,8 +70,6 @@ import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.supervisorScope
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
@ -81,13 +77,12 @@ import java.text.DateFormat
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
import java.text.DecimalFormatSymbols
|
import java.text.DecimalFormatSymbols
|
||||||
import java.util.Date
|
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 mangaId: Long,
|
||||||
val isFromSource: Boolean,
|
private val isFromSource: Boolean,
|
||||||
private val basePreferences: BasePreferences = Injekt.get(),
|
basePreferences: BasePreferences = Injekt.get(),
|
||||||
private val downloadPreferences: DownloadPreferences = Injekt.get(),
|
private val downloadPreferences: DownloadPreferences = Injekt.get(),
|
||||||
private val libraryPreferences: LibraryPreferences = Injekt.get(),
|
private val libraryPreferences: LibraryPreferences = Injekt.get(),
|
||||||
private val trackManager: TrackManager = Injekt.get(),
|
private val trackManager: TrackManager = Injekt.get(),
|
||||||
|
@ -103,34 +98,23 @@ class MangaPresenter(
|
||||||
private val updateManga: UpdateManga = Injekt.get(),
|
private val updateManga: UpdateManga = Injekt.get(),
|
||||||
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
|
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
|
||||||
private val getCategories: GetCategories = Injekt.get(),
|
private val getCategories: GetCategories = Injekt.get(),
|
||||||
private val deleteTrack: DeleteTrack = Injekt.get(),
|
|
||||||
private val getTracks: GetTracks = Injekt.get(),
|
private val getTracks: GetTracks = Injekt.get(),
|
||||||
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
||||||
private val insertTrack: InsertTrack = Injekt.get(),
|
val snackbarHostState: SnackbarHostState = SnackbarHostState(),
|
||||||
private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get(),
|
) : StateScreenModel<MangaScreenState>(MangaScreenState.Loading) {
|
||||||
) : BasePresenter<MangaController>() {
|
|
||||||
|
|
||||||
private val _state: MutableStateFlow<MangaScreenState> = MutableStateFlow(MangaScreenState.Loading)
|
|
||||||
val state = _state.asStateFlow()
|
|
||||||
|
|
||||||
private val successState: MangaScreenState.Success?
|
private val successState: MangaScreenState.Success?
|
||||||
get() = state.value as? 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 val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
|
||||||
|
|
||||||
private var searchTrackerJob: Job? = null
|
val manga: Manga?
|
||||||
private var refreshTrackersJob: Job? = null
|
|
||||||
|
|
||||||
val manga: DomainManga?
|
|
||||||
get() = successState?.manga
|
get() = successState?.manga
|
||||||
|
|
||||||
val source: Source?
|
val source: Source?
|
||||||
get() = successState?.source
|
get() = successState?.source
|
||||||
|
|
||||||
val isFavoritedManga: Boolean
|
private val isFavoritedManga: Boolean
|
||||||
get() = manga?.favorite ?: false
|
get() = manga?.favorite ?: false
|
||||||
|
|
||||||
private val processedChapters: Sequence<ChapterItem>?
|
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
|
* Helper function to update the UI state only if it's currently in success state
|
||||||
*/
|
*/
|
||||||
private fun updateSuccessState(func: (MangaScreenState.Success) -> MangaScreenState.Success) {
|
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
|
private var incognitoMode = false
|
||||||
|
@ -156,20 +140,18 @@ class MangaPresenter(
|
||||||
field = value
|
field = value
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedState: Bundle?) {
|
init {
|
||||||
super.onCreate(savedState)
|
val toChapterItemsParams: List<Chapter>.(manga: Manga) -> List<ChapterItem> = { manga ->
|
||||||
|
|
||||||
val toChapterItemsParams: List<DomainChapter>.(manga: DomainManga) -> List<ChapterItem> = { manga ->
|
|
||||||
val uiPreferences = Injekt.get<UiPreferences>()
|
val uiPreferences = Injekt.get<UiPreferences>()
|
||||||
toChapterItems(
|
toChapterItems(
|
||||||
context = view?.activity ?: Injekt.get<Application>(),
|
context = context,
|
||||||
manga = manga,
|
manga = manga,
|
||||||
dateRelativeTime = uiPreferences.relativeTime().get(),
|
dateRelativeTime = uiPreferences.relativeTime().get(),
|
||||||
dateFormat = UiPreferences.dateFormat(uiPreferences.dateFormat().get()),
|
dateFormat = UiPreferences.dateFormat(uiPreferences.dateFormat().get()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
presenterScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
combine(
|
combine(
|
||||||
getMangaAndChapters.subscribe(mangaId).distinctUntilChanged(),
|
getMangaAndChapters.subscribe(mangaId).distinctUntilChanged(),
|
||||||
downloadCache.changes,
|
downloadCache.changes,
|
||||||
|
@ -187,7 +169,7 @@ class MangaPresenter(
|
||||||
|
|
||||||
observeDownloads()
|
observeDownloads()
|
||||||
|
|
||||||
presenterScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
val manga = getMangaAndChapters.awaitManga(mangaId)
|
val manga = getMangaAndChapters.awaitManga(mangaId)
|
||||||
val chapters = getMangaAndChapters.awaitChapters(mangaId)
|
val chapters = getMangaAndChapters.awaitChapters(mangaId)
|
||||||
.toChapterItemsParams(manga)
|
.toChapterItemsParams(manga)
|
||||||
|
@ -200,12 +182,11 @@ class MangaPresenter(
|
||||||
val needRefreshChapter = chapters.isEmpty()
|
val needRefreshChapter = chapters.isEmpty()
|
||||||
|
|
||||||
// Show what we have earlier
|
// Show what we have earlier
|
||||||
_state.update {
|
mutableState.update {
|
||||||
MangaScreenState.Success(
|
MangaScreenState.Success(
|
||||||
manga = manga,
|
manga = manga,
|
||||||
source = Injekt.get<SourceManager>().getOrStub(manga.source),
|
source = Injekt.get<SourceManager>().getOrStub(manga.source),
|
||||||
isFromSource = isFromSource,
|
isFromSource = isFromSource,
|
||||||
trackingAvailable = trackManager.hasLoggedServices(),
|
|
||||||
chapters = chapters,
|
chapters = chapters,
|
||||||
isRefreshingData = needRefreshInfo || needRefreshChapter,
|
isRefreshingData = needRefreshInfo || needRefreshChapter,
|
||||||
isIncognitoMode = incognitoMode,
|
isIncognitoMode = incognitoMode,
|
||||||
|
@ -216,10 +197,9 @@ class MangaPresenter(
|
||||||
|
|
||||||
// Start observe tracking since it only needs mangaId
|
// Start observe tracking since it only needs mangaId
|
||||||
observeTrackers()
|
observeTrackers()
|
||||||
observeTrackingCount()
|
|
||||||
|
|
||||||
// Fetch info-chapters when needed
|
// Fetch info-chapters when needed
|
||||||
if (presenterScope.isActive) {
|
if (coroutineScope.isActive) {
|
||||||
val fetchFromSourceTasks = listOf(
|
val fetchFromSourceTasks = listOf(
|
||||||
async { if (needRefreshInfo) fetchMangaFromSource() },
|
async { if (needRefreshInfo) fetchMangaFromSource() },
|
||||||
async { if (needRefreshChapter) fetchChaptersFromSource() },
|
async { if (needRefreshChapter) fetchChaptersFromSource() },
|
||||||
|
@ -233,15 +213,15 @@ class MangaPresenter(
|
||||||
|
|
||||||
basePreferences.incognitoMode()
|
basePreferences.incognitoMode()
|
||||||
.asHotFlow { incognitoMode = it }
|
.asHotFlow { incognitoMode = it }
|
||||||
.launchIn(presenterScope)
|
.launchIn(coroutineScope)
|
||||||
|
|
||||||
basePreferences.downloadedOnly()
|
basePreferences.downloadedOnly()
|
||||||
.asHotFlow { downloadedOnlyMode = it }
|
.asHotFlow { downloadedOnlyMode = it }
|
||||||
.launchIn(presenterScope)
|
.launchIn(coroutineScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchAllFromSource(manualFetch: Boolean = true) {
|
fun fetchAllFromSource(manualFetch: Boolean = true) {
|
||||||
presenterScope.launch {
|
coroutineScope.launch {
|
||||||
updateSuccessState { it.copy(isRefreshingData = true) }
|
updateSuccessState { it.copy(isRefreshingData = true) }
|
||||||
val fetchFromSourceTasks = listOf(
|
val fetchFromSourceTasks = listOf(
|
||||||
async { fetchMangaFromSource(manualFetch) },
|
async { fetchMangaFromSource(manualFetch) },
|
||||||
|
@ -265,21 +245,44 @@ class MangaPresenter(
|
||||||
updateManga.awaitUpdateFromSource(it.manga, networkManga, manualFetch)
|
updateManga.awaitUpdateFromSource(it.manga, networkManga, manualFetch)
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} 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.
|
* Update favorite status of manga, (removes / adds) manga (to / from) library.
|
||||||
*/
|
*/
|
||||||
fun toggleFavorite(
|
fun toggleFavorite(
|
||||||
onRemoved: () -> Unit,
|
onRemoved: () -> Unit,
|
||||||
onAdded: () -> Unit,
|
|
||||||
checkDuplicate: Boolean = true,
|
checkDuplicate: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val state = successState ?: return
|
val state = successState ?: return
|
||||||
presenterScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
val manga = state.manga
|
val manga = state.manga
|
||||||
|
|
||||||
if (isFavoritedManga) {
|
if (isFavoritedManga) {
|
||||||
|
@ -298,7 +301,7 @@ class MangaPresenter(
|
||||||
val duplicate = getDuplicateLibraryManga.await(manga.title, manga.source)
|
val duplicate = getDuplicateLibraryManga.await(manga.title, manga.source)
|
||||||
|
|
||||||
if (duplicate != null) {
|
if (duplicate != null) {
|
||||||
_state.update { state ->
|
mutableState.update { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
MangaScreenState.Loading -> state
|
MangaScreenState.Loading -> state
|
||||||
is MangaScreenState.Success -> state.copy(dialog = Dialog.DuplicateManga(manga, duplicate))
|
is MangaScreenState.Success -> state.copy(dialog = Dialog.DuplicateManga(manga, duplicate))
|
||||||
|
@ -318,7 +321,6 @@ class MangaPresenter(
|
||||||
val result = updateManga.awaitUpdateFavorite(manga.id, true)
|
val result = updateManga.awaitUpdateFavorite(manga.id, true)
|
||||||
if (!result) return@launchIO
|
if (!result) return@launchIO
|
||||||
moveMangaToCategory(defaultCategory)
|
moveMangaToCategory(defaultCategory)
|
||||||
withUIContext { onAdded() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatic 'Default' or no categories
|
// Automatic 'Default' or no categories
|
||||||
|
@ -326,7 +328,6 @@ class MangaPresenter(
|
||||||
val result = updateManga.awaitUpdateFavorite(manga.id, true)
|
val result = updateManga.awaitUpdateFavorite(manga.id, true)
|
||||||
if (!result) return@launchIO
|
if (!result) return@launchIO
|
||||||
moveMangaToCategory(null)
|
moveMangaToCategory(null)
|
||||||
withUIContext { onAdded() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choose a category
|
// Choose a category
|
||||||
|
@ -335,7 +336,7 @@ class MangaPresenter(
|
||||||
|
|
||||||
// Finally match with enhanced tracking when available
|
// Finally match with enhanced tracking when available
|
||||||
val source = state.source
|
val source = state.source
|
||||||
trackList
|
state.trackItems
|
||||||
.map { it.service }
|
.map { it.service }
|
||||||
.filterIsInstance<EnhancedTrackService>()
|
.filterIsInstance<EnhancedTrackService>()
|
||||||
.filter { it.accept(source) }
|
.filter { it.accept(source) }
|
||||||
|
@ -343,7 +344,7 @@ class MangaPresenter(
|
||||||
launchIO {
|
launchIO {
|
||||||
try {
|
try {
|
||||||
service.match(manga.toDbManga())?.let { track ->
|
service.match(manga.toDbManga())?.let { track ->
|
||||||
registerTracking(track, service as TrackService)
|
(service as TrackService).registerTracking(track, mangaId)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.WARN, e) {
|
logcat(LogPriority.WARN, e) {
|
||||||
|
@ -359,10 +360,10 @@ class MangaPresenter(
|
||||||
fun promptChangeCategories() {
|
fun promptChangeCategories() {
|
||||||
val state = successState ?: return
|
val state = successState ?: return
|
||||||
val manga = state.manga
|
val manga = state.manga
|
||||||
presenterScope.launch {
|
coroutineScope.launch {
|
||||||
val categories = getCategories()
|
val categories = getCategories()
|
||||||
val selection = getMangaCategoryIds(manga)
|
val selection = getMangaCategoryIds(manga)
|
||||||
_state.update { state ->
|
mutableState.update { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
MangaScreenState.Loading -> state
|
MangaScreenState.Loading -> state
|
||||||
is MangaScreenState.Success -> state.copy(
|
is MangaScreenState.Success -> state.copy(
|
||||||
|
@ -387,7 +388,7 @@ class MangaPresenter(
|
||||||
/**
|
/**
|
||||||
* Deletes all the downloads for the manga.
|
* Deletes all the downloads for the manga.
|
||||||
*/
|
*/
|
||||||
fun deleteDownloads() {
|
private fun deleteDownloads() {
|
||||||
val state = successState ?: return
|
val state = successState ?: return
|
||||||
downloadManager.deleteManga(state.manga, state.source)
|
downloadManager.deleteManga(state.manga, state.source)
|
||||||
}
|
}
|
||||||
|
@ -407,15 +408,15 @@ class MangaPresenter(
|
||||||
* @param manga the manga to get categories from.
|
* @param manga the manga to get categories from.
|
||||||
* @return Array of category ids the manga is in, if none returns default id
|
* @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)
|
return getCategories.await(manga.id)
|
||||||
.map { it.id }
|
.map { it.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun moveMangaToCategoriesAndAddToLibrary(manga: DomainManga, categories: List<Long>) {
|
fun moveMangaToCategoriesAndAddToLibrary(manga: Manga, categories: List<Long>) {
|
||||||
moveMangaToCategory(categories)
|
moveMangaToCategory(categories)
|
||||||
if (!manga.favorite) {
|
if (!manga.favorite) {
|
||||||
presenterScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
updateManga.awaitUpdateFavorite(manga.id, true)
|
updateManga.awaitUpdateFavorite(manga.id, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -432,7 +433,7 @@ class MangaPresenter(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun moveMangaToCategory(categoryIds: List<Long>) {
|
private fun moveMangaToCategory(categoryIds: List<Long>) {
|
||||||
presenterScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
setMangaCategories.await(mangaId, categoryIds)
|
setMangaCategories.await(mangaId, categoryIds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -446,28 +447,12 @@ class MangaPresenter(
|
||||||
moveMangaToCategories(listOfNotNull(category))
|
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
|
// Manga info - end
|
||||||
|
|
||||||
// Chapters list - start
|
// Chapters list - start
|
||||||
|
|
||||||
private fun observeDownloads() {
|
private fun observeDownloads() {
|
||||||
presenterScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
downloadManager.queue.statusFlow()
|
downloadManager.queue.statusFlow()
|
||||||
.filter { it.manga.id == successState?.manga?.id }
|
.filter { it.manga.id == successState?.manga?.id }
|
||||||
.catch { error -> logcat(LogPriority.ERROR, error) }
|
.catch { error -> logcat(LogPriority.ERROR, error) }
|
||||||
|
@ -478,7 +463,7 @@ class MangaPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
presenterScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
downloadManager.queue.progressFlow()
|
downloadManager.queue.progressFlow()
|
||||||
.filter { it.manga.id == successState?.manga?.id }
|
.filter { it.manga.id == successState?.manga?.id }
|
||||||
.catch { error -> logcat(LogPriority.ERROR, error) }
|
.catch { error -> logcat(LogPriority.ERROR, error) }
|
||||||
|
@ -504,9 +489,9 @@ class MangaPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<DomainChapter>.toChapterItems(
|
private fun List<Chapter>.toChapterItems(
|
||||||
context: Context,
|
context: Context,
|
||||||
manga: DomainManga,
|
manga: Manga,
|
||||||
dateRelativeTime: Int,
|
dateRelativeTime: Int,
|
||||||
dateFormat: DateFormat,
|
dateFormat: DateFormat,
|
||||||
): List<ChapterItem> {
|
): List<ChapterItem> {
|
||||||
|
@ -522,7 +507,7 @@ class MangaPresenter(
|
||||||
chapter = chapter,
|
chapter = chapter,
|
||||||
downloadState = downloadState,
|
downloadState = downloadState,
|
||||||
downloadProgress = activeDownload?.progress ?: 0,
|
downloadProgress = activeDownload?.progress ?: 0,
|
||||||
chapterTitleString = if (manga.displayMode == DomainManga.CHAPTER_DISPLAY_NUMBER) {
|
chapterTitleString = if (manga.displayMode == Manga.CHAPTER_DISPLAY_NUMBER) {
|
||||||
context.getString(
|
context.getString(
|
||||||
R.string.display_mode_chapter,
|
R.string.display_mode_chapter,
|
||||||
chapterDecimalFormat.format(chapter.chapterNumber.toDouble()),
|
chapterDecimalFormat.format(chapter.chapterNumber.toDouble()),
|
||||||
|
@ -569,7 +554,14 @@ class MangaPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} 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.
|
* Returns the next unread chapter or null if everything is read.
|
||||||
*/
|
*/
|
||||||
fun getNextUnreadChapter(): DomainChapter? {
|
fun getNextUnreadChapter(): Chapter? {
|
||||||
val successState = successState ?: return null
|
val successState = successState ?: return null
|
||||||
return successState.chapters.getNextUnread(successState.manga)
|
return successState.chapters.getNextUnread(successState.manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUnreadChapters(): List<DomainChapter> {
|
fun getUnreadChapters(): List<Chapter> {
|
||||||
return successState?.processedChapters
|
return successState?.processedChapters
|
||||||
?.filter { (chapter, dlStatus) -> !chapter.read && dlStatus == Download.State.NOT_DOWNLOADED }
|
?.filter { (chapter, dlStatus) -> !chapter.read && dlStatus == Download.State.NOT_DOWNLOADED }
|
||||||
?.map { it.chapter }
|
?.map { it.chapter }
|
||||||
|
@ -590,14 +582,76 @@ class MangaPresenter(
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUnreadChaptersSorted(): List<DomainChapter> {
|
fun getUnreadChaptersSorted(): List<Chapter> {
|
||||||
val manga = successState?.manga ?: return emptyList()
|
val manga = successState?.manga ?: return emptyList()
|
||||||
val chapters = getUnreadChapters().sortedWith(getChapterSort(manga))
|
val chapters = getUnreadChapters().sortedWith(getChapterSort(manga))
|
||||||
return if (manga.sortDescending()) chapters.reversed() else chapters
|
return if (manga.sortDescending()) chapters.reversed() else chapters
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startDownloadingNow(chapterId: Long) {
|
fun startDownload(
|
||||||
downloadManager.startDownloadNow(chapterId)
|
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) {
|
fun cancelDownload(chapterId: Long) {
|
||||||
|
@ -606,7 +660,7 @@ class MangaPresenter(
|
||||||
updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED })
|
updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED })
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markPreviousChapterRead(pointer: DomainChapter) {
|
fun markPreviousChapterRead(pointer: Chapter) {
|
||||||
val successState = successState ?: return
|
val successState = successState ?: return
|
||||||
val chapters = processedChapters.orEmpty().map { it.chapter }.toList()
|
val chapters = processedChapters.orEmpty().map { it.chapter }.toList()
|
||||||
val prevChapters = if (successState.manga.sortDescending()) chapters.asReversed() else chapters
|
val prevChapters = if (successState.manga.sortDescending()) chapters.asReversed() else chapters
|
||||||
|
@ -619,8 +673,8 @@ class MangaPresenter(
|
||||||
* @param chapters the list of selected chapters.
|
* @param chapters the list of selected chapters.
|
||||||
* @param read whether to mark chapters as read or unread.
|
* @param read whether to mark chapters as read or unread.
|
||||||
*/
|
*/
|
||||||
fun markChaptersRead(chapters: List<DomainChapter>, read: Boolean) {
|
fun markChaptersRead(chapters: List<Chapter>, read: Boolean) {
|
||||||
presenterScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
setReadStatus.await(
|
setReadStatus.await(
|
||||||
read = read,
|
read = read,
|
||||||
chapters = chapters.toTypedArray(),
|
chapters = chapters.toTypedArray(),
|
||||||
|
@ -633,7 +687,7 @@ class MangaPresenter(
|
||||||
* Downloads the given list of chapters with the manager.
|
* Downloads the given list of chapters with the manager.
|
||||||
* @param chapters the list of chapters to download.
|
* @param chapters the list of chapters to download.
|
||||||
*/
|
*/
|
||||||
fun downloadChapters(chapters: List<DomainChapter>) {
|
private fun downloadChapters(chapters: List<Chapter>) {
|
||||||
val manga = successState?.manga ?: return
|
val manga = successState?.manga ?: return
|
||||||
downloadManager.downloadChapters(manga, chapters.map { it.toDbChapter() })
|
downloadManager.downloadChapters(manga, chapters.map { it.toDbChapter() })
|
||||||
toggleAllSelection(false)
|
toggleAllSelection(false)
|
||||||
|
@ -643,8 +697,8 @@ class MangaPresenter(
|
||||||
* Bookmarks the given list of chapters.
|
* Bookmarks the given list of chapters.
|
||||||
* @param chapters the list of chapters to bookmark.
|
* @param chapters the list of chapters to bookmark.
|
||||||
*/
|
*/
|
||||||
fun bookmarkChapters(chapters: List<DomainChapter>, bookmarked: Boolean) {
|
fun bookmarkChapters(chapters: List<Chapter>, bookmarked: Boolean) {
|
||||||
presenterScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
chapters
|
chapters
|
||||||
.filterNot { it.bookmark == bookmarked }
|
.filterNot { it.bookmark == bookmarked }
|
||||||
.map { ChapterUpdate(id = it.id, bookmark = bookmarked) }
|
.map { ChapterUpdate(id = it.id, bookmark = bookmarked) }
|
||||||
|
@ -658,8 +712,8 @@ class MangaPresenter(
|
||||||
*
|
*
|
||||||
* @param chapters the list of chapters to delete.
|
* @param chapters the list of chapters to delete.
|
||||||
*/
|
*/
|
||||||
fun deleteChapters(chapters: List<DomainChapter>) {
|
fun deleteChapters(chapters: List<Chapter>) {
|
||||||
presenterScope.launchNonCancellable {
|
coroutineScope.launchNonCancellable {
|
||||||
try {
|
try {
|
||||||
successState?.let { state ->
|
successState?.let { state ->
|
||||||
downloadManager.deleteChapters(
|
downloadManager.deleteChapters(
|
||||||
|
@ -674,8 +728,8 @@ class MangaPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadNewChapters(chapters: List<DomainChapter>) {
|
private fun downloadNewChapters(chapters: List<Chapter>) {
|
||||||
presenterScope.launchNonCancellable {
|
coroutineScope.launchNonCancellable {
|
||||||
val manga = successState?.manga ?: return@launchNonCancellable
|
val manga = successState?.manga ?: return@launchNonCancellable
|
||||||
val categories = getCategories.await(manga.id).map { it.id }
|
val categories = getCategories.await(manga.id).map { it.id }
|
||||||
if (chapters.isEmpty() || !manga.shouldDownloadNewChapters(categories, downloadPreferences)) return@launchNonCancellable
|
if (chapters.isEmpty() || !manga.shouldDownloadNewChapters(categories, downloadPreferences)) return@launchNonCancellable
|
||||||
|
@ -687,15 +741,15 @@ class MangaPresenter(
|
||||||
* Sets the read filter and requests an UI update.
|
* Sets the read filter and requests an UI update.
|
||||||
* @param state whether to display only unread chapters or all chapters.
|
* @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 manga = successState?.manga ?: return
|
||||||
|
|
||||||
val flag = when (state) {
|
val flag = when (state) {
|
||||||
State.IGNORE -> DomainManga.SHOW_ALL
|
TriStateFilter.DISABLED -> Manga.SHOW_ALL
|
||||||
State.INCLUDE -> DomainManga.CHAPTER_SHOW_UNREAD
|
TriStateFilter.ENABLED_IS -> Manga.CHAPTER_SHOW_UNREAD
|
||||||
State.EXCLUDE -> DomainManga.CHAPTER_SHOW_READ
|
TriStateFilter.ENABLED_NOT -> Manga.CHAPTER_SHOW_READ
|
||||||
}
|
}
|
||||||
presenterScope.launchNonCancellable {
|
coroutineScope.launchNonCancellable {
|
||||||
setMangaChapterFlags.awaitSetUnreadFilter(manga, flag)
|
setMangaChapterFlags.awaitSetUnreadFilter(manga, flag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -704,16 +758,16 @@ class MangaPresenter(
|
||||||
* Sets the download filter and requests an UI update.
|
* Sets the download filter and requests an UI update.
|
||||||
* @param state whether to display only downloaded chapters or all chapters.
|
* @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 manga = successState?.manga ?: return
|
||||||
|
|
||||||
val flag = when (state) {
|
val flag = when (state) {
|
||||||
State.IGNORE -> DomainManga.SHOW_ALL
|
TriStateFilter.DISABLED -> Manga.SHOW_ALL
|
||||||
State.INCLUDE -> DomainManga.CHAPTER_SHOW_DOWNLOADED
|
TriStateFilter.ENABLED_IS -> Manga.CHAPTER_SHOW_DOWNLOADED
|
||||||
State.EXCLUDE -> DomainManga.CHAPTER_SHOW_NOT_DOWNLOADED
|
TriStateFilter.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_DOWNLOADED
|
||||||
}
|
}
|
||||||
|
|
||||||
presenterScope.launchNonCancellable {
|
coroutineScope.launchNonCancellable {
|
||||||
setMangaChapterFlags.awaitSetDownloadedFilter(manga, flag)
|
setMangaChapterFlags.awaitSetDownloadedFilter(manga, flag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -722,16 +776,16 @@ class MangaPresenter(
|
||||||
* Sets the bookmark filter and requests an UI update.
|
* Sets the bookmark filter and requests an UI update.
|
||||||
* @param state whether to display only bookmarked chapters or all chapters.
|
* @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 manga = successState?.manga ?: return
|
||||||
|
|
||||||
val flag = when (state) {
|
val flag = when (state) {
|
||||||
State.IGNORE -> DomainManga.SHOW_ALL
|
TriStateFilter.DISABLED -> Manga.SHOW_ALL
|
||||||
State.INCLUDE -> DomainManga.CHAPTER_SHOW_BOOKMARKED
|
TriStateFilter.ENABLED_IS -> Manga.CHAPTER_SHOW_BOOKMARKED
|
||||||
State.EXCLUDE -> DomainManga.CHAPTER_SHOW_NOT_BOOKMARKED
|
TriStateFilter.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_BOOKMARKED
|
||||||
}
|
}
|
||||||
|
|
||||||
presenterScope.launchNonCancellable {
|
coroutineScope.launchNonCancellable {
|
||||||
setMangaChapterFlags.awaitSetBookmarkFilter(manga, flag)
|
setMangaChapterFlags.awaitSetBookmarkFilter(manga, flag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -743,7 +797,7 @@ class MangaPresenter(
|
||||||
fun setDisplayMode(mode: Long) {
|
fun setDisplayMode(mode: Long) {
|
||||||
val manga = successState?.manga ?: return
|
val manga = successState?.manga ?: return
|
||||||
|
|
||||||
presenterScope.launchNonCancellable {
|
coroutineScope.launchNonCancellable {
|
||||||
setMangaChapterFlags.awaitSetDisplayMode(manga, mode)
|
setMangaChapterFlags.awaitSetDisplayMode(manga, mode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -755,11 +809,22 @@ class MangaPresenter(
|
||||||
fun setSorting(sort: Long) {
|
fun setSorting(sort: Long) {
|
||||||
val manga = successState?.manga ?: return
|
val manga = successState?.manga ?: return
|
||||||
|
|
||||||
presenterScope.launchNonCancellable {
|
coroutineScope.launchNonCancellable {
|
||||||
setMangaChapterFlags.awaitSetSortingModeOrFlipOrder(manga, sort)
|
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(
|
fun toggleSelection(
|
||||||
item: ChapterItem,
|
item: ChapterItem,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
|
@ -850,7 +915,7 @@ class MangaPresenter(
|
||||||
private fun observeTrackers() {
|
private fun observeTrackers() {
|
||||||
val manga = successState?.manga ?: return
|
val manga = successState?.manga ?: return
|
||||||
|
|
||||||
presenterScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
getTracks.subscribe(manga.id)
|
getTracks.subscribe(manga.id)
|
||||||
.catch { logcat(LogPriority.ERROR, it) }
|
.catch { logcat(LogPriority.ERROR, it) }
|
||||||
.map { tracks ->
|
.map { tracks ->
|
||||||
|
@ -861,184 +926,31 @@ class MangaPresenter(
|
||||||
// Show only if the service supports this manga's source
|
// Show only if the service supports this manga's source
|
||||||
.filter { (it.service as? EnhancedTrackService)?.accept(source!!) ?: true }
|
.filter { (it.service as? EnhancedTrackService)?.accept(source!!) ?: true }
|
||||||
}
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
.collectLatest { trackItems ->
|
.collectLatest { trackItems ->
|
||||||
_trackList = trackItems
|
updateSuccessState { it.copy(trackItems = trackItems) }
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
view?.onNextTrackers(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
|
// Track sheet - end
|
||||||
|
|
||||||
fun getSourceOrStub(manga: DomainManga): Source {
|
fun getSourceOrStub(manga: Manga): Source {
|
||||||
return sourceManager.getOrStub(manga.source)
|
return sourceManager.getOrStub(manga.source)
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class Dialog {
|
sealed class Dialog {
|
||||||
data class ChangeCategory(val manga: DomainManga, val initialSelection: List<CheckboxState<Category>>) : Dialog()
|
data class ChangeCategory(val manga: Manga, val initialSelection: List<CheckboxState<Category>>) : Dialog()
|
||||||
data class DeleteChapters(val chapters: List<DomainChapter>) : Dialog()
|
data class DeleteChapters(val chapters: List<Chapter>) : Dialog()
|
||||||
data class DuplicateManga(val manga: DomainManga, val duplicate: DomainManga) : Dialog()
|
data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog()
|
||||||
data class DownloadCustomAmount(val max: Int) : Dialog()
|
data class DownloadCustomAmount(val max: Int) : Dialog()
|
||||||
|
object SettingsSheet : Dialog()
|
||||||
|
object TrackSheet : Dialog()
|
||||||
|
object FullCover : Dialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissDialog() {
|
fun dismissDialog() {
|
||||||
_state.update { state ->
|
mutableState.update { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
MangaScreenState.Loading -> state
|
MangaScreenState.Loading -> state
|
||||||
is MangaScreenState.Success -> state.copy(dialog = null)
|
is MangaScreenState.Success -> state.copy(dialog = null)
|
||||||
|
@ -1046,9 +958,9 @@ class MangaPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showDownloadCustomDialog() {
|
private fun showDownloadCustomDialog() {
|
||||||
val max = processedChapters?.count() ?: return
|
val max = processedChapters?.count() ?: return
|
||||||
_state.update { state ->
|
mutableState.update { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
MangaScreenState.Loading -> state
|
MangaScreenState.Loading -> state
|
||||||
is MangaScreenState.Success -> state.copy(dialog = Dialog.DownloadCustomAmount(max))
|
is MangaScreenState.Success -> state.copy(dialog = Dialog.DownloadCustomAmount(max))
|
||||||
|
@ -1056,14 +968,45 @@ class MangaPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showDeleteChapterDialog(chapters: List<DomainChapter>) {
|
fun showDeleteChapterDialog(chapters: List<Chapter>) {
|
||||||
_state.update { state ->
|
mutableState.update { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
MangaScreenState.Loading -> state
|
MangaScreenState.Loading -> state
|
||||||
is MangaScreenState.Success -> state.copy(dialog = Dialog.DeleteChapters(chapters))
|
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 {
|
sealed class MangaScreenState {
|
||||||
|
@ -1072,26 +1015,65 @@ sealed class MangaScreenState {
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class Success(
|
data class Success(
|
||||||
val manga: DomainManga,
|
val manga: Manga,
|
||||||
val source: Source,
|
val source: Source,
|
||||||
val isFromSource: Boolean,
|
val isFromSource: Boolean,
|
||||||
val chapters: List<ChapterItem>,
|
val chapters: List<ChapterItem>,
|
||||||
val trackingAvailable: Boolean = false,
|
val trackItems: List<TrackItem> = emptyList(),
|
||||||
val trackingCount: Int = 0,
|
|
||||||
val isRefreshingData: Boolean = false,
|
val isRefreshingData: Boolean = false,
|
||||||
val isIncognitoMode: Boolean = false,
|
val isIncognitoMode: Boolean = false,
|
||||||
val isDownloadedOnlyMode: Boolean = false,
|
val isDownloadedOnlyMode: Boolean = false,
|
||||||
val dialog: MangaPresenter.Dialog? = null,
|
val dialog: MangaInfoScreenModel.Dialog? = null,
|
||||||
) : MangaScreenState() {
|
) : MangaScreenState() {
|
||||||
|
|
||||||
val processedChapters: Sequence<ChapterItem>
|
val processedChapters: Sequence<ChapterItem>
|
||||||
get() = chapters.applyFilters(manga)
|
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
|
@Immutable
|
||||||
data class ChapterItem(
|
data class ChapterItem(
|
||||||
val chapter: DomainChapter,
|
val chapter: Chapter,
|
||||||
val downloadState: Download.State,
|
val downloadState: Download.State,
|
||||||
val downloadProgress: Int,
|
val downloadProgress: Int,
|
||||||
|
|
||||||
|
@ -1104,7 +1086,7 @@ data class ChapterItem(
|
||||||
val isDownloaded = downloadState == Download.State.DOWNLOADED
|
val isDownloaded = downloadState == Download.State.DOWNLOADED
|
||||||
}
|
}
|
||||||
|
|
||||||
private val chapterDecimalFormat = DecimalFormat(
|
val chapterDecimalFormat = DecimalFormat(
|
||||||
"#.###",
|
"#.###",
|
||||||
DecimalFormatSymbols()
|
DecimalFormatSymbols()
|
||||||
.apply { decimalSeparator = '.' },
|
.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>
|
<item name="cornerSize">@dimen/card_radius</item>
|
||||||
</style>
|
</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">
|
<style name="Animation.Tachiyomi.Dialog" parent="Animation.AppCompat.Dialog">
|
||||||
<item name="android:windowEnterAnimation">@anim/fade_in_short</item>
|
<item name="android:windowEnterAnimation">@anim/fade_in_short</item>
|
||||||
<item name="android:windowExitAnimation">@anim/fade_out_short</item>
|
<item name="android:windowExitAnimation">@anim/fade_out_short</item>
|
||||||
|
|
|
@ -11,6 +11,7 @@ leakcanary = "2.9.1"
|
||||||
voyager = "1.0.0-rc06"
|
voyager = "1.0.0-rc06"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
|
desugar = "com.android.tools:desugar_jdk_libs:1.2.2"
|
||||||
android-shortcut-gradle = "com.github.zellius:android-shortcut-gradle-plugin:0.1.2"
|
android-shortcut-gradle = "com.github.zellius:android-shortcut-gradle-plugin:0.1.2"
|
||||||
google-services-gradle = "com.google.gms:google-services:4.3.14"
|
google-services-gradle = "com.google.gms:google-services:4.3.14"
|
||||||
|
|
||||||
|
@ -62,6 +63,8 @@ photoview = "com.github.chrisbanes:PhotoView:2.3.0"
|
||||||
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
|
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
|
||||||
insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
|
insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
|
||||||
cascade = "me.saket.cascade:cascade-compose:2.0.0-beta1"
|
cascade = "me.saket.cascade:cascade-compose:2.0.0-beta1"
|
||||||
|
numberpicker = "com.chargemap.compose:numberpicker:1.0.3"
|
||||||
|
wheelpicker = "com.github.commandiron:WheelPickerCompose:1.0.11"
|
||||||
|
|
||||||
conductor-core = { module = "com.bluelinelabs:conductor", version.ref = "conductor_version" }
|
conductor-core = { module = "com.bluelinelabs:conductor", version.ref = "conductor_version" }
|
||||||
conductor-support-preference = { module = "com.github.tachiyomiorg:conductor-support-preference", version.ref = "conductor_version" }
|
conductor-support-preference = { module = "com.github.tachiyomiorg:conductor-support-preference", version.ref = "conductor_version" }
|
||||||
|
@ -93,8 +96,6 @@ junit = "org.junit.jupiter:junit-jupiter:5.9.1"
|
||||||
voyager-navigator = { module = "ca.gosyer:voyager-navigator", version.ref = "voyager" }
|
voyager-navigator = { module = "ca.gosyer:voyager-navigator", version.ref = "voyager" }
|
||||||
voyager-transitions = { module = "ca.gosyer:voyager-transitions", version.ref = "voyager" }
|
voyager-transitions = { module = "ca.gosyer:voyager-transitions", version.ref = "voyager" }
|
||||||
|
|
||||||
numberpicker= "com.chargemap.compose:numberpicker:1.0.3"
|
|
||||||
|
|
||||||
[bundles]
|
[bundles]
|
||||||
reactivex = ["rxandroid", "rxjava", "rxrelay"]
|
reactivex = ["rxandroid", "rxjava", "rxrelay"]
|
||||||
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"]
|
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"]
|
||||||
|
|
|
@ -705,6 +705,9 @@
|
||||||
<string name="myanimelist_relogin">Please login to MAL again</string>
|
<string name="myanimelist_relogin">Please login to MAL again</string>
|
||||||
<string name="source_unsupported">Source is not supported</string>
|
<string name="source_unsupported">Source is not supported</string>
|
||||||
<string name="error_no_match">No match found</string>
|
<string name="error_no_match">No match found</string>
|
||||||
|
<string name="track_remove_date_conf_title">Remove date?</string>
|
||||||
|
<string name="track_remove_start_date_conf_text">This will remove your previously selected start date from %s</string>
|
||||||
|
<string name="track_remove_finish_date_conf_text">This will remove your previously selected finish date from %s</string>
|
||||||
|
|
||||||
<!-- Category activity -->
|
<!-- Category activity -->
|
||||||
<string name="error_category_exists">A category with this name already exists!</string>
|
<string name="error_category_exists">A category with this name already exists!</string>
|
||||||
|
|
Reference in a new issue