فهرست منبع

Full Compose MangaController (#8452)

* Full Compose MangaController

* unique key

* Use StateScreenModel

* dismiss

* rebase fix

* toShareIntent
Ivan Iskandar 2 سال پیش
والد
کامیت
18ccde082d
40فایلهای تغییر یافته به همراه3465 افزوده شده و 2922 حذف شده
  1. 5 0
      app/build.gradle.kts
  2. 289 0
      app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt
  3. 93 0
      app/src/main/java/eu/kanade/presentation/components/AlertDialog.kt
  4. 31 4
      app/src/main/java/eu/kanade/presentation/components/Divider.kt
  5. 495 0
      app/src/main/java/eu/kanade/presentation/manga/MangaSettingsDialog.kt
  6. 335 0
      app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogHome.kt
  7. 235 0
      app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogSelector.kt
  8. 315 0
      app/src/main/java/eu/kanade/presentation/manga/TrackServiceSearch.kt
  9. 120 106
      app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt
  10. 4 0
      app/src/main/java/eu/kanade/presentation/util/Navigator.kt
  11. 12 0
      app/src/main/java/eu/kanade/presentation/util/WindowSize.kt
  12. 0 20
      app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt
  13. 98 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt
  14. 8 450
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt
  15. 164 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaCoverScreenModel.kt
  16. 329 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt
  17. 274 292
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt
  18. 0 298
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt
  19. 0 61
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetChapterSettingsDialog.kt
  20. 0 240
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaFullCoverDialog.kt
  21. 0 71
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt
  22. 0 71
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt
  23. 0 60
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt
  24. 0 52
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt
  25. 0 139
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt
  26. 652 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt
  27. 0 55
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt
  28. 0 194
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt
  29. 0 63
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchHolder.kt
  30. 0 228
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt
  31. 0 13
      app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiFullscreenDialog.kt
  32. 0 16
      app/src/main/res/layout/track_chapters_dialog.xml
  33. 0 9
      app/src/main/res/layout/track_controller.xml
  34. 0 203
      app/src/main/res/layout/track_item.xml
  35. 0 16
      app/src/main/res/layout/track_score_dialog.xml
  36. 0 104
      app/src/main/res/layout/track_search_dialog.xml
  37. 0 150
      app/src/main/res/layout/track_search_item.xml
  38. 0 5
      app/src/main/res/values/styles.xml
  39. 3 2
      gradle/libs.versions.toml
  40. 3 0
      i18n/src/main/res/values/strings.xml

+ 5 - 0
app/build.gradle.kts

@@ -144,6 +144,8 @@ android {
     compileOptions {
         sourceCompatibility = JavaVersion.VERSION_1_8
         targetCompatibility = JavaVersion.VERSION_1_8
+
+        isCoreLibraryDesugaringEnabled = true
     }
 
     kotlinOptions {
@@ -163,6 +165,8 @@ dependencies {
     implementation(project(":core"))
     implementation(project(":source-api"))
 
+    coreLibraryDesugaring(libs.desugar)
+
     // Compose
     implementation(platform(compose.bom))
     implementation(compose.activity)
@@ -267,6 +271,7 @@ dependencies {
     implementation(libs.cascade)
     implementation(libs.numberpicker)
     implementation(libs.bundles.voyager)
+    implementation(libs.wheelpicker)
 
     // Conductor
     implementation(libs.bundles.conductor)

+ 289 - 0
app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt

@@ -0,0 +1,289 @@
+package eu.kanade.presentation.components
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredWidthIn
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.ZeroCornerSize
+import androidx.compose.material.SwipeableState
+import androidx.compose.material.rememberSwipeableState
+import androidx.compose.material.swipeable
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.Velocity
+import androidx.compose.ui.unit.dp
+import eu.kanade.presentation.util.isTabletUi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.launch
+import kotlin.math.roundToInt
+import kotlin.time.Duration.Companion.milliseconds
+
+private const val SheetAnimationDuration = 500
+private val SheetAnimationSpec = tween<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
+}

+ 93 - 0
app/src/main/java/eu/kanade/presentation/components/AlertDialog.kt

@@ -0,0 +1,93 @@
+package eu.kanade.presentation.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun AlertDialogContent(
+    buttons: @Composable () -> Unit,
+    modifier: Modifier = Modifier,
+    icon: (@Composable () -> Unit)? = null,
+    title: (@Composable () -> Unit)? = null,
+    text: @Composable (() -> Unit)? = null,
+) {
+    Column(
+        modifier = modifier
+            .sizeIn(minWidth = MinWidth, maxWidth = MaxWidth)
+            .padding(DialogPadding),
+    ) {
+        icon?.let {
+            CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.secondary) {
+                Box(
+                    Modifier
+                        .padding(IconPadding)
+                        .align(Alignment.CenterHorizontally),
+                ) {
+                    icon()
+                }
+            }
+        }
+        title?.let {
+            CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
+                val textStyle = MaterialTheme.typography.headlineSmall
+                ProvideTextStyle(textStyle) {
+                    Box(
+                        // Align the title to the center when an icon is present.
+                        Modifier
+                            .padding(TitlePadding)
+                            .align(
+                                if (icon == null) {
+                                    Alignment.Start
+                                } else {
+                                    Alignment.CenterHorizontally
+                                },
+                            ),
+                    ) {
+                        title()
+                    }
+                }
+            }
+        }
+        text?.let {
+            CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
+                val textStyle = MaterialTheme.typography.bodyMedium
+                ProvideTextStyle(textStyle) {
+                    Box(
+                        Modifier
+                            .weight(weight = 1f, fill = false)
+                            .padding(TextPadding)
+                            .align(Alignment.Start),
+                    ) {
+                        text()
+                    }
+                }
+            }
+        }
+        Box(modifier = Modifier.align(Alignment.End)) {
+            CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) {
+                val textStyle = MaterialTheme.typography.labelLarge
+                ProvideTextStyle(value = textStyle, content = buttons)
+            }
+        }
+    }
+}
+
+// Paddings for each of the dialog's parts.
+private val DialogPadding = PaddingValues(all = 24.dp)
+private val IconPadding = PaddingValues(bottom = 16.dp)
+private val TitlePadding = PaddingValues(bottom = 16.dp)
+private val TextPadding = PaddingValues(bottom = 24.dp)
+
+private val MinWidth = 280.dp
+private val MaxWidth = 560.dp

+ 31 - 4
app/src/main/java/eu/kanade/presentation/components/Divider.kt

@@ -1,17 +1,44 @@
 package eu.kanade.presentation.components
 
