Full Compose MangaController (#8452)

* Full Compose MangaController

* unique key

* Use StateScreenModel

* dismiss

* rebase fix

* toShareIntent

(cherry picked from commit 18ccde082d5529766ad1297f9850752508805156)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt
#	app/src/main/res/layout/track_chapters_dialog.xml
#	app/src/main/res/layout/track_controller.xml
#	app/src/main/res/layout/track_item.xml
#	app/src/main/res/layout/track_score_dialog.xml
#	app/src/main/res/layout/track_search_dialog.xml
#	app/src/main/res/layout/track_search_item.xml
This commit is contained in:
Ivan Iskandar 2022-11-10 10:31:56 +07:00 committed by Jobobby04
parent 5be39961c3
commit 7b1bc790b3
45 changed files with 4224 additions and 3475 deletions

View File

@ -141,6 +141,8 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
@ -160,6 +162,8 @@ dependencies {
implementation(project(":core"))
implementation(project(":source-api"))
coreLibraryDesugaring(libs.desugar)
// Compose
implementation(platform(compose.bom))
implementation(compose.activity)
@ -264,6 +268,7 @@ dependencies {
implementation(libs.cascade)
implementation(libs.numberpicker)
implementation(libs.bundles.voyager)
implementation(libs.wheelpicker)
// 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
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.DividerDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
const val DIVIDER_ALPHA = 0.2f
@Composable
fun Divider(
modifier: Modifier = Modifier,
color: Color = DividerDefaults.color,
) {
androidx.compose.material3.Divider(
modifier = modifier,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = DIVIDER_ALPHA),
Box(
modifier
.fillMaxWidth()
.height(1.dp)
.background(color = color)
.alpha(DIVIDER_ALPHA),
)
}
@Composable
fun VerticalDivider(
modifier: Modifier = Modifier,
color: Color = DividerDefaults.color,
) {
Box(
modifier
.fillMaxHeight()
.width(1.dp)
.background(color = color)
.alpha(DIVIDER_ALPHA),
)
}

View File

@ -0,0 +1,541 @@
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.outlined.PeopleAlt
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,
onClickShowScanlatorSelection: (() -> Unit)?,
) {
AdaptiveSheet(
onDismissRequest = onDismissRequest,
) { contentPadding ->
ChapterSettingsDialogImpl(
manga = manga,
contentPadding = contentPadding,
onDownloadFilterChanged = onDownloadFilterChanged,
onUnreadFilterChanged = onUnreadFilterChanged,
onBookmarkedFilterChanged = onBookmarkedFilterChanged,
onSortModeChanged = onSortModeChanged,
onDisplayModeChanged = onDisplayModeChanged,
onSetAsDefault = onSetAsDefault,
// SY -->
onClickShowScanlatorSelection = onClickShowScanlatorSelection,
// SY <--
)
}
}
@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,
onClickShowScanlatorSelection: (() -> 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,
// SY -->
onClickShowScanlatorSelection = onClickShowScanlatorSelection,
// SY <--
)
}
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,
// SY -->
onClickShowScanlatorSelection: (() -> Unit)?,
// SY <--
) {
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,
)
// SY -->
if (onClickShowScanlatorSelection != null) {
SetScanlatorsItem(onClickShowScanlatorSelection)
}
// SY <--
}
}
@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,
)
}
}
// SY -->
@Composable
private fun SetScanlatorsItem(
onClickShowScanlatorSelection: () -> Unit,
) {
Row(
modifier = Modifier
.clickable(
onClick = onClickShowScanlatorSelection,
)
.fillMaxWidth()
.padding(horizontal = HorizontalPadding, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
Icon(
imageVector = Icons.Outlined.PeopleAlt,
contentDescription = null,
)
Text(
text = stringResource(R.string.scanlator),
style = MaterialTheme.typography.bodyMedium,
)
}
}
// SY <--
@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 = {},
onSetAsDefault = {},
onClickShowScanlatorSelection = {},
)
}
}
}

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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -34,6 +36,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.view.updatePadding
import coil.imageLoader
import coil.request.ImageRequest
@ -50,124 +54,134 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
fun MangaCoverDialog(
coverDataProvider: () -> Manga,
isCustomCover: Boolean,
snackbarHostState: SnackbarHostState,
onShareClick: () -> Unit,
onSaveClick: () -> Unit,
onEditClick: ((EditCoverAction) -> Unit)?,
onDismissRequest: () -> Unit,
) {
Scaffold(
bottomBar = {
Row(
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background.copy(alpha = 0.9f))
.padding(horizontal = 4.dp, vertical = 4.dp)
.navigationBarsPadding(),
) {
IconButton(onClick = onDismissRequest) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(R.string.action_close),
)
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = onShareClick) {
Icon(
imageVector = Icons.Outlined.Share,
contentDescription = stringResource(R.string.action_share),
)
}
IconButton(onClick = onSaveClick) {
Icon(
imageVector = Icons.Outlined.Save,
contentDescription = stringResource(R.string.action_save),
)
}
if (onEditClick != null) {
Box {
var expanded by remember { mutableStateOf(false) }
IconButton(
onClick = {
if (isCustomCover) {
expanded = true
} else {
onEditClick(EditCoverAction.EDIT)
}
},
) {
Icon(
imageVector = Icons.Outlined.Edit,
contentDescription = stringResource(R.string.action_edit_cover),
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
offset = DpOffset(8.dp, 0.dp),
) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_edit)) },
onClick = {
onEditClick(EditCoverAction.EDIT)
expanded = false
},
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_delete)) },
onClick = {
onEditClick(EditCoverAction.DELETE)
expanded = false
},
)
}
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false, // Doesn't work https://issuetracker.google.com/issues/246909281
),
) {
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
bottomBar = {
Row(
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background.copy(alpha = 0.9f))
.padding(horizontal = 4.dp, vertical = 4.dp)
.navigationBarsPadding(),
) {
IconButton(onClick = onDismissRequest) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(R.string.action_close),
)
}
}
}
},
) { contentPadding ->
val statusBarPaddingPx = WindowInsets.systemBars.getTop(LocalDensity.current)
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
Box(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colorScheme.background)
.clickableNoIndication(onClick = onDismissRequest),
) {
AndroidView(
factory = {
ReaderPageImageView(it).apply {
onViewClicked = onDismissRequest
clipToPadding = false
clipChildren = false
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = onShareClick) {
Icon(
imageVector = Icons.Outlined.Share,
contentDescription = stringResource(R.string.action_share),
)
}
},
update = { view ->
val request = ImageRequest.Builder(view.context)
.data(coverDataProvider())
.size(Size.ORIGINAL)
.target { drawable ->
// Copy bitmap in case it came from memory cache
// Because SSIV needs to thoroughly read the image
val copy = (drawable as? BitmapDrawable)?.let {
val config = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Bitmap.Config.HARDWARE
} else {
Bitmap.Config.ARGB_8888
}
BitmapDrawable(
view.context.resources,
it.bitmap.copy(config, false),
IconButton(onClick = onSaveClick) {
Icon(
imageVector = Icons.Outlined.Save,
contentDescription = stringResource(R.string.action_save),
)
}
if (onEditClick != null) {
Box {
var expanded by remember { mutableStateOf(false) }
IconButton(
onClick = {
if (isCustomCover) {
expanded = true
} else {
onEditClick(EditCoverAction.EDIT)
}
},
) {
Icon(
imageVector = Icons.Outlined.Edit,
contentDescription = stringResource(R.string.action_edit_cover),
)
} ?: drawable
view.setImage(copy, ReaderPageImageView.Config(zoomDuration = 500))
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
offset = DpOffset(8.dp, 0.dp),
) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_edit)) },
onClick = {
onEditClick(EditCoverAction.EDIT)
expanded = false
},
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_delete)) },
onClick = {
onEditClick(EditCoverAction.DELETE)
expanded = false
},
)
}
}
.build()
view.context.imageLoader.enqueue(request)
}
}
},
) { contentPadding ->
val statusBarPaddingPx = WindowInsets.systemBars.getTop(LocalDensity.current)
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
Box(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colorScheme.background)
.clickableNoIndication(onClick = onDismissRequest),
) {
AndroidView(
factory = {
ReaderPageImageView(it).apply {
onViewClicked = onDismissRequest
clipToPadding = false
clipChildren = false
}
},
update = { view ->
val request = ImageRequest.Builder(view.context)
.data(coverDataProvider())
.size(Size.ORIGINAL)
.target { drawable ->
// Copy bitmap in case it came from memory cache
// Because SSIV needs to thoroughly read the image
val copy = (drawable as? BitmapDrawable)?.let {
val config = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Bitmap.Config.HARDWARE
} else {
Bitmap.Config.ARGB_8888
}
BitmapDrawable(
view.context.resources,
it.bitmap.copy(config, false),
)
} ?: drawable
view.setImage(copy, ReaderPageImageView.Config(zoomDuration = 500))
}
.build()
view.context.imageLoader.enqueue(request)
view.updatePadding(top = statusBarPaddingPx, bottom = bottomPaddingPx)
},
modifier = Modifier.fillMaxSize(),
)
view.updatePadding(top = statusBarPaddingPx, bottom = bottomPaddingPx)
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
}

