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 7ea5123..a713020 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 @@ -245,9 +245,7 @@ data class Medium( } @Transient - val characters: Set = _characters.filterNot { it.id == 36309 }.sortedByDescending { - it.isFavorite.toInt() - }.toSet() + val characters: Set = _characters.filterNot { it.id == 36309 }.toSet() @Transient val isFavoriteBlocked: Boolean = _isFavoriteBlocked || type == MediaType.UNKNOWN__ diff --git a/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/module/PlatformModule.android.kt b/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/module/PlatformModule.android.kt index ac3fe74..98029e2 100644 --- a/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/module/PlatformModule.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/module/PlatformModule.android.kt @@ -13,9 +13,7 @@ import dev.datlag.aniflow.firebase.initialize import dev.datlag.aniflow.other.BurningSeriesResolver import dev.datlag.aniflow.other.Constants import dev.datlag.aniflow.other.StateSaver -import dev.datlag.aniflow.settings.DataStoreUserSettings -import dev.datlag.aniflow.settings.Settings -import dev.datlag.aniflow.settings.UserSettingsSerializer +import dev.datlag.aniflow.settings.* import io.github.aakira.napier.Napier import io.ktor.client.* import io.ktor.client.engine.okhttp.* @@ -101,9 +99,24 @@ actual object PlatformModule { ) ) } + bindSingleton { + val app: Context = instance() + DataStoreFactory.create( + storage = OkioStorage( + fileSystem = FileSystem.SYSTEM, + serializer = AppSettingsSerializer, + producePath = { + app.filesDir.toOkioPath().resolve("datastore").resolve("app.settings") + } + ) + ) + } bindSingleton { DataStoreUserSettings(instance()) } + bindSingleton { + DataStoreAppSettings(instance()) + } bindSingleton { BurningSeriesResolver(context = instance()) } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/other/StateSaver.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/other/StateSaver.kt index 0082985..6bbd653 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/other/StateSaver.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/other/StateSaver.kt @@ -10,6 +10,9 @@ data object StateSaver { var mediaOverview: Int = 0 var mediaOverviewOffset: Int = 0 + var settingsOverview: Int = 0 + var settingsOverviewOffset: Int = 0 + data object Home { var airingOverview: Int = 0 var airingOverviewOffset: Int = 0 diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/InitialScreenComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/InitialScreenComponent.kt index c921f68..5e2ae6b 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/InitialScreenComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/InitialScreenComponent.kt @@ -2,6 +2,7 @@ package dev.datlag.aniflow.ui.navigation.screen.initial import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ExperimentalDecomposeApi @@ -14,6 +15,7 @@ import dev.datlag.aniflow.common.onRender import dev.datlag.aniflow.ui.navigation.Component import dev.datlag.aniflow.ui.navigation.ContentHolderComponent import dev.datlag.aniflow.ui.navigation.screen.initial.home.HomeScreenComponent +import dev.datlag.aniflow.ui.navigation.screen.initial.settings.SettingsScreenComponent import org.kodein.di.DI class InitialScreenComponent( @@ -26,6 +28,10 @@ class InitialScreenComponent( InitialComponent.PagerItem( label = SharedRes.strings.home, icon = Icons.Default.Home + ), + InitialComponent.PagerItem( + label = SharedRes.strings.settings, + icon = Icons.Default.Settings ) ) @@ -39,7 +45,8 @@ class InitialScreenComponent( initialPages = { Pages( items = listOf( - View.Home + View.Home, + View.Settings ), selectedIndex = 0 ) @@ -67,6 +74,10 @@ class InitialScreenComponent( di = di, onMediumDetails = onMediumDetails ) + is View.Settings -> SettingsScreenComponent( + componentContext = componentContext, + di = di + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/View.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/View.kt index 10a89b8..1d42a81 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/View.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/View.kt @@ -6,4 +6,7 @@ import kotlinx.serialization.Serializable sealed class View { @Serializable data object Home : View() + + @Serializable + data object Settings : View() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/component/AiringOverview.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/component/AiringOverview.kt index e5e87f1..1314c8b 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/component/AiringOverview.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/component/AiringOverview.kt @@ -87,7 +87,10 @@ private fun SuccessContent( items(data, key = { it.episode to it.media?.id }) { media -> AiringCard( airing = media, - modifier = Modifier.height(150.dp).fillParentMaxWidth(fraction = 0.9F), + modifier = Modifier + .height(150.dp) + .fillParentMaxWidth(fraction = 0.9F) + .animateItemPlacement(), onClick = onClick ) } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/component/PopularSeasonOverview.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/component/PopularSeasonOverview.kt index 3f04f58..6d6fcf7 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/component/PopularSeasonOverview.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/component/PopularSeasonOverview.kt @@ -1,5 +1,6 @@ package dev.datlag.aniflow.ui.navigation.screen.initial.home.component +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed @@ -61,6 +62,7 @@ private fun Loading() { } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun SuccessContent( data: List, @@ -89,7 +91,10 @@ private fun SuccessContent( itemsIndexed(data, key = { _, it -> it.id }) { _, medium -> MediumCard( medium = Medium(medium), - modifier = Modifier.width(200.dp).height(280.dp), + modifier = Modifier + .width(200.dp) + .height(280.dp) + .animateItemPlacement(), onClick = onClick ) } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/component/TrendingOverview.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/component/TrendingOverview.kt index a6a18e2..2e05f76 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/component/TrendingOverview.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/component/TrendingOverview.kt @@ -1,5 +1,6 @@ package dev.datlag.aniflow.ui.navigation.screen.initial.home.component +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed @@ -57,6 +58,7 @@ private fun Loading() { } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun SuccessContent( data: List, @@ -76,7 +78,10 @@ private fun SuccessContent( itemsIndexed(data, key = { _, it -> it.id }) { _, medium -> MediumCard( medium = Medium(medium), - modifier = Modifier.width(200.dp).height(280.dp), + modifier = Modifier + .width(200.dp) + .height(280.dp) + .animateItemPlacement(), onClick = onClick ) } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsComponent.kt new file mode 100644 index 0000000..3ac12e6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsComponent.kt @@ -0,0 +1,10 @@ +package dev.datlag.aniflow.ui.navigation.screen.initial.settings + +import dev.datlag.aniflow.ui.navigation.Component +import kotlinx.coroutines.flow.Flow + +interface SettingsComponent : Component { + val adultContent: Flow + + fun changeAdultContent(value: Boolean) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsScreen.kt new file mode 100644 index 0000000..a4baff4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsScreen.kt @@ -0,0 +1,88 @@ +package dev.datlag.aniflow.ui.navigation.screen.initial.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.NoAdultContent +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.haze +import dev.datlag.aniflow.LocalHaze +import dev.datlag.aniflow.LocalPaddingValues +import dev.datlag.aniflow.SharedRes +import dev.datlag.aniflow.common.plus +import dev.datlag.aniflow.other.StateSaver +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun SettingsScreen(component: SettingsComponent) { + val padding = PaddingValues(16.dp) + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = StateSaver.List.settingsOverview, + initialFirstVisibleItemScrollOffset = StateSaver.List.settingsOverviewOffset + ) + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().haze(state = LocalHaze.current), + contentPadding = LocalPaddingValues.current?.plus(padding) ?: padding, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + Text( + text = "Settings", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + item { + val adultContent by component.adultContent.collectAsStateWithLifecycle(false) + + Row( + modifier = Modifier.fillParentMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.NoAdultContent, + contentDescription = null, + ) + Text( + text = stringResource(SharedRes.strings.adult_content_setting), + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.weight(1F)) + Switch( + checked = adultContent, + onCheckedChange = component::changeAdultContent, + thumbContent = { + if (adultContent) { + Icon( + modifier = Modifier.size(SwitchDefaults.IconSize), + imageVector = Icons.Default.Check, + contentDescription = null + ) + } + } + ) + } + } + } + + DisposableEffect(listState) { + onDispose { + StateSaver.List.settingsOverview = listState.firstVisibleItemIndex + StateSaver.List.settingsOverviewOffset = listState.firstVisibleItemScrollOffset + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsScreenComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsScreenComponent.kt new file mode 100644 index 0000000..1afb2b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsScreenComponent.kt @@ -0,0 +1,33 @@ +package dev.datlag.aniflow.ui.navigation.screen.initial.settings + +import androidx.compose.runtime.Composable +import com.arkivanov.decompose.ComponentContext +import dev.datlag.aniflow.common.onRender +import dev.datlag.aniflow.settings.Settings +import dev.datlag.tooling.compose.ioDispatcher +import dev.datlag.tooling.decompose.ioScope +import kotlinx.coroutines.flow.* +import org.kodein.di.DI +import org.kodein.di.instance + +class SettingsScreenComponent( + componentContext: ComponentContext, + override val di: DI +) : SettingsComponent, ComponentContext by componentContext { + + private val appSettings by di.instance() + override val adultContent: Flow = appSettings.adultContent.flowOn(ioDispatcher()) + + @Composable + override fun render() { + onRender { + SettingsScreen(this) + } + } + + override fun changeAdultContent(value: Boolean) { + launchIO { + appSettings.setAdultContent(value) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumComponent.kt index 3b8b7b3..d7e779d 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumComponent.kt @@ -10,12 +10,16 @@ import dev.datlag.aniflow.anilist.type.MediaStatus import dev.datlag.aniflow.ui.navigation.Component import dev.datlag.aniflow.ui.navigation.ContentHolderComponent import dev.datlag.aniflow.ui.navigation.DialogComponent +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow interface MediumComponent : ContentHolderComponent { val initialMedium: Medium val mediumState: StateFlow + val isAdult: StateFlow + val isAdultAllowed: Flow + val bannerImage: StateFlow val coverImage: StateFlow val title: StateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreen.kt index 79b867b..a6725e9 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreen.kt @@ -1,40 +1,21 @@ package dev.datlag.aniflow.ui.navigation.screen.medium -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.animateIntAsState -import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.CutCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max -import coil3.compose.AsyncImage -import coil3.compose.rememberAsyncImagePainter import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.maxkeppeker.sheets.core.models.base.Header import com.maxkeppeker.sheets.core.models.base.IconSource @@ -55,32 +36,16 @@ import dev.datlag.aniflow.anilist.type.MediaStatus import dev.datlag.aniflow.common.* import dev.datlag.aniflow.other.StateSaver import dev.datlag.aniflow.ui.custom.EditFAB -import dev.datlag.aniflow.ui.navigation.screen.initial.home.component.GenreChip import dev.datlag.aniflow.ui.navigation.screen.medium.component.* -import dev.datlag.tooling.compose.ifTrue -import dev.datlag.tooling.compose.onClick 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.coroutines.flow.map -import kotlin.math.max -@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, ExperimentalFoundationApi::class) @Composable fun MediumScreen(component: MediumComponent) { - val appBarState = rememberTopAppBarState() - val scrollState = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( - state = appBarState - ) val coverImage by component.coverImage.collectAsStateWithLifecycle() val ratingState = rememberUseCaseState() val userRating by component.rating.collectAsStateWithLifecycle() val dialogState by component.dialog.subscribeAsState() - val listState = rememberLazyListState( - initialFirstVisibleItemIndex = StateSaver.List.mediaOverview, - initialFirstVisibleItemScrollOffset = StateSaver.List.mediaOverviewOffset - ) dialogState.child?.instance?.render() @@ -105,112 +70,131 @@ fun MediumScreen(component: MediumComponent) { ) ) - Scaffold( - modifier = Modifier.nestedScroll(scrollState.nestedScrollConnection), - topBar = { - CollapsingToolbar( - state = appBarState, - scrollBehavior = scrollState, - mediumStateFlow = component.mediumState, - bannerImageFlow = component.bannerImage, - coverImage = coverImage, - titleFlow = component.title, - isFavoriteFlow = component.isFavorite, - isFavoriteBlockedFlow = component.isFavoriteBlocked, - onBack = { component.back() }, - onToggleFavorite = { component.toggleFavorite() } - ) - }, - floatingActionButton = { - val alreadyAdded by component.alreadyAdded.collectAsStateWithLifecycle() - val notReleased by component.status.mapCollect { - it == MediaStatus.UNKNOWN__ || it == MediaStatus.NOT_YET_RELEASED - } + Box( + modifier = Modifier.fillMaxSize(), + ) { + val appBarState = rememberTopAppBarState() + val scrollState = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + state = appBarState + ) + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = StateSaver.List.mediaOverview, + initialFirstVisibleItemScrollOffset = StateSaver.List.mediaOverviewOffset + ) + + Scaffold( + modifier = Modifier.nestedScroll(scrollState.nestedScrollConnection), + topBar = { + CollapsingToolbar( + state = appBarState, + scrollBehavior = scrollState, + mediumStateFlow = component.mediumState, + bannerImageFlow = component.bannerImage, + coverImage = coverImage, + titleFlow = component.title, + isFavoriteFlow = component.isFavorite, + isFavoriteBlockedFlow = component.isFavoriteBlocked, + onBack = { component.back() }, + onToggleFavorite = { component.toggleFavorite() } + ) + }, + floatingActionButton = { + val alreadyAdded by component.alreadyAdded.collectAsStateWithLifecycle() + val notReleased by component.status.mapCollect { + it == MediaStatus.UNKNOWN__ || it == MediaStatus.NOT_YET_RELEASED + } - if (!notReleased) { - EditFAB( - displayAdd = !alreadyAdded, - bsAvailable = component.bsAvailable, - expanded = listState.isScrollingUp(), - onBS = { + if (!notReleased) { + EditFAB( + displayAdd = !alreadyAdded, + bsAvailable = component.bsAvailable, + expanded = listState.isScrollingUp(), + onBS = { - }, - onRate = { - component.rate { - ratingState.show() + }, + onRate = { + component.rate { + ratingState.show() + } + }, + onProgress = { + // ratingState.show() } - }, - onProgress = { - // ratingState.show() - } - ) + ) + } } - } - ) { - CompositionLocalProvider( - LocalPaddingValues provides LocalPadding().merge(it) ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize().haze(state = LocalHaze.current), - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = LocalPadding(top = 16.dp) + CompositionLocalProvider( + LocalPaddingValues provides LocalPadding().merge(it) ) { - item { - CoverSection( - coverImage = coverImage, - formatFlow = component.format, - episodesFlow = component.episodes, - durationFlow = component.duration, - statusFlow = component.status, - modifier = Modifier.fillParentMaxWidth().padding(horizontal = 16.dp) - ) - } - item { - RatingSection( - ratedFlow = component.rated, - popularFlow = component.popular, - scoreFlow = component.score, - modifier = Modifier.fillParentMaxWidth().padding(16.dp) - ) - } - item { - GenreSection( - genreFlow = component.genres, - modifier = Modifier.fillParentMaxWidth() - ) - } - item { - DescriptionSection( - descriptionFlow = component.description, - translatedDescriptionFlow = component.translatedDescription, - modifier = Modifier.fillParentMaxWidth() - ) { translated -> - component.descriptionTranslation(translated) + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().haze(state = LocalHaze.current), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = LocalPadding(top = 16.dp) + ) { + item { + CoverSection( + coverImage = coverImage, + formatFlow = component.format, + episodesFlow = component.episodes, + durationFlow = component.duration, + statusFlow = component.status, + modifier = Modifier.fillParentMaxWidth().padding(horizontal = 16.dp) + ) } - } - item { - CharacterSection( - characterFlow = component.characters, - modifier = Modifier.fillParentMaxWidth() - ) { char -> - component.showCharacter(char) + item { + RatingSection( + ratedFlow = component.rated, + popularFlow = component.popular, + scoreFlow = component.score, + modifier = Modifier.fillParentMaxWidth().padding(16.dp) + ) + } + item { + GenreSection( + genreFlow = component.genres, + modifier = Modifier.fillParentMaxWidth() + ) + } + item { + DescriptionSection( + descriptionFlow = component.description, + translatedDescriptionFlow = component.translatedDescription, + modifier = Modifier.fillParentMaxWidth() + ) { translated -> + component.descriptionTranslation(translated) + } + } + item { + CharacterSection( + characterFlow = component.characters, + modifier = Modifier.fillParentMaxWidth().animateItemPlacement() + ) { char -> + component.showCharacter(char) + } + } + item { + TrailerSection( + trailerFlow = component.trailer, + modifier = Modifier.fillParentMaxWidth().animateItemPlacement() + ) } - } - item { - TrailerSection( - trailerFlow = component.trailer, - modifier = Modifier.fillParentMaxWidth() - ) } } } - } - DisposableEffect(listState) { - onDispose { - StateSaver.List.mediaOverview = listState.firstVisibleItemIndex - StateSaver.List.mediaOverviewOffset = listState.firstVisibleItemScrollOffset + AdultSection( + isAdultContentFlow = component.isAdult, + isAdultContentAllowedFlow = component.isAdultAllowed, + onBack = component::back + ) + + DisposableEffect(listState) { + onDispose { + StateSaver.List.mediaOverview = listState.firstVisibleItemIndex + StateSaver.List.mediaOverviewOffset = listState.firstVisibleItemScrollOffset + } } } } \ No newline at end of file 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 653139e..5c82fe0 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 @@ -51,6 +51,7 @@ class MediumScreenComponent( private val aniListClient by di.instance(Constants.AniList.APOLLO_CLIENT) private val aniListFallbackClient by di.instance(Constants.AniList.FALLBACK_APOLLO_CLIENT) private val tokenRefreshHandler by di.instance() + private val appSettings by di.instance() private val mediumStateMachine = MediumStateMachine( client = aniListClient, @@ -58,6 +59,7 @@ class MediumScreenComponent( crashlytics = di.nullableFirebaseInstance()?.crashlytics, id = initialMedium.id ) + override val mediumState = mediumStateMachine.state.flowOn( context = ioDispatcher() ).stateIn( @@ -65,6 +67,7 @@ class MediumScreenComponent( started = SharingStarted.WhileSubscribed(), initialValue = mediumStateMachine.currentState ) + private val mediumSuccessState = mediumState.mapNotNull { it.safeCast() }.flowOn( @@ -75,6 +78,18 @@ class MediumScreenComponent( initialValue = null ) + override val isAdult: StateFlow = mediumSuccessState.mapNotNull { + it?.data?.isAdult + }.flowOn( + context = ioDispatcher() + ).stateIn( + scope = ioScope(), + started = SharingStarted.WhileSubscribed(), + initialValue = initialMedium.isAdult + ) + + override val isAdultAllowed: Flow = appSettings.adultContent + private val type: StateFlow = mediumSuccessState.mapNotNull { it?.data?.type }.flowOn( diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/AdultSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/AdultSection.kt new file mode 100644 index 0000000..4b9048f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/AdultSection.kt @@ -0,0 +1,70 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBackIosNew +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import dev.datlag.aniflow.SharedRes +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun AdultSection( + isAdultContentFlow: StateFlow, + isAdultContentAllowedFlow: Flow, + onBack: () -> Unit +) { + val isAdult by isAdultContentFlow.collectAsStateWithLifecycle() + val isAdultAllowed by isAdultContentAllowedFlow.collectAsStateWithLifecycle(false) + val hideContent = remember(isAdult, isAdultAllowed) { isAdult && !isAdultAllowed } + + if (hideContent) { + Column( + modifier = Modifier + .fillMaxSize() + .background(color = Color.Black.copy(alpha = 0.9F)) + .pointerInput(hideContent) { }, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(SharedRes.strings.adult_content_prevent), + color = Color.White + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onBack, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color.Black + ) + ) { + Icon( + modifier = Modifier.size(ButtonDefaults.IconSize), + imageVector = Icons.Default.ArrowBackIosNew, + contentDescription = null + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = stringResource(SharedRes.strings.back)) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/character/CharacterDialog.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/character/CharacterDialog.kt index 0e3441d..ce6d4ba 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/character/CharacterDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/character/CharacterDialog.kt @@ -79,6 +79,7 @@ fun CharacterDialog(component: CharacterComponent) { AsyncImage( modifier = Modifier + .padding(12.dp) .size(96.dp) .clip(CircleShape), model = image.large, @@ -93,6 +94,16 @@ fun CharacterDialog(component: CharacterComponent) { contentDescription = component.initialChar.preferredName() ) + this@ModalBottomSheet.AnimatedVisibility( + visible = state.isLoading, + enter = fadeIn(), + exit = fadeOut() + ) { + CircularProgressIndicator( + modifier = Modifier.size(108.dp) + ) + } + this@ModalBottomSheet.AnimatedVisibility( modifier = Modifier.align(Alignment.CenterEnd), visible = state.isSuccess, @@ -123,9 +134,7 @@ fun CharacterDialog(component: CharacterComponent) { } Text( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(top = 8.dp), + modifier = Modifier.align(Alignment.CenterHorizontally), text = name.preferred(), style = MaterialTheme.typography.headlineMedium, maxLines = 2, @@ -235,13 +244,6 @@ fun CharacterDialog(component: CharacterComponent) { ), text = it.htmlToAnnotatedString() ) - } ?: run { - val state by component.state.collectAsStateWithLifecycle() - - if (state is CharacterStateMachine.State.Loading) { - CircularProgressIndicator() - } - // ToDo("Display something went wrong") } } } diff --git a/composeApp/src/commonMain/moko-resources/base/strings.xml b/composeApp/src/commonMain/moko-resources/base/strings.xml index db294d1..e5b92c8 100644 --- a/composeApp/src/commonMain/moko-resources/base/strings.xml +++ b/composeApp/src/commonMain/moko-resources/base/strings.xml @@ -33,4 +33,8 @@ Add Edit Trailer + Settings + NSFW / Adult Content + NSFW / Adult Content is not enabled. + Back diff --git a/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/AppSettingsSerializer.kt b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/AppSettingsSerializer.kt new file mode 100644 index 0000000..6b1e5c2 --- /dev/null +++ b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/AppSettingsSerializer.kt @@ -0,0 +1,38 @@ +package dev.datlag.aniflow.settings + +import androidx.datastore.core.okio.OkioSerializer +import dev.datlag.aniflow.settings.model.AppSettings +import dev.datlag.tooling.async.suspendCatching +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf +import okio.BufferedSink +import okio.BufferedSource + +data object AppSettingsSerializer : OkioSerializer { + override val defaultValue: AppSettings = AppSettings() + + @OptIn(ExperimentalSerializationApi::class) + private val protobuf = ProtoBuf { + encodeDefaults = true + } + + @OptIn(ExperimentalSerializationApi::class) + override suspend fun readFrom(source: BufferedSource): AppSettings { + return suspendCatching { + protobuf.decodeFromByteArray(source.readByteArray()) + }.getOrNull() ?: defaultValue + } + + @OptIn(ExperimentalSerializationApi::class) + override suspend fun writeTo(t: AppSettings, sink: BufferedSink) { + val newSink = suspendCatching { + sink.write(protobuf.encodeToByteArray(t)) + }.getOrNull() ?: sink + + suspendCatching { + newSink.emit() + }.getOrNull() + } +} \ No newline at end of file diff --git a/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/DataStoreAppSettings.kt b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/DataStoreAppSettings.kt new file mode 100644 index 0000000..38bd6d5 --- /dev/null +++ b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/DataStoreAppSettings.kt @@ -0,0 +1,20 @@ +package dev.datlag.aniflow.settings + +import androidx.datastore.core.DataStore +import dev.datlag.aniflow.settings.model.AppSettings +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class DataStoreAppSettings( + private val dateStore: DataStore +) : Settings.PlatformAppSettings { + override val adultContent: Flow = dateStore.data.map { it.adultContent } + + override suspend fun setAdultContent(value: Boolean) { + dateStore.updateData { + it.copy( + adultContent = value + ) + } + } +} \ No newline at end of file diff --git a/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/DataStoreUserSettings.kt b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/DataStoreUserSettings.kt index 8e63a01..6ee4485 100644 --- a/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/DataStoreUserSettings.kt +++ b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/DataStoreUserSettings.kt @@ -1,6 +1,7 @@ package dev.datlag.aniflow.settings import androidx.datastore.core.DataStore +import dev.datlag.aniflow.settings.model.UserSettings import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.Clock diff --git a/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/Settings.kt b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/Settings.kt index 23aab0d..e6ee515 100644 --- a/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/Settings.kt +++ b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/Settings.kt @@ -1,5 +1,6 @@ package dev.datlag.aniflow.settings +import dev.datlag.aniflow.settings.model.UserSettings import kotlinx.coroutines.flow.Flow data object Settings { @@ -19,4 +20,10 @@ data object Settings { expires: Int? ) } + + interface PlatformAppSettings { + val adultContent: Flow + + suspend fun setAdultContent(value: Boolean) + } } \ No newline at end of file diff --git a/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/UserSettingsSerializer.kt b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/UserSettingsSerializer.kt index 9b9d335..664224e 100644 --- a/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/UserSettingsSerializer.kt +++ b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/UserSettingsSerializer.kt @@ -1,6 +1,7 @@ package dev.datlag.aniflow.settings import androidx.datastore.core.okio.OkioSerializer +import dev.datlag.aniflow.settings.model.UserSettings import dev.datlag.tooling.async.suspendCatching import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.decodeFromByteArray diff --git a/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/model/AppSettings.kt b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/model/AppSettings.kt new file mode 100644 index 0000000..69f3d65 --- /dev/null +++ b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/model/AppSettings.kt @@ -0,0 +1,11 @@ +package dev.datlag.aniflow.settings.model + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@Serializable +@OptIn(ExperimentalSerializationApi::class) +data class AppSettings( + @ProtoNumber(1) val adultContent: Boolean = false +) diff --git a/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/UserSettings.kt b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/model/UserSettings.kt similarity index 93% rename from settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/UserSettings.kt rename to settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/model/UserSettings.kt index b130cb4..746e979 100644 --- a/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/UserSettings.kt +++ b/settings/src/commonMain/kotlin/dev/datlag/aniflow/settings/model/UserSettings.kt @@ -1,4 +1,4 @@ -package dev.datlag.aniflow.settings +package dev.datlag.aniflow.settings.model import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable