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>
This commit is contained in:
Maddie Witman 2024-03-28 15:02:33 -04:00 committed by GitHub
parent 0265c16eb2
commit 72222ad86d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 797 additions and 2 deletions

View File

@ -237,6 +237,7 @@ dependencies {
implementation(libs.compose.materialmotion) implementation(libs.compose.materialmotion)
implementation(libs.swipe) implementation(libs.swipe)
implementation(libs.compose.webview) implementation(libs.compose.webview)
implementation(libs.compose.grid)
// Logging // Logging

View File

@ -32,6 +32,7 @@ import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService import mihon.domain.extensionrepo.service.ExtensionRepoService
import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.data.category.CategoryRepositoryImpl import tachiyomi.data.category.CategoryRepositoryImpl
import tachiyomi.data.chapter.ChapterRepositoryImpl import tachiyomi.data.chapter.ChapterRepositoryImpl
import tachiyomi.data.history.HistoryRepositoryImpl import tachiyomi.data.history.HistoryRepositoryImpl
@ -117,6 +118,7 @@ class DomainModule : InjektModule {
addFactory { GetMangaByUrlAndSourceId(get()) } addFactory { GetMangaByUrlAndSourceId(get()) }
addFactory { GetManga(get()) } addFactory { GetManga(get()) }
addFactory { GetNextChapters(get(), get(), get()) } addFactory { GetNextChapters(get(), get(), get()) }
addFactory { GetUpcomingManga(get()) }
addFactory { ResetViewerFlags(get()) } addFactory { ResetViewerFlags(get()) }
addFactory { SetMangaChapterFlags(get()) } addFactory { SetMangaChapterFlags(get()) }
addFactory { FetchInterval(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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons 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.FlipToBack
import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.SelectAll import androidx.compose.material.icons.outlined.SelectAll
@ -47,6 +48,7 @@ fun UpdateScreen(
onClickCover: (UpdatesItem) -> Unit, onClickCover: (UpdatesItem) -> Unit,
onSelectAll: (Boolean) -> Unit, onSelectAll: (Boolean) -> Unit,
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Boolean, onUpdateLibrary: () -> Boolean,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit, onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit, onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
@ -60,6 +62,7 @@ fun UpdateScreen(
Scaffold( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
UpdatesAppBar( UpdatesAppBar(
onCalendarClicked = { onCalendarClicked() },
onUpdateLibrary = { onUpdateLibrary() }, onUpdateLibrary = { onUpdateLibrary() },
actionModeCounter = state.selected.size, actionModeCounter = state.selected.size,
onSelectAll = { onSelectAll(true) }, onSelectAll = { onSelectAll(true) },
@ -126,6 +129,7 @@ fun UpdateScreen(
@Composable @Composable
private fun UpdatesAppBar( private fun UpdatesAppBar(
onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Unit, onUpdateLibrary: () -> Unit,
// For action mode // For action mode
actionModeCounter: Int, actionModeCounter: Int,
@ -141,6 +145,11 @@ private fun UpdatesAppBar(
actions = { actions = {
AppBarActions( AppBarActions(
persistentListOf( persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.action_view_upcoming),
icon = Icons.Outlined.CalendarMonth,
onClick = onCalendarClicked,
),
AppBar.Action( AppBar.Action(
title = stringResource(MR.strings.action_update_library), title = stringResource(MR.strings.action_update_library),
icon = Icons.Outlined.Refresh, icon = Icons.Outlined.Refresh,

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

View File

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

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

@ -2,6 +2,7 @@ package tachiyomi.core.common
object Constants { object Constants {
const val URL_HELP = "https://mihon.app/docs/guides/troubleshooting/" 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" 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 { override suspend fun resetViewerFlags(): Boolean {
return try { return try {
handler.await { mangasQueries.resetViewerFlags() } handler.await { mangasQueries.resetViewerFlags() }

View File

@ -112,6 +112,14 @@ WHERE favorite = 1
AND LOWER(title) = :title AND LOWER(title) = :title
AND _id != :id; AND _id != :id;
getUpcomingManga:
SELECT *
FROM mangas
WHERE next_update > 0
AND favorite = 1
AND status IN :statuses
ORDER BY next_update ASC;
resetViewerFlags: resetViewerFlags:
UPDATE mangas UPDATE mangas
SET viewer = 0; 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 getDuplicateLibraryManga(id: Long, title: String): List<Manga>
suspend fun getUpcomingManga(statuses: Set<Long>): Flow<List<Manga>>
suspend fun resetViewerFlags(): Boolean suspend fun resetViewerFlags(): Boolean
suspend fun setMangaCategories(mangaId: Long, categoryIds: List<Long>) 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" insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
compose-materialmotion = "io.github.fornewid:material-motion-compose-core:1.2.0" compose-materialmotion = "io.github.fornewid:material-motion-compose-core:1.2.0"
compose-webview = "io.github.kevinnzou:compose-webview:0.33.4" 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" 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_download_queue">Download queue</string>
<string name="label_library">Library</string> <string name="label_library">Library</string>
<string name="label_recent_updates">Updates</string> <string name="label_recent_updates">Updates</string>
<string name="label_upcoming">Upcoming</string>
<string name="label_recent_manga">History</string> <string name="label_recent_manga">History</string>
<string name="label_sources">Sources</string> <string name="label_sources">Sources</string>
<string name="label_backup">Backup and restore</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">Library last updated: %s</string>
<string name="updates_last_update_info_just_now">Just now</string> <string name="updates_last_update_info_just_now">Just now</string>
<string name="relative_time_span_never">Never</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 --> <!-- History -->
<string name="recent_manga_time">Ch. %1$s - %2$s</string> <string name="recent_manga_time">Ch. %1$s - %2$s</string>