View File

@ -1,10 +1,25 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
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.unit.dp
import androidx.compose.ui.window.DialogProperties
import eu.kanade.presentation.util.minimumTouchTargetSize
import eu.kanade.tachiyomi.R
@Composable
@ -37,3 +52,80 @@ fun DeleteChaptersDialog(
},
)
}
// SY -->
@Composable
fun SelectScanlatorsDialog(
onDismissRequest: () -> Unit,
availableScanlators: List<String>,
initialSelectedScanlators: List<String>,
onSelectScanlators: (List<String>) -> Unit,
) {
val selected = remember {
initialSelectedScanlators.toMutableStateList()
}
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(R.string.select_scanlators)) },
text = {
LazyColumn {
availableScanlators.forEach { current ->
item {
val isSelected = selected.contains(current)
val onSelectionChanged = {
when (!isSelected) {
true -> selected.add(current)
false -> selected.remove(current)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.selectable(
selected = isSelected,
onClick = { onSelectionChanged() },
)
.minimumTouchTargetSize()
.fillMaxWidth(),
) {
Checkbox(
checked = isSelected,
onCheckedChange = null,
)
Text(
text = current,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 24.dp),
)
}
}
}
}
},
properties = DialogProperties(
usePlatformDefaultWidth = true,
),
confirmButton = {
TextButton(
onClick = {
onSelectScanlators(selected.toList())
onDismissRequest()
},
) {
Text(text = stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(
onClick = {
onSelectScanlators(availableScanlators)
onDismissRequest()
},
) {
Text(text = stringResource(R.string.action_reset))
}
},
)
}
// SY <--

View File

@ -1,6 +1,8 @@
package eu.kanade.presentation.util
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import com.bluelinelabs.conductor.Router
@ -13,3 +15,5 @@ val LocalRouter: ProvidableCompositionLocal<Router?> = staticCompositionLocalOf
* For invoking back press to the parent activity
*/
val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null }
val LocalNavigatorContentPadding: ProvidableCompositionLocal<PaddingValues> = compositionLocalOf { PaddingValues() }

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 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
import android.app.Application
import androidx.annotation.CallSuper
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
import eu.kanade.domain.chapter.interactor.SyncChaptersWithTrackServiceTwoWay
import eu.kanade.domain.track.interactor.InsertTrack
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toast
import logcat.LogPriority
import okhttp3.OkHttpClient
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
abstract class TrackService(val id: Long) {
@ -78,4 +91,89 @@ abstract class TrackService(val id: Long) {
fun saveCredentials(username: String, password: String) {
trackPreferences.setTrackCredentials(this, username, password)
}
suspend fun registerTracking(item: Track, mangaId: Long) {
item.manga_id = mangaId
try {
withIOContext {
val allChapters = Injekt.get<GetChapterByMangaId>().await(mangaId)
val hasReadChapters = allChapters.any { it.read }
bind(item, hasReadChapters)
val track = item.toDomainTrack(idRequired = false) ?: return@withIOContext
Injekt.get<InsertTrack>().await(track)
// Update chapter progress if newer chapters marked read locally
if (hasReadChapters) {
val latestLocalReadChapterNumber = allChapters
.sortedBy { it.chapterNumber }
.takeWhile { it.read }
.lastOrNull()
?.chapterNumber?.toDouble() ?: -1.0
if (latestLocalReadChapterNumber > track.lastChapterRead) {
val updatedTrack = track.copy(
lastChapterRead = latestLocalReadChapterNumber,
)
setRemoteLastChapterRead(updatedTrack.toDbTrack(), latestLocalReadChapterNumber.toInt())
}
}
if (this is EnhancedTrackService) {
Injekt.get<SyncChaptersWithTrackServiceTwoWay>().await(allChapters, track, this@TrackService)
}
}
} catch (e: Throwable) {
withUIContext { Injekt.get<Application>().toast(e.message) }
}
}
suspend fun setRemoteStatus(track: Track, status: Int) {
track.status = status
if (track.status == getCompletionStatus() && track.total_chapters != 0) {
track.last_chapter_read = track.total_chapters.toFloat()
}
withIOContext { updateRemote(track) }
}
suspend fun setRemoteLastChapterRead(track: Track, chapterNumber: Int) {
if (track.last_chapter_read == 0F && track.last_chapter_read < chapterNumber && track.status != getRereadingStatus()) {
track.status = getReadingStatus()
}
track.last_chapter_read = chapterNumber.toFloat()
if (track.total_chapters != 0 && track.last_chapter_read.toInt() == track.total_chapters) {
track.status = getCompletionStatus()
}
withIOContext { updateRemote(track) }
}
suspend fun setRemoteScore(track: Track, scoreString: String) {
track.score = indexToScore(getScoreList().indexOf(scoreString))
withIOContext { updateRemote(track) }
}
suspend fun setRemoteStartDate(track: Track, epochMillis: Long) {
track.started_reading_date = epochMillis
withIOContext { updateRemote(track) }
}
suspend fun setRemoteFinishDate(track: Track, epochMillis: Long) {
track.finished_reading_date = epochMillis
withIOContext { updateRemote(track) }
}
private suspend fun updateRemote(track: Track) {
withIOContext {
try {
update(track)
track.toDomainTrack(idRequired = false)?.let {
Injekt.get<InsertTrack>().await(it)
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=$id" }
withUIContext { Injekt.get<Application>().toast(e.message) }
}
}
}
}

View File

@ -1,240 +1,260 @@
package eu.kanade.tachiyomi.ui.manga
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.widget.ArrayAdapter
import android.widget.ScrollView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.view.children
import coil.load
import coil.transform.RoundedCornersTransformation
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.isLocal
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.EditMangaDialogBinding
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.util.lang.chop
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.widget.materialdialogs.setTextInput
import exh.util.dropBlank
import exh.util.trimOrNull
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import reactivecircus.flowbinding.android.view.clicks
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class EditMangaDialog : DialogController {
private lateinit var binding: EditMangaDialogBinding
private val manga: Manga
private val infoController
get() = targetController as MangaController
private val context: Context get() = binding.root.context
constructor(target: MangaController, manga: Manga) : super(
bundleOf(KEY_MANGA to manga.id),
) {
targetController = target
this.manga = manga
@Composable
fun EditMangaDialog(
manga: Manga,
onDismissRequest: () -> Unit,
onPositiveClick: (
title: String?,
author: String?,
artist: String?,
description: String?,
tags: List<String>?,
status: Long?,
) -> Unit,
) {
val scope = rememberCoroutineScope()
var binding by remember {
mutableStateOf<EditMangaDialogBinding?>(null)
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
manga = runBlocking { Injekt.get<GetManga>().await(bundle.getLong(KEY_MANGA))!! }
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
binding = EditMangaDialogBinding.inflate(activity!!.layoutInflater)
val view = ScrollView(activity!!).apply {
addView(binding.root)
}
onViewCreated()
return MaterialAlertDialogBuilder(activity!!)
.setView(view)
.setPositiveButton(R.string.action_save) { _, _ -> onPositiveButtonClick() }
.setNegativeButton(android.R.string.cancel, null)
.create()
}
fun onViewCreated() {
loadCover()
val statusAdapter: ArrayAdapter<String> = ArrayAdapter(
context,
android.R.layout.simple_spinner_dropdown_item,
listOf(
R.string.label_default,
R.string.ongoing,
R.string.completed,
R.string.licensed,
R.string.publishing_finished,
R.string.cancelled,
R.string.on_hiatus,
).map { context.getString(it) },
)
binding.status.adapter = statusAdapter
if (manga.status != manga.ogStatus) {
binding.status.setSelection(
when (manga.status.toInt()) {
SManga.UNKNOWN -> 0
SManga.ONGOING -> 1
SManga.COMPLETED -> 2
SManga.LICENSED -> 3
SManga.PUBLISHING_FINISHED, 61 -> 4
SManga.CANCELLED, 62 -> 5
SManga.ON_HIATUS, 63 -> 6
else -> 0
},
)
}
if (manga.isLocal()) {
if (manga.title != manga.url) {
binding.title.setText(manga.title)
}
binding.title.hint = context.getString(R.string.title_hint, manga.url)
binding.mangaAuthor.setText(manga.author.orEmpty())
binding.mangaArtist.setText(manga.artist.orEmpty())
binding.mangaDescription.setText(manga.description.orEmpty())
binding.mangaGenresTags.setChips(manga.genre.orEmpty().dropBlank())
} else {
if (manga.title != manga.ogTitle) {
binding.title.append(manga.title)
}
if (manga.author != manga.ogAuthor) {
binding.mangaAuthor.append(manga.author.orEmpty())
}
if (manga.artist != manga.ogArtist) {
binding.mangaArtist.append(manga.artist.orEmpty())
}
if (manga.description != manga.ogDescription) {
binding.mangaDescription.append(manga.description.orEmpty())
}
binding.mangaGenresTags.setChips(manga.genre.orEmpty().dropBlank())
binding.title.hint = context.getString(R.string.title_hint, manga.ogTitle)
if (manga.ogAuthor != null) {
binding.mangaAuthor.hint = context.getString(R.string.author_hint, manga.ogAuthor)
}
if (manga.ogArtist != null) {
binding.mangaArtist.hint = context.getString(R.string.artist_hint, manga.ogArtist)
}
if (!manga.ogDescription.isNullOrBlank()) {
binding.mangaDescription.hint =
context.getString(
R.string.description_hint,
manga.ogDescription.replace("\n", " ").chop(20),
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = {
val binding = binding ?: return@TextButton
onPositiveClick(
binding.title.text.toString(),
binding.mangaAuthor.text.toString(),
binding.mangaArtist.text.toString(),
binding.mangaDescription.text.toString(),
binding.mangaGenresTags.getTextStrings(),
binding.status.selectedItemPosition.let {
when (it) {
1 -> SManga.ONGOING
2 -> SManga.COMPLETED
3 -> SManga.LICENSED
4 -> SManga.PUBLISHING_FINISHED
5 -> SManga.CANCELLED
6 -> SManga.ON_HIATUS
else -> null
}
}?.toLong(),
)
},
) {
Text(stringResource(R.string.action_save))
}
}
binding.mangaGenresTags.clearFocus()
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(android.R.string.cancel))
}
},
text = {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
AndroidView(
factory = { factoryContext ->
EditMangaDialogBinding.inflate(LayoutInflater.from(factoryContext))
.also { binding = it }
.apply {
onViewCreated(manga, factoryContext, this, scope)
}
.root
},
modifier = Modifier.fillMaxWidth(),
)
}
},
)
}
binding.resetTags.clicks()
.onEach { resetTags() }
.launchIn(infoController.viewScope)
}
private fun onViewCreated(manga: Manga, context: Context, binding: EditMangaDialogBinding, scope: CoroutineScope) {
loadCover(manga, context, binding)
private fun resetTags() {
if (manga.genre.isNullOrEmpty() || manga.isLocal()) {
binding.mangaGenresTags.setChips(emptyList())
} else {
binding.mangaGenresTags.setChips(manga.ogGenre.orEmpty())
}
}
val statusAdapter: ArrayAdapter<String> = ArrayAdapter(
context,
android.R.layout.simple_spinner_dropdown_item,
listOf(
R.string.label_default,
R.string.ongoing,
R.string.completed,
R.string.licensed,
R.string.publishing_finished,
R.string.cancelled,
R.string.on_hiatus,
).map { context.getString(it) },
)
private fun loadCover() {
val radius = context.resources.getDimension(R.dimen.card_radius)
binding.mangaCover.load(manga) {
transformations(RoundedCornersTransformation(radius))
}
}
private fun onPositiveButtonClick() {
infoController.presenter.updateMangaInfo(
binding.title.text.toString(),
binding.mangaAuthor.text.toString(),
binding.mangaArtist.text.toString(),
binding.mangaDescription.text.toString(),
binding.mangaGenresTags.getTextStrings(),
binding.status.selectedItemPosition.let {
when (it) {
1 -> SManga.ONGOING
2 -> SManga.COMPLETED
3 -> SManga.LICENSED
4 -> SManga.PUBLISHING_FINISHED
5 -> SManga.CANCELLED
6 -> SManga.ON_HIATUS
else -> null
}
}?.toLong(),
binding.status.adapter = statusAdapter
if (manga.status != manga.ogStatus) {
binding.status.setSelection(
when (manga.status.toInt()) {
SManga.UNKNOWN -> 0
SManga.ONGOING -> 1
SManga.COMPLETED -> 2
SManga.LICENSED -> 3
SManga.PUBLISHING_FINISHED, 61 -> 4
SManga.CANCELLED, 62 -> 5
SManga.ON_HIATUS, 63 -> 6
else -> 0
},
)
}
private fun ChipGroup.setChips(items: List<String>) {
removeAllViews()
items.asSequence().map { item ->
Chip(context).apply {
text = item
isCloseIconVisible = true
closeIcon?.setTint(context.getResourceColor(R.attr.colorAccent))
setOnCloseIconClickListener {
removeView(this)
}
}
}.forEach {
addView(it)
if (manga.isLocal()) {
if (manga.title != manga.url) {
binding.title.setText(manga.title)
}
val addTagChip = Chip(context).apply {
setText(R.string.add_tag)
chipIcon = ContextCompat.getDrawable(context, R.drawable.ic_add_24dp)?.apply {
isChipIconVisible = true
setTint(context.getResourceColor(R.attr.colorAccent))
}
clicks().onEach {
var newTag: String? = null
MaterialAlertDialogBuilder(context)
.setTitle(R.string.add_tag)
.setTextInput {
newTag = it.trimOrNull()
}
.setPositiveButton(android.R.string.ok) { _, _ ->
if (newTag != null) setChips(items + listOfNotNull(newTag))
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}.launchIn(infoController.viewScope)
binding.title.hint = context.getString(R.string.title_hint, manga.url)
binding.mangaAuthor.setText(manga.author.orEmpty())
binding.mangaArtist.setText(manga.artist.orEmpty())
binding.mangaDescription.setText(manga.description.orEmpty())
binding.mangaGenresTags.setChips(manga.genre.orEmpty().dropBlank(), scope)
} else {
if (manga.title != manga.ogTitle) {
binding.title.append(manga.title)
}
if (manga.author != manga.ogAuthor) {
binding.mangaAuthor.append(manga.author.orEmpty())
}
if (manga.artist != manga.ogArtist) {
binding.mangaArtist.append(manga.artist.orEmpty())
}
if (manga.description != manga.ogDescription) {
binding.mangaDescription.append(manga.description.orEmpty())
}
binding.mangaGenresTags.setChips(manga.genre.orEmpty().dropBlank(), scope)
binding.title.hint = context.getString(R.string.title_hint, manga.ogTitle)
if (manga.ogAuthor != null) {
binding.mangaAuthor.hint = context.getString(R.string.author_hint, manga.ogAuthor)
}
if (manga.ogArtist != null) {
binding.mangaArtist.hint = context.getString(R.string.artist_hint, manga.ogArtist)
}
if (!manga.ogDescription.isNullOrBlank()) {
binding.mangaDescription.hint =
context.getString(
R.string.description_hint,
manga.ogDescription.replace("\n", " ").chop(20),
)
}
addView(addTagChip)
}
binding.mangaGenresTags.clearFocus()
private fun ChipGroup.getTextStrings(): List<String> = children.mapNotNull {
if (it is Chip && !it.text.toString().contains(context.getString(R.string.add_tag), ignoreCase = true)) {
it.text.toString()
} else {
null
}
}.toList()
binding.resetTags.clicks()
.onEach { resetTags(manga, binding, scope) }
.launchIn(scope)
}
private companion object {
const val KEY_MANGA = "manga_id"
private fun resetTags(manga: Manga, binding: EditMangaDialogBinding, scope: CoroutineScope) {
if (manga.genre.isNullOrEmpty() || manga.isLocal()) {
binding.mangaGenresTags.setChips(emptyList(), scope)
} else {
binding.mangaGenresTags.setChips(manga.ogGenre.orEmpty(), scope)
}
}
private fun loadCover(manga: Manga, context: Context, binding: EditMangaDialogBinding) {
val radius = context.resources.getDimension(R.dimen.card_radius)
binding.mangaCover.load(manga) {
transformations(RoundedCornersTransformation(radius))
}
}
private fun ChipGroup.setChips(items: List<String>, scope: CoroutineScope) {
removeAllViews()
items.asSequence().map { item ->
Chip(context).apply {
text = item
isCloseIconVisible = true
closeIcon?.setTint(context.getResourceColor(R.attr.colorAccent))
setOnCloseIconClickListener {
removeView(this)
}
}
}.forEach {
addView(it)
}
val addTagChip = Chip(context).apply {
setText(R.string.add_tag)
chipIcon = ContextCompat.getDrawable(context, R.drawable.ic_add_24dp)?.apply {
isChipIconVisible = true
setTint(context.getResourceColor(R.attr.colorAccent))
}
clicks().onEach {
var newTag: String? = null
MaterialAlertDialogBuilder(context)
.setTitle(R.string.add_tag)
.setTextInput {
newTag = it.trimOrNull()
}
.setPositiveButton(android.R.string.ok) { _, _ ->
if (newTag != null) setChips(items + listOfNotNull(newTag), scope)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}.launchIn(scope)
}
addView(addTagChip)
}
private fun ChipGroup.getTextStrings(): List<String> = children.mapNotNull {
if (it is Chip && !it.text.toString().contains(context.getString(R.string.add_tag), ignoreCase = true)) {
it.text.toString()
} else {
null
}
}.toList()

View File

@ -1,90 +1,14 @@
package eu.kanade.tachiyomi.ui.manga
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.os.bundleOf
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.data.chapter.NoChaptersException
import eu.kanade.domain.UnsortedPreferences
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.CatalogueSource
import eu.kanade.tachiyomi.source.SourceManager
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.popControllerWithTag
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationController
import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
import eu.kanade.tachiyomi.ui.browse.source.SourcesController
import eu.kanade.tachiyomi.ui.browse.source.SourcesController.Companion.SMART_SEARCH_SOURCE_TAG
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedController
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.merged.EditMergedSettingsDialog
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.lang.launchUI
import eu.kanade.tachiyomi.util.system.getParcelableCompat
import eu.kanade.tachiyomi.util.system.isTabletUi
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toast
import exh.md.similar.MangaDexSimilarController
import exh.pagepreview.PagePreviewController
import exh.recs.RecommendsController
import exh.source.MERGED_SOURCE_ID
import exh.source.getMainSource
import exh.source.isMdBasedSource
import exh.ui.metadata.MetadataViewController
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import eu.kanade.domain.chapter.model.Chapter as DomainChapter
import eu.kanade.domain.manga.model.Manga as DomainManga
class MangaController : FullComposeController<MangaPresenter> {
class MangaController : BasicFullComposeController {
@Suppress("unused")
constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA))
@ -93,570 +17,32 @@ class MangaController : FullComposeController<MangaPresenter> {
mangaId: Long,
fromSource: Boolean = false,
smartSearchConfig: SourcesController.SmartSearchConfig? = null,
update: Boolean = false,
) : super(bundleOf(MANGA_EXTRA to mangaId, FROM_SOURCE_EXTRA to fromSource, SMART_SEARCH_CONFIG_EXTRA to smartSearchConfig, UPDATE_EXTRA to update)) {
this.mangaId = mangaId
}
) : super(bundleOf(MANGA_EXTRA to mangaId, FROM_SOURCE_EXTRA to fromSource, SMART_SEARCH_CONFIG_EXTRA to smartSearchConfig))
// SY -->
constructor(redirect: MangaPresenter.EXHRedirect) : super(
bundleOf(
MANGA_EXTRA to redirect.mangaId,
UPDATE_EXTRA to redirect.update,
),
) {
this.mangaId = redirect.mangaId
}
// SY <--
var mangaId: Long
val fromSource: Boolean
get() = presenter.isFromSource
// SY -->
val smartSearchConfig = args.getParcelableCompat<SourcesController.SmartSearchConfig>(
SMART_SEARCH_CONFIG_EXTRA,
constructor(redirect: MangaInfoScreenModel.EXHRedirect) : super(
bundleOf(MANGA_EXTRA to redirect.mangaId),
)
// SY <--
// Sheet containing filter/sort/display items.
private lateinit var settingsSheet: ChaptersSettingsSheet
val mangaId: Long
get() = args.getLong(MANGA_EXTRA)
private lateinit var trackSheet: TrackSheet
val fromSource: Boolean
get() = args.getBoolean(FROM_SOURCE_EXTRA)
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),
// SY -->
smartSearched = smartSearchConfig != null,
// SY <--
// SY -->
val smartSearchConfig: SourcesController.SmartSearchConfig?
get() = args.getParcelableCompat<SourcesController.SmartSearchConfig>(
SMART_SEARCH_CONFIG_EXTRA,
)
}
// SY <--
@Composable
override fun ComposeContent() {
val state by presenter.state.collectAsState()
if (state is MangaScreenState.Loading) {
LoadingScreen()
return
}
val successState = state as MangaScreenState.Success
val isHttpSource = remember { successState.source is HttpSource }
val scope = rememberCoroutineScope()
val configuration = LocalConfiguration.current
val isTabletUi = remember { configuration.isTabletUi() } // won't survive config change
MangaScreen(
state = successState,
snackbarHostState = snackbarHostState,
isTabletUi = isTabletUi,
onBackClicked = router::popCurrentController,
onChapterClicked = this::openChapter,
onDownloadChapter = this::onDownloadChapters.takeIf { !successState.source.isLocalOrStub() },
onAddToLibraryClicked = this::onFavoriteClick,
// SY -->
onWebViewClicked = { if (successState.mergedData == null) openMangaInWebView() else openMergedMangaWebview() }.takeIf { isHttpSource },
// SY <--
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 },
// SY -->
onMetadataViewerClicked = this::openMetadataViewer,
onEditInfoClicked = this::openEditMangaInfoDialog,
onRecommendClicked = this::openRecommends,
onMergedSettingsClicked = this::openMergedSettingsDialog,
onMergeClicked = this::openSmartSearch,
onMergeWithAnotherClicked = this::mergeWithAnother,
onOpenPagePreview = this::openPagePreview,
onMorePreviewsClicked = this::openMorePagePreviews,
// SY <--
onMultiBookmarkClicked = presenter::bookmarkChapters,
onMultiMarkAsReadClicked = presenter::markChaptersRead,
onMarkPreviousAsReadClicked = presenter::markPreviousChapterRead,
onMultiDeleteClicked = presenter::showDeleteChapterDialog,
onChapterSelected = presenter::toggleSelection,
onAllChapterSelected = presenter::toggleAllSelection,
onInvertSelection = presenter::invertSelection,
)
val onDismissRequest = { presenter.dismissDialog() }
when (val dialog = (state as? MangaScreenState.Success)?.dialog) {
is Dialog.ChangeCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = {
router.pushController(CategoryController())
},
onConfirm = { include, _ ->
presenter.moveMangaToCategoriesAndAddToLibrary(dialog.manga, include)
},
)
}
is Dialog.DeleteChapters -> {
DeleteChaptersDialog(
onDismissRequest = onDismissRequest,
onConfirm = {
presenter.toggleAllSelection(false)
deleteChapters(dialog.chapters)
},
)
}
is Dialog.DownloadCustomAmount -> {
DownloadCustomAmountDialog(
maxAmount = dialog.max,
onDismissRequest = onDismissRequest,
onConfirm = { amount ->
val chaptersToDownload = presenter.getUnreadChaptersSorted().take(amount)
if (chaptersToDownload.isNotEmpty()) {
scope.launch { downloadChapters(chaptersToDownload) }
}
},
)
}
is Dialog.DuplicateManga -> {
DuplicateMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = {
presenter.toggleFavorite(
onRemoved = {},
onAdded = {},
checkDuplicate = false,
)
},
onOpenManga = { router.pushController(MangaController(dialog.duplicate.id)) },
duplicateFrom = presenter.getSourceOrStub(dialog.duplicate),
)
}
null -> {}
}
Navigator(screen = MangaScreen(mangaId, fromSource, smartSearchConfig))
}
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)
}
// SY -->
private fun openEditMangaInfoDialog() {
EditMangaDialog(
this,
presenter.manga ?: return,
).showDialog(router)
}
private fun openMergedSettingsDialog() {
EditMergedSettingsDialog(
this,
presenter.manga ?: return,
).showDialog(router)
}
private fun openMetadataViewer() {
router.pushController(MetadataViewController(presenter.manga ?: return))
}
private fun openMergedMangaWebview() {
val sourceManager: SourceManager = Injekt.get()
val mergedManga = (presenter.state.value as? MangaScreenState.Success)?.mergedData?.manga?.values?.filterNot { it.source == MERGED_SOURCE_ID }
.orEmpty()
val sources = mergedManga.map { sourceManager.getOrStub(it.source) }
MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.action_open_in_web_view)
.setSingleChoiceItems(
mergedManga.mapIndexed { index, _ -> sources[index].toString() }
.toTypedArray(),
-1,
) { dialog, index ->
dialog.dismiss()
openMangaInWebView(mergedManga[index], sources[index] as? HttpSource)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
// SY <--
private fun openMangaInWebView(manga: DomainManga? = presenter.manga, source: HttpSource? = presenter.source as? HttpSource) {
source ?: return
manga ?: 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()
}
}
}
// SY -->
private fun openMorePagePreviews() {
val manga = presenter.manga ?: return
router.pushController(PagePreviewController(manga.id))
}
private fun openPagePreview(page: Int) {
val chapter = presenter.getNextUnreadChapter() ?: return
activity?.run {
startActivity(ReaderActivity.newIntent(this, chapter.mangaId, chapter.id, page))
}
}
// SY <--
// EXH -->
private fun openSmartSearch() {
val manga = presenter.manga ?: return
val smartSearchConfig = SourcesController.SmartSearchConfig(manga.title, manga.id)
router.pushController(
SourcesController(
bundleOf(
SourcesController.SMART_SEARCH_CONFIG to smartSearchConfig,
),
).withFadeTransaction().tag(SMART_SEARCH_SOURCE_TAG),
)
}
private fun mergeWithAnother() {
launchUI {
try {
val mergedManga = withContext(Dispatchers.IO + NonCancellable) {
presenter.smartSearchMerge(applicationContext!!, presenter.manga!!, smartSearchConfig?.origMangaId!!)
}
router?.popControllerWithTag(SMART_SEARCH_SOURCE_TAG)
router?.popCurrentController()
router?.replaceTopController(
MangaController(
mergedManga.id,
true,
update = true,
).withFadeTransaction(),
)
applicationContext?.toast(R.string.manga_merged)
} catch (e: Exception) {
if (e is CancellationException) throw e
val activity = activity ?: return@launchUI
activity.toast(activity.getString(R.string.failed_merge, e.message))
}
}
}
// EXH <--
// AZ -->
fun openRecommends() {
val source = presenter.source!!.getMainSource()
if (source.isMdBasedSource()) {
MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.az_recommends)
.setSingleChoiceItems(
arrayOf(
activity!!.getString(R.string.mangadex_similar),
activity!!.getString(R.string.community_recommendations),
),
-1,
) { dialog, index ->
dialog.dismiss()
when (index) {
0 -> router.pushController(MangaDexSimilarController(presenter.manga!!, source as CatalogueSource))
1 -> router.pushController(RecommendsController(presenter.manga!!, source as CatalogueSource))
}
}
.show()
} else if (source is CatalogueSource) {
router.pushController(RecommendsController(presenter.manga!!, source))
}
}
// AZ <--
/**
* 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)
}
// SY -->
is SourceFeedController -> {
router.handleBack()
previousController.onBrowseClick(query)
}
// SY <--
}
}
/**
* 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
// SY -->
PreMigrationController.navigateToMigration(
Injekt.get<UnsortedPreferences>().skipPreMigration().get(),
router,
listOf(manga.id),
)
// SY <--
}
// Manga info - end
// Chapters list - start
private fun continueReading() {
val chapter = presenter.getNextUnreadChapter()
if (chapter != null) openChapter(chapter)
}
private fun openChapter(chapter: DomainChapter) {
activity?.run {
startActivity(ReaderActivity.newIntent(this, chapter.mangaId, chapter.id))
}
}
fun onFetchChaptersError(error: Throwable) {
if (error is NoChaptersException) {
activity?.toast(R.string.no_chapters_error)
} else {
activity?.toast(error.message)
}
}
// SELECTION MODE ACTIONS
private fun onDownloadChapters(
items: List<ChapterItem>,
action: ChapterDownloadAction,
) {
viewScope.launch {
when (action) {
ChapterDownloadAction.START -> {
downloadChapters(items.map { it.chapter })
if (items.any { it.downloadState == Download.State.ERROR }) {
DownloadService.start(activity!!)
}
}
ChapterDownloadAction.START_NOW -> {
downloadChapters(items.map { it.chapter }, startNow = true)
}
ChapterDownloadAction.CANCEL -> {
val chapterId = items.singleOrNull()?.chapter?.id ?: return@launch
presenter.cancelDownload(chapterId)
}
ChapterDownloadAction.DELETE -> {
deleteChapters(items.map { it.chapter })
}
}
}
}
private suspend fun downloadChapters(chapters: List<DomainChapter>, startNow: Boolean = false) {
if (startNow) {
val chapterId = chapters.singleOrNull()?.id ?: return
presenter.startDownloadingNow(chapterId)
} else {
presenter.downloadChapters(chapters)
}
if (!presenter.isFavoritedManga) {
val result = snackbarHostState.showSnackbar(
message = activity!!.getString(R.string.snack_add_to_library),
actionLabel = activity!!.getString(R.string.action_add),
withDismissAction = true,
)
if (result == SnackbarResult.ActionPerformed && !presenter.isFavoritedManga) {
onFavoriteClick()
}
}
}
private fun deleteChapters(chapters: List<DomainChapter>) {
if (chapters.isEmpty()) return
presenter.deleteChapters(chapters)
}
// OVERFLOW MENU DIALOGS
private fun runDownloadChapterAction(action: DownloadAction) {
val chaptersToDownload = when (action) {
DownloadAction.NEXT_1_CHAPTER -> presenter.getUnreadChaptersSorted().take(1)
DownloadAction.NEXT_5_CHAPTERS -> presenter.getUnreadChaptersSorted().take(5)
DownloadAction.NEXT_10_CHAPTERS -> presenter.getUnreadChaptersSorted().take(10)
DownloadAction.CUSTOM -> {
presenter.showDownloadCustomDialog()
return
}
DownloadAction.UNREAD_CHAPTERS -> presenter.getUnreadChapters()
DownloadAction.ALL_CHAPTERS -> {
(presenter.state.value as? MangaScreenState.Success)?.chapters?.map { it.chapter }
}
}
if (!chaptersToDownload.isNullOrEmpty()) {
viewScope.launch { downloadChapters(chaptersToDownload) }
}
}
// Chapters list - end
// Tracker sheet - start
fun onNextTrackers(trackers: List<TrackItem>) {
trackSheet.onNextTrackers(trackers)
}
fun onTrackingRefreshDone() {
}
fun onTrackingRefreshError(error: Throwable) {
logcat(LogPriority.ERROR, error)
activity?.toast(error.message)
}
fun onTrackingSearchResults(results: List<TrackSearch>) {
getTrackingSearchDialog()?.onSearchResults(results)
}
fun onTrackingSearchResultsError(error: Throwable) {
logcat(LogPriority.ERROR, error)
getTrackingSearchDialog()?.onSearchResultsError(error.message)
}
private fun getTrackingSearchDialog(): TrackSearchDialog? {
return trackSheet.getSearchDialog()
}
// Tracker sheet - end
companion object {
const val FROM_SOURCE_EXTRA = "from_source"
const val MANGA_EXTRA = "manga"

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,535 @@
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.LaunchedEffect
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 androidx.core.os.bundleOf
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 com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.domain.UnsortedPreferences
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.manga.components.SelectScanlatorsDialog
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.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.isLocalOrStub
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationController
import eu.kanade.tachiyomi.ui.browse.source.SourcesController
import eu.kanade.tachiyomi.ui.browse.source.SourcesController.Companion.SMART_SEARCH_SOURCE_TAG
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedController
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.merged.EditMergedSettingsDialog
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.chapter.getNextUnread
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast
import exh.md.similar.MangaDexSimilarController
import exh.pagepreview.PagePreviewController
import exh.recs.RecommendsController
import exh.source.MERGED_SOURCE_ID
import exh.source.getMainSource
import exh.source.isMdBasedSource
import exh.ui.metadata.MetadataViewController
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MangaScreen(
private val mangaId: Long,
private val fromSource: Boolean = false,
private val smartSearchConfig: SourcesController.SmartSearchConfig? = null,
) : 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, smartSearchConfig != null) }
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 }
// SY -->
LaunchedEffect(Unit) {
screenModel.redirectFlow
.take(1)
.onEach {
router.replaceTopController(
MangaController(it).withFadeTransaction(),
)
}
.launchIn(this)
}
// SY <--
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)
},
// SY -->
onWebViewClicked = {
if (successState.mergedData == null) {
openMangaInWebView(context, screenModel.manga, screenModel.source)
} else {
openMergedMangaWebview(context, successState.mergedData)
}
}.takeIf { isHttpSource },
// SY <--
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 },
// SY -->
onMetadataViewerClicked = { openMetadataViewer(router, successState.manga) },
onEditInfoClicked = screenModel::showEditMangaInfoDialog,
onRecommendClicked = { openRecommends(context, router, screenModel.source?.getMainSource(), successState.manga) },
onMergedSettingsClicked = screenModel::showEditMergedSettingsDialog,
onMergeClicked = { openSmartSearch(router, successState.manga) },
onMergeWithAnotherClicked = { mergeWithAnother(router, context, successState.manga, screenModel::smartSearchMerge) },
onOpenPagePreview = { openPagePreview(context, successState.chapters.getNextUnread(successState.manga), it) },
onMorePreviewsClicked = { openMorePagePreviews(router, successState.manga) },
// SY <--
onMultiBookmarkClicked = screenModel::bookmarkChapters,
onMultiMarkAsReadClicked = screenModel::markChaptersRead,
onMarkPreviousAsReadClicked = screenModel::markPreviousChapterRead,
onMultiDeleteClicked = screenModel::showDeleteChapterDialog,
onChapterSelected = screenModel::toggleSelection,
onAllChapterSelected = screenModel::toggleAllSelection,
onInvertSelection = screenModel::invertSelection,
)
// SY -->
var showScanlatorsDialog by remember { mutableStateOf(false) }
// SY <--
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,
onClickShowScanlatorSelection = { showScanlatorsDialog = true }.takeIf { successState.scanlators.size > 1 },
)
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())
}
}
is MangaInfoScreenModel.Dialog.EditMangaInfo -> {
EditMangaDialog(
manga = dialog.manga,
onDismissRequest = screenModel::dismissDialog,
onPositiveClick = screenModel::updateMangaInfo,
)
}
is MangaInfoScreenModel.Dialog.EditMergedSettings -> {
EditMergedSettingsDialog(
mergedData = dialog.mergedData,
onDismissRequest = screenModel::dismissDialog,
onDeleteClick = screenModel::deleteMerge,
onPositiveClick = screenModel::updateMergeSettings,
)
}
}
// SY -->
if (showScanlatorsDialog) {
SelectScanlatorsDialog(
onDismissRequest = { showScanlatorsDialog = false },
availableScanlators = successState.scanlators,
initialSelectedScanlators = successState.manga.filteredScanlators ?: successState.scanlators,
onSelectScanlators = screenModel::setScanlatorFilter,
)
}
// SY <--
}
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)
}
// SY -->
is SourceFeedController -> {
router.handleBack()
previousController.onBrowseClick(query)
}
// SY <--
}
}
/**
* 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) {
// SY -->
PreMigrationController.navigateToMigration(
Injekt.get<UnsortedPreferences>().skipPreMigration().get(),
router,
listOf(manga.id),
)
// SY <--
}
// SY -->
private fun openMetadataViewer(router: Router, manga: Manga) {
router.pushController(MetadataViewController(manga))
}
private fun openMergedMangaWebview(context: Context, mergedMangaData: MergedMangaData) {
val sourceManager: SourceManager = Injekt.get()
val mergedManga = mergedMangaData.manga.values.filterNot { it.source == MERGED_SOURCE_ID }
val sources = mergedManga.map { sourceManager.getOrStub(it.source) }
MaterialAlertDialogBuilder(context)
.setTitle(R.string.action_open_in_web_view)
.setSingleChoiceItems(
mergedManga.mapIndexed { index, _ -> sources[index].toString() }
.toTypedArray(),
-1,
) { dialog, index ->
dialog.dismiss()
openMangaInWebView(context, mergedManga[index], sources[index] as? HttpSource)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
// SY <--
// SY -->
private fun openMorePagePreviews(router: Router, manga: Manga) {
router.pushController(PagePreviewController(manga.id))
}
private fun openPagePreview(context: Context, chapter: Chapter?, page: Int) {
chapter ?: return
context.startActivity(ReaderActivity.newIntent(context, chapter.mangaId, chapter.id, page))
}
// SY <--
// EXH -->
private fun openSmartSearch(router: Router, manga: Manga) {
val smartSearchConfig = SourcesController.SmartSearchConfig(manga.title, manga.id)
router.pushController(
SourcesController(
bundleOf(
SourcesController.SMART_SEARCH_CONFIG to smartSearchConfig,
),
).withFadeTransaction().tag(SMART_SEARCH_SOURCE_TAG),
)
}
private fun mergeWithAnother(
router: Router,
context: Context,
manga: Manga,
smartSearchMerge: suspend (Manga, Long) -> Manga,
) {
launchUI {
try {
val mergedManga = withNonCancellableContext {
smartSearchMerge(manga, smartSearchConfig?.origMangaId!!)
}
router.popControllerWithTag(SMART_SEARCH_SOURCE_TAG)
router.popCurrentController()
router.replaceTopController(
MangaController(
mergedManga.id,
true,
).withFadeTransaction(),
)
context.toast(R.string.manga_merged)
} catch (e: Exception) {
if (e is CancellationException) throw e
context.toast(context.getString(R.string.failed_merge, e.message))
}
}
}
// EXH <--
// AZ -->
private fun openRecommends(context: Context, router: Router, source: Source?, manga: Manga) {
source ?: return
if (source.isMdBasedSource()) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.az_recommends)
.setSingleChoiceItems(
arrayOf(
context.getString(R.string.mangadex_similar),
context.getString(R.string.community_recommendations),
),
-1,
) { dialog, index ->
dialog.dismiss()
when (index) {
0 -> router.pushController(MangaDexSimilarController(manga, source as CatalogueSource))
1 -> router.pushController(RecommendsController(manga, source as CatalogueSource))
}
}
.show()
} else if (source is CatalogueSource) {
router.pushController(RecommendsController(manga, source))
}
}
// AZ <--
}

View File

@ -1,328 +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 com.google.android.material.dialog.MaterialAlertDialogBuilder
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 } || presenter.manga?.filteredScanlators != null
}
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)
private val scanlatorFilters = Item.DrawableSelection(0, this, R.string.scanlator, R.drawable.ic_outline_people_alt_24dp)
override val header: Item? = null
override val items = listOf(downloaded, unread, bookmarked, scanlatorFilters)
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
// SY -->
scanlatorFilters.isVisible = presenter.allChapterScanlators.size > 1
// SY <--
}
fun updateModels() {
initModels()
adapter.notifyItemRangeChanged(0, 3)
}
override fun onItemClicked(item: Item) {
// SY -->
if (item is Item.DrawableSelection) {
val scanlators = presenter.allChapterScanlators.toTypedArray()
val filteredScanlators = presenter.manga?.filteredScanlators?.toSet() ?: scanlators.toSet()
val selection = scanlators.map {
it in filteredScanlators
}.toBooleanArray()
MaterialAlertDialogBuilder(context)
.setTitle(R.string.select_scanlators)
.setMultiChoiceItems(scanlators, selection) { _, which, selected ->
selection[which] = selected
}
.setPositiveButton(android.R.string.ok) { _, _ ->
val selected = scanlators.filterIndexed { index, _ -> selection[index] }
presenter.setScanlatorFilter(selected)
}
.setNegativeButton(R.string.action_reset) { _, _ ->
presenter.setScanlatorFilter(presenter.allChapterScanlators)
}
.show()
return
}
// SY <--
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

@ -8,14 +8,14 @@ import eu.davidea.flexibleadapter.FlexibleAdapter
* @param controller the context of the fragment containing this adapter.
* @param isPriorityOrder if deduplication mode is based on priority
*/
class EditMergedMangaAdapter(controller: EditMergedSettingsDialog, var isPriorityOrder: Boolean) :
FlexibleAdapter<EditMergedMangaItem>(null, controller, true),
class EditMergedMangaAdapter(listener: EditMergedSettingsState, var isPriorityOrder: Boolean) :
FlexibleAdapter<EditMergedMangaItem>(null, listener, true),
EditMergedSettingsHeaderAdapter.SortingListener {
/**
* Listener called when an item of the list is released.
*/
val editMergedMangaItemListener: EditMergedMangaItemListener = controller
val editMergedMangaItemListener: EditMergedMangaItemListener = listener
interface EditMergedMangaItemListener {
fun onItemReleased(position: Int)

View File

@ -1,80 +1,57 @@
package eu.kanade.tachiyomi.ui.manga.merged
import android.app.Dialog
import android.os.Bundle
import android.widget.ScrollView
import androidx.core.os.bundleOf
import android.content.Context
import android.view.LayoutInflater
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.DialogProperties
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.domain.manga.interactor.DeleteMergeById
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.manga.interactor.GetMergedMangaById
import eu.kanade.domain.manga.interactor.GetMergedReferencesById
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.EditMergedSettingsDialogBinding
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.ui.manga.MergedMangaData
import eu.kanade.tachiyomi.util.system.toast
import exh.merged.sql.models.MergedMangaReference
import exh.source.MERGED_SOURCE_ID
import kotlinx.coroutines.runBlocking
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class EditMergedSettingsDialog : DialogController, EditMergedMangaAdapter.EditMergedMangaItemListener {
private val manga: Manga
@Stable
class EditMergedSettingsState(
private val context: Context,
private val onDeleteClick: (MergedMangaReference) -> Unit,
private val onDismissRequest: () -> Unit,
private val onPositiveClick: (List<MergedMangaReference>) -> Unit,
) : EditMergedMangaAdapter.EditMergedMangaItemListener {
var mergedMangas: List<Pair<Manga?, MergedMangaReference>> by mutableStateOf(emptyList())
var mergeReference: MergedMangaReference? by mutableStateOf(null)
var mergedMangaAdapter: EditMergedMangaAdapter? by mutableStateOf(null)
var mergedMangaHeaderAdapter: EditMergedSettingsHeaderAdapter? by mutableStateOf(null)
val mergedMangas: MutableList<Pair<Manga?, MergedMangaReference>> = mutableListOf()
var mergeReference: MergedMangaReference? = null
lateinit var binding: EditMergedSettingsDialogBinding
private val getMergedMangaById: GetMergedMangaById by injectLazy()
private val getMergedReferencesById: GetMergedReferencesById by injectLazy()
private val deleteMergeById: DeleteMergeById by injectLazy()
private val mangaController
get() = targetController as MangaController
constructor(target: MangaController, manga: Manga) : super(
bundleOf(KEY_MANGA to manga.id),
fun onViewCreated(
context: Context,
binding: EditMergedSettingsDialogBinding,
mergedManga: List<Manga>,
mergedReferences: List<MergedMangaReference>,
) {
targetController = target
this.manga = manga
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
manga = runBlocking { Injekt.get<GetManga>().await(bundle.getLong(KEY_MANGA))!! }
}
private var mergedHeaderAdapter: EditMergedSettingsHeaderAdapter? = null
private var mergedMangaAdapter: EditMergedMangaAdapter? = null
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
binding = EditMergedSettingsDialogBinding.inflate(activity!!.layoutInflater)
val view = ScrollView(activity!!).apply {
addView(binding.root)
}
onViewCreated()
return MaterialAlertDialogBuilder(activity!!)
.setView(view)
.setPositiveButton(R.string.action_save) { _, _ -> onPositiveButtonClick() }
.setNegativeButton(android.R.string.cancel, null)
.create()
}
fun onViewCreated() {
val mergedManga = runBlocking { getMergedMangaById.await(manga.id) }
val mergedReferences = runBlocking { getMergedReferencesById.await(manga.id) }
if (mergedReferences.isEmpty() || mergedReferences.size == 1) {
activity?.toast(R.string.merged_references_invalid)
router.popCurrentController()
context.toast(R.string.merged_references_invalid)
onDismissRequest()
}
mergedMangas += mergedReferences.filter { it.mangaSourceId != MERGED_SOURCE_ID }.map { reference -> mergedManga.firstOrNull { it.id == reference.mangaId } to reference }
mergeReference = mergedReferences.firstOrNull { it.mangaSourceId == MERGED_SOURCE_ID }
@ -82,20 +59,16 @@ class EditMergedSettingsDialog : DialogController, EditMergedMangaAdapter.EditMe
val isPriorityOrder = mergeReference?.let { it.chapterSortMode == MergedMangaReference.CHAPTER_SORT_PRIORITY } ?: false
mergedMangaAdapter = EditMergedMangaAdapter(this, isPriorityOrder)
mergedHeaderAdapter = EditMergedSettingsHeaderAdapter(this, mergedMangaAdapter!!)
mergedMangaHeaderAdapter = EditMergedSettingsHeaderAdapter(this, mergedMangaAdapter!!)
binding.recycler.adapter = ConcatAdapter(mergedHeaderAdapter, mergedMangaAdapter)
binding.recycler.layoutManager = LinearLayoutManager(activity!!)
binding.recycler.adapter = ConcatAdapter(mergedMangaHeaderAdapter, mergedMangaAdapter)
binding.recycler.layoutManager = LinearLayoutManager(context)
mergedMangaAdapter?.isHandleDragEnabled = isPriorityOrder
mergedMangaAdapter?.updateDataSet(mergedMangas.map { it.toModel() })
}
private fun onPositiveButtonClick() {
mangaController.presenter.updateMergeSettings(listOfNotNull(mergeReference) + mergedMangas.map { it.second })
}
override fun onItemReleased(position: Int) {
val mergedMangaAdapter = mergedMangaAdapter ?: return
mergedMangas.onEach { mergedManga ->
@ -109,22 +82,19 @@ class EditMergedSettingsDialog : DialogController, EditMergedMangaAdapter.EditMe
val mergedMangaAdapter = mergedMangaAdapter ?: return
val mergeMangaReference = mergedMangaAdapter.currentItems.getOrNull(position)?.mergedMangaReference ?: return
MaterialAlertDialogBuilder(activity!!)
MaterialAlertDialogBuilder(context)
.setTitle(R.string.delete_merged_manga)
.setMessage(R.string.delete_merged_manga_desc)
.setPositiveButton(android.R.string.ok) { _, _ ->
launchIO {
deleteMergeById.await(mergeMangaReference.id!!)
}
dialog?.dismiss()
mangaController.router.popController(mangaController)
onDeleteClick(mergeMangaReference)
onDismissRequest()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
override fun onToggleChapterUpdatesClicked(position: Int) {
MaterialAlertDialogBuilder(activity!!)
MaterialAlertDialogBuilder(context)
.setTitle(R.string.chapter_updates_merged_manga)
.setMessage(R.string.chapter_updates_merged_manga_desc)
.setPositiveButton(android.R.string.ok) { _, _ ->
@ -143,12 +113,12 @@ class EditMergedSettingsDialog : DialogController, EditMergedMangaAdapter.EditMe
if (it is EditMergedMangaHolder) {
it.updateChapterUpdatesIcon(second.getChapterUpdates)
}
} ?: activity!!.toast(R.string.merged_chapter_updates_error)
} ?: activity!!.toast(R.string.merged_toggle_chapter_updates_find_error)
} ?: context.toast(R.string.merged_chapter_updates_error)
} ?: context.toast(R.string.merged_toggle_chapter_updates_find_error)
}
override fun onToggleChapterDownloadsClicked(position: Int) {
MaterialAlertDialogBuilder(activity!!)
MaterialAlertDialogBuilder(context)
.setTitle(R.string.download_merged_manga)
.setMessage(R.string.download_merged_manga_desc)
.setPositiveButton(android.R.string.ok) { _, _ ->
@ -167,15 +137,60 @@ class EditMergedSettingsDialog : DialogController, EditMergedMangaAdapter.EditMe
if (it is EditMergedMangaHolder) {
it.updateDownloadChaptersIcon(second.downloadChapters)
}
} ?: activity!!.toast(R.string.merged_toggle_download_chapters_error)
} ?: activity!!.toast(R.string.merged_toggle_download_chapters_find_error)
} ?: context.toast(R.string.merged_toggle_download_chapters_error)
} ?: context.toast(R.string.merged_toggle_download_chapters_find_error)
}
private fun Pair<Manga?, MergedMangaReference>.toModel(): EditMergedMangaItem {
return EditMergedMangaItem(first, second)
}
private companion object {
const val KEY_MANGA = "manga_id"
fun onPositiveButtonClick() {
onPositiveClick(listOfNotNull(mergeReference) + mergedMangas.map { it.second })
}
}
@Composable
fun EditMergedSettingsDialog(
onDismissRequest: () -> Unit,
mergedData: MergedMangaData,
onDeleteClick: (MergedMangaReference) -> Unit,
onPositiveClick: (List<MergedMangaReference>) -> Unit,
) {
val context = LocalContext.current
val state = remember {
EditMergedSettingsState(context, onDeleteClick, onDismissRequest, onPositiveClick)
}
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = state::onPositiveButtonClick) {
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(android.R.string.cancel))
}
},
text = {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
AndroidView(
factory = { factoryContext ->
val binding = EditMergedSettingsDialogBinding.inflate(LayoutInflater.from(factoryContext))
state.onViewCreated(factoryContext, binding, mergedData.manga.values.toList(), mergedData.references)
binding.root
},
modifier = Modifier.fillMaxWidth(),
)
}
},
properties = DialogProperties(
usePlatformDefaultWidth = true,
),
)
}
private fun Pair<Manga?, MergedMangaReference>.toModel(): EditMergedMangaItem {
return EditMergedMangaItem(first, second)
}

