From 72222ad86d6fb328d20eead86c6357833d08c061 Mon Sep 17 00:00:00 2001 From: Maddie Witman Date: Thu, 28 Mar 2024 15:02:33 -0400 Subject: [PATCH] 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> --- app/build.gradle.kts | 1 + .../java/eu/kanade/domain/DomainModule.kt | 2 + .../presentation/updates/UpdatesScreen.kt | 9 + .../kanade/tachiyomi/ui/updates/UpdatesTab.kt | 2 + .../tachiyomi/util/lang/DateExtensions.kt | 6 +- .../core/designsystem/utils/WindowSize.kt | 23 ++ .../mihon/feature/upcoming/UpcomingScreen.kt | 27 +++ .../feature/upcoming/UpcomingScreenContent.kt | 198 ++++++++++++++++++ .../feature/upcoming/UpcomingScreenModel.kt | 87 ++++++++ .../mihon/feature/upcoming/UpcomingUIModel.kt | 9 + .../upcoming/components/UpcomingItem.kt | 54 +++++ .../upcoming/components/calendar/Calendar.kt | 112 ++++++++++ .../components/calendar/CalendarDay.kt | 92 ++++++++ .../components/calendar/CalendarHeader.kt | 100 +++++++++ .../components/calendar/CalendarIndicator.kt | 32 +++ .../kotlin/tachiyomi/core/common/Constants.kt | 1 + .../data/manga/MangaRepositoryImpl.kt | 6 + .../main/sqldelight/tachiyomi/data/mangas.sq | 8 + .../upcoming/interactor/GetUpcomingManga.kt | 20 ++ .../manga/repository/MangaRepository.kt | 2 + gradle/libs.versions.toml | 1 + .../commonMain/resources/MR/base/strings.xml | 7 + 22 files changed, 797 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/mihon/core/designsystem/utils/WindowSize.kt create mode 100644 app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt create mode 100644 app/src/main/java/mihon/feature/upcoming/UpcomingScreenContent.kt create mode 100644 app/src/main/java/mihon/feature/upcoming/UpcomingScreenModel.kt create mode 100644 app/src/main/java/mihon/feature/upcoming/UpcomingUIModel.kt create mode 100644 app/src/main/java/mihon/feature/upcoming/components/UpcomingItem.kt create mode 100644 app/src/main/java/mihon/feature/upcoming/components/calendar/Calendar.kt create mode 100644 app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarDay.kt create mode 100644 app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarHeader.kt create mode 100644 app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarIndicator.kt create mode 100644 domain/src/main/java/mihon/domain/upcoming/interactor/GetUpcomingManga.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9edadd5fc..3101b6a0e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -237,6 +237,7 @@ dependencies { implementation(libs.compose.materialmotion) implementation(libs.swipe) implementation(libs.compose.webview) + implementation(libs.compose.grid) // Logging diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index cace3e974..48c183a92 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -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()) } diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt index ac97de83f..fa2bd53fe 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt @@ -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, ChapterDownloadAction) -> Unit, onMultiBookmarkClicked: (List, 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, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt index a9dc4281a..8064d123a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt @@ -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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt index 8326fe2f8..8827ae4a6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt @@ -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) } } diff --git a/app/src/main/java/mihon/core/designsystem/utils/WindowSize.kt b/app/src/main/java/mihon/core/designsystem/utils/WindowSize.kt new file mode 100644 index 000000000..ca80625ca --- /dev/null +++ b/app/src/main/java/mihon/core/designsystem/utils/WindowSize.kt @@ -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 diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt new file mode 100644 index 000000000..981c3d540 --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt @@ -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)) }, + ) + } +} diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingScreenContent.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingScreenContent.kt new file mode 100644 index 000000000..0baa1c8ee --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingScreenContent.kt @@ -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, + events: ImmutableMap, + 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, + events: ImmutableMap, + 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)) + } + } + } + } + }, + ) +} diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingScreenModel.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingScreenModel.kt new file mode 100644 index 000000000..b404a7f96 --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingScreenModel.kt @@ -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(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.toUpcomingUIModels(): ImmutableList { + 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.toEvents(): ImmutableMap { + return groupBy { it.expectedNextUpdate?.toLocalDate() ?: LocalDate.MAX } + .mapValues { it.value.size } + .toImmutableMap() + } + + private fun List.getHeaderIndexes(): ImmutableMap { + 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 = persistentListOf(), + val events: ImmutableMap = persistentMapOf(), + val headerIndexes: ImmutableMap = persistentMapOf(), + ) +} diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingUIModel.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingUIModel.kt new file mode 100644 index 000000000..c394f45f6 --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingUIModel.kt @@ -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 +} diff --git a/app/src/main/java/mihon/feature/upcoming/components/UpcomingItem.kt b/app/src/main/java/mihon/feature/upcoming/components/UpcomingItem.kt new file mode 100644 index 000000000..bf82c73cd --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/components/UpcomingItem.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/mihon/feature/upcoming/components/calendar/Calendar.kt b/app/src/main/java/mihon/feature/upcoming/components/calendar/Calendar.kt new file mode 100644 index 000000000..da511ddad --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/components/calendar/Calendar.kt @@ -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, + 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, + 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, + ) + } + } +} diff --git a/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarDay.kt b/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarDay.kt new file mode 100644 index 000000000..46ed355ab --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarDay.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarHeader.kt b/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarHeader.kt new file mode 100644 index 000000000..55498ebb9 --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarHeader.kt @@ -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.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 = {}, + ) +} diff --git a/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarIndicator.kt b/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarIndicator.kt new file mode 100644 index 000000000..9aaca69de --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarIndicator.kt @@ -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)), + ) +} diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/Constants.kt b/core/common/src/main/kotlin/tachiyomi/core/common/Constants.kt index 649418997..38caeb3dc 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/Constants.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/Constants.kt @@ -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" diff --git a/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt index d7ac06abf..a1fce650f 100644 --- a/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt @@ -65,6 +65,12 @@ class MangaRepositoryImpl( } } + override suspend fun getUpcomingManga(statuses: Set): Flow> { + return handler.subscribeToList { + mangasQueries.getUpcomingManga(statuses, MangaMapper::mapManga) + } + } + override suspend fun resetViewerFlags(): Boolean { return try { handler.await { mangasQueries.resetViewerFlags() } diff --git a/data/src/main/sqldelight/tachiyomi/data/mangas.sq b/data/src/main/sqldelight/tachiyomi/data/mangas.sq index 6bc655a76..1baea800f 100644 --- a/data/src/main/sqldelight/tachiyomi/data/mangas.sq +++ b/data/src/main/sqldelight/tachiyomi/data/mangas.sq @@ -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; diff --git a/domain/src/main/java/mihon/domain/upcoming/interactor/GetUpcomingManga.kt b/domain/src/main/java/mihon/domain/upcoming/interactor/GetUpcomingManga.kt new file mode 100644 index 000000000..dd618b955 --- /dev/null +++ b/domain/src/main/java/mihon/domain/upcoming/interactor/GetUpcomingManga.kt @@ -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> { + return mangaRepository.getUpcomingManga(includedStatuses) + } +} diff --git a/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt b/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt index c460038cd..8c74851f3 100644 --- a/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt @@ -25,6 +25,8 @@ interface MangaRepository { suspend fun getDuplicateLibraryManga(id: Long, title: String): List + suspend fun getUpcomingManga(statuses: Set): Flow> + suspend fun resetViewerFlags(): Boolean suspend fun setMangaCategories(mangaId: Long, categoryIds: List) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index affef4ac6..0bf8d3e11 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index 23ce8a441..bd30e2900 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -26,6 +26,7 @@ Download queue Library Updates + Upcoming History Sources Backup and restore @@ -789,6 +790,12 @@ Library last updated: %s Just now Never + View Upcoming Updates + + + Upcoming Guide + Next Month + Previous Month Ch. %1$s - %2$s