-import androidx.compose.material3.MaterialTheme
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.DividerDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
 
 const val DIVIDER_ALPHA = 0.2f
 
 @Composable
 fun Divider(
     modifier: Modifier = Modifier,
+    color: Color = DividerDefaults.color,
 ) {
-    androidx.compose.material3.Divider(
-        modifier = modifier,
-        color = MaterialTheme.colorScheme.onSurface.copy(alpha = DIVIDER_ALPHA),
+    Box(
+        modifier
+            .fillMaxWidth()
+            .height(1.dp)
+            .background(color = color)
+            .alpha(DIVIDER_ALPHA),
+    )
+}
+
+@Composable
+fun VerticalDivider(
+    modifier: Modifier = Modifier,
+    color: Color = DividerDefaults.color,
+) {
+    Box(
+        modifier
+            .fillMaxHeight()
+            .width(1.dp)
+            .background(color = color)
+            .alpha(DIVIDER_ALPHA),
     )
 }

+ 495 - 0
app/src/main/java/eu/kanade/presentation/manga/MangaSettingsDialog.kt

@@ -0,0 +1,495 @@
+package eu.kanade.presentation.manga
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDownward
+import androidx.compose.material.icons.filled.ArrowUpward
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.rounded.CheckBox
+import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank
+import androidx.compose.material.icons.rounded.DisabledByDefault
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastForEachIndexed
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.domain.manga.model.TriStateFilter
+import eu.kanade.presentation.components.AdaptiveSheet
+import eu.kanade.presentation.components.Divider
+import eu.kanade.presentation.components.DropdownMenu
+import eu.kanade.presentation.components.HorizontalPager
+import eu.kanade.presentation.components.TabIndicator
+import eu.kanade.presentation.components.rememberPagerState
+import eu.kanade.presentation.theme.TachiyomiTheme
+import eu.kanade.tachiyomi.R
+import kotlinx.coroutines.launch
+
+@Composable
+fun ChapterSettingsDialog(
+    onDismissRequest: () -> Unit,
+    manga: Manga? = null,
+    onDownloadFilterChanged: (TriStateFilter) -> Unit,
+    onUnreadFilterChanged: (TriStateFilter) -> Unit,
+    onBookmarkedFilterChanged: (TriStateFilter) -> Unit,
+    onSortModeChanged: (Long) -> Unit,
+    onDisplayModeChanged: (Long) -> Unit,
+    onSetAsDefault: (applyToExistingManga: Boolean) -> Unit,
+) {
+    AdaptiveSheet(
+        onDismissRequest = onDismissRequest,
+    ) { contentPadding ->
+        ChapterSettingsDialogImpl(
+            manga = manga,
+            contentPadding = contentPadding,
+            onDownloadFilterChanged = onDownloadFilterChanged,
+            onUnreadFilterChanged = onUnreadFilterChanged,
+            onBookmarkedFilterChanged = onBookmarkedFilterChanged,
+            onSortModeChanged = onSortModeChanged,
+            onDisplayModeChanged = onDisplayModeChanged,
+            onSetAsDefault = onSetAsDefault,
+        )
+    }
+}
+
+@Composable
+private fun ChapterSettingsDialogImpl(
+    manga: Manga? = null,
+    contentPadding: PaddingValues = PaddingValues(),
+    onDownloadFilterChanged: (TriStateFilter) -> Unit,
+    onUnreadFilterChanged: (TriStateFilter) -> Unit,
+    onBookmarkedFilterChanged: (TriStateFilter) -> Unit,
+    onSortModeChanged: (Long) -> Unit,
+    onDisplayModeChanged: (Long) -> Unit,
+    onSetAsDefault: (applyToExistingManga: Boolean) -> Unit,
+) {
+    val scope = rememberCoroutineScope()
+    val tabTitles = listOf(
+        stringResource(R.string.action_filter),
+        stringResource(R.string.action_sort),
+        stringResource(R.string.action_display),
+    )
+    val pagerState = rememberPagerState()
+
+    var showSetAsDefaultDialog by rememberSaveable { mutableStateOf(false) }
+    if (showSetAsDefaultDialog) {
+        SetAsDefaultDialog(
+            onDismissRequest = { showSetAsDefaultDialog = false },
+            onConfirmed = onSetAsDefault,
+        )
+    }
+
+    Column {
+        Row {
+            TabRow(
+                modifier = Modifier.weight(1f),
+                selectedTabIndex = pagerState.currentPage,
+                indicator = { TabIndicator(it[pagerState.currentPage]) },
+                divider = {},
+            ) {
+                tabTitles.fastForEachIndexed { i, s ->
+                    val selected = pagerState.currentPage == i
+                    Tab(
+                        selected = selected,
+                        onClick = { scope.launch { pagerState.animateScrollToPage(i) } },
+                        text = {
+                            Text(
+                                text = s,
+                                color = if (selected) {
+                                    MaterialTheme.colorScheme.primary
+                                } else {
+                                    MaterialTheme.colorScheme.onSurfaceVariant
+                                },
+                            )
+                        },
+                    )
+                }
+            }
+
+            MoreMenu(onSetAsDefault = { showSetAsDefaultDialog = true })
+        }
+
+        Divider()
+
+        val density = LocalDensity.current
+        var largestHeight by rememberSaveable { mutableStateOf(0f) }
+        HorizontalPager(
+            modifier = Modifier.heightIn(min = largestHeight.dp),
+            count = tabTitles.size,
+            state = pagerState,
+            verticalAlignment = Alignment.Top,
+        ) { page ->
+            Box(
+                modifier = Modifier.onSizeChanged {
+                    with(density) {
+                        val heightDp = it.height.toDp()
+                        if (heightDp.value > largestHeight) {
+                            largestHeight = heightDp.value
+                        }
+                    }
+                },
+            ) {
+                when (page) {
+                    0 -> {
+                        val forceDownloaded = manga?.forceDownloaded() == true
+                        FilterPage(
+                            contentPadding = contentPadding,
+                            downloadFilter = if (forceDownloaded) {
+                                TriStateFilter.ENABLED_NOT
+                            } else {
+                                manga?.downloadedFilter
+                            } ?: TriStateFilter.DISABLED,
+                            onDownloadFilterChanged = onDownloadFilterChanged.takeUnless { forceDownloaded },
+                            unreadFilter = manga?.unreadFilter ?: TriStateFilter.DISABLED,
+                            onUnreadFilterChanged = onUnreadFilterChanged,
+                            bookmarkedFilter = manga?.bookmarkedFilter ?: TriStateFilter.DISABLED,
+                            onBookmarkedFilterChanged = onBookmarkedFilterChanged,
+                        )
+                    }
+                    1 -> SortPage(
+                        contentPadding = contentPadding,
+                        sortingMode = manga?.sorting ?: 0,
+                        sortDescending = manga?.sortDescending() ?: false,
+                        onItemSelected = onSortModeChanged,
+                    )
+                    2 -> DisplayPage(
+                        contentPadding = contentPadding,
+                        displayMode = manga?.displayMode ?: 0,
+                        onItemSelected = onDisplayModeChanged,
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun SetAsDefaultDialog(
+    onDismissRequest: () -> Unit,
+    onConfirmed: (optionalChecked: Boolean) -> Unit,
+) {
+    var optionalChecked by rememberSaveable { mutableStateOf(false) }
+    AlertDialog(
+        onDismissRequest = onDismissRequest,
+        title = { Text(text = stringResource(id = R.string.chapter_settings)) },
+        text = {
+            Column(
+                verticalArrangement = Arrangement.spacedBy(12.dp),
+            ) {
+                Text(text = stringResource(id = R.string.confirm_set_chapter_settings))
+
+                Row(
+                    modifier = Modifier
+                        .clickable { optionalChecked = !optionalChecked }
+                        .padding(vertical = 8.dp)
+                        .fillMaxWidth(),
+                    horizontalArrangement = Arrangement.spacedBy(12.dp),
+                    verticalAlignment = Alignment.CenterVertically,
+                ) {
+                    Checkbox(
+                        checked = optionalChecked,
+                        onCheckedChange = null,
+                    )
+                    Text(text = stringResource(id = R.string.also_set_chapter_settings_for_library))
+                }
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onDismissRequest) {
+                Text(text = stringResource(id = android.R.string.cancel))
+            }
+        },
+        confirmButton = {
+            TextButton(
+                onClick = {
+                    onConfirmed(optionalChecked)
+                },
+            ) {
+                Text(text = stringResource(id = android.R.string.ok))
+            }
+        },
+    )
+}
+
+@Composable
+private fun MoreMenu(
+    onSetAsDefault: () -> Unit,
+) {
+    var expanded by remember { mutableStateOf(false) }
+    Box(modifier = Modifier.wrapContentSize(Alignment.TopStart)) {
+        IconButton(onClick = { expanded = true }) {
+            Icon(
+                imageVector = Icons.Default.MoreVert,
+                contentDescription = stringResource(id = R.string.label_more),
+            )
+        }
+        DropdownMenu(
+            expanded = expanded,
+            onDismissRequest = { expanded = false },
+        ) {
+            DropdownMenuItem(
+                text = { Text(stringResource(R.string.set_chapter_settings_as_default)) },
+                onClick = {
+                    onSetAsDefault()
+                    expanded = false
+                },
+            )
+        }
+    }
+}
+
+@Composable
+private fun FilterPage(
+    contentPadding: PaddingValues,
+    downloadFilter: TriStateFilter,
+    onDownloadFilterChanged: ((TriStateFilter) -> Unit)?,
+    unreadFilter: TriStateFilter,
+    onUnreadFilterChanged: (TriStateFilter) -> Unit,
+    bookmarkedFilter: TriStateFilter,
+    onBookmarkedFilterChanged: (TriStateFilter) -> Unit,
+) {
+    Column(
+        modifier = Modifier
+            .padding(vertical = VerticalPadding)
+            .padding(contentPadding)
+            .verticalScroll(rememberScrollState()),
+    ) {
+        FilterPageItem(
+            label = stringResource(id = R.string.action_filter_downloaded),
+            state = downloadFilter,
+            onClick = onDownloadFilterChanged,
+        )
+        FilterPageItem(
+            label = stringResource(id = R.string.action_filter_unread),
+            state = unreadFilter,
+            onClick = onUnreadFilterChanged,
+        )
+        FilterPageItem(
+            label = stringResource(id = R.string.action_filter_bookmarked),
+            state = bookmarkedFilter,
+            onClick = onBookmarkedFilterChanged,
+        )
+    }
+}
+
+@Composable
+private fun FilterPageItem(
+    label: String,
+    state: TriStateFilter,
+    onClick: ((TriStateFilter) -> Unit)?,
+) {
+    Row(
+        modifier = Modifier
+            .clickable(
+                enabled = onClick != null,
+                onClick = {
+                    when (state) {
+                        TriStateFilter.DISABLED -> onClick?.invoke(TriStateFilter.ENABLED_IS)
+                        TriStateFilter.ENABLED_IS -> onClick?.invoke(TriStateFilter.ENABLED_NOT)
+                        TriStateFilter.ENABLED_NOT -> onClick?.invoke(TriStateFilter.DISABLED)
+                    }
+                },
+            )
+            .fillMaxWidth()
+            .padding(horizontal = HorizontalPadding, vertical = 12.dp),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.spacedBy(24.dp),
+    ) {
+        Icon(
+            imageVector = when (state) {
+                TriStateFilter.DISABLED -> Icons.Rounded.CheckBoxOutlineBlank
+                TriStateFilter.ENABLED_IS -> Icons.Rounded.CheckBox
+                TriStateFilter.ENABLED_NOT -> Icons.Rounded.DisabledByDefault
+            },
+            contentDescription = null,
+            tint = if (state == TriStateFilter.DISABLED) {
+                MaterialTheme.colorScheme.onSurfaceVariant
+            } else {
+                MaterialTheme.colorScheme.primary
+            },
+        )
+        Text(
+            text = label,
+            style = MaterialTheme.typography.bodyMedium,
+        )
+    }
+}
+
+@Composable
+private fun SortPage(
+    contentPadding: PaddingValues,
+    sortingMode: Long,
+    sortDescending: Boolean,
+    onItemSelected: (Long) -> Unit,
+) {
+    Column(
+        modifier = Modifier
+            .padding(contentPadding)
+            .padding(vertical = VerticalPadding)
+            .verticalScroll(rememberScrollState()),
+    ) {
+        val arrowIcon = if (sortDescending) {
+            Icons.Default.ArrowDownward
+        } else {
+            Icons.Default.ArrowUpward
+        }
+
+        SortPageItem(
+            label = stringResource(id = R.string.sort_by_source),
+            statusIcon = arrowIcon.takeIf { sortingMode == Manga.CHAPTER_SORTING_SOURCE },
+            onClick = { onItemSelected(Manga.CHAPTER_SORTING_SOURCE) },
+        )
+        SortPageItem(
+            label = stringResource(id = R.string.sort_by_number),
+            statusIcon = arrowIcon.takeIf { sortingMode == Manga.CHAPTER_SORTING_NUMBER },
+            onClick = { onItemSelected(Manga.CHAPTER_SORTING_NUMBER) },
+        )
+        SortPageItem(
+            label = stringResource(id = R.string.sort_by_upload_date),
+            statusIcon = arrowIcon.takeIf { sortingMode == Manga.CHAPTER_SORTING_UPLOAD_DATE },
+            onClick = { onItemSelected(Manga.CHAPTER_SORTING_UPLOAD_DATE) },
+        )
+    }
+}
+
+@Composable
+private fun SortPageItem(
+    label: String,
+    statusIcon: ImageVector?,
+    onClick: () -> Unit,
+) {
+    Row(
+        modifier = Modifier
+            .clickable(onClick = onClick)
+            .fillMaxWidth()
+            .padding(horizontal = HorizontalPadding, vertical = 12.dp),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.spacedBy(24.dp),
+    ) {
+        if (statusIcon != null) {
+            Icon(
+                imageVector = statusIcon,
+                contentDescription = null,
+                tint = MaterialTheme.colorScheme.primary,
+            )
+        } else {
+            Spacer(modifier = Modifier.size(24.dp))
+        }
+        Text(
+            text = label,
+            style = MaterialTheme.typography.bodyMedium,
+        )
+    }
+}
+
+@Composable
+private fun DisplayPage(
+    contentPadding: PaddingValues,
+    displayMode: Long,
+    onItemSelected: (Long) -> Unit,
+) {
+    Column(
+        modifier = Modifier
+            .padding(contentPadding)
+            .padding(vertical = VerticalPadding)
+            .verticalScroll(rememberScrollState()),
+    ) {
+        DisplayPageItem(
+            label = stringResource(id = R.string.show_title),
+            selected = displayMode == Manga.CHAPTER_DISPLAY_NAME,
+            onClick = { onItemSelected(Manga.CHAPTER_DISPLAY_NAME) },
+        )
+        DisplayPageItem(
+            label = stringResource(id = R.string.show_chapter_number),
+            selected = displayMode == Manga.CHAPTER_DISPLAY_NUMBER,
+            onClick = { onItemSelected(Manga.CHAPTER_DISPLAY_NUMBER) },
+        )
+    }
+}
+
+@Composable
+private fun DisplayPageItem(
+    label: String,
+    selected: Boolean,
+    onClick: () -> Unit,
+) {
+    Row(
+        modifier = Modifier
+            .clickable(onClick = onClick)
+            .fillMaxWidth()
+            .padding(horizontal = HorizontalPadding, vertical = 12.dp),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.spacedBy(24.dp),
+    ) {
+        RadioButton(
+            selected = selected,
+            onClick = null,
+        )
+        Text(
+            text = label,
+            style = MaterialTheme.typography.bodyMedium,
+        )
+    }
+}
+
+private val HorizontalPadding = 24.dp
+private val VerticalPadding = 8.dp
+
+@Preview(
+    name = "Light",
+)
+@Preview(
+    name = "Dark",
+    uiMode = UI_MODE_NIGHT_YES,
+)
+@Composable
+private fun ChapterSettingsDialogPreview() {
+    TachiyomiTheme {
+        Surface {
+            ChapterSettingsDialogImpl(
+                onDownloadFilterChanged = {},
+                onUnreadFilterChanged = {},
+                onBookmarkedFilterChanged = {},
+                onSortModeChanged = {},
+                onDisplayModeChanged = {},
+            ) {}
+        }
+    }
+}

+ 335 - 0
app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogHome.kt

@@ -0,0 +1,335 @@
+package eu.kanade.presentation.manga
+
+import androidx.annotation.ColorInt
+import androidx.annotation.DrawableRes
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.OpenInBrowser
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import eu.kanade.presentation.components.Divider
+import eu.kanade.presentation.components.DropdownMenu
+import eu.kanade.presentation.components.VerticalDivider
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.ui.manga.track.TrackItem
+import java.text.DateFormat
+
+private const val UnsetStatusTextAlpha = 0.5F
+
+@Composable
+fun TrackInfoDialogHome(
+    trackItems: List<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
+                },
+            )
+        }
+    }
+}

+ 235 - 0
app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogSelector.kt

@@ -0,0 +1,235 @@
+package eu.kanade.presentation.manga
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.commandiron.wheel_picker_compose.WheelDatePicker
+import com.commandiron.wheel_picker_compose.WheelTextPicker
+import eu.kanade.presentation.components.AlertDialogContent
+import eu.kanade.presentation.components.Divider
+import eu.kanade.presentation.components.ScrollbarLazyColumn
+import eu.kanade.presentation.util.isScrolledToEnd
+import eu.kanade.presentation.util.isScrolledToStart
+import eu.kanade.presentation.util.minimumTouchTargetSize
+import eu.kanade.tachiyomi.R
+import java.time.LocalDate
+import java.time.format.TextStyle
+
+@Composable
+fun TrackStatusSelector(
+    contentPadding: PaddingValues,
+    selection: Int,
+    onSelectionChange: (Int) -> Unit,
+    selections: Map<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))
+                }
+            }
+        },
+    )
+}

+ 315 - 0
app/src/main/java/eu/kanade/presentation/manga/TrackServiceSearch.kt

@@ -0,0 +1,315 @@
+package eu.kanade.presentation.manga
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.paddingFromBaseline
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.capitalize
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.intl.Locale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.toLowerCase
+import androidx.compose.ui.unit.dp
+import eu.kanade.presentation.components.Divider
+import eu.kanade.presentation.components.EmptyScreen
+import eu.kanade.presentation.components.LoadingScreen
+import eu.kanade.presentation.components.MangaCover
+import eu.kanade.presentation.components.ScrollbarLazyColumn
+import eu.kanade.presentation.util.plus
+import eu.kanade.presentation.util.secondaryItemAlpha
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.track.model.TrackSearch
+
+@Composable
+fun TrackServiceSearch(
+    contentPadding: PaddingValues = PaddingValues(),
+    query: TextFieldValue,
+    onQueryChange: (TextFieldValue) -> Unit,
+    onDispatchQuery: () -> Unit,
+    queryResult: Result<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,
+        )
+    }
+}

+ 120 - 106
app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt

@@ -22,6 +22,8 @@ import androidx.compose.material3.DropdownMenuItem
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
@@ -34,6 +36,8 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.DpOffset
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.viewinterop.AndroidView
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
 import androidx.core.view.updatePadding
 import coil.imageLoader
 import coil.request.ImageRequest
@@ -50,124 +54,134 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
 fun MangaCoverDialog(
     coverDataProvider: () -> Manga,
     isCustomCover: Boolean,
+    snackbarHostState: SnackbarHostState,
     onShareClick: () -> Unit,
     onSaveClick: () -> Unit,
     onEditClick: ((EditCoverAction) -> Unit)?,
     onDismissRequest: () -> Unit,
 ) {
-    Scaffold(
-        bottomBar = {
-            Row(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .background(color = MaterialTheme.colorScheme.background.copy(alpha = 0.9f))
-                    .padding(horizontal = 4.dp, vertical = 4.dp)
-                    .navigationBarsPadding(),
-            ) {
-                IconButton(onClick = onDismissRequest) {
-                    Icon(
-                        imageVector = Icons.Outlined.Close,
-                        contentDescription = stringResource(R.string.action_close),
-                    )
-                }
-                Spacer(modifier = Modifier.weight(1f))
-                IconButton(onClick = onShareClick) {
-                    Icon(
-                        imageVector = Icons.Outlined.Share,
-                        contentDescription = stringResource(R.string.action_share),
-                    )
-                }
-                IconButton(onClick = onSaveClick) {
-                    Icon(
-                        imageVector = Icons.Outlined.Save,
-                        contentDescription = stringResource(R.string.action_save),
-                    )
-                }
-                if (onEditClick != null) {
-                    Box {
-                        var expanded by remember { mutableStateOf(false) }
-                        IconButton(
-                            onClick = {
-                                if (isCustomCover) {
-                                    expanded = true
-                                } else {
-                                    onEditClick(EditCoverAction.EDIT)
-                                }
-                            },
-                        ) {
-                            Icon(
-                                imageVector = Icons.Outlined.Edit,
-                                contentDescription = stringResource(R.string.action_edit_cover),
-                            )
-                        }
-                        DropdownMenu(
-                            expanded = expanded,
-                            onDismissRequest = { expanded = false },
-                            offset = DpOffset(8.dp, 0.dp),
-                        ) {
-                            DropdownMenuItem(
-                                text = { Text(text = stringResource(R.string.action_edit)) },
-                                onClick = {
-                                    onEditClick(EditCoverAction.EDIT)
-                                    expanded = false
-                                },
-                            )
-                            DropdownMenuItem(
-                                text = { Text(text = stringResource(R.string.action_delete)) },
+    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),
+                        )
+                    }
+                    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 = {
-                                    onEditClick(EditCoverAction.DELETE)
-                                    expanded = false
+                                    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
+                                    },
+                                )
+                            }
                         }
                     }
                 }
-            }
-        },
-    ) { 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))
+            },
+        ) { 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
                         }
-                        .build()
-                    view.context.imageLoader.enqueue(request)
+                    },
+                    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(),
+                )
+            }
         }
     }
 }

+ 4 - 0
app/src/main/java/eu/kanade/presentation/util/Navigator.kt

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

+ 12 - 0
app/src/main/java/eu/kanade/presentation/util/WindowSize.kt

@@ -0,0 +1,12 @@
+package eu.kanade.presentation.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.platform.LocalConfiguration
+import eu.kanade.tachiyomi.util.system.isTabletUi
+
+@Composable
+@ReadOnlyComposable
+fun isTabletUi(): Boolean {
+    return LocalConfiguration.current.isTabletUi()
+}

+ 0 - 20
app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt

@@ -27,24 +27,4 @@ class TrackImpl : Track {
     override var finished_reading_date: Long = 0
 
     override var tracking_url: String = ""
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (javaClass != other?.javaClass) return false
-
-        other as TrackImpl
-
-        if (manga_id != other.manga_id) return false
-        if (sync_id != other.sync_id) return false
-        if (media_id != other.media_id) return false
-
-        return true
-    }
-
-    override fun hashCode(): Int {
-        var result = manga_id.hashCode()
-        result = 31 * result + sync_id
-        result = 31 * result + media_id.hashCode()
-        return result
-    }
 }

+ 98 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt

@@ -1,15 +1,28 @@
 package eu.kanade.tachiyomi.data.track
 
+import android.app.Application
 import androidx.annotation.CallSuper
 import androidx.annotation.ColorInt
 import androidx.annotation.DrawableRes
 import androidx.annotation.StringRes
 import eu.kanade.domain.base.BasePreferences
+import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
+import eu.kanade.domain.chapter.interactor.SyncChaptersWithTrackServiceTwoWay
+import eu.kanade.domain.track.interactor.InsertTrack
+import eu.kanade.domain.track.model.toDbTrack
+import eu.kanade.domain.track.model.toDomainTrack
 import eu.kanade.domain.track.service.TrackPreferences
 import eu.kanade.tachiyomi.data.database.models.Track
 import eu.kanade.tachiyomi.data.track.model.TrackSearch
 import eu.kanade.tachiyomi.network.NetworkHelper
+import eu.kanade.tachiyomi.util.lang.withIOContext
+import eu.kanade.tachiyomi.util.lang.withUIContext
+import eu.kanade.tachiyomi.util.system.logcat
+import eu.kanade.tachiyomi.util.system.toast
+import logcat.LogPriority
 import okhttp3.OkHttpClient
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
 import uy.kohesive.injekt.injectLazy
 
 abstract class TrackService(val id: Long) {
@@ -78,4 +91,89 @@ abstract class TrackService(val id: Long) {
     fun saveCredentials(username: String, password: String) {
         trackPreferences.setTrackCredentials(this, username, password)
     }
+
+    suspend fun registerTracking(item: Track, mangaId: Long) {
+        item.manga_id = mangaId
+        try {
+            withIOContext {
+                val allChapters = Injekt.get<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) }
+            }
+        }
+    }
 }

+ 8 - 450
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt

@@ -1,65 +1,12 @@
 package eu.kanade.tachiyomi.ui.manga
 
-import android.content.Intent
 import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.material3.SnackbarHostState
-import androidx.compose.material3.SnackbarResult
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.platform.LocalConfiguration
 import androidx.core.os.bundleOf
-import com.bluelinelabs.conductor.ControllerChangeHandler
-import com.bluelinelabs.conductor.ControllerChangeType
-import eu.kanade.data.chapter.NoChaptersException
-import eu.kanade.presentation.components.ChangeCategoryDialog
-import eu.kanade.presentation.components.ChapterDownloadAction
-import eu.kanade.presentation.components.DuplicateMangaDialog
-import eu.kanade.presentation.components.LoadingScreen
-import eu.kanade.presentation.manga.DownloadAction
-import eu.kanade.presentation.manga.MangaScreen
-import eu.kanade.presentation.manga.components.DeleteChaptersDialog
-import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.download.DownloadService
-import eu.kanade.tachiyomi.data.download.model.Download
-import eu.kanade.tachiyomi.data.track.model.TrackSearch
-import eu.kanade.tachiyomi.network.HttpException
-import eu.kanade.tachiyomi.source.isLocalOrStub
-import eu.kanade.tachiyomi.source.online.HttpSource
-import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
-import eu.kanade.tachiyomi.ui.base.controller.pushController
-import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
-import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
-import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
-import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
-import eu.kanade.tachiyomi.ui.category.CategoryController
-import eu.kanade.tachiyomi.ui.history.HistoryController
-import eu.kanade.tachiyomi.ui.library.LibraryController
-import eu.kanade.tachiyomi.ui.main.MainActivity
-import eu.kanade.tachiyomi.ui.manga.MangaPresenter.Dialog
-import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersSettingsSheet
-import eu.kanade.tachiyomi.ui.manga.info.MangaFullCoverDialog
-import eu.kanade.tachiyomi.ui.manga.track.TrackItem
-import eu.kanade.tachiyomi.ui.manga.track.TrackSearchDialog
-import eu.kanade.tachiyomi.ui.manga.track.TrackSheet
-import eu.kanade.tachiyomi.ui.reader.ReaderActivity
-import eu.kanade.tachiyomi.ui.updates.UpdatesController
-import eu.kanade.tachiyomi.ui.webview.WebViewActivity
-import eu.kanade.tachiyomi.util.system.isTabletUi
-import eu.kanade.tachiyomi.util.system.logcat
-import eu.kanade.tachiyomi.util.system.toast
-import kotlinx.coroutines.launch
-import logcat.LogPriority
-import eu.kanade.domain.chapter.model.Chapter as DomainChapter
+import cafe.adriel.voyager.navigator.Navigator
+import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
 
-class MangaController : FullComposeController<MangaPresenter> {
+class MangaController : BasicFullComposeController {
 
     @Suppress("unused")
     constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA))
@@ -67,408 +14,19 @@ class MangaController : FullComposeController<MangaPresenter> {
     constructor(
         mangaId: Long,
         fromSource: Boolean = false,
-    ) : super(bundleOf(MANGA_EXTRA to mangaId, FROM_SOURCE_EXTRA to fromSource)) {
-        this.mangaId = mangaId
-    }
+    ) : super(bundleOf(MANGA_EXTRA to mangaId, FROM_SOURCE_EXTRA to fromSource))
 
-    var mangaId: Long
+    val mangaId: Long
+        get() = args.getLong(MANGA_EXTRA)
 
     val fromSource: Boolean
-        get() = presenter.isFromSource
-
-    // Sheet containing filter/sort/display items.
-    private lateinit var settingsSheet: ChaptersSettingsSheet
-
-    private lateinit var trackSheet: TrackSheet
-
-    private val snackbarHostState = SnackbarHostState()
-
-    override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
-        super.onChangeStarted(handler, type)
-        val actionBar = (activity as? AppCompatActivity)?.supportActionBar
-        if (type.isEnter) {
-            actionBar?.hide()
-        } else {
-            actionBar?.show()
-        }
-    }
-
-    override fun createPresenter(): MangaPresenter {
-        return MangaPresenter(
-            mangaId = mangaId,
-            isFromSource = args.getBoolean(FROM_SOURCE_EXTRA, false),
-        )
-    }
+        get() = args.getBoolean(FROM_SOURCE_EXTRA)
 
     @Composable
     override fun ComposeContent() {
-        val state by presenter.state.collectAsState()
-
-        if (state is MangaScreenState.Loading) {
-            LoadingScreen()
-            return
-        }
-
-        val successState = state as MangaScreenState.Success
-        val isHttpSource = remember { successState.source is HttpSource }
-        val scope = rememberCoroutineScope()
-
-        val configuration = LocalConfiguration.current
-        val isTabletUi = remember { configuration.isTabletUi() } // won't survive config change
-
-        MangaScreen(
-            state = successState,
-            snackbarHostState = snackbarHostState,
-            isTabletUi = isTabletUi,
-            onBackClicked = router::popCurrentController,
-            onChapterClicked = this::openChapter,
-            onDownloadChapter = this::onDownloadChapters.takeIf { !successState.source.isLocalOrStub() },
-            onAddToLibraryClicked = this::onFavoriteClick,
-            onWebViewClicked = this::openMangaInWebView.takeIf { isHttpSource },
-            onTrackingClicked = trackSheet::show.takeIf { successState.trackingAvailable },
-            onTagClicked = this::performGenreSearch,
-            onFilterButtonClicked = settingsSheet::show,
-            onRefresh = presenter::fetchAllFromSource,
-            onContinueReading = this::continueReading,
-            onSearch = this::performSearch,
-            onCoverClicked = this::openCoverDialog,
-            onShareClicked = this::shareManga.takeIf { isHttpSource },
-            onDownloadActionClicked = this::runDownloadChapterAction.takeIf { !successState.source.isLocalOrStub() },
-            onEditCategoryClicked = presenter::promptChangeCategories.takeIf { successState.manga.favorite },
-            onMigrateClicked = this::migrateManga.takeIf { successState.manga.favorite },
-            onMultiBookmarkClicked = presenter::bookmarkChapters,
-            onMultiMarkAsReadClicked = presenter::markChaptersRead,
-            onMarkPreviousAsReadClicked = presenter::markPreviousChapterRead,
-            onMultiDeleteClicked = presenter::showDeleteChapterDialog,
-            onChapterSelected = presenter::toggleSelection,
-            onAllChapterSelected = presenter::toggleAllSelection,
-            onInvertSelection = presenter::invertSelection,
-        )
-
-        val onDismissRequest = { presenter.dismissDialog() }
-        when (val dialog = (state as? MangaScreenState.Success)?.dialog) {
-            is Dialog.ChangeCategory -> {
-                ChangeCategoryDialog(
-                    initialSelection = dialog.initialSelection,
-                    onDismissRequest = onDismissRequest,
-                    onEditCategories = {
-                        router.pushController(CategoryController())
-                    },
-                    onConfirm = { include, _ ->
-                        presenter.moveMangaToCategoriesAndAddToLibrary(dialog.manga, include)
-                    },
-                )
-            }
-            is Dialog.DeleteChapters -> {
-                DeleteChaptersDialog(
-                    onDismissRequest = onDismissRequest,
-                    onConfirm = {
-                        presenter.toggleAllSelection(false)
-                        deleteChapters(dialog.chapters)
-                    },
-                )
-            }
-            is Dialog.DownloadCustomAmount -> {
-                DownloadCustomAmountDialog(
-                    maxAmount = dialog.max,
-                    onDismissRequest = onDismissRequest,
-                    onConfirm = { amount ->
-                        val chaptersToDownload = presenter.getUnreadChaptersSorted().take(amount)
-                        if (chaptersToDownload.isNotEmpty()) {
-                            scope.launch { downloadChapters(chaptersToDownload) }
-                        }
-                    },
-                )
-            }
-            is Dialog.DuplicateManga -> {
-                DuplicateMangaDialog(
-                    onDismissRequest = onDismissRequest,
-                    onConfirm = {
-                        presenter.toggleFavorite(
-                            onRemoved = {},
-                            onAdded = {},
-                            checkDuplicate = false,
-                        )
-                    },
-                    onOpenManga = { router.pushController(MangaController(dialog.duplicate.id)) },
-                    duplicateFrom = presenter.getSourceOrStub(dialog.duplicate),
-                )
-            }
-            null -> {}
-        }
-    }
-
-    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)
+        Navigator(screen = MangaScreen(mangaId, fromSource))
     }
 