View File

@ -13,7 +13,7 @@ import exh.log.xLogD
import exh.merged.sql.models.MergedMangaReference
import uy.kohesive.injekt.injectLazy
class EditMergedSettingsHeaderAdapter(private val controller: EditMergedSettingsDialog, adapter: EditMergedMangaAdapter) : RecyclerView.Adapter<EditMergedSettingsHeaderAdapter.HeaderViewHolder>() {
class EditMergedSettingsHeaderAdapter(private val state: EditMergedSettingsState, adapter: EditMergedMangaAdapter) : RecyclerView.Adapter<EditMergedSettingsHeaderAdapter.HeaderViewHolder>() {
private val sourceManager: SourceManager by injectLazy()
@ -50,7 +50,7 @@ class EditMergedSettingsHeaderAdapter(private val controller: EditMergedSettings
)
dedupeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.dedupeModeSpinner.adapter = dedupeAdapter
controller.mergeReference?.let {
state.mergeReference?.let {
binding.dedupeModeSpinner.setSelection(
when (it.chapterSortMode) {
MergedMangaReference.CHAPTER_SORT_NO_DEDUPE -> 0
@ -68,23 +68,23 @@ class EditMergedSettingsHeaderAdapter(private val controller: EditMergedSettings
position: Int,
id: Long,
) {
controller.mergeReference?.chapterSortMode = when (position) {
state.mergeReference?.chapterSortMode = when (position) {
0 -> MergedMangaReference.CHAPTER_SORT_NO_DEDUPE
/*1 -> MergedMangaReference.CHAPTER_SORT_PRIORITY*/
1 -> MergedMangaReference.CHAPTER_SORT_MOST_CHAPTERS
2 -> MergedMangaReference.CHAPTER_SORT_HIGHEST_CHAPTER_NUMBER
else -> MergedMangaReference.CHAPTER_SORT_NO_DEDUPE
}
xLogD(controller.mergeReference?.chapterSortMode)
xLogD(state.mergeReference?.chapterSortMode)
editMergedMangaItemSortingListener.onSetPrioritySort(canMove())
}
override fun onNothingSelected(parent: AdapterView<*>?) {
controller.mergeReference?.chapterSortMode = MergedMangaReference.CHAPTER_SORT_NO_DEDUPE
state.mergeReference?.chapterSortMode = MergedMangaReference.CHAPTER_SORT_NO_DEDUPE
}
}
val mergedMangas = controller.mergedMangas
val mergedMangas = state.mergedMangas
val mangaInfoAdapter: ArrayAdapter<String> = ArrayAdapter(
itemView.context,
@ -111,13 +111,13 @@ class EditMergedSettingsHeaderAdapter(private val controller: EditMergedSettings
position: Int,
id: Long,
) {
val mergedInfoManga = controller.mergedMangas
val mergedInfoManga = state.mergedMangas
.find { mergedManga ->
mergedManga.second.id == mergedMangas.getOrNull(position)?.second?.id
}
if (mergedInfoManga != null) {
controller.mergedMangas.onEach {
state.mergedMangas.onEach {
it.second.isInfoManga = false
}
mergedInfoManga.second.isInfoManga = true
@ -126,7 +126,7 @@ class EditMergedSettingsHeaderAdapter(private val controller: EditMergedSettings
override fun onNothingSelected(parent: AdapterView<*>?) {
mergedMangas.find { it.second.isInfoManga }?.second?.let { newInfoManga ->
controller.mergedMangas.onEach {
state.mergedMangas.onEach {
it.second.isInfoManga = false
}
newInfoManga.isInfoManga = true
@ -134,14 +134,14 @@ class EditMergedSettingsHeaderAdapter(private val controller: EditMergedSettings
}
}
binding.dedupeSwitch.isChecked = controller.mergeReference?.let { it.chapterSortMode != MergedMangaReference.CHAPTER_SORT_NONE } ?: false
binding.dedupeSwitch.isChecked = state.mergeReference?.let { it.chapterSortMode != MergedMangaReference.CHAPTER_SORT_NONE } ?: false
binding.dedupeSwitch.setOnCheckedChangeListener { _, isChecked ->
binding.dedupeModeSpinner.isEnabled = isChecked
binding.dedupeModeSpinner.alpha = when (isChecked) {
true -> 1F
false -> 0.5F
}
controller.mergeReference?.chapterSortMode = when (isChecked) {
state.mergeReference?.chapterSortMode = when (isChecked) {
true -> MergedMangaReference.CHAPTER_SORT_NO_DEDUPE
false -> MergedMangaReference.CHAPTER_SORT_NONE
}
@ -157,7 +157,7 @@ class EditMergedSettingsHeaderAdapter(private val controller: EditMergedSettings
}
}
fun canMove() = controller.mergeReference?.let { it.chapterSortMode == MergedMangaReference.CHAPTER_SORT_PRIORITY } ?: false
fun canMove() = state.mergeReference?.let { it.chapterSortMode == MergedMangaReference.CHAPTER_SORT_PRIORITY } ?: false
interface SortingListener {
fun onSetPrioritySort(isPriorityOrder: Boolean)

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>
</style>
<style name="ThemeOverlay.Tachiyomi.Dialog.Fullscreen" parent="ThemeOverlay.Material3">
<item name="android:windowIsFloating">false</item>
<item name="android:windowAnimationStyle">@style/Animation.Tachiyomi.Dialog</item>
</style>
<style name="Animation.Tachiyomi.Dialog" parent="Animation.AppCompat.Dialog">
<item name="android:windowEnterAnimation">@anim/fade_in_short</item>
<item name="android:windowExitAnimation">@anim/fade_out_short</item>

View File

@ -11,6 +11,7 @@ leakcanary = "2.9.1"
voyager = "1.0.0-rc06"
[libraries]
desugar = "com.android.tools:desugar_jdk_libs:1.2.2"
android-shortcut-gradle = "com.github.zellius:android-shortcut-gradle-plugin:0.1.2"
google-services-gradle = "com.google.gms:google-services:4.3.10"
@ -62,6 +63,8 @@ photoview = "com.github.chrisbanes:PhotoView:2.3.0"
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
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-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-transitions = { module = "ca.gosyer:voyager-transitions", version.ref = "voyager" }
numberpicker= "com.chargemap.compose:numberpicker:1.0.3"
[bundles]
reactivex = ["rxandroid", "rxjava", "rxrelay"]
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="source_unsupported">Source is not supported</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 -->
<string name="error_category_exists">A category with this name already exists!</string>