Full Compose MangaController (#8452)

* Full Compose MangaController

* unique key

* Use StateScreenModel

* dismiss

* rebase fix

* toShareIntent
This commit is contained in:
Ivan Iskandar 2022-11-10 10:31:56 +07:00 committed by GitHub
parent 21bc0f1952
commit 18ccde082d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 3470 additions and 2927 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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),
) )
} }

View File

@ -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 = {},
) {}
}
}
}

View File

@ -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
},
)
}
}
}

View File

@ -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))
}
}
},
)
}

View File

@ -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,
)
}
}

View File

@ -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,12 +54,21 @@ 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,
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false, // Doesn't work https://issuetracker.google.com/issues/246909281
),
) { ) {
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
bottomBar = { bottomBar = {
Row( Row(
modifier = Modifier modifier = Modifier
@ -171,3 +184,4 @@ fun MangaCoverDialog(
} }
} }
} }
}

View File

@ -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() }

View 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()
}

View File

@ -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
}
} }

View File

@ -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) }
}
}
}
} }

View File

@ -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"

View File

@ -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)
}
}
}

View 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)
}
}

View File

@ -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(
chapters: List<Chapter>,
startNow: Boolean,
) {
if (startNow) {
val chapterId = chapters.singleOrNull()?.id ?: return
downloadManager.startDownloadNow(chapterId) 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 = '.' },

View File

@ -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)
}
}
}
}
}

View File

@ -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"

View File

@ -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
}
}

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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,
)
}
}

View File

@ -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
}
}

View File

@ -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"

View File

@ -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
}
}

View File

@ -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"

View File

@ -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)
}
}

View File

@ -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>

View File

@ -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" />

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"]

View File

@ -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>