diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/MediumRepository.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/MediumRepository.kt index 1141f2c..042f38a 100644 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/MediumRepository.kt +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/MediumRepository.kt @@ -1,5 +1,6 @@ package dev.datlag.aniflow.anilist +import com.apollographql.apollo3.ApolloCall import com.apollographql.apollo3.ApolloClient import com.apollographql.apollo3.api.Optional import dev.datlag.aniflow.anilist.model.Medium @@ -54,6 +55,15 @@ class MediumRepository( fun load(id: Int) = this.id.update { id } + fun updateRatingCall(value: Int): ApolloCall { + val mutation = RatingMutation( + mediaId = Optional.present(id.value), + rating = Optional.present(value) + ) + + return client.mutation(mutation) + } + private data class Query( val id: Int, ) { @@ -65,6 +75,7 @@ class MediumRepository( } sealed interface State { + data object None : State data class Success(val medium: Medium) : State data object Error : State diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/AnimatedSmallFABWithLabel.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/AnimatedSmallFABWithLabel.kt new file mode 100644 index 0000000..624a21a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/AnimatedSmallFABWithLabel.kt @@ -0,0 +1,51 @@ +package dev.datlag.aniflow.ui.custom.speeddial + +import androidx.compose.animation.* +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale + +@Composable +fun AnimatedSmallFABWithLabel( + state: SpeedDialFABState, + showLabel: Boolean, + modifier: Modifier = Modifier, + labelContent: @Composable () -> Unit = { }, + fabContent: @Composable () -> Unit, +) { + val alpha = state.transition?.animateFloat( + transitionSpec = { + tween(durationMillis = 50) + }, + targetValueByState = { + if (it == SpeedDialState.Expanded) 1F else 0F + } + ) + val scale = state.transition?.animateFloat( + targetValueByState = { + if (it == SpeedDialState.Expanded) 1F else 0F + } + ) + + Row( + modifier = modifier + .alpha(animateFloatAsState((alpha?.value ?: 0F)).value) + .scale(animateFloatAsState(scale?.value ?: 0F).value), + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility( + visible = showLabel, + enter = slideInHorizontally { it / 2 } + fadeIn(), + exit = slideOutHorizontally { it / 2 } + fadeOut() + ) { + labelContent.invoke() + } + fabContent.invoke() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/FABItem.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/FABItem.kt new file mode 100644 index 0000000..a53718a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/FABItem.kt @@ -0,0 +1,17 @@ +package dev.datlag.aniflow.ui.custom.speeddial + +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +open class FABItem( + val icon: ImageVector? = null, + val painter: Painter? = null, + val tint: Boolean = false, + val modifier: Modifier = if (painter == null) Modifier else Modifier.size(24.dp), + val label: String, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/SpeedDialFAB.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/SpeedDialFAB.kt new file mode 100644 index 0000000..6518422 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/SpeedDialFAB.kt @@ -0,0 +1,72 @@ +package dev.datlag.aniflow.ui.custom.speeddial + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.FloatingActionButtonElevation +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape + +@Composable +fun SpeedDialFAB( + modifier: Modifier = Modifier, + state: SpeedDialFABState, + onClick: (SpeedDialFABState) -> Unit = { it.changeState() }, + iconRotation: Float = 45F, + expanded: Boolean = false, + shape: Shape = FloatingActionButtonDefaults.extendedFabShape, + containerColor: Color = FloatingActionButtonDefaults.containerColor, + contentColor: Color = contentColorFor(containerColor), + elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), + text: @Composable () -> Unit = { }, + icon: @Composable () -> Unit, +) { + val rotation = if (iconRotation > 0F) { + state.transition?.animateFloat( + transitionSpec = { + if (targetState == SpeedDialState.Expanded) { + spring(stiffness = Spring.StiffnessLow) + } else { + spring(stiffness = Spring.StiffnessMedium) + } + }, + label = "", + targetValueByState = { + if (it == SpeedDialState.Expanded) iconRotation else 0F + } + ) + } else { + null + } + + ExtendedFloatingActionButton( + modifier = modifier, + onClick = { + onClick(state) + }, + expanded = expanded, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + elevation = elevation, + icon = { + Box( + modifier = Modifier.rotate(rotation?.value ?: 0F), + contentAlignment = Alignment.Center + ) { + icon() + } + }, + text = { + text() + } + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/SpeedDialFABState.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/SpeedDialFABState.kt new file mode 100644 index 0000000..c8c2b38 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/SpeedDialFABState.kt @@ -0,0 +1,37 @@ +package dev.datlag.aniflow.ui.custom.speeddial + +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.updateTransition +import androidx.compose.runtime.* + +@Composable +fun rememberSpeedDialState(): SpeedDialFABState { + val state = remember { SpeedDialFABState() } + + state.transition = updateTransition(targetState = state.currentState) + + return state +} + +class SpeedDialFABState { + + var currentState by mutableStateOf(SpeedDialState.Collapsed) + var transition: Transition? = null + + fun changeState() { + currentState = if (transition?.currentState == SpeedDialState.Expanded + || (transition?.isRunning == true && transition?.targetState == SpeedDialState.Expanded)) { + SpeedDialState.Collapsed + } else { + SpeedDialState.Expanded + } + } + + fun collapse() { + currentState = SpeedDialState.Collapsed + } + + fun expand() { + currentState = SpeedDialState.Expanded + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/SpeedDialState.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/SpeedDialState.kt new file mode 100644 index 0000000..21288c3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/SpeedDialState.kt @@ -0,0 +1,12 @@ +package dev.datlag.aniflow.ui.custom.speeddial + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface SpeedDialState { + @Serializable + data object Collapsed : SpeedDialState + + @Serializable + data object Expanded : SpeedDialState +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/SubSpeedDialFABs.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/SubSpeedDialFABs.kt new file mode 100644 index 0000000..8be6adb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/speeddial/SubSpeedDialFABs.kt @@ -0,0 +1,75 @@ +package dev.datlag.aniflow.ui.custom.speeddial + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.unit.dp + +@Composable +fun SubSpeedDialFABs( + state: SpeedDialFABState, + items: List, + showLabels: Boolean = true, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + labelContent: @Composable (T) -> Unit = { + val backgroundColor = FloatingActionButtonDefaults.containerColor + + Surface( + color = backgroundColor, + shape = MaterialTheme.shapes.small, + shadowElevation = 2.dp, + onClick = { it.onClick() } + ) { + Text( + text = it.label, + color = contentColorFor(backgroundColor), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp) + ) + } + }, + fabContent: @Composable (T) -> Unit = { + SmallFloatingActionButton( + modifier = Modifier.padding(4.dp), + onClick = { it.onClick() }, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 4.dp, + hoveredElevation = 4.dp + ) + ) { + if (it.icon != null) { + Icon( + modifier = it.modifier, + imageVector = it.icon, + contentDescription = it.label + ) + } else if (it.painter != null) { + Image( + modifier = it.modifier, + painter = it.painter, + contentDescription = it.label, + colorFilter = if (it.tint) ColorFilter.tint(LocalContentColor.current) else null + ) + } + } + } +) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = verticalArrangement, + ) { + items.forEach { item -> + AnimatedSmallFABWithLabel( + state = state, + showLabel = showLabels, + labelContent = { labelContent(item) }, + fabContent = { fabContent(item) } + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/component/CollapsingToolbar.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/component/CollapsingToolbar.kt index 98e0c75..3a01b7a 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/component/CollapsingToolbar.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/component/CollapsingToolbar.kt @@ -93,7 +93,11 @@ fun CollapsingToolbar( ) { val user by userFlow.collectAsStateWithLifecycle(null) val tintColor = LocalContentColor.current - var colorFilter by remember(user) { mutableStateOf(null) } + var colorFilter by remember(user, tintColor) { + mutableStateOf( + if (user == null) ColorFilter.tint(tintColor) else null + ) + } AsyncImage( model = user?.avatar?.large, diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/dialog/settings/SettingsDialog.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/dialog/settings/SettingsDialog.kt index b1bb025..5086d79 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/dialog/settings/SettingsDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/dialog/settings/SettingsDialog.kt @@ -155,7 +155,7 @@ fun SettingsScreen(component: SettingsComponent) { contentDescription = null, colorFilter = ColorFilter.tint(LocalContentColor.current) ) - Text(text = "GitHub Repository") + Text(text = stringResource(SharedRes.strings.github_repository)) } } item { @@ -176,7 +176,7 @@ fun SettingsScreen(component: SettingsComponent) { imageVector = Icons.Rounded.Code, contentDescription = null, ) - Text(text = "Developed by DatLag") + Text(text = stringResource(SharedRes.strings.developed_by_datlag)) } } item { diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/dialog/settings/component/ColorSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/dialog/settings/component/ColorSection.kt index 3fe90be..83fe271 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/dialog/settings/component/ColorSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/dialog/settings/component/ColorSection.kt @@ -21,6 +21,7 @@ import com.maxkeppeler.sheets.option.models.DisplayMode import com.maxkeppeler.sheets.option.models.Option import com.maxkeppeler.sheets.option.models.OptionConfig import com.maxkeppeler.sheets.option.models.OptionSelection +import dev.datlag.aniflow.SharedRes import dev.datlag.aniflow.common.toComposeColor import dev.datlag.aniflow.common.toComposeString import dev.datlag.aniflow.other.StateSaver @@ -91,7 +92,7 @@ fun ColorSection( contentDescription = null, ) Text( - text = "Profile Color" + text = stringResource(SharedRes.strings.profile_color) ) Spacer(modifier = Modifier.weight(1F)) IconButton( diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/dialog/settings/component/UserSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/dialog/settings/component/UserSection.kt index 3a4efe1..70564e9 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/dialog/settings/component/UserSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/dialog/settings/component/UserSection.kt @@ -95,7 +95,7 @@ fun UserSection( ) Markdown( modifier = Modifier.padding(bottom = 16.dp), - content = user?.description ?: "Login with [AniList](${loginUri})" + content = user?.description ?: stringResource(SharedRes.strings.login_markdown, loginUri) ) } } \ 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 832916b..d5787de 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 @@ -61,11 +61,13 @@ interface MediumComponent : ContentHolderComponent { val dialog: Value> + val bsAvailable: Boolean + val bsOptions: Flow> + fun back() override fun dismissContent() { back() } - fun rate(onLoggedIn: () -> Unit) fun rate(value: Int) fun descriptionTranslation(text: String?) fun showCharacter(character: Character) 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 b482533..5c367cb 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 @@ -86,54 +86,10 @@ fun MediumScreen(component: MediumComponent) { ) }, floatingActionButton = { - InstantAppContent( - onInstantApp = { helper -> - ExtendedFloatingActionButton( - onClick = { helper.showInstallPrompt() }, - expanded = listState.isScrollingUp() && listState.canScrollForward, - icon = { - Icon( - imageVector = Icons.Rounded.GetApp, - contentDescription = null, - ) - }, - text = { - Text(text = stringResource(SharedRes.strings.install)) - } - ) - } - ) { - val notReleased by component.status.mapCollect(component.initialMedium.status) { - it == MediaStatus.UNKNOWN__ || it == MediaStatus.NOT_YET_RELEASED - } - - if (!notReleased) { - val loggedIn by component.isLoggedIn.collectAsStateWithLifecycle(false) - val status by component.listStatus.collectAsStateWithLifecycle(component.initialMedium.entry?.status ?: MediaListStatus.UNKNOWN__) - val type by component.type.collectAsStateWithLifecycle(component.initialMedium.type) - val uriHandler = LocalUriHandler.current - - ExtendedFloatingActionButton( - onClick = { - if (!loggedIn) { - uriHandler.openUri(component.loginUri) - } else { - component.edit() - } - }, - expanded = listState.isScrollingUp() && listState.canScrollForward, - icon = { - Icon( - imageVector = status.icon(), - contentDescription = null, - ) - }, - text = { - Text(text = stringResource(status.stringRes(type))) - } - ) - } - } + FABContent( + expanded = listState.isScrollingUp() && listState.canScrollForward, + component = component + ) } ) { CompositionLocalProvider( 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 d4609b7..34a6138 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 @@ -5,6 +5,8 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import com.apollographql.apollo3.ApolloClient import com.apollographql.apollo3.api.Optional +import com.apollographql.apollo3.cache.normalized.FetchPolicy +import com.apollographql.apollo3.cache.normalized.fetchPolicy import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.slot.* import com.arkivanov.decompose.value.Value @@ -21,6 +23,7 @@ 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 @@ -34,6 +37,7 @@ 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 @@ -61,7 +65,11 @@ class MediumScreenComponent( override val charLanguage: Flow = appSettings.charLanguage.flowOn(ioDispatcher()) private val mediumRepository by di.instance() - override val mediumState = mediumRepository.medium + override val mediumState = mediumRepository.medium.stateIn( + scope = ioScope(), + started = SharingStarted.WhileSubscribed(), + initialValue = MediumRepository.State.None + ) private val mediumSuccessState = mediumState.mapNotNull { it.safeCast() @@ -143,20 +151,13 @@ class MediumScreenComponent( it.medium.characters }.mapNotEmpty() - private val changedRating: MutableStateFlow = MutableStateFlow(initialMedium.entry?.score?.toInt() ?: -1) @OptIn(ExperimentalCoroutinesApi::class) - override val rating: Flow = combine( - mediumSuccessState.mapLatest { - it.medium.entry?.score?.toInt() - }, - changedRating - ) { t1, t2 -> - if (t2 > -1) { - t2 - } else { - t1 ?: t2 - } - } + override val rating: MutableStateFlow = mediumSuccessState.mapNotNull { + it.medium.entry?.score?.toInt() + }.mutableStateIn( + scope = ioScope(), + initialValue = initialMedium.entry?.score?.toInt() ?: -1 + ) @OptIn(ExperimentalCoroutinesApi::class) override val trailer: Flow = mediumSuccessState.mapLatest { @@ -198,6 +199,20 @@ class MediumScreenComponent( it.medium.volumes } + private val burningSeriesResolver by instance() + + override val bsAvailable: Boolean + get() = burningSeriesResolver.isAvailable + + @OptIn(ExperimentalCoroutinesApi::class) + override val bsOptions = title.mapLatest { + burningSeriesResolver.resolveByName(it.english, it.romaji) + }.flowOn(ioDispatcher()).stateIn( + scope = ioScope(), + started = SharingStarted.WhileSubscribed(), + initialValue = emptySet() + ) + private val dialogNavigation = SlotNavigation() override val dialog: Value> = childSlot( source = dialogNavigation, @@ -213,8 +228,7 @@ class MediumScreenComponent( is DialogConfig.Edit -> EditDialogComponent( componentContext = context, di = di, - titleFlow = title, - onDismiss = dialogNavigation::dismiss + onDismiss = dialogNavigation::dismiss, ) } } @@ -240,41 +254,17 @@ class MediumScreenComponent( onBack() } - private suspend fun requestMediaListEntry() { - val query = MediaListEntryQuery( - id = Optional.present(initialMedium.id) - ) - val execution = CatchResult.timeout(5.seconds) { - aniListClient.query(query).execute() - }.asNullableSuccess() - - execution?.data?.MediaList?.let { entry -> - changedRating.update { entry.score?.toInt() ?: it } - } - } - - override fun rate(onLoggedIn: () -> Unit) { - launchIO { - val currentRating = rating.safeFirstOrNull() ?: initialMedium.entry?.score?.toInt() ?: -1 - if (currentRating <= -1) { - requestMediaListEntry() + override fun rate(value: Int) { + val newRating = mediumRepository + .updateRatingCall(value * 20) + .fetchPolicy(FetchPolicy.NetworkOnly) + .toFlow() + .mapNotNull { + it.data?.SaveMediaListEntry?.score?.toInt() } - withMainContext { - onLoggedIn() - } - } - } - - override fun rate(value: Int) { - val mutation = RatingMutation( - mediaId = Optional.present(initialMedium.id), - rating = Optional.present(value * 20) - ) launchIO { - aniListClient.mutation(mutation).execute().data?.SaveMediaListEntry?.score?.let { - changedRating.emit(it.toInt()) - } + rating.emitAll(newRating) } } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/BSDialog.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/BSDialog.kt new file mode 100644 index 0000000..264e72a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/BSDialog.kt @@ -0,0 +1,53 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import com.maxkeppeker.sheets.core.models.base.Header +import com.maxkeppeker.sheets.core.models.base.IconSource +import com.maxkeppeker.sheets.core.models.base.UseCaseState +import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState +import com.maxkeppeler.sheets.option.OptionDialog +import com.maxkeppeler.sheets.option.models.DisplayMode +import com.maxkeppeler.sheets.option.models.Option +import com.maxkeppeler.sheets.option.models.OptionConfig +import com.maxkeppeler.sheets.option.models.OptionSelection +import dev.datlag.aniflow.SharedRes +import dev.datlag.aniflow.other.Series +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.StateFlow + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BSDialog( + state: UseCaseState, + bsOptions: Collection +) { + OptionDialog( + state = state, + config = OptionConfig( + mode = DisplayMode.LIST + ), + selection = OptionSelection.Single( + options = bsOptions.map { + Option( + titleText = it.title + ) + }, + onSelectOption = { option, _ -> + + } + ), + header = Header.Default( + icon = IconSource( + painter = painterResource(SharedRes.images.bs), + tint = LocalContentColor.current + ), + title = stringResource(SharedRes.strings.bs) + ) + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/FABContent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/FABContent.kt new file mode 100644 index 0000000..6431639 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/FABContent.kt @@ -0,0 +1,152 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.GetApp +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState +import dev.datlag.aniflow.SharedRes +import dev.datlag.aniflow.anilist.type.MediaListStatus +import dev.datlag.aniflow.anilist.type.MediaStatus +import dev.datlag.aniflow.common.icon +import dev.datlag.aniflow.common.isScrollingUp +import dev.datlag.aniflow.common.mapCollect +import dev.datlag.aniflow.common.stringRes +import dev.datlag.aniflow.ui.custom.InstantAppContent +import dev.datlag.aniflow.ui.custom.speeddial.FABItem +import dev.datlag.aniflow.ui.custom.speeddial.SpeedDialFAB +import dev.datlag.aniflow.ui.custom.speeddial.SubSpeedDialFABs +import dev.datlag.aniflow.ui.custom.speeddial.rememberSpeedDialState +import dev.datlag.aniflow.ui.navigation.screen.medium.MediumComponent +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun FABContent( + expanded: Boolean, + component: MediumComponent +) { + InstantAppContent( + onInstantApp = { helper -> + ExtendedFloatingActionButton( + onClick = { helper.showInstallPrompt() }, + expanded = expanded, + icon = { + Icon( + imageVector = Icons.Rounded.GetApp, + contentDescription = null, + ) + }, + text = { + Text(text = stringResource(SharedRes.strings.install)) + } + ) + } + ) { + val notReleased by component.status.mapCollect(component.initialMedium.status) { + it == MediaStatus.UNKNOWN__ || it == MediaStatus.NOT_YET_RELEASED + } + + if (!notReleased) { + val loggedIn by component.isLoggedIn.collectAsStateWithLifecycle(false) + val status by component.listStatus.collectAsStateWithLifecycle(component.initialMedium.entry?.status ?: MediaListStatus.UNKNOWN__) + val type by component.type.collectAsStateWithLifecycle(component.initialMedium.type) + val uriHandler = LocalUriHandler.current + val speedDialFABState = rememberSpeedDialState() + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val bsAvailable = component.bsAvailable + val bsOptions by component.bsOptions.collectAsStateWithLifecycle(emptySet()) + val bsState = rememberUseCaseState() + + val rating by component.rating.collectAsStateWithLifecycle(-1) + val ratingState = rememberUseCaseState() + + BSDialog( + state = bsState, + bsOptions = bsOptions, + ) + + RatingDialog( + state = ratingState, + initialValue = rating, + onRating = { + component.rate(it) + } + ) + + SubSpeedDialFABs( + state = speedDialFABState, + items = listOfNotNull( + FABItem( + icon = Icons.Rounded.PlayArrow, + label = stringResource(status.stringRes(type)), + onClick = { + speedDialFABState.collapse() + component.edit() + } + ), + FABItem( + icon = Icons.Rounded.Star, + label = "Rating", + onClick = { + speedDialFABState.collapse() + ratingState.show() + } + ), + if (bsAvailable && bsOptions.isNotEmpty()) { + FABItem( + painter = painterResource(SharedRes.images.bs), + label = stringResource(SharedRes.strings.bs), + tint = true, + onClick = { + speedDialFABState.collapse() + bsState.show() + } + ) + } else { + null + } + ), + showLabels = expanded + ) + SpeedDialFAB( + state = speedDialFABState, + expanded = expanded, + onClick = { + if (!loggedIn) { + uriHandler.openUri(component.loginUri) + } else { + it.changeState() + } + }, + iconRotation = 0F, + icon = { + Icon( + imageVector = status.icon(), + contentDescription = null, + ) + }, + text = { + Text(text = stringResource(status.stringRes(type))) + } + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/RatingDialog.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/RatingDialog.kt new file mode 100644 index 0000000..62b61b9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/RatingDialog.kt @@ -0,0 +1,40 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.component + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import com.maxkeppeker.sheets.core.models.base.Header +import com.maxkeppeker.sheets.core.models.base.IconSource +import com.maxkeppeker.sheets.core.models.base.UseCaseState +import com.maxkeppeler.sheets.rating.models.RatingBody +import com.maxkeppeler.sheets.rating.models.RatingConfig +import com.maxkeppeler.sheets.rating.models.RatingSelection +import io.github.aakira.napier.Napier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RatingDialog( + state: UseCaseState, + initialValue: Int, + onRating: (Int) -> Unit, +) { + com.maxkeppeler.sheets.rating.RatingDialog( + state = state, + config = RatingConfig( + ratingZeroValid = true, + ratingOptionsCount = 5, + ratingOptionsSelected = if (initialValue < 0) null else initialValue + ), + selection = RatingSelection( + onSelectRating = { rating, _ -> + onRating(rating) + } + ), + body = RatingBody.Custom(body = { }), + header = Header.Default( + icon = IconSource(imageVector = Icons.Rounded.Star), + title = "Rating" + ) + ) +} \ No newline at end of file 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 0381a4c..b832fc0 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 @@ -6,6 +6,4 @@ import kotlinx.coroutines.flow.Flow interface EditComponent : DialogComponent { - val bsAvailable: Boolean - val bsOptions: 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 dbaf710..fbc1149 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,9 +1,36 @@ 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.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.font.FontWeight +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 dev.datlag.aniflow.LocalEdgeToEdge +import dev.datlag.aniflow.SharedRes +import dev.datlag.aniflow.common.isFullyExpandedOrTargeted +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 @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -25,6 +52,33 @@ fun EditDialog(component: EditComponent) { windowInsets = insets, sheetState = sheetState ) { - Text(text = "Edit Dialog") + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = bottomPadding.merge(PaddingValues(16.dp)), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + TopSection( + state = sheetState, + modifier = Modifier.fillParentMaxWidth(), + onBack = component::dismiss, + onSave = { } + ) + } + item { + RatingView( + useCaseState = rememberUseCaseState(visible = true), + config = RatingConfig( + ratingZeroValid = true + ), + body = RatingBody.Default( + bodyText = "Rate" + ), + selection = RatingSelection { count, _ -> + + } + ) + } + } } } \ No newline at end of file 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 4e04c0f..a7b69fa 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 @@ -5,8 +5,11 @@ import com.arkivanov.decompose.ComponentContext import dev.datlag.aniflow.anilist.model.Medium import dev.datlag.aniflow.common.onRender import dev.datlag.aniflow.other.BurningSeriesResolver +import dev.datlag.aniflow.other.Series +import dev.datlag.tooling.compose.ioDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest import org.kodein.di.DI import org.kodein.di.instance @@ -14,19 +17,10 @@ import org.kodein.di.instance class EditDialogComponent( componentContext: ComponentContext, override val di: DI, - private val titleFlow: Flow, private val onDismiss: () -> Unit ) : EditComponent, ComponentContext by componentContext { - private val burningSeriesResolver by instance() - override val bsAvailable: Boolean - get() = burningSeriesResolver.isAvailable - - @OptIn(ExperimentalCoroutinesApi::class) - override val bsOptions = titleFlow.mapLatest { - burningSeriesResolver.resolveByName(it.english, it.romaji) - } @Composable override fun render() { diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/component/TopSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/component/TopSection.kt new file mode 100644 index 0000000..b7b26b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/dialog/edit/component/TopSection.kt @@ -0,0 +1,80 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.dialog.edit.component + +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.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBackIosNew +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.datlag.aniflow.SharedRes +import dev.datlag.aniflow.common.isFullyExpandedOrTargeted +import dev.datlag.aniflow.other.Series +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.Flow + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopSection( + state: SheetState, + modifier: Modifier = Modifier, + onBack: () -> Unit, + onSave: () -> Unit +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Box( + modifier = Modifier.size(40.dp), + contentAlignment = Alignment.Center + ) { + this@Row.AnimatedVisibility( + visible = state.isFullyExpandedOrTargeted(forceFullExpand = true), + enter = fadeIn(), + exit = fadeOut() + ) { + IconButton( + onClick = onBack + ) { + Icon( + imageVector = Icons.Rounded.ArrowBackIosNew, + contentDescription = stringResource(SharedRes.strings.close) + ) + } + } + } + Text( + modifier = Modifier.weight(1F), + text = "Edit", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + IconButton( + onClick = { + onSave() + } + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/moko-resources/base/strings.xml b/composeApp/src/commonMain/moko-resources/base/strings.xml index 908f4ce..d8e063f 100644 --- a/composeApp/src/commonMain/moko-resources/base/strings.xml +++ b/composeApp/src/commonMain/moko-resources/base/strings.xml @@ -76,4 +76,8 @@ Login Logout Open Links + GitHub Repository + Developed by DatLag + Profile Color + Login with [AniList](%s)