-    // Manga info - start
-
-    fun onFetchMangaInfoError(error: Throwable) {
-        // Ignore early hints "errors" that aren't handled by OkHttp
-        if (error is HttpException && error.code == 103) {
-            return
-        }
-        activity?.toast(error.message)
-    }
-
-    private fun openMangaInWebView() {
-        val manga = presenter.manga ?: return
-        val source = presenter.source as? HttpSource ?: return
-
-        val url = try {
-            source.getMangaUrl(manga.toSManga())
-        } catch (e: Exception) {
-            return
-        }
-
-        val activity = activity ?: return
-        val intent = WebViewActivity.newIntent(activity, url, source.id, manga.title)
-        startActivity(intent)
-    }
-
-    private fun shareManga() {
-        val context = view?.context ?: return
-        val manga = presenter.manga ?: return
-        val source = presenter.source as? HttpSource ?: return
-        try {
-            val url = source.getMangaUrl(manga.toSManga())
-            val intent = Intent(Intent.ACTION_SEND).apply {
-                type = "text/plain"
-                putExtra(Intent.EXTRA_TEXT, url)
-            }
-            startActivity(Intent.createChooser(intent, context.getString(R.string.action_share)))
-        } catch (e: Exception) {
-            context.toast(e.message)
-        }
-    }
-
-    private fun onFavoriteClick() {
-        presenter.toggleFavorite(
-            onRemoved = this::onFavoriteRemoved,
-            onAdded = { activity?.toast(R.string.manga_added_library) },
-        )
-    }
-
-    private fun onFavoriteRemoved() {
-        val context = activity ?: return
-        context.toast(R.string.manga_removed_library)
-        viewScope.launch {
-            if (!presenter.hasDownloads()) return@launch
-            val result = snackbarHostState.showSnackbar(
-                message = context.getString(R.string.delete_downloads_for_manga),
-                actionLabel = context.getString(R.string.action_delete),
-                withDismissAction = true,
-            )
-            if (result == SnackbarResult.ActionPerformed) {
-                presenter.deleteDownloads()
-            }
-        }
-    }
-
-    /**
-     * Perform a search using the provided query.
-     *
-     * @param query the search query to the parent controller
-     */
-    private fun performSearch(query: String, global: Boolean) {
-        if (global) {
-            router.pushController(GlobalSearchController(query))
-            return
-        }
-
-        if (router.backstackSize < 2) {
-            return
-        }
-
-        when (val previousController = router.backstack[router.backstackSize - 2].controller) {
-            is LibraryController -> {
-                router.handleBack()
-                previousController.search(query)
-            }
-            is UpdatesController,
-            is HistoryController,
-            -> {
-                // Manually navigate to LibraryController
-                router.handleBack()
-                (router.activity as MainActivity).setSelectedNavItem(R.id.nav_library)
-                val controller = router.getControllerWithTag(R.id.nav_library.toString()) as LibraryController
-                controller.search(query)
-            }
-            is BrowseSourceController -> {
-                router.handleBack()
-                previousController.searchWithQuery(query)
-            }
-        }
-    }
-
-    /**
-     * Performs a genre search using the provided genre name.
-     *
-     * @param genreName the search genre to the parent controller
-     */
-    private fun performGenreSearch(genreName: String) {
-        if (router.backstackSize < 2) {
-            return
-        }
-
-        val previousController = router.backstack[router.backstackSize - 2].controller
-        val presenterSource = presenter.source
-
-        if (previousController is BrowseSourceController &&
-            presenterSource is HttpSource
-        ) {
-            router.handleBack()
-            previousController.searchWithGenre(genreName)
-        } else {
-            performSearch(genreName, global = false)
-        }
-    }
-
-    private fun openCoverDialog() {
-        val mangaId = presenter.manga?.id ?: return
-        router.pushController(MangaFullCoverDialog(mangaId).withFadeTransaction())
-    }
-
-    /**
-     * Initiates source migration for the specific manga.
-     */
-    private fun migrateManga() {
-        val manga = presenter.manga ?: return
-        val controller = SearchController(manga)
-        controller.targetController = this
-        router.pushController(controller)
-    }
-
-    // Manga info - end
-
-    // Chapters list - start
-
-    private fun continueReading() {
-        val chapter = presenter.getNextUnreadChapter()
-        if (chapter != null) openChapter(chapter)
-    }
-
-    private fun openChapter(chapter: DomainChapter) {
-        activity?.run {
-            startActivity(ReaderActivity.newIntent(this, chapter.mangaId, chapter.id))
-        }
-    }
-
-    fun onFetchChaptersError(error: Throwable) {
-        if (error is NoChaptersException) {
-            activity?.toast(R.string.no_chapters_error)
-        } else {
-            activity?.toast(error.message)
-        }
-    }
-
-    // SELECTION MODE ACTIONS
-
-    private fun onDownloadChapters(
-        items: List<ChapterItem>,
-        action: ChapterDownloadAction,
-    ) {
-        viewScope.launch {
-            when (action) {
-                ChapterDownloadAction.START -> {
-                    downloadChapters(items.map { it.chapter })
-                    if (items.any { it.downloadState == Download.State.ERROR }) {
-                        DownloadService.start(activity!!)
-                    }
-                }
-                ChapterDownloadAction.START_NOW -> {
-                    downloadChapters(items.map { it.chapter }, startNow = true)
-                }
-                ChapterDownloadAction.CANCEL -> {
-                    val chapterId = items.singleOrNull()?.chapter?.id ?: return@launch
-                    presenter.cancelDownload(chapterId)
-                }
-                ChapterDownloadAction.DELETE -> {
-                    deleteChapters(items.map { it.chapter })
-                }
-            }
-        }
-    }
-
-    private suspend fun downloadChapters(chapters: List<DomainChapter>, startNow: Boolean = false) {
-        if (startNow) {
-            val chapterId = chapters.singleOrNull()?.id ?: return
-            presenter.startDownloadingNow(chapterId)
-        } else {
-            presenter.downloadChapters(chapters)
-        }
-
-        if (!presenter.isFavoritedManga) {
-            val result = snackbarHostState.showSnackbar(
-                message = activity!!.getString(R.string.snack_add_to_library),
-                actionLabel = activity!!.getString(R.string.action_add),
-                withDismissAction = true,
-            )
-            if (result == SnackbarResult.ActionPerformed && !presenter.isFavoritedManga) {
-                onFavoriteClick()
-            }
-        }
-    }
-
-    private fun deleteChapters(chapters: List<DomainChapter>) {
-        if (chapters.isEmpty()) return
-        presenter.deleteChapters(chapters)
-    }
-
-    // OVERFLOW MENU DIALOGS
-
-    private fun runDownloadChapterAction(action: DownloadAction) {
-        val chaptersToDownload = when (action) {
-            DownloadAction.NEXT_1_CHAPTER -> presenter.getUnreadChaptersSorted().take(1)
-            DownloadAction.NEXT_5_CHAPTERS -> presenter.getUnreadChaptersSorted().take(5)
-            DownloadAction.NEXT_10_CHAPTERS -> presenter.getUnreadChaptersSorted().take(10)
-            DownloadAction.CUSTOM -> {
-                presenter.showDownloadCustomDialog()
-                return
-            }
-            DownloadAction.UNREAD_CHAPTERS -> presenter.getUnreadChapters()
-            DownloadAction.ALL_CHAPTERS -> {
-                (presenter.state.value as? MangaScreenState.Success)?.chapters?.map { it.chapter }
-            }
-        }
-        if (!chaptersToDownload.isNullOrEmpty()) {
-            viewScope.launch { downloadChapters(chaptersToDownload) }
-        }
-    }
-
-    // Chapters list - end
-
-    // Tracker sheet - start
-    fun onNextTrackers(trackers: List<TrackItem>) {
-        trackSheet.onNextTrackers(trackers)
-    }
-
-    fun onTrackingRefreshDone() {
-    }
-
-    fun onTrackingRefreshError(error: Throwable) {
-        logcat(LogPriority.ERROR, error)
-        activity?.toast(error.message)
-    }
-
-    fun onTrackingSearchResults(results: List<TrackSearch>) {
-        getTrackingSearchDialog()?.onSearchResults(results)
-    }
-
-    fun onTrackingSearchResultsError(error: Throwable) {
-        logcat(LogPriority.ERROR, error)
-        getTrackingSearchDialog()?.onSearchResultsError(error.message)
-    }
-
-    private fun getTrackingSearchDialog(): TrackSearchDialog? {
-        return trackSheet.getSearchDialog()
-    }
-
-    // Tracker sheet - end
-
     companion object {
         const val FROM_SOURCE_EXTRA = "from_source"
         const val MANGA_EXTRA = "manga"

+ 164 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaCoverScreenModel.kt

@@ -0,0 +1,164 @@
+package eu.kanade.tachiyomi.ui.manga
+
+import android.content.Context
+import android.graphics.drawable.BitmapDrawable
+import android.net.Uri
+import androidx.compose.material3.SnackbarHostState
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.coroutineScope
+import coil.imageLoader
+import coil.request.ImageRequest
+import coil.size.Size
+import eu.kanade.domain.manga.interactor.GetManga
+import eu.kanade.domain.manga.interactor.UpdateManga
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.cache.CoverCache
+import eu.kanade.tachiyomi.data.saver.Image
+import eu.kanade.tachiyomi.data.saver.ImageSaver
+import eu.kanade.tachiyomi.data.saver.Location
+import eu.kanade.tachiyomi.util.editCover
+import eu.kanade.tachiyomi.util.lang.launchIO
+import eu.kanade.tachiyomi.util.lang.withIOContext
+import eu.kanade.tachiyomi.util.lang.withUIContext
+import eu.kanade.tachiyomi.util.system.logcat
+import eu.kanade.tachiyomi.util.system.toShareIntent
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import logcat.LogPriority
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+class MangaCoverScreenModel(
+    private val mangaId: Long,
+    private val getManga: GetManga = Injekt.get(),
+    private val imageSaver: ImageSaver = Injekt.get(),
+    private val coverCache: CoverCache = Injekt.get(),
+    private val updateManga: UpdateManga = Injekt.get(),
+
+    val snackbarHostState: SnackbarHostState = SnackbarHostState(),
+) : StateScreenModel<Manga?>(null) {
+
+    init {
+        coroutineScope.launchIO {
+            getManga.subscribe(mangaId)
+                .collect { newManga -> mutableState.update { newManga } }
+        }
+    }
+
+    fun saveCover(context: Context) {
+        coroutineScope.launch {
+            try {
+                saveCoverInternal(context, temp = false)
+                snackbarHostState.showSnackbar(
+                    context.getString(R.string.cover_saved),
+                    withDismissAction = true,
+                )
+            } catch (e: Throwable) {
+                logcat(LogPriority.ERROR, e)
+                snackbarHostState.showSnackbar(
+                    context.getString(R.string.error_saving_cover),
+                    withDismissAction = true,
+                )
+            }
+        }
+    }
+
+    fun shareCover(context: Context) {
+        coroutineScope.launch {
+            try {
+                val uri = saveCoverInternal(context, temp = true) ?: return@launch
+                withUIContext {
+                    context.startActivity(uri.toShareIntent(context))
+                }
+            } catch (e: Throwable) {
+                logcat(LogPriority.ERROR, e)
+                snackbarHostState.showSnackbar(
+                    context.getString(R.string.error_sharing_cover),
+                    withDismissAction = true,
+                )
+            }
+        }
+    }
+
+    /**
+     * Save manga cover Bitmap to picture or temporary share directory.
+     *
+     * @param context The context for building and executing the ImageRequest
+     * @return the uri to saved file
+     */
+    private suspend fun saveCoverInternal(context: Context, temp: Boolean): Uri? {
+        val manga = state.value ?: return null
+        val req = ImageRequest.Builder(context)
+            .data(manga)
+            .size(Size.ORIGINAL)
+            .build()
+
+        return withIOContext {
+            val result = context.imageLoader.execute(req).drawable
+
+            // TODO: Handle animated cover
+            val bitmap = (result as? BitmapDrawable)?.bitmap ?: return@withIOContext null
+            imageSaver.save(
+                Image.Cover(
+                    bitmap = bitmap,
+                    name = manga.title,
+                    location = if (temp) Location.Cache else Location.Pictures.create(),
+                ),
+            )
+        }
+    }
+
+    /**
+     * Update cover with local file.
+     *
+     * @param context Context.
+     * @param data uri of the cover resource.
+     */
+    fun editCover(context: Context, data: Uri) {
+        val manga = state.value ?: return
+        coroutineScope.launchIO {
+            @Suppress("BlockingMethodInNonBlockingContext")
+            context.contentResolver.openInputStream(data)?.use {
+                try {
+                    manga.editCover(context, it, updateManga, coverCache)
+                    notifyCoverUpdated(context)
+                } catch (e: Exception) {
+                    notifyFailedCoverUpdate(context, e)
+                }
+            }
+        }
+    }
+
+    fun deleteCustomCover(context: Context) {
+        val mangaId = state.value?.id ?: return
+        coroutineScope.launchIO {
+            try {
+                coverCache.deleteCustomCover(mangaId)
+                updateManga.awaitUpdateCoverLastModified(mangaId)
+                notifyCoverUpdated(context)
+            } catch (e: Exception) {
+                notifyFailedCoverUpdate(context, e)
+            }
+        }
+    }
+
+    private fun notifyCoverUpdated(context: Context) {
+        coroutineScope.launch {
+            snackbarHostState.showSnackbar(
+                context.getString(R.string.cover_updated),
+                withDismissAction = true,
+            )
+        }
+    }
+
+    private fun notifyFailedCoverUpdate(context: Context, e: Throwable) {
+        coroutineScope.launch {
+            snackbarHostState.showSnackbar(
+                context.getString(R.string.notification_cover_update_failed),
+                withDismissAction = true,
+            )
+            logcat(LogPriority.ERROR, e)
+        }
+    }
+}

+ 329 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt

@@ -0,0 +1,329 @@
+package eu.kanade.tachiyomi.ui.manga
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.with
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.core.screen.uniqueScreenKey
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.Navigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import cafe.adriel.voyager.transitions.ScreenTransition
+import com.bluelinelabs.conductor.Router
+import eu.kanade.domain.chapter.model.Chapter
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.domain.manga.model.hasCustomCover
+import eu.kanade.presentation.components.AdaptiveSheet
+import eu.kanade.presentation.components.ChangeCategoryDialog
+import eu.kanade.presentation.components.DuplicateMangaDialog
+import eu.kanade.presentation.components.LoadingScreen
+import eu.kanade.presentation.manga.ChapterSettingsDialog
+import eu.kanade.presentation.manga.EditCoverAction
+import eu.kanade.presentation.manga.MangaScreen
+import eu.kanade.presentation.manga.components.DeleteChaptersDialog
+import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog
+import eu.kanade.presentation.manga.components.MangaCoverDialog
+import eu.kanade.presentation.util.LocalNavigatorContentPadding
+import eu.kanade.presentation.util.LocalRouter
+import eu.kanade.presentation.util.isTabletUi
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.source.isLocalOrStub
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.ui.base.controller.pushController
+import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
+import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
+import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
+import eu.kanade.tachiyomi.ui.category.CategoryController
+import eu.kanade.tachiyomi.ui.history.HistoryController
+import eu.kanade.tachiyomi.ui.library.LibraryController
+import eu.kanade.tachiyomi.ui.main.MainActivity
+import eu.kanade.tachiyomi.ui.manga.track.TrackInfoDialogHomeScreen
+import eu.kanade.tachiyomi.ui.reader.ReaderActivity
+import eu.kanade.tachiyomi.ui.updates.UpdatesController
+import eu.kanade.tachiyomi.ui.webview.WebViewActivity
+import eu.kanade.tachiyomi.util.system.toShareIntent
+import eu.kanade.tachiyomi.util.system.toast
+
+class MangaScreen(
+    private val mangaId: Long,
+    private val fromSource: Boolean = false,
+) : Screen {
+
+    override val key = uniqueScreenKey
+
+    @Composable
+    override fun Content() {
+        val navigator = LocalNavigator.currentOrThrow
+        val router = LocalRouter.currentOrThrow
+        val context = LocalContext.current
+        val haptic = LocalHapticFeedback.current
+        val screenModel = rememberScreenModel { MangaInfoScreenModel(context, mangaId, fromSource) }
+
+        val state by screenModel.state.collectAsState()
+
+        if (state is MangaScreenState.Loading) {
+            LoadingScreen()
+            return
+        }
+
+        val successState = state as MangaScreenState.Success
+        val isHttpSource = remember { successState.source is HttpSource }
+
+        MangaScreen(
+            state = successState,
+            snackbarHostState = screenModel.snackbarHostState,
+            isTabletUi = isTabletUi(),
+            onBackClicked = router::popCurrentController,
+            onChapterClicked = { openChapter(context, it) },
+            onDownloadChapter = screenModel::runChapterDownloadActions.takeIf { !successState.source.isLocalOrStub() },
+            onAddToLibraryClicked = {
+                screenModel.toggleFavorite()
+                haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+            },
+            onWebViewClicked = { openMangaInWebView(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource },
+            onTrackingClicked = screenModel::showTrackDialog.takeIf { successState.trackingAvailable },
+            onTagClicked = { performGenreSearch(router, it, screenModel.source!!) },
+            onFilterButtonClicked = screenModel::showSettingsDialog,
+            onRefresh = screenModel::fetchAllFromSource,
+            onContinueReading = { continueReading(context, screenModel.getNextUnreadChapter()) },
+            onSearch = { query, global -> performSearch(router, query, global) },
+            onCoverClicked = screenModel::showCoverDialog,
+            onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource },
+            onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() },
+            onEditCategoryClicked = screenModel::promptChangeCategories.takeIf { successState.manga.favorite },
+            onMigrateClicked = { migrateManga(router, screenModel.manga!!) }.takeIf { successState.manga.favorite },
+            onMultiBookmarkClicked = screenModel::bookmarkChapters,
+            onMultiMarkAsReadClicked = screenModel::markChaptersRead,
+            onMarkPreviousAsReadClicked = screenModel::markPreviousChapterRead,
+            onMultiDeleteClicked = screenModel::showDeleteChapterDialog,
+            onChapterSelected = screenModel::toggleSelection,
+            onAllChapterSelected = screenModel::toggleAllSelection,
+            onInvertSelection = screenModel::invertSelection,
+        )
+
+        val onDismissRequest = { screenModel.dismissDialog() }
+        when (val dialog = (state as? MangaScreenState.Success)?.dialog) {
+            null -> {}
+            is MangaInfoScreenModel.Dialog.ChangeCategory -> {
+                ChangeCategoryDialog(
+                    initialSelection = dialog.initialSelection,
+                    onDismissRequest = onDismissRequest,
+                    onEditCategories = { router.pushController(CategoryController()) },
+                    onConfirm = { include, _ ->
+                        screenModel.moveMangaToCategoriesAndAddToLibrary(dialog.manga, include)
+                    },
+                )
+            }
+            is MangaInfoScreenModel.Dialog.DeleteChapters -> {
+                DeleteChaptersDialog(
+                    onDismissRequest = onDismissRequest,
+                    onConfirm = {
+                        screenModel.toggleAllSelection(false)
+                        screenModel.deleteChapters(dialog.chapters)
+                    },
+                )
+            }
+            is MangaInfoScreenModel.Dialog.DownloadCustomAmount -> {
+                DownloadCustomAmountDialog(
+                    maxAmount = dialog.max,
+                    onDismissRequest = onDismissRequest,
+                    onConfirm = { amount ->
+                        val chaptersToDownload = screenModel.getUnreadChaptersSorted().take(amount)
+                        if (chaptersToDownload.isNotEmpty()) {
+                            screenModel.startDownload(chapters = chaptersToDownload, startNow = false)
+                        }
+                    },
+                )
+            }
+            is MangaInfoScreenModel.Dialog.DuplicateManga -> DuplicateMangaDialog(
+                onDismissRequest = onDismissRequest,
+                onConfirm = { screenModel.toggleFavorite(onRemoved = {}, checkDuplicate = false) },
+                onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
+                duplicateFrom = screenModel.getSourceOrStub(dialog.duplicate),
+            )
+            MangaInfoScreenModel.Dialog.SettingsSheet -> ChapterSettingsDialog(
+                onDismissRequest = onDismissRequest,
+                manga = successState.manga,
+                onDownloadFilterChanged = screenModel::setDownloadedFilter,
+                onUnreadFilterChanged = screenModel::setUnreadFilter,
+                onBookmarkedFilterChanged = screenModel::setBookmarkedFilter,
+                onSortModeChanged = screenModel::setSorting,
+                onDisplayModeChanged = screenModel::setDisplayMode,
+                onSetAsDefault = screenModel::setCurrentSettingsAsDefault,
+            )
+            MangaInfoScreenModel.Dialog.TrackSheet -> {
+                var enableSwipeDismiss by remember { mutableStateOf(true) }
+                AdaptiveSheet(
+                    enableSwipeDismiss = enableSwipeDismiss,
+                    onDismissRequest = onDismissRequest,
+                ) { contentPadding ->
+                    Navigator(
+                        screen = TrackInfoDialogHomeScreen(
+                            mangaId = successState.manga.id,
+                            mangaTitle = successState.manga.title,
+                            sourceId = successState.source.id,
+                        ),
+                        content = {
+                            enableSwipeDismiss = it.lastItem is TrackInfoDialogHomeScreen
+                            CompositionLocalProvider(LocalNavigatorContentPadding provides contentPadding) {
+                                ScreenTransition(
+                                    navigator = it,
+                                    transition = {
+                                        fadeIn(animationSpec = tween(220, delayMillis = 90)) with
+                                            fadeOut(animationSpec = tween(90))
+                                    },
+                                )
+                            }
+                        },
+                    )
+                }
+            }
+            MangaInfoScreenModel.Dialog.FullCover -> {
+                val sm = rememberScreenModel { MangaCoverScreenModel(successState.manga.id) }
+                val manga by sm.state.collectAsState()
+                if (manga != null) {
+                    val getContent = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
+                        if (it == null) return@rememberLauncherForActivityResult
+                        sm.editCover(context, it)
+                    }
+                    MangaCoverDialog(
+                        coverDataProvider = { manga!! },
+                        snackbarHostState = sm.snackbarHostState,
+                        isCustomCover = remember(manga) { manga!!.hasCustomCover() },
+                        onShareClick = { sm.shareCover(context) },
+                        onSaveClick = { sm.saveCover(context) },
+                        onEditClick = {
+                            when (it) {
+                                EditCoverAction.EDIT -> getContent.launch("image/*")
+                                EditCoverAction.DELETE -> sm.deleteCustomCover(context)
+                            }
+                        },
+                        onDismissRequest = onDismissRequest,
+                    )
+                } else {
+                    LoadingScreen(Modifier.systemBarsPadding())
+                }
+            }
+        }
+    }
+
+    private fun continueReading(context: Context, unreadChapter: Chapter?) {
+        if (unreadChapter != null) openChapter(context, unreadChapter)
+    }
+
+    private fun openChapter(context: Context, chapter: Chapter) {
+        context.startActivity(ReaderActivity.newIntent(context, chapter.mangaId, chapter.id))
+    }
+
+    private fun openMangaInWebView(context: Context, manga_: Manga?, source_: Source?) {
+        val manga = manga_ ?: return
+        val source = source_ as? HttpSource ?: return
+
+        val url = try {
+            source.getMangaUrl(manga.toSManga())
+        } catch (e: Exception) {
+            return
+        }
+
+        val intent = WebViewActivity.newIntent(context, url, source.id, manga.title)
+        context.startActivity(intent)
+    }
+
+    private fun shareManga(context: Context, manga_: Manga?, source_: Source?) {
+        val manga = manga_ ?: return
+        val source = source_ as? HttpSource ?: return
+        try {
+            val uri = Uri.parse(source.getMangaUrl(manga.toSManga()))
+            val intent = uri.toShareIntent(context, type = "text/plain")
+            context.startActivity(Intent.createChooser(intent, context.getString(R.string.action_share)))
+        } catch (e: Exception) {
+            context.toast(e.message)
+        }
+    }
+
+    /**
+     * Perform a search using the provided query.
+     *
+     * @param query the search query to the parent controller
+     */
+    private fun performSearch(router: Router, query: String, global: Boolean) {
+        if (global) {
+            router.pushController(GlobalSearchController(query))
+            return
+        }
+
+        if (router.backstackSize < 2) {
+            return
+        }
+
+        when (val previousController = router.backstack[router.backstackSize - 2].controller) {
+            is LibraryController -> {
+                router.handleBack()
+                previousController.search(query)
+            }
+            is UpdatesController,
+            is HistoryController,
+            -> {
+                // Manually navigate to LibraryController
+                router.handleBack()
+                (router.activity as MainActivity).setSelectedNavItem(R.id.nav_library)
+                val controller = router.getControllerWithTag(R.id.nav_library.toString()) as LibraryController
+                controller.search(query)
+            }
+            is BrowseSourceController -> {
+                router.handleBack()
+                previousController.searchWithQuery(query)
+            }
+        }
+    }
+
+    /**
+     * Performs a genre search using the provided genre name.
+     *
+     * @param genreName the search genre to the parent controller
+     */
+    private fun performGenreSearch(router: Router, genreName: String, source: Source) {
+        if (router.backstackSize < 2) {
+            return
+        }
+
+        val previousController = router.backstack[router.backstackSize - 2].controller
+
+        if (previousController is BrowseSourceController &&
+            source is HttpSource
+        ) {
+            router.handleBack()
+            previousController.searchWithGenre(genreName)
+        } else {
+            performSearch(router, genreName, global = false)
+        }
+    }
+
+    /**
+     * Initiates source migration for the specific manga.
+     */
+    private fun migrateManga(router: Router, manga: Manga) {
+        val controller = SearchController(manga)
+        router.pushController(controller)
+    }
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 274 - 292
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt


+ 0 - 298
app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt

@@ -1,298 +0,0 @@
-package eu.kanade.tachiyomi.ui.manga.chapter
-
-import android.content.Context
-import android.os.Bundle
-import android.util.AttributeSet
-import android.view.View
-import androidx.core.view.isVisible
-import com.bluelinelabs.conductor.Router
-import eu.kanade.domain.manga.model.Manga
-import eu.kanade.domain.manga.model.toTriStateGroupState
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.ui.manga.MangaPresenter
-import eu.kanade.tachiyomi.ui.manga.MangaScreenState
-import eu.kanade.tachiyomi.util.view.popupMenu
-import eu.kanade.tachiyomi.widget.ExtendedNavigationView
-import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
-import eu.kanade.tachiyomi.widget.sheet.TabbedBottomSheetDialog
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.MainScope
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.filterIsInstance
-import kotlinx.coroutines.launch
-
-class ChaptersSettingsSheet(
-    private val router: Router,
-    private val presenter: MangaPresenter,
-) : TabbedBottomSheetDialog(router.activity!!) {
-
-    private lateinit var scope: CoroutineScope
-
-    private var manga: Manga? = null
-
-    private val filters = Filter(context)
-    private val sort = Sort(context)
-    private val display = Display(context)
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-
-        binding.menu.isVisible = true
-        binding.menu.setOnClickListener { it.post { showPopupMenu(it) } }
-    }
-
-    override fun onAttachedToWindow() {
-        super.onAttachedToWindow()
-        scope = MainScope()
-        scope.launch {
-            presenter.state
-                .filterIsInstance<MangaScreenState.Success>()
-                .collectLatest {
-                    manga = it.manga
-                    getTabViews().forEach { settings -> (settings as Settings).updateView() }
-                }
-        }
-    }
-
-    override fun onDetachedFromWindow() {
-        super.onDetachedFromWindow()
-        scope.cancel()
-    }
-
-    override fun getTabViews(): List<View> = listOf(
-        filters,
-        sort,
-        display,
-    )
-
-    override fun getTabTitles(): List<Int> = listOf(
-        R.string.action_filter,
-        R.string.action_sort,
-        R.string.action_display,
-    )
-
-    private fun showPopupMenu(view: View) {
-        view.popupMenu(
-            menuRes = R.menu.default_chapter_filter,
-            onMenuItemClick = {
-                when (itemId) {
-                    R.id.set_as_default -> {
-                        SetChapterSettingsDialog(presenter.manga!!).showDialog(router)
-                    }
-                }
-            },
-        )
-    }
-
-    /**
-     * Filters group (unread, downloaded, ...).
-     */
-    inner class Filter @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
-        Settings(context, attrs) {
-
-        private val filterGroup = FilterGroup()
-
-        init {
-            setGroups(listOf(filterGroup))
-        }
-
-        /**
-         * Returns true if there's at least one filter from [FilterGroup] active.
-         */
-        fun hasActiveFilters(): Boolean {
-            return filterGroup.items.any { it.state != State.IGNORE.value }
-        }
-
-        override fun updateView() {
-            filterGroup.updateModels()
-        }
-
-        inner class FilterGroup : Group {
-
-            private val downloaded = Item.TriStateGroup(R.string.action_filter_downloaded, this)
-            private val unread = Item.TriStateGroup(R.string.action_filter_unread, this)
-            private val bookmarked = Item.TriStateGroup(R.string.action_filter_bookmarked, this)
-
-            override val header: Item? = null
-            override val items = listOf(downloaded, unread, bookmarked)
-            override val footer: Item? = null
-
-            override fun initModels() {
-                val manga = manga ?: return
-                if (manga.forceDownloaded()) {
-                    downloaded.state = State.INCLUDE.value
-                    downloaded.enabled = false
-                } else {
-                    downloaded.state = manga.downloadedFilter.toTriStateGroupState().value
-                }
-                unread.state = manga.unreadFilter.toTriStateGroupState().value
-                bookmarked.state = manga.bookmarkedFilter.toTriStateGroupState().value
-            }
-
-            fun updateModels() {
-                initModels()
-                adapter.notifyItemRangeChanged(0, 3)
-            }
-
-            override fun onItemClicked(item: Item) {
-                item as Item.TriStateGroup
-                val newState = when (item.state) {
-                    State.IGNORE.value -> State.INCLUDE
-                    State.INCLUDE.value -> State.EXCLUDE
-                    State.EXCLUDE.value -> State.IGNORE
-                    else -> throw Exception("Unknown State")
-                }
-                when (item) {
-                    downloaded -> presenter.setDownloadedFilter(newState)
-                    unread -> presenter.setUnreadFilter(newState)
-                    bookmarked -> presenter.setBookmarkedFilter(newState)
-                    else -> {}
-                }
-            }
-        }
-    }
-
-    /**
-     * Sorting group (alphabetically, by last read, ...) and ascending or descending.
-     */
-    inner class Sort @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
-        Settings(context, attrs) {
-
-        private val group = SortGroup()
-
-        init {
-            setGroups(listOf(group))
-        }
-
-        override fun updateView() {
-            group.updateModels()
-        }
-
-        inner class SortGroup : Group {
-
-            private val source = Item.MultiSort(R.string.sort_by_source, this)
-            private val chapterNum = Item.MultiSort(R.string.sort_by_number, this)
-            private val uploadDate = Item.MultiSort(R.string.sort_by_upload_date, this)
-
-            override val header: Item? = null
-            override val items = listOf(source, uploadDate, chapterNum)
-            override val footer: Item? = null
-
-            override fun initModels() {
-                val manga = manga ?: return
-                val sorting = manga.sorting
-                val order = if (manga.sortDescending()) {
-                    Item.MultiSort.SORT_DESC
-                } else {
-                    Item.MultiSort.SORT_ASC
-                }
-
-                source.state =
-                    if (sorting == Manga.CHAPTER_SORTING_SOURCE) order else Item.MultiSort.SORT_NONE
-                chapterNum.state =
-                    if (sorting == Manga.CHAPTER_SORTING_NUMBER) order else Item.MultiSort.SORT_NONE
-                uploadDate.state =
-                    if (sorting == Manga.CHAPTER_SORTING_UPLOAD_DATE) order else Item.MultiSort.SORT_NONE
-            }
-
-            fun updateModels() {
-                initModels()
-                adapter.notifyItemRangeChanged(0, 3)
-            }
-
-            override fun onItemClicked(item: Item) {
-                when (item) {
-                    source -> presenter.setSorting(Manga.CHAPTER_SORTING_SOURCE)
-                    chapterNum -> presenter.setSorting(Manga.CHAPTER_SORTING_NUMBER)
-                    uploadDate -> presenter.setSorting(Manga.CHAPTER_SORTING_UPLOAD_DATE)
-                    else -> throw Exception("Unknown sorting")
-                }
-            }
-        }
-    }
-
-    /**
-     * Display group, to show the library as a list or a grid.
-     */
-    inner class Display @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
-        Settings(context, attrs) {
-
-        private val group = DisplayGroup()
-
-        init {
-            setGroups(listOf(group))
-        }
-
-        override fun updateView() {
-            group.updateModels()
-        }
-
-        inner class DisplayGroup : Group {
-
-            private val displayTitle = Item.Radio(R.string.show_title, this)
-            private val displayChapterNum = Item.Radio(R.string.show_chapter_number, this)
-
-            override val header: Item? = null
-            override val items = listOf(displayTitle, displayChapterNum)
-            override val footer: Item? = null
-
-            override fun initModels() {
-                val mode = manga?.displayMode ?: return
-                displayTitle.checked = mode == Manga.CHAPTER_DISPLAY_NAME
-                displayChapterNum.checked = mode == Manga.CHAPTER_DISPLAY_NUMBER
-            }
-
-            fun updateModels() {
-                initModels()
-                adapter.notifyItemRangeChanged(0, 2)
-            }
-
-            override fun onItemClicked(item: Item) {
-                item as Item.Radio
-                if (item.checked) return
-
-                when (item) {
-                    displayTitle -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NAME)
-                    displayChapterNum -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NUMBER)
-                    else -> throw NotImplementedError("Unknown display mode")
-                }
-            }
-        }
-    }
-
-    open inner class Settings(context: Context, attrs: AttributeSet?) :
-        ExtendedNavigationView(context, attrs) {
-
-        lateinit var adapter: Adapter
-
-        /**
-         * Click listener to notify the parent fragment when an item from a group is clicked.
-         */
-        var onGroupClicked: (Group) -> Unit = {}
-
-        fun setGroups(groups: List<Group>) {
-            adapter = Adapter(groups.map { it.createItems() }.flatten())
-            recycler.adapter = adapter
-
-            groups.forEach { it.initModels() }
-            addView(recycler)
-        }
-
-        open fun updateView() {
-        }
-
-        /**
-         * Adapter of the recycler view.
-         */
-        inner class Adapter(items: List<Item>) : ExtendedNavigationView.Adapter(items) {
-
-            override fun onItemClicked(item: Item) {
-                if (item is GroupedItem) {
-                    item.group.onItemClicked(item)
-                    onGroupClicked(item.group)
-                }
-            }
-        }
-    }
-}

+ 0 - 61
app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetChapterSettingsDialog.kt

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

+ 0 - 240
app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaFullCoverDialog.kt

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

+ 0 - 71
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt

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

+ 0 - 71
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt

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

+ 0 - 60
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt

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

+ 0 - 52
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt

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

+ 0 - 139
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt

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

+ 652 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt

@@ -0,0 +1,652 @@
+package eu.kanade.tachiyomi.ui.manga.track
+
+import android.app.Application
+import android.content.Context
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.coroutineScope
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.Navigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import eu.kanade.domain.chapter.interactor.SyncChaptersWithTrackServiceTwoWay
+import eu.kanade.domain.manga.interactor.GetManga
+import eu.kanade.domain.manga.interactor.GetMangaWithChapters
+import eu.kanade.domain.manga.model.toDbManga
+import eu.kanade.domain.track.interactor.DeleteTrack
+import eu.kanade.domain.track.interactor.GetTracks
+import eu.kanade.domain.track.interactor.InsertTrack
+import eu.kanade.domain.track.model.toDbTrack
+import eu.kanade.domain.track.model.toDomainTrack
+import eu.kanade.domain.ui.UiPreferences
+import eu.kanade.presentation.components.AlertDialogContent
+import eu.kanade.presentation.manga.TrackChapterSelector
+import eu.kanade.presentation.manga.TrackDateSelector
+import eu.kanade.presentation.manga.TrackInfoDialogHome
+import eu.kanade.presentation.manga.TrackScoreSelector
+import eu.kanade.presentation.manga.TrackServiceSearch
+import eu.kanade.presentation.manga.TrackStatusSelector
+import eu.kanade.presentation.util.LocalNavigatorContentPadding
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.database.models.Track
+import eu.kanade.tachiyomi.data.track.EnhancedTrackService
+import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.data.track.TrackService
+import eu.kanade.tachiyomi.data.track.model.TrackSearch
+import eu.kanade.tachiyomi.source.SourceManager
+import eu.kanade.tachiyomi.util.lang.launchNonCancellable
+import eu.kanade.tachiyomi.util.lang.withIOContext
+import eu.kanade.tachiyomi.util.lang.withUIContext
+import eu.kanade.tachiyomi.util.system.logcat
+import eu.kanade.tachiyomi.util.system.openInBrowser
+import eu.kanade.tachiyomi.util.system.toast
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import logcat.LogPriority
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.time.Instant
+import java.time.LocalDate
+import java.time.ZoneId
+import java.time.ZoneOffset
+
+data class TrackInfoDialogHomeScreen(
+    private val mangaId: Long,
+    private val mangaTitle: String,
+    private val sourceId: Long,
+) : Screen {
+    @Composable
+    override fun Content() {
+        val navigator = LocalNavigator.currentOrThrow
+        val context = LocalContext.current
+        val sm = rememberScreenModel { Model(mangaId, sourceId) }
+
+        val dateFormat = remember { UiPreferences.dateFormat(Injekt.get<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,
+        )
+    }
+}

+ 0 - 55
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt

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

+ 0 - 194
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt

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

+ 0 - 63
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchHolder.kt

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

+ 0 - 228
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt

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

+ 0 - 13
app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiFullscreenDialog.kt

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

+ 0 - 16
app/src/main/res/layout/track_chapters_dialog.xml

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

+ 0 - 9
app/src/main/res/layout/track_controller.xml

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

+ 0 - 203
app/src/main/res/layout/track_item.xml

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

+ 0 - 16
app/src/main/res/layout/track_score_dialog.xml

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

+ 0 - 104
app/src/main/res/layout/track_search_dialog.xml

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

+ 0 - 150
app/src/main/res/layout/track_search_item.xml

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

+ 0 - 5
app/src/main/res/values/styles.xml

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

+ 3 - 2
gradle/libs.versions.toml

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

+ 3 - 0
i18n/src/main/res/values/strings.xml

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

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است