From 1d2ab6f1431e50bc10fb501493d5bb57959c6481 Mon Sep 17 00:00:00 2001 From: DatLag Date: Tue, 14 May 2024 16:31:20 +0200 Subject: [PATCH] prepare edit dialog --- .../commonMain/graphql/AiringQuery.graphql | 16 +- .../graphql/MediaListEntryQuery.graphql | 6 - .../commonMain/graphql/MediumQuery.graphql | 16 +- .../commonMain/graphql/SeasonQuery.graphql | 16 +- .../commonMain/graphql/TrendingQuery.graphql | 16 +- .../aniflow/anilist/common/ExtendDateTime.kt | 76 ++++- .../datlag/aniflow/anilist/model/Medium.kt | 40 ++- composeApp/build.gradle.kts | 1 + .../dev/datlag/aniflow/common/ExtendMedium.kt | 2 +- .../screen/home/component/DefaultOverview.kt | 3 +- .../screen/home/component/ScheduleOverview.kt | 3 +- .../screen/medium/MediumScreenComponent.kt | 21 +- .../medium/dialog/edit/EditComponent.kt | 9 +- .../screen/medium/dialog/edit/EditDialog.kt | 177 ++++++++++-- .../medium/dialog/edit/EditDialogComponent.kt | 7 + .../screen/medium/dialog/edit/EditState.kt | 260 ++++++++++++++++++ 16 files changed, 596 insertions(+), 73 deletions(-) delete mode 100644 anilist/src/commonMain/graphql/MediaListEntryQuery.graphql create mode 100644 composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditState.kt diff --git a/anilist/src/commonMain/graphql/AiringQuery.graphql b/anilist/src/commonMain/graphql/AiringQuery.graphql index 95489b1..b2e19cc 100644 --- a/anilist/src/commonMain/graphql/AiringQuery.graphql +++ b/anilist/src/commonMain/graphql/AiringQuery.graphql @@ -80,7 +80,14 @@ query AiringQuery( }, mediaListEntry { score(format: POINT_5), - status + status, + progress, + repeat, + startedAt { + year, + month, + day + } }, trailer { id, @@ -89,7 +96,12 @@ query AiringQuery( }, siteUrl, chapters, - volumes + volumes, + startDate { + year, + month, + day + } } } } diff --git a/anilist/src/commonMain/graphql/MediaListEntryQuery.graphql b/anilist/src/commonMain/graphql/MediaListEntryQuery.graphql deleted file mode 100644 index 893e7d6..0000000 --- a/anilist/src/commonMain/graphql/MediaListEntryQuery.graphql +++ /dev/null @@ -1,6 +0,0 @@ -query MediaListEntryQuery($id: Int) { - MediaList(mediaId: $id) { - score(format: POINT_5), - status - } -} \ No newline at end of file diff --git a/anilist/src/commonMain/graphql/MediumQuery.graphql b/anilist/src/commonMain/graphql/MediumQuery.graphql index 17b0d17..a3f8742 100644 --- a/anilist/src/commonMain/graphql/MediumQuery.graphql +++ b/anilist/src/commonMain/graphql/MediumQuery.graphql @@ -69,7 +69,14 @@ query MediumQuery($id: Int, $statusVersion: Int, $html: Boolean) { }, mediaListEntry { score(format: POINT_5), - status + status, + progress, + repeat, + startedAt { + year, + month, + day + } }, trailer { id, @@ -78,6 +85,11 @@ query MediumQuery($id: Int, $statusVersion: Int, $html: Boolean) { }, siteUrl, chapters, - volumes + volumes, + startDate { + year, + month, + day + } } } \ No newline at end of file diff --git a/anilist/src/commonMain/graphql/SeasonQuery.graphql b/anilist/src/commonMain/graphql/SeasonQuery.graphql index 133ae5c..26c65d7 100644 --- a/anilist/src/commonMain/graphql/SeasonQuery.graphql +++ b/anilist/src/commonMain/graphql/SeasonQuery.graphql @@ -81,7 +81,14 @@ query SeasonQuery( }, mediaListEntry { score(format: POINT_5), - status + status, + progress, + repeat, + startedAt { + year, + month, + day + } }, trailer { id, @@ -90,7 +97,12 @@ query SeasonQuery( }, siteUrl, chapters, - volumes + volumes, + startDate { + year, + month, + day + } } } } \ No newline at end of file diff --git a/anilist/src/commonMain/graphql/TrendingQuery.graphql b/anilist/src/commonMain/graphql/TrendingQuery.graphql index 5fe393a..b825002 100644 --- a/anilist/src/commonMain/graphql/TrendingQuery.graphql +++ b/anilist/src/commonMain/graphql/TrendingQuery.graphql @@ -84,7 +84,14 @@ query TrendingQuery( }, mediaListEntry { score(format: POINT_5), - status + status, + progress, + repeat, + startedAt { + year, + month, + day + } }, trailer { id, @@ -93,7 +100,12 @@ query TrendingQuery( }, siteUrl, chapters, - volumes + volumes, + startDate { + year, + month, + day + } } } } \ No newline at end of file diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/common/ExtendDateTime.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/common/ExtendDateTime.kt index df0f3d4..868296f 100644 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/common/ExtendDateTime.kt +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/common/ExtendDateTime.kt @@ -1,10 +1,12 @@ package dev.datlag.aniflow.anilist.common +import dev.datlag.aniflow.anilist.AiringQuery +import dev.datlag.aniflow.anilist.MediumQuery +import dev.datlag.aniflow.anilist.SeasonQuery +import dev.datlag.aniflow.anilist.TrendingQuery +import dev.datlag.aniflow.anilist.type.FuzzyDate import dev.datlag.aniflow.anilist.type.MediaSeason -import kotlinx.datetime.Instant -import kotlinx.datetime.Month -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.* internal val Month.season: MediaSeason get() { @@ -27,4 +29,68 @@ internal val Instant.nextSeason: Pair get() { val date = this.toLocalDateTime(TimeZone.currentSystemDefault()) return date.month.season.next(date.date) - } \ No newline at end of file + } + +internal fun TrendingQuery.StartDate.toLocalDate(): LocalDate? { + return LocalDate( + year = year ?: return null, + monthNumber = month ?: return null, + dayOfMonth = day ?: 1 + ) +} + +internal fun AiringQuery.StartDate.toLocalDate(): LocalDate? { + return LocalDate( + year = year ?: return null, + monthNumber = month ?: return null, + dayOfMonth = day ?: 1 + ) +} + +internal fun SeasonQuery.StartDate.toLocalDate(): LocalDate? { + return LocalDate( + year = year ?: return null, + monthNumber = month ?: return null, + dayOfMonth = day ?: 1 + ) +} + +internal fun MediumQuery.StartDate.toLocalDate(): LocalDate? { + return LocalDate( + year = year ?: return null, + monthNumber = month ?: return null, + dayOfMonth = day ?: 1 + ) +} + +internal fun AiringQuery.StartedAt.toLocalDate(): LocalDate? { + return LocalDate( + year = year ?: return null, + monthNumber = month ?: return null, + dayOfMonth = day ?: 1 + ) +} + +internal fun TrendingQuery.StartedAt.toLocalDate(): LocalDate? { + return LocalDate( + year = year ?: return null, + monthNumber = month ?: return null, + dayOfMonth = day ?: 1 + ) +} + +internal fun MediumQuery.StartedAt.toLocalDate(): LocalDate? { + return LocalDate( + year = year ?: return null, + monthNumber = month ?: return null, + dayOfMonth = day ?: 1 + ) +} + +internal fun SeasonQuery.StartedAt.toLocalDate(): LocalDate? { + return LocalDate( + year = year ?: return null, + monthNumber = month ?: return null, + dayOfMonth = day ?: 1 + ) +} \ No newline at end of file diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/Medium.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/Medium.kt index dfeec35..f04504f 100644 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/Medium.kt +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/Medium.kt @@ -3,11 +3,13 @@ package dev.datlag.aniflow.anilist.model import dev.datlag.aniflow.anilist.* import dev.datlag.aniflow.anilist.AdultContent import dev.datlag.aniflow.anilist.common.lastMonth +import dev.datlag.aniflow.anilist.common.toLocalDate import dev.datlag.aniflow.anilist.type.* import dev.datlag.aniflow.model.ifValue import dev.datlag.aniflow.model.toInt import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate import kotlinx.datetime.Month import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @@ -49,6 +51,7 @@ data class Medium( val siteUrl: String = "$SITE_URL$id", val chapters: Int = -1, val volumes: Int = -1, + val startDate: LocalDate? = null ) { constructor(trending: TrendingQuery.Medium) : this( id = trending.id, @@ -98,7 +101,8 @@ data class Medium( _isFavoriteBlocked = trending.isFavouriteBlocked, siteUrl = trending.siteUrl?.ifBlank { null } ?: "$SITE_URL${trending.id}", chapters = trending.chapters ?: -1, - volumes = trending.volumes ?: -1 + volumes = trending.volumes ?: -1, + startDate = trending.startDate?.toLocalDate() ) constructor(airing: AiringQuery.Media) : this( @@ -149,7 +153,8 @@ data class Medium( _isFavoriteBlocked = airing.isFavouriteBlocked, siteUrl = airing.siteUrl?.ifBlank { null } ?: "$SITE_URL${airing.id}", chapters = airing.chapters ?: -1, - volumes = airing.volumes ?: -1 + volumes = airing.volumes ?: -1, + startDate = airing.startDate?.toLocalDate() ) constructor(season: SeasonQuery.Medium) : this( @@ -200,7 +205,8 @@ data class Medium( _isFavoriteBlocked = season.isFavouriteBlocked, siteUrl = season.siteUrl?.ifBlank { null } ?: "$SITE_URL${season.id}", chapters = season.chapters ?: -1, - volumes = season.volumes ?: -1 + volumes = season.volumes ?: -1, + startDate = season.startDate?.toLocalDate() ) constructor(query: MediumQuery.Media) : this( @@ -251,7 +257,8 @@ data class Medium( _isFavoriteBlocked = query.isFavouriteBlocked, siteUrl = query.siteUrl?.ifBlank { null } ?: "$SITE_URL${query.id}", chapters = query.chapters ?: -1, - volumes = query.volumes ?: -1 + volumes = query.volumes ?: -1, + startDate = query.startDate?.toLocalDate() ) @Transient @@ -387,26 +394,41 @@ data class Medium( @Serializable data class Entry( val score: Double?, - val status: MediaListStatus + val status: MediaListStatus, + val progress: Int?, + val repeatCount: Int?, + val startDate: LocalDate? ) { constructor(entry: MediumQuery.MediaListEntry) : this( score = entry.score, - status = entry.status ?: MediaListStatus.UNKNOWN__ + status = entry.status ?: MediaListStatus.UNKNOWN__, + progress = entry.progress, + repeatCount = entry.repeat, + startDate = entry.startedAt?.toLocalDate() ) constructor(entry: TrendingQuery.MediaListEntry) : this( score = entry.score, - status = entry.status ?: MediaListStatus.UNKNOWN__ + status = entry.status ?: MediaListStatus.UNKNOWN__, + progress = entry.progress, + repeatCount = entry.repeat, + startDate = entry.startedAt?.toLocalDate() ) constructor(entry: AiringQuery.MediaListEntry) : this( score = entry.score, - status = entry.status ?: MediaListStatus.UNKNOWN__ + status = entry.status ?: MediaListStatus.UNKNOWN__, + progress = entry.progress, + repeatCount = entry.repeat, + startDate = entry.startedAt?.toLocalDate() ) constructor(entry: SeasonQuery.MediaListEntry) : this( score = entry.score, - status = entry.status ?: MediaListStatus.UNKNOWN__ + status = entry.status ?: MediaListStatus.UNKNOWN__, + progress = entry.progress, + repeatCount = entry.repeat, + startDate = entry.startedAt?.toLocalDate() ) } diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 908b62d..4219d93 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -115,6 +115,7 @@ kotlin { implementation("dev.datlag.sheets-compose-dialogs:rating:2.0.0-SNAPSHOT") implementation("dev.datlag.sheets-compose-dialogs:option:2.0.0-SNAPSHOT") + implementation("dev.datlag.sheets-compose-dialogs:calendar:2.0.0-SNAPSHOT") implementation(project(":firebase")) implementation(project(":anilist")) diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/common/ExtendMedium.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/common/ExtendMedium.kt index 5ac71a9..ed309d5 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/common/ExtendMedium.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/common/ExtendMedium.kt @@ -160,7 +160,7 @@ fun SearchResponse.Result.AniList.asMedium(): Medium { } fun MediaListStatus.icon() = when (this) { - MediaListStatus.CURRENT -> Icons.Rounded.Edit + MediaListStatus.CURRENT -> Icons.Rounded.PlayArrow MediaListStatus.COMPLETED -> Icons.Rounded.Check MediaListStatus.PAUSED -> Icons.Rounded.Pause MediaListStatus.DROPPED -> Icons.Rounded.Close diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/component/DefaultOverview.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/component/DefaultOverview.kt index 5cd690c..180a279 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/component/DefaultOverview.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/component/DefaultOverview.kt @@ -65,8 +65,7 @@ fun DefaultOverview( titleLanguage = titleLanguage, modifier = Modifier .width(200.dp) - .height(280.dp) - .animateItemPlacement(), + .height(280.dp), onClick = onMediumClick, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/component/ScheduleOverview.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/component/ScheduleOverview.kt index 795c2ad..521263a 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/component/ScheduleOverview.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/component/ScheduleOverview.kt @@ -74,8 +74,7 @@ fun ScheduleOverview( titleLanguage = titleLanguage, modifier = Modifier .height(150.dp) - .fillParentMaxWidth(fraction = 0.9F) - .animateItemPlacement(), + .fillParentMaxWidth(fraction = 0.9F), onClick = onMediumClick ) } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreenComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreenComponent.kt index 56d3656..7b50154 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreenComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreenComponent.kt @@ -23,28 +23,19 @@ import dev.datlag.aniflow.common.* import dev.datlag.aniflow.model.* import dev.datlag.aniflow.other.BurningSeriesResolver import dev.datlag.aniflow.other.Constants -import dev.datlag.aniflow.other.Series import dev.datlag.aniflow.other.UserHelper import dev.datlag.aniflow.settings.Settings -import dev.datlag.aniflow.settings.model.AppSettings import dev.datlag.aniflow.settings.model.CharLanguage import dev.datlag.aniflow.ui.navigation.DialogComponent import dev.datlag.aniflow.ui.navigation.screen.medium.dialog.character.CharacterDialogComponent import dev.datlag.aniflow.ui.navigation.screen.medium.dialog.edit.EditDialogComponent -import dev.datlag.tooling.alsoTrue -import dev.datlag.tooling.async.suspendCatching import dev.datlag.tooling.compose.ioDispatcher -import dev.datlag.tooling.compose.withMainContext import dev.datlag.tooling.decompose.ioScope import dev.datlag.tooling.safeCast -import io.github.aakira.napier.Napier import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant import org.kodein.di.DI import org.kodein.di.instance -import kotlin.time.Duration.Companion.seconds import dev.datlag.aniflow.settings.model.TitleLanguage as SettingsTitle class MediumScreenComponent( @@ -216,6 +207,7 @@ class MediumScreenComponent( ) private val dialogNavigation = SlotNavigation() + @OptIn(ExperimentalCoroutinesApi::class) override val dialog: Value> = childSlot( source = dialogNavigation, serializer = DialogConfig.serializer() @@ -230,6 +222,17 @@ class MediumScreenComponent( is DialogConfig.Edit -> EditDialogComponent( componentContext = context, di = di, + episodes = episodes, + progress = mediumSuccessState.mapLatest { + it.medium.entry?.progress + }, + listStatus = listStatus, + repeatCount = mediumSuccessState.mapLatest { + it.medium.entry?.repeatCount + }, + episodeStartDate = mediumSuccessState.mapLatest { + it.medium.startDate + }, onDismiss = dialogNavigation::dismiss, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditComponent.kt index b832fc0..6566aea 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditComponent.kt @@ -1,9 +1,14 @@ package dev.datlag.aniflow.ui.navigation.screen.medium.dialog.edit -import dev.datlag.aniflow.other.Series +import dev.datlag.aniflow.anilist.type.MediaListStatus import dev.datlag.aniflow.ui.navigation.DialogComponent import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.LocalDate interface EditComponent : DialogComponent { - + val episodes: Flow + val progress: Flow + val listStatus: Flow + val repeatCount: Flow + val episodeStartDate: Flow } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditDialog.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditDialog.kt index fbc1149..1aa8eee 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditDialog.kt @@ -1,38 +1,34 @@ package dev.datlag.aniflow.ui.navigation.screen.medium.dialog.edit -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBackIosNew +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState -import com.maxkeppeler.sheets.rating.RatingView -import com.maxkeppeler.sheets.rating.models.RatingBody -import com.maxkeppeler.sheets.rating.models.RatingConfig -import com.maxkeppeler.sheets.rating.models.RatingSelection +import com.maxkeppeler.sheets.calendar.CalendarDialog +import com.maxkeppeler.sheets.calendar.models.CalendarConfig +import com.maxkeppeler.sheets.calendar.models.CalendarSelection import dev.datlag.aniflow.LocalEdgeToEdge -import dev.datlag.aniflow.SharedRes -import dev.datlag.aniflow.common.isFullyExpandedOrTargeted +import dev.datlag.aniflow.anilist.type.MediaListStatus +import dev.datlag.aniflow.common.icon import dev.datlag.aniflow.common.merge import dev.datlag.aniflow.ui.navigation.screen.medium.dialog.edit.component.TopSection import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource +import io.github.aakira.napier.Napier +import kotlinx.datetime.* +import kotlin.math.max +import kotlin.math.min -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun EditDialog(component: EditComponent) { val sheetState = rememberModalBottomSheetState() @@ -52,6 +48,14 @@ fun EditDialog(component: EditComponent) { windowInsets = insets, sheetState = sheetState ) { + val editState = rememberEditState( + mediumEpisodes = component.episodes, + progress = component.progress, + repeat = component.repeatCount, + listStatus = component.listStatus, + ) + val currentListStatus by editState.listStatus.collectAsStateWithLifecycle() + LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = bottomPadding.merge(PaddingValues(16.dp)), @@ -66,18 +70,133 @@ fun EditDialog(component: EditComponent) { ) } item { - RatingView( - useCaseState = rememberUseCaseState(visible = true), - config = RatingConfig( - ratingZeroValid = true - ), - body = RatingBody.Default( - bodyText = "Rate" - ), - selection = RatingSelection { count, _ -> + Text( + modifier = Modifier.fillParentMaxWidth().padding(top = 32.dp), + text = "Watched Episode", + textAlign = TextAlign.Center, + fontWeight = FontWeight.Medium + ) + } + item { + FlowRow( + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + MediaListStatus.knownEntries.forEach { entry -> + val selected = remember(entry, currentListStatus) { entry == currentListStatus } + IconButton( + onClick = { + editState.setStatus(entry) + }, + enabled = !selected + ) { + Icon( + imageVector = entry.icon(), + contentDescription = null, + tint = if (selected) { + MaterialTheme.colorScheme.primary + } else { + LocalContentColor.current + } + ) + } } - ) + } + } + item { + val currentEpisode by editState.episode.collectAsStateWithLifecycle() + + Row( + modifier = Modifier.fillParentMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + editState.minusEpisode() + }, + enabled = editState.canRemoveEpisode + ) { + Text(text = "-1") + } + OutlinedTextField( + modifier = Modifier.weight(1F), + value = if (currentEpisode <= 0) "" else currentEpisode.toString(), + onValueChange = { + editState.setEpisode(it.toIntOrNull()) + }, + placeholder = { + Text( + modifier = Modifier.fillMaxWidth(), + text = "Episode", + textAlign = TextAlign.Center, + style = LocalTextStyle.current.copy(textAlign = TextAlign.Center) + ) + }, + singleLine = true, + maxLines = 1, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + shape = MaterialTheme.shapes.medium + ) + Button( + onClick = { + editState.plusEpisode() + }, + enabled = editState.canAddEpisode + ) { + Text(text = "+1") + } + } + } + if (currentListStatus == MediaListStatus.REPEATING) { + item { + val currentRepeating by editState.repeat.collectAsStateWithLifecycle() + + Row( + modifier = Modifier.fillParentMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + editState.minusRepeat() + }, + enabled = editState.canRemoveRepeat + ) { + Text(text = "-1") + } + OutlinedTextField( + modifier = Modifier.weight(1F), + value = if (currentRepeating <= 0) "" else currentRepeating.toString(), + onValueChange = { + editState.setRepeat(it.toIntOrNull()) + }, + placeholder = { + Text( + modifier = Modifier.fillMaxWidth(), + text = "Repeat", + textAlign = TextAlign.Center, + style = LocalTextStyle.current.copy(textAlign = TextAlign.Center) + ) + }, + singleLine = true, + maxLines = 1, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + shape = MaterialTheme.shapes.medium + ) + Button( + onClick = { + editState.plusRepeat() + }, + enabled = editState.canAddRepeat + ) { + Text(text = "+1") + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditDialogComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditDialogComponent.kt index a7b69fa..b840528 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditDialogComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditDialogComponent.kt @@ -3,6 +3,7 @@ package dev.datlag.aniflow.ui.navigation.screen.medium.dialog.edit import androidx.compose.runtime.Composable import com.arkivanov.decompose.ComponentContext import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.aniflow.anilist.type.MediaListStatus import dev.datlag.aniflow.common.onRender import dev.datlag.aniflow.other.BurningSeriesResolver import dev.datlag.aniflow.other.Series @@ -11,12 +12,18 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest +import kotlinx.datetime.LocalDate import org.kodein.di.DI import org.kodein.di.instance class EditDialogComponent( componentContext: ComponentContext, override val di: DI, + override val episodes: Flow, + override val progress: Flow, + override val listStatus: Flow, + override val repeatCount: Flow, + override val episodeStartDate: Flow, private val onDismiss: () -> Unit ) : EditComponent, ComponentContext by componentContext { diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditState.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditState.kt new file mode 100644 index 0000000..85776dd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/EditState.kt @@ -0,0 +1,260 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.dialog.edit + +import androidx.compose.runtime.* +import dev.datlag.aniflow.anilist.type.MediaListStatus +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlin.math.max +import kotlin.math.min + +class EditState( + private val initialStatus: MediaListStatus, + private val episodeState: EpisodeState, + private val repeatState: RepeatState, +) { + + val listStatus = MutableStateFlow(initialStatus) + + val episode = episodeState.currentEpisode + val repeat = repeatState.currentCount + + val canRemoveEpisode: Boolean + get() = episodeState.canRemove + + val canAddEpisode: Boolean + get() = episodeState.canAdd + + val canRemoveRepeat: Boolean + get() = repeatState.canRemove + + val canAddRepeat: Boolean + get() = repeatState.canAdd + + init { + if (listStatus.value == MediaListStatus.COMPLETED) { + episodeState.complete() + } + if (episodeState.isCompleted) { + if (listStatus.value != MediaListStatus.COMPLETED && listStatus.value != MediaListStatus.REPEATING) { + listStatus.update { + MediaListStatus.COMPLETED + } + } + } + } + + fun plusEpisode(value: Int = 1) { + episodeState.plus( + value = value, + onNotComplete = { notEmpty -> + updateStatusNotCompleted(notEmpty) + }, + onComplete = { + updateStatusCompleted() + } + ) + } + + fun minusEpisode(value: Int = 1) { + episodeState.minus( + value = value, + onNotComplete = { notEmpty -> + updateStatusNotCompleted(notEmpty) + } + ) + } + + fun plusRepeat(value: Int = 1) { + repeatState.plus(value) { + listStatus.update { + MediaListStatus.REPEATING + } + } + } + + fun minusRepeat(value: Int = 1) { + repeatState.minus(value) + } + + fun setStatus(status: MediaListStatus) { + val updated = listStatus.updateAndGet { status } + + if (updated == MediaListStatus.COMPLETED) { + episodeState.complete() + } + } + + fun setEpisode(value: Int?) { + episodeState.set( + value = value, + onNotComplete = { notEmpty -> + updateStatusNotCompleted(notEmpty) + }, + onComplete = { + updateStatusCompleted() + } + ) + } + + fun setRepeat(value: Int?) { + repeatState.set(value) + } + + private fun updateStatusNotCompleted(notEmpty: Boolean) { + listStatus.update { + if (it == MediaListStatus.REPEATING) { + if (notEmpty) { + it + } else { + MediaListStatus.CURRENT + } + } else { + if (it == MediaListStatus.COMPLETED) { + MediaListStatus.CURRENT + } else { + it + } + } + } + } + + private fun updateStatusCompleted() { + listStatus.update { + if (it != MediaListStatus.COMPLETED && it != MediaListStatus.REPEATING) { + MediaListStatus.COMPLETED + } else { + it + } + } + } + + class EpisodeState( + private val maxEpisodes: Int, + private val progress: Int? + ) { + internal val currentEpisode = MutableStateFlow(min(max(progress ?: 0, 0), maxEpisodes)) + + internal val isCompleted: Boolean + get() = currentEpisode.value == maxEpisodes + + internal val canRemove: Boolean + get() = currentEpisode.value >= 1 + + internal val canAdd: Boolean + get() = currentEpisode.value < maxEpisodes + + internal fun plus( + value: Int = 1, + onNotComplete: (Boolean) -> Unit, + onComplete: () -> Unit + ) { + val updated = currentEpisode.updateAndGet { + min(max(it, 0) + value, maxEpisodes) + } + + if (updated == maxEpisodes) { + onComplete() + } else { + onNotComplete(updated > 0) + } + } + + internal fun minus(value: Int = 1, onNotComplete: (Boolean) -> Unit) { + val updated = currentEpisode.updateAndGet { + min(max(it - value, 0), maxEpisodes) + } + + if (updated < maxEpisodes) { + onNotComplete(updated > 0) + } + } + + internal fun complete() { + currentEpisode.update { + maxEpisodes + } + } + + internal fun set( + value: Int?, + onNotComplete: (Boolean) -> Unit, + onComplete: () -> Unit + ) { + val updated = currentEpisode.updateAndGet { + min(max(value ?: 0, 0), maxEpisodes) + } + + if (updated == maxEpisodes) { + onComplete() + } else { + onNotComplete(updated > 0) + } + } + } + + class RepeatState( + private val count: Int? + ) { + internal var currentCount = MutableStateFlow(max(count ?: 0, 0)) + + internal val canRemove: Boolean + get() = currentCount.value >= 1 + + internal val canAdd: Boolean + get() = true + + internal fun plus(value: Int = 1, onRepeating: () -> Unit) { + val updated = currentCount.updateAndGet { it + 1 } + + if (updated >= 1) { + onRepeating() + } + } + + internal fun minus(value: Int = 1) { + currentCount.update { + max(it - value, 0) + } + } + + internal fun set(value: Int?) { + currentCount.update { + max(value ?: 0, 0) + } + } + } +} + +@Composable +fun rememberEditState( + mediumEpisodes: Flow, + progress: Flow, + repeat: Flow, + listStatus: Flow +): EditState { + val maxEpisodes by mediumEpisodes.collectAsStateWithLifecycle(0) + val prog by progress.collectAsStateWithLifecycle(null) + val episodeState = remember(maxEpisodes, prog) { + EditState.EpisodeState( + maxEpisodes = maxEpisodes, + progress = prog + ) + } + + val repeatCount by repeat.collectAsStateWithLifecycle(null) + val repeatState = remember(repeatCount) { + EditState.RepeatState(repeatCount) + } + + val status by listStatus.collectAsStateWithLifecycle(MediaListStatus.UNKNOWN__) + return remember(status, episodeState, repeatState) { + EditState( + initialStatus = status, + episodeState = episodeState, + repeatState = repeatState, + ) + } +} \ No newline at end of file