Compare commits

...

2 Commits

Author SHA1 Message Date
Maddie Witman
72222ad86d New Feature: Introduce Upcoming page to Mihon (#420)
* Work in progress upcoming feature

* Checkpointing WIP upcoming feature

* Functional Upcoming Screen

* Rename UpdateCalendar to UpdateUpcoming

* Converted Strings to resources

* Cleanup

* Fixed detekt issues

* Removed Link icon per @AntsyLich's suggestion.

* Detekt

* Fixed Calendar display on wide form factor devices

* Added Key to upcoming lazycolumn

* Updated tablet mode UI to support two column view

* Updated header creation logic

* Updated header creation logic... again

* Moved stray string to resources

* Fixed PR Comments and query refactor

* Tweaks to query, refactored to flow, comments on calendar

* Switched to Date Formatter

* Cleaned up date formatter

* More Refactor work

* Updated Calendar to support localized week formats

* Fixed year format

* Refactored Header animation

* Moved upcoming FAQ

* Completed YearMonth Migration

* Replaced currentYearMonth with delegate

* Even more cleanup

* cleaned up alignment modifiers

* Click Handler and other refactors

* Removed Wrapped Content Height/Size/extra clips

* Huge Refactor for CalendarDay

* Another cleanup attempt

* Migrated to new mihon.feature.* module pattern

* changed access modifier

* A Bunch of changes from the next round of reviews

* Cleanups

* Cleanup 2

---------

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2024-03-29 01:02:33 +06:00
Andreas
0265c16eb2 Migrator improvements (#588) 2024-03-29 00:36:33 +06:00
32 changed files with 1081 additions and 118 deletions

View File

@@ -237,6 +237,7 @@ dependencies {
implementation(libs.compose.materialmotion)
implementation(libs.swipe)
implementation(libs.compose.webview)
implementation(libs.compose.grid)
// Logging
@@ -254,6 +255,8 @@ dependencies {
// For detecting memory leaks; see https://square.github.io/leakcanary/
// debugImplementation(libs.leakcanary.android)
implementation(libs.leakcanary.plumber)
testImplementation(kotlinx.coroutines.test)
}
androidComponents {

View File

@@ -32,6 +32,7 @@ import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.data.category.CategoryRepositoryImpl
import tachiyomi.data.chapter.ChapterRepositoryImpl
import tachiyomi.data.history.HistoryRepositoryImpl
@@ -117,6 +118,7 @@ class DomainModule : InjektModule {
addFactory { GetMangaByUrlAndSourceId(get()) }
addFactory { GetManga(get()) }
addFactory { GetNextChapters(get(), get(), get()) }
addFactory { GetUpcomingManga(get()) }
addFactory { ResetViewerFlags(get()) }
addFactory { SetMangaChapterFlags(get()) }
addFactory { FetchInterval(get()) }

View File

@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.FlipToBack
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.SelectAll
@@ -47,6 +48,7 @@ fun UpdateScreen(
onClickCover: (UpdatesItem) -> Unit,
onSelectAll: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Boolean,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
@@ -60,6 +62,7 @@ fun UpdateScreen(
Scaffold(
topBar = { scrollBehavior ->
UpdatesAppBar(
onCalendarClicked = { onCalendarClicked() },
onUpdateLibrary = { onUpdateLibrary() },
actionModeCounter = state.selected.size,
onSelectAll = { onSelectAll(true) },
@@ -126,6 +129,7 @@ fun UpdateScreen(
@Composable
private fun UpdatesAppBar(
onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Unit,
// For action mode
actionModeCounter: Int,
@@ -141,6 +145,11 @@ private fun UpdatesAppBar(
actions = {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.action_view_upcoming),
icon = Icons.Outlined.CalendarMonth,
onClick = onCalendarClicked,
),
AppBar.Action(
title = stringResource(MR.strings.action_update_library),
icon = Icons.Outlined.Refresh,

View File

@@ -50,8 +50,12 @@ import kotlinx.coroutines.flow.onEach
import logcat.AndroidLogcatLogger
import logcat.LogPriority
import logcat.LogcatLogger
import mihon.core.migration.Migrator
import mihon.core.migration.migrations.migrations
import org.conscrypt.Conscrypt
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
import tachiyomi.presentation.widget.WidgetManager
@@ -131,6 +135,23 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
if (!LogcatLogger.isInstalled && networkPreferences.verboseLogging().get()) {
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
}
initializeMigrator()
}
private fun initializeMigrator() {
val preferenceStore = Injekt.get<PreferenceStore>()
val preference = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0)
logcat { "Migration from ${preference.get()} to ${BuildConfig.VERSION_CODE}" }
Migrator.initialize(
old = preference.get(),
new = BuildConfig.VERSION_CODE,
migrations = migrations,
onMigrationComplete = {
logcat { "Updating last version to ${BuildConfig.VERSION_CODE}" }
preference.set(BuildConfig.VERSION_CODE)
},
)
}
override fun newImageLoader(context: Context): ImageLoader {

View File

@@ -87,10 +87,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.core.migration.Migrator
import mihon.core.migration.migrations.migrations
import tachiyomi.core.common.Constants
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.library.service.LibraryPreferences
@@ -99,8 +96,6 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import androidx.compose.ui.graphics.Color.Companion as ComposeColor
@@ -129,7 +124,7 @@ class MainActivity : BaseActivity() {
super.onCreate(savedInstanceState)
val didMigration = migrate()
val didMigration = Migrator.awaitAndRelease()
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
if (!isTaskRoot) {
@@ -340,21 +335,6 @@ class MainActivity : BaseActivity() {
}
}
private fun migrate(): Boolean {
val preferenceStore = Injekt.get<PreferenceStore>()
val preference = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0)
logcat { "Migration from ${preference.get()} to ${BuildConfig.VERSION_CODE}" }
return Migrator.migrate(
old = preference.get(),
new = BuildConfig.VERSION_CODE,
migrations = migrations,
onMigrationComplete = {
logcat { "Updating last version to ${BuildConfig.VERSION_CODE}" }
preference.set(BuildConfig.VERSION_CODE)
},
)
}
/**
* Sets custom splash screen exit animation on devices prior to Android 12.
*

View File

@@ -26,6 +26,7 @@ import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.updates.UpdatesScreenModel.Event
import kotlinx.coroutines.flow.collectLatest
import mihon.feature.upcoming.UpcomingScreen
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@@ -72,6 +73,7 @@ object UpdatesTab : Tab {
val intent = ReaderActivity.newIntent(context, it.update.mangaId, it.update.chapterId)
context.startActivity(intent)
},
onCalendarClicked = { navigator.push(UpcomingScreen()) },
)
val onDismissDialog = { screenModel.setDialog(null) }

View File

@@ -39,6 +39,10 @@ fun Long.toLocalDate(): LocalDate {
return LocalDate.ofInstant(Instant.ofEpochMilli(this), ZoneId.systemDefault())
}
fun Instant.toLocalDate(zoneId: ZoneId = ZoneId.systemDefault()): LocalDate {
return LocalDate.ofInstant(this, zoneId)
}
fun LocalDate.toRelativeString(
context: Context,
relative: Boolean = true,
@@ -56,14 +60,12 @@ fun LocalDate.toRelativeString(
difference.toInt().absoluteValue,
difference.toInt().absoluteValue,
)
difference < 1 -> context.stringResource(MR.strings.relative_time_today)
difference < 7 -> context.pluralStringResource(
MR.plurals.relative_time,
difference.toInt(),
difference.toInt(),
)
else -> dateFormat.format(this)
}
}

View File

@@ -0,0 +1,23 @@
package mihon.core.designsystem.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
@Composable
@ReadOnlyComposable
fun isMediumWidthWindow(): Boolean {
val configuration = LocalConfiguration.current
return configuration.screenWidthDp > MediumWidthWindowSize.value
}
@Composable
@ReadOnlyComposable
fun isExpandedWidthWindow(): Boolean {
val configuration = LocalConfiguration.current
return configuration.screenWidthDp > ExpandedWidthWindowSize.value
}
val MediumWidthWindowSize = 600.dp
val ExpandedWidthWindowSize = 840.dp

View File

@@ -5,6 +5,9 @@ interface Migration {
suspend operator fun invoke(migrationContext: MigrationContext): Boolean
val isAlways: Boolean
get() = version == ALWAYS
companion object {
const val ALWAYS = -1f

View File

@@ -0,0 +1,3 @@
package mihon.core.migration
typealias MigrationCompletedListener = () -> Unit

View File

@@ -2,7 +2,7 @@ package mihon.core.migration
import uy.kohesive.injekt.Injekt
class MigrationContext {
class MigrationContext(val dryrun: Boolean) {
inline fun <reified T> get(): T? {
return Injekt.getInstanceOrNull(T::class.java)

View File

@@ -0,0 +1,30 @@
package mihon.core.migration
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import tachiyomi.core.common.util.system.logcat
class MigrationJobFactory(
private val migrationContext: MigrationContext,
private val scope: CoroutineScope
) {
@SuppressWarnings("MaxLineLength")
fun create(migrations: List<Migration>): Deferred<Boolean> = with(scope) {
return migrations.sortedBy { it.version }
.fold(CompletableDeferred(true)) { acc: Deferred<Boolean>, migration: Migration ->
if (!migrationContext.dryrun) {
logcat { "Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" }
async {
val prev = acc.await()
migration(migrationContext) || prev
}
} else {
logcat { "(Dry-run) Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" }
CompletableDeferred(true)
}
}
}
}

View File

@@ -0,0 +1,55 @@
package mihon.core.migration
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.launch
interface MigrationStrategy {
operator fun invoke(migrations: List<Migration>): Deferred<Boolean>
}
class DefaultMigrationStrategy(
private val migrationJobFactory: MigrationJobFactory,
private val migrationCompletedListener: MigrationCompletedListener,
private val scope: CoroutineScope
) : MigrationStrategy {
override operator fun invoke(migrations: List<Migration>): Deferred<Boolean> = with(scope) {
if (migrations.isEmpty()) {
return@with CompletableDeferred(false)
}
val chain = migrationJobFactory.create(migrations)
launch {
if (chain.await()) migrationCompletedListener()
}.start()
chain
}
}
class InitialMigrationStrategy(private val strategy: DefaultMigrationStrategy) : MigrationStrategy {
override operator fun invoke(migrations: List<Migration>): Deferred<Boolean> {
return strategy(migrations.filter { it.isAlways })
}
}
class NoopMigrationStrategy(val state: Boolean) : MigrationStrategy {
override fun invoke(migrations: List<Migration>): Deferred<Boolean> {
return CompletableDeferred(state)
}
}
class VersionRangeMigrationStrategy(
private val versions: IntRange,
private val strategy: DefaultMigrationStrategy
) : MigrationStrategy {
override operator fun invoke(migrations: List<Migration>): Deferred<Boolean> {
return strategy(migrations.filter { it.isAlways || it.version.toInt() in versions })
}
}

View File

@@ -0,0 +1,23 @@
package mihon.core.migration
class MigrationStrategyFactory(
private val factory: MigrationJobFactory,
private val migrationCompletedListener: MigrationCompletedListener,
) {
fun create(old: Int, new: Int): MigrationStrategy {
val versions = (old + 1)..new
val strategy = when {
old == 0 -> InitialMigrationStrategy(
strategy = DefaultMigrationStrategy(factory, migrationCompletedListener, Migrator.scope),
)
old >= new -> NoopMigrationStrategy(false)
else -> VersionRangeMigrationStrategy(
versions = versions,
strategy = DefaultMigrationStrategy(factory, migrationCompletedListener, Migrator.scope),
)
}
return strategy
}
}

View File

@@ -1,53 +1,41 @@
package mihon.core.migration
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.runBlocking
import tachiyomi.core.common.util.system.logcat
object Migrator {
@SuppressWarnings("ReturnCount")
fun migrate(
private var result: Deferred<Boolean>? = null
val scope = CoroutineScope(Dispatchers.Main + Job())
fun initialize(
old: Int,
new: Int,
migrations: List<Migration>,
dryrun: Boolean = false,
onMigrationComplete: () -> Unit
): Boolean {
val migrationContext = MigrationContext()
if (old == 0) {
return migrationContext.migrate(
migrations = migrations.filter { it.isAlways() },
dryrun = dryrun
)
.also { onMigrationComplete() }
}
if (old >= new) {
return false
}
return migrationContext.migrate(
migrations = migrations.filter { it.isAlways() || it.version.toInt() in (old + 1)..new },
dryrun = dryrun
)
.also { onMigrationComplete() }
) {
val migrationContext = MigrationContext(dryrun)
val migrationJobFactory = MigrationJobFactory(migrationContext, scope)
val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, onMigrationComplete)
val strategy = migrationStrategyFactory.create(old, new)
result = strategy(migrations)
}
private fun Migration.isAlways() = version == Migration.ALWAYS
suspend fun await(): Boolean {
val result = result ?: CompletableDeferred(false)
return result.await()
}
@SuppressWarnings("MaxLineLength")
private fun MigrationContext.migrate(migrations: List<Migration>, dryrun: Boolean): Boolean {
return migrations.sortedBy { it.version }
.map { migration ->
if (!dryrun) {
logcat { "Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" }
runBlocking { migration(this@migrate) }
} else {
logcat { "(Dry-run) Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" }
true
}
}
.reduce { acc, b -> acc || b }
fun release() {
result = null
}
fun awaitAndRelease(): Boolean = runBlocking {
await().also { release() }
}
}

View File

@@ -0,0 +1,27 @@
package mihon.feature.upcoming
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
class UpcomingScreen : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { UpcomingScreenModel() }
val state by screenModel.state.collectAsState()
UpcomingScreenContent(
state = state,
setSelectedYearMonth = screenModel::setSelectedYearMonth,
onClickUpcoming = { navigator.push(MangaScreen(it.id)) },
)
}
}

View File

@@ -0,0 +1,198 @@
package mihon.feature.upcoming
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.relativeDateText
import eu.kanade.presentation.util.isTabletUi
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.coroutines.launch
import mihon.feature.upcoming.components.UpcomingItem
import mihon.feature.upcoming.components.calendar.Calendar
import tachiyomi.core.common.Constants
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.ListGroupHeader
import tachiyomi.presentation.core.components.TwoPanelBox
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import java.time.LocalDate
import java.time.YearMonth
@Composable
fun UpcomingScreenContent(
state: UpcomingScreenModel.State,
setSelectedYearMonth: (YearMonth) -> Unit,
onClickUpcoming: (manga: Manga) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val onClickDay: (LocalDate, Int) -> Unit = { date, offset ->
state.headerIndexes[date]?.let {
scope.launch {
listState.animateScrollToItem(it + offset)
}
}
}
Scaffold(
topBar = { UpcomingToolbar() },
modifier = modifier,
) { paddingValues ->
if (isTabletUi()) {
UpcomingScreenLargeImpl(
listState = listState,
items = state.items,
events = state.events,
paddingValues = paddingValues,
selectedYearMonth = state.selectedYearMonth,
setSelectedYearMonth = setSelectedYearMonth,
onClickDay = { onClickDay(it, 0) },
onClickUpcoming = onClickUpcoming,
)
} else {
UpcomingScreenSmallImpl(
listState = listState,
items = state.items,
events = state.events,
paddingValues = paddingValues,
selectedYearMonth = state.selectedYearMonth,
setSelectedYearMonth = setSelectedYearMonth,
onClickDay = { onClickDay(it, 1) },
onClickUpcoming = onClickUpcoming,
)
}
}
}
@Composable
private fun UpcomingToolbar() {
val navigator = LocalNavigator.currentOrThrow
val uriHandler = LocalUriHandler.current
AppBar(
title = stringResource(MR.strings.label_upcoming),
navigateUp = navigator::pop,
actions = {
IconButton(onClick = { uriHandler.openUri(Constants.URL_HELP_UPCOMING) }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.HelpOutline,
contentDescription = stringResource(MR.strings.upcoming_guide),
)
}
},
)
}
@Composable
private fun UpcomingScreenSmallImpl(
listState: LazyListState,
items: ImmutableList<UpcomingUIModel>,
events: ImmutableMap<LocalDate, Int>,
paddingValues: PaddingValues,
selectedYearMonth: YearMonth,
setSelectedYearMonth: (YearMonth) -> Unit,
onClickDay: (LocalDate) -> Unit,
onClickUpcoming: (manga: Manga) -> Unit,
) {
FastScrollLazyColumn(
contentPadding = paddingValues,
state = listState,
) {
item(key = "upcoming-calendar") {
Calendar(
selectedYearMonth = selectedYearMonth,
events = events,
setSelectedYearMonth = setSelectedYearMonth,
onClickDay = onClickDay,
)
}
items(
items = items,
key = { "upcoming-${it.hashCode()}" },
contentType = {
when (it) {
is UpcomingUIModel.Header -> "header"
is UpcomingUIModel.Item -> "item"
}
},
) { item ->
when (item) {
is UpcomingUIModel.Item -> {
UpcomingItem(
upcoming = item.manga,
onClick = { onClickUpcoming(item.manga) },
)
}
is UpcomingUIModel.Header -> {
ListGroupHeader(text = relativeDateText(item.date))
}
}
}
}
}
@Composable
private fun UpcomingScreenLargeImpl(
listState: LazyListState,
items: ImmutableList<UpcomingUIModel>,
events: ImmutableMap<LocalDate, Int>,
paddingValues: PaddingValues,
selectedYearMonth: YearMonth,
setSelectedYearMonth: (YearMonth) -> Unit,
onClickDay: (LocalDate) -> Unit,
onClickUpcoming: (manga: Manga) -> Unit,
) {
TwoPanelBox(
modifier = Modifier.padding(paddingValues),
startContent = {
Calendar(
selectedYearMonth = selectedYearMonth,
events = events,
setSelectedYearMonth = setSelectedYearMonth,
onClickDay = onClickDay,
)
},
endContent = {
FastScrollLazyColumn(state = listState) {
items(
items = items,
key = { "upcoming-${it.hashCode()}" },
contentType = {
when (it) {
is UpcomingUIModel.Header -> "header"
is UpcomingUIModel.Item -> "item"
}
},
) { item ->
when (item) {
is UpcomingUIModel.Item -> {
UpcomingItem(
upcoming = item.manga,
onClick = { onClickUpcoming(item.manga) },
)
}
is UpcomingUIModel.Header -> {
ListGroupHeader(text = relativeDateText(item.date))
}
}
}
}
},
)
}

View File

@@ -0,0 +1,87 @@
package mihon.feature.upcoming
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.util.fastMapIndexedNotNull
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.core.util.insertSeparators
import eu.kanade.tachiyomi.util.lang.toLocalDate
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.LocalDate
import java.time.YearMonth
class UpcomingScreenModel(
private val getUpcomingManga: GetUpcomingManga = Injekt.get(),
) : StateScreenModel<UpcomingScreenModel.State>(State()) {
init {
screenModelScope.launch {
getUpcomingManga.subscribe().collectLatest {
mutableState.update { state ->
val upcomingItems = it.toUpcomingUIModels()
state.copy(
items = upcomingItems,
events = it.toEvents(),
headerIndexes = upcomingItems.getHeaderIndexes(),
)
}
}
}
}
private fun List<Manga>.toUpcomingUIModels(): ImmutableList<UpcomingUIModel> {
return fastMap { UpcomingUIModel.Item(it) }
.insertSeparators { before, after ->
val beforeDate = before?.manga?.expectedNextUpdate?.toLocalDate()
val afterDate = after?.manga?.expectedNextUpdate?.toLocalDate()
if (beforeDate != afterDate && afterDate != null) {
UpcomingUIModel.Header(afterDate)
} else {
null
}
}
.toImmutableList()
}
private fun List<Manga>.toEvents(): ImmutableMap<LocalDate, Int> {
return groupBy { it.expectedNextUpdate?.toLocalDate() ?: LocalDate.MAX }
.mapValues { it.value.size }
.toImmutableMap()
}
private fun List<UpcomingUIModel>.getHeaderIndexes(): ImmutableMap<LocalDate, Int> {
return fastMapIndexedNotNull { index, upcomingUIModel ->
if (upcomingUIModel is UpcomingUIModel.Header) {
upcomingUIModel.date to index
} else {
null
}
}
.toMap()
.toImmutableMap()
}
fun setSelectedYearMonth(yearMonth: YearMonth) {
mutableState.update { it.copy(selectedYearMonth = yearMonth) }
}
data class State(
val selectedYearMonth: YearMonth = YearMonth.now(),
val items: ImmutableList<UpcomingUIModel> = persistentListOf(),
val events: ImmutableMap<LocalDate, Int> = persistentMapOf(),
val headerIndexes: ImmutableMap<LocalDate, Int> = persistentMapOf(),
)
}

View File

@@ -0,0 +1,9 @@
package mihon.feature.upcoming
import tachiyomi.domain.manga.model.Manga
import java.time.LocalDate
sealed interface UpcomingUIModel {
data class Header(val date: LocalDate) : UpcomingUIModel
data class Item(val manga: Manga) : UpcomingUIModel
}

View File

@@ -0,0 +1,54 @@
package mihon.feature.upcoming.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.manga.components.MangaCover
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.asMangaCover
import tachiyomi.presentation.core.components.material.padding
private val UpcomingItemHeight = 96.dp
@Composable
fun UpcomingItem(
upcoming: Manga,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.clickable(onClick = onClick)
.height(UpcomingItemHeight)
.padding(
horizontal = MaterialTheme.padding.medium,
vertical = MaterialTheme.padding.small,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.large),
) {
MangaCover.Book(
modifier = Modifier.fillMaxHeight(),
data = upcoming.asMangaCover(),
)
Text(
modifier = Modifier.weight(1f),
text = upcoming.title,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
}
}

View File

@@ -0,0 +1,112 @@
package mihon.feature.upcoming.components.calendar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastForEach
import io.woong.compose.grid.SimpleGridCells
import io.woong.compose.grid.VerticalGrid
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableList
import mihon.core.designsystem.utils.isExpandedWidthWindow
import mihon.core.designsystem.utils.isMediumWidthWindow
import tachiyomi.presentation.core.components.material.padding
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.TextStyle
import java.time.temporal.WeekFields
import java.util.Locale
private val FontSize = 16.sp
private const val DaysOfWeek = 7
@Composable
fun Calendar(
selectedYearMonth: YearMonth,
events: ImmutableMap<LocalDate, Int>,
setSelectedYearMonth: (YearMonth) -> Unit,
onClickDay: (day: LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
CalenderHeader(
yearMonth = selectedYearMonth,
onPreviousClick = { setSelectedYearMonth(selectedYearMonth.minusMonths(1L)) },
onNextClick = { setSelectedYearMonth(selectedYearMonth.plusMonths(1L)) },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = MaterialTheme.padding.small)
.padding(start = MaterialTheme.padding.medium)
)
CalendarGrid(
selectedYearMonth = selectedYearMonth,
events = events,
onClickDay = onClickDay,
)
}
}
@Composable
private fun CalendarGrid(
selectedYearMonth: YearMonth,
events: ImmutableMap<LocalDate, Int>,
onClickDay: (day: LocalDate) -> Unit,
) {
val localeFirstDayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek.value
val weekDays = remember {
(0 until DaysOfWeek)
.map { DayOfWeek.of((localeFirstDayOfWeek - 1 + it) % DaysOfWeek + 1) }
.toImmutableList()
}
val emptyFieldCount = weekDays.indexOf(selectedYearMonth.atDay(1).dayOfWeek)
val daysInMonth = selectedYearMonth.lengthOfMonth()
VerticalGrid(
columns = SimpleGridCells.Fixed(DaysOfWeek),
modifier = if (isMediumWidthWindow() && !isExpandedWidthWindow()) {
Modifier.widthIn(max = 360.dp)
} else {
Modifier
}
) {
weekDays.fastForEach { item ->
Text(
text = item.getDisplayName(
TextStyle.NARROW,
Locale.getDefault(),
),
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold,
fontSize = FontSize,
)
}
repeat(emptyFieldCount) { Box { } }
repeat(daysInMonth) { dayIndex ->
val localDate = selectedYearMonth.atDay(dayIndex + 1)
CalendarDay(
date = localDate,
onDayClick = { onClickDay(localDate) },
events = events[localDate] ?: 0,
)
}
}
}

View File

@@ -0,0 +1,92 @@
package mihon.feature.upcoming.components.calendar
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.layout.layout
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.time.LocalDate
private const val MaxEvents = 3
@Composable
fun CalendarDay(
date: LocalDate,
events: Int,
onDayClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val today = remember { LocalDate.now() }
Box(
modifier = modifier
.then(
if (today == date) {
Modifier.border(
border = BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.onBackground
),
shape = CircleShape,
)
} else {
Modifier
},
)
.clip(shape = CircleShape)
.clickable(onClick = onDayClick)
.circleLayout(),
contentAlignment = Alignment.Center,
) {
Text(
text = date.dayOfMonth.toString(),
textAlign = TextAlign.Center,
fontSize = 16.sp,
color = if (date.isBefore(today)) {
MaterialTheme.colorScheme.onBackground.copy(alpha = 0.38f)
} else {
MaterialTheme.colorScheme.onBackground
},
fontWeight = FontWeight.SemiBold,
)
Row(Modifier.offset(y = 12.dp)) {
val size = events.coerceAtMost(MaxEvents)
for (index in 0 until size) {
CalendarIndicator(
index = index,
size = 56.dp,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
private fun Modifier.circleLayout() = layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val currentHeight = placeable.height
val currentWidth = placeable.width
val newDiameter = maxOf(currentHeight, currentWidth)
layout(newDiameter, newDiameter) {
placeable.placeRelative(
x = (newDiameter - currentWidth) / 2,
y = (newDiameter - currentHeight) / 2,
)
}
}

View File

@@ -0,0 +1,100 @@
package mihon.feature.upcoming.components.calendar
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun CalenderHeader(
yearMonth: YearMonth,
onPreviousClick: () -> Unit,
onNextClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedContent(
targetState = yearMonth,
transitionSpec = { getAnimation() },
label = "Change Month",
) { monthYear ->
Text(
text = getTitleText(monthYear),
style = MaterialTheme.typography.titleLarge,
)
}
Row {
IconButton(onClick = onPreviousClick) {
Icon(Icons.Default.KeyboardArrowLeft, stringResource(MR.strings.upcoming_calendar_prev))
}
IconButton(onClick = onNextClick) {
Icon(Icons.Default.KeyboardArrowRight, stringResource(MR.strings.upcoming_calendar_next))
}
}
}
}
private const val MonthYearChangeAnimationDuration = 200
private fun AnimatedContentTransitionScope<YearMonth>.getAnimation(): ContentTransform {
val movingForward = targetState > initialState
val enterTransition = slideInVertically(
animationSpec = tween(durationMillis = MonthYearChangeAnimationDuration),
) { height -> if (movingForward) height else -height } + fadeIn(
animationSpec = tween(durationMillis = MonthYearChangeAnimationDuration),
)
val exitTransition = slideOutVertically(
animationSpec = tween(durationMillis = MonthYearChangeAnimationDuration),
) { height -> if (movingForward) -height else height } + fadeOut(
animationSpec = tween(durationMillis = MonthYearChangeAnimationDuration),
)
return (enterTransition togetherWith exitTransition)
.using(SizeTransform(clip = false))
}
@Composable
@ReadOnlyComposable
private fun getTitleText(monthYear: YearMonth): String {
val formatter = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.getDefault())
return formatter.format(monthYear)
}
@Preview
@Composable
private fun CalenderHeaderPreview() {
CalenderHeader(
yearMonth = YearMonth.now(),
onNextClick = {},
onPreviousClick = {},
)
}

View File

@@ -0,0 +1,32 @@
package mihon.feature.upcoming.components.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
private const val IndicatorScale = 12
private const val IndicatorAlphaMultiplier = 0.3f
@Composable
fun CalendarIndicator(
index: Int,
size: Dp,
color: Color,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.padding(horizontal = 1.dp)
.clip(shape = CircleShape)
.background(color = color.copy(alpha = (index + 1) * IndicatorAlphaMultiplier))
.size(size = size.div(IndicatorScale)),
)
}

View File

@@ -1,59 +1,97 @@
package mihon.core.migration
import io.mockk.Called
import io.mockk.slot
import io.mockk.spyk
import io.mockk.verify
import org.junit.jupiter.api.Assertions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class MigratorTest {
@Test
fun initialVersion() {
val onMigrationComplete: () -> Unit = {}
val onMigrationCompleteSpy = spyk(onMigrationComplete)
val didMigration = Migrator.migrate(
old = 0,
new = 1,
migrations = listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { false }),
onMigrationComplete = onMigrationCompleteSpy
)
verify { onMigrationCompleteSpy() }
Assertions.assertTrue(didMigration)
lateinit var migrationCompletedListener: MigrationCompletedListener
lateinit var migrationContext: MigrationContext
lateinit var migrationJobFactory: MigrationJobFactory
lateinit var migrationStrategyFactory: MigrationStrategyFactory
@BeforeEach
fun initilize() {
migrationContext = MigrationContext(false)
migrationJobFactory = spyk(MigrationJobFactory(migrationContext, CoroutineScope(Dispatchers.Main + Job())))
migrationCompletedListener = spyk<() -> Unit>({})
migrationStrategyFactory = spyk(MigrationStrategyFactory(migrationJobFactory, migrationCompletedListener))
}
@Test
fun sameVersion() {
val onMigrationComplete: () -> Unit = {}
val onMigrationCompleteSpy = spyk(onMigrationComplete)
val didMigration = Migrator.migrate(
old = 1,
new = 1,
migrations = listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { true }),
onMigrationComplete = onMigrationCompleteSpy
)
verify { onMigrationCompleteSpy wasNot Called }
Assertions.assertFalse(didMigration)
fun initialVersion() = runBlocking {
val strategy = migrationStrategyFactory.create(0, 1)
assertInstanceOf(InitialMigrationStrategy::class.java, strategy)
val migrations = slot<List<Migration>>()
val execute = strategy(listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { false }))
execute.await()
verify { migrationJobFactory.create(capture(migrations)) }
assertEquals(1, migrations.captured.size)
verify { migrationCompletedListener() }
}
@Test
fun smallMigration() {
val onMigrationComplete: () -> Unit = {}
val onMigrationCompleteSpy = spyk(onMigrationComplete)
val didMigration = Migrator.migrate(
old = 1,
new = 2,
migrations = listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { true }),
onMigrationComplete = onMigrationCompleteSpy
)
verify { onMigrationCompleteSpy() }
Assertions.assertTrue(didMigration)
fun sameVersion() = runBlocking {
val strategy = migrationStrategyFactory.create(1, 1)
assertInstanceOf(NoopMigrationStrategy::class.java, strategy)
val execute = strategy(listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { false }))
val result = execute.await()
assertFalse(result)
verify { migrationJobFactory.create(any()) wasNot Called }
}
@Test
fun largeMigration() {
val onMigrationComplete: () -> Unit = {}
val onMigrationCompleteSpy = spyk(onMigrationComplete)
fun noMigrations() = runBlocking {
val strategy = migrationStrategyFactory.create(1, 2)
assertInstanceOf(VersionRangeMigrationStrategy::class.java, strategy)
val execute = strategy(emptyList())
val result = execute.await()
assertFalse(result)
verify { migrationJobFactory.create(any()) wasNot Called }
}
@Test
fun smallMigration() = runBlocking {
val strategy = migrationStrategyFactory.create(1, 2)
assertInstanceOf(VersionRangeMigrationStrategy::class.java, strategy)
val migrations = slot<List<Migration>>()
val execute = strategy(listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { true }))
execute.await()
verify { migrationJobFactory.create(capture(migrations)) }
assertEquals(2, migrations.captured.size)
verify { migrationCompletedListener() }
}
@Test
fun largeMigration() = runBlocking {
val input = listOf(
Migration.of(Migration.ALWAYS) { true },
Migration.of(2f) { true },
@@ -66,31 +104,56 @@ class MigratorTest {
Migration.of(9f) { true },
Migration.of(10f) { true },
)
val didMigration = Migrator.migrate(
old = 1,
new = 10,
migrations = input,
onMigrationComplete = onMigrationCompleteSpy
)
verify { onMigrationCompleteSpy() }
Assertions.assertTrue(didMigration)
val strategy = migrationStrategyFactory.create(1, 10)
assertInstanceOf(VersionRangeMigrationStrategy::class.java, strategy)
val migrations = slot<List<Migration>>()
val execute = strategy(input)
execute.await()
verify { migrationJobFactory.create(capture(migrations)) }
assertEquals(10, migrations.captured.size)
verify { migrationCompletedListener() }
}
@Test
fun withinRangeMigration() {
val onMigrationComplete: () -> Unit = {}
val onMigrationCompleteSpy = spyk(onMigrationComplete)
val didMigration = Migrator.migrate(
old = 1,
new = 2,
migrations = listOf(
fun withinRangeMigration() = runBlocking {
val strategy = migrationStrategyFactory.create(1, 2)
assertInstanceOf(VersionRangeMigrationStrategy::class.java, strategy)
val migrations = slot<List<Migration>>()
val execute = strategy(
listOf(
Migration.of(Migration.ALWAYS) { true },
Migration.of(2f) { true },
Migration.of(3f) { false }
),
onMigrationComplete = onMigrationCompleteSpy
)
)
verify { onMigrationCompleteSpy() }
Assertions.assertTrue(didMigration)
execute.await()
verify { migrationJobFactory.create(capture(migrations)) }
assertEquals(2, migrations.captured.size)
verify { migrationCompletedListener() }
}
companion object {
val mainThreadSurrogate = newSingleThreadContext("UI thread")
@BeforeAll
@JvmStatic
fun setUp() {
Dispatchers.setMain(mainThreadSurrogate)
}
@AfterAll
@JvmStatic
fun tearDown() {
Dispatchers.resetMain() // reset the main dispatcher to the original Main dispatcher
mainThreadSurrogate.close()
}
}
}

View File

@@ -2,6 +2,7 @@ package tachiyomi.core.common
object Constants {
const val URL_HELP = "https://mihon.app/docs/guides/troubleshooting/"
const val URL_HELP_UPCOMING = "https://mihon.app/docs/faq/updates/upcoming"
const val MANGA_EXTRA = "manga"

View File

@@ -65,6 +65,12 @@ class MangaRepositoryImpl(
}
}
override suspend fun getUpcomingManga(statuses: Set<Long>): Flow<List<Manga>> {
return handler.subscribeToList {
mangasQueries.getUpcomingManga(statuses, MangaMapper::mapManga)
}
}
override suspend fun resetViewerFlags(): Boolean {
return try {
handler.await { mangasQueries.resetViewerFlags() }

View File

@@ -112,6 +112,14 @@ WHERE favorite = 1
AND LOWER(title) = :title
AND _id != :id;
getUpcomingManga:
SELECT *
FROM mangas
WHERE next_update > 0
AND favorite = 1
AND status IN :statuses
ORDER BY next_update ASC;
resetViewerFlags:
UPDATE mangas
SET viewer = 0;

View File

@@ -0,0 +1,20 @@
package mihon.domain.upcoming.interactor
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.coroutines.flow.Flow
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.repository.MangaRepository
class GetUpcomingManga(
private val mangaRepository: MangaRepository,
) {
private val includedStatuses = setOf(
SManga.ONGOING.toLong(),
SManga.PUBLISHING_FINISHED.toLong(),
)
suspend fun subscribe(): Flow<List<Manga>> {
return mangaRepository.getUpcomingManga(includedStatuses)
}
}

View File

@@ -25,6 +25,8 @@ interface MangaRepository {
suspend fun getDuplicateLibraryManga(id: Long, title: String): List<Manga>
suspend fun getUpcomingManga(statuses: Set<Long>): Flow<List<Manga>>
suspend fun resetViewerFlags(): Boolean
suspend fun setMangaCategories(mangaId: Long, categoryIds: List<Long>)

View File

@@ -64,6 +64,7 @@ directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
compose-materialmotion = "io.github.fornewid:material-motion-compose-core:1.2.0"
compose-webview = "io.github.kevinnzou:compose-webview:0.33.4"
compose-grid = "io.woong.compose.grid:grid:1.2.2"
swipe = "me.saket.swipe:swipe:1.3.0"

View File

@@ -26,6 +26,7 @@
<string name="label_download_queue">Download queue</string>
<string name="label_library">Library</string>
<string name="label_recent_updates">Updates</string>
<string name="label_upcoming">Upcoming</string>
<string name="label_recent_manga">History</string>
<string name="label_sources">Sources</string>
<string name="label_backup">Backup and restore</string>
@@ -789,6 +790,12 @@
<string name="updates_last_update_info">Library last updated: %s</string>
<string name="updates_last_update_info_just_now">Just now</string>
<string name="relative_time_span_never">Never</string>
<string name="action_view_upcoming">View Upcoming Updates</string>
<!-- Upcoming -->
<string name="upcoming_guide">Upcoming Guide</string>
<string name="upcoming_calendar_next">Next Month</string>
<string name="upcoming_calendar_prev">Previous Month</string>
<!-- History -->
<string name="recent_manga_time">Ch. %1$s - %2$s</string>