diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/ListRepository.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/ListRepository.kt new file mode 100644 index 0000000..fa5d3e6 --- /dev/null +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/ListRepository.kt @@ -0,0 +1,128 @@ +package dev.datlag.aniflow.anilist + +import com.apollographql.apollo3.ApolloClient +import com.apollographql.apollo3.api.Optional +import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.aniflow.anilist.model.User +import dev.datlag.aniflow.anilist.type.MediaType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* + +class ListRepository( + private val client: ApolloClient, + private val fallbackClient: ApolloClient, + private val user: Flow, + private val viewManga: Flow = flowOf(false), +) { + + private val page = MutableStateFlow(0) + private val _type = MutableStateFlow(MediaType.UNKNOWN__) + + @OptIn(ExperimentalCoroutinesApi::class) + private val type = _type.transformLatest { + return@transformLatest if (it == MediaType.UNKNOWN__) { + emitAll(viewManga.map { m -> + if (m) { + MediaType.MANGA + } else { + MediaType.ANIME + } + }) + } else { + emit(it) + } + }.distinctUntilChanged() + + private val query = combine( + page, + type, + user.filterNotNull().distinctUntilChanged(), + ) { p, t, u -> + Query( + page = p, + type = t, + userId = u.id + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val fallbackQuery = query.transformLatest { + return@transformLatest emitAll(fallbackClient.query(it.toGraphQL()).toFlow()) + }.mapNotNull { + val data = it.data + if (data == null) { + if (it.hasErrors()) { + State.fromGraphQL(data) + } else { + null + } + } else { + State.fromGraphQL(data) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val list = query.transformLatest { + return@transformLatest emitAll(client.query(it.toGraphQL()).toFlow()) + }.mapNotNull { + val data = it.data + if (data == null) { + if (it.hasErrors()) { + State.fromGraphQL(data) + } else { + null + } + } else { + State.fromGraphQL(data) + } + }.transformLatest { + return@transformLatest if (it is State.Error) { + emitAll(fallbackQuery) + } else { + emit(it) + } + } + + private data class Query( + val page: Int, + val type: MediaType, + val userId: Int + ) { + fun toGraphQL() = ListQuery( + page = Optional.present(page), + type = if (type == MediaType.UNKNOWN__) { + Optional.absent() + } else { + Optional.present(type) + }, + userId = Optional.present(userId) + ) + } + + sealed interface State { + data object None : State + + data class Success( + val hasNextPage: Boolean, + val medium: Collection + ) : State + + data object Error : State + + companion object { + fun fromGraphQL(query: ListQuery.Data?): State { + val medium = query?.Page?.mediaListFilterNotNull()?.mapNotNull { + Medium( + media = it.media ?: return@mapNotNull null, + list = it + ) + } ?: return Error + + return Success( + hasNextPage = query.Page.pageInfo?.hasNextPage ?: false, + medium = medium.distinctBy { it.id } + ) + } + } + } +} \ No newline at end of file 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 0266204..85a2b3e 100644 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/MediumRepository.kt +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/MediumRepository.kt @@ -5,23 +5,22 @@ import com.apollographql.apollo3.ApolloClient import com.apollographql.apollo3.api.Optional import dev.datlag.aniflow.anilist.model.Medium import dev.datlag.aniflow.anilist.type.MediaListStatus +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* class MediumRepository( private val client: ApolloClient, - private val fallbackClient: ApolloClient, - private val isLoggedIn: Flow + private val fallbackClient: ApolloClient ) { private val id = MutableStateFlow(null) - private val query = combine( - id.filterNotNull(), - isLoggedIn.distinctUntilChanged() - ) { t1, _ -> - Query(t1) - } - private val fallbackQuery = query.transform { - return@transform emitAll(fallbackClient.query(it.toGraphQL()).toFlow()) + + @OptIn(ExperimentalCoroutinesApi::class) + private val query = id.filterNotNull().mapLatest { Query(it) } + + @OptIn(ExperimentalCoroutinesApi::class) + private val fallbackQuery = query.transformLatest { + return@transformLatest emitAll(fallbackClient.query(it.toGraphQL()).toFlow()) }.mapNotNull { val data = it.data if (data == null) { @@ -35,8 +34,9 @@ class MediumRepository( } } - val medium = query.transform { - return@transform emitAll(client.query(it.toGraphQL()).toFlow()) + @OptIn(ExperimentalCoroutinesApi::class) + val medium = query.transformLatest { + return@transformLatest emitAll(client.query(it.toGraphQL()).toFlow()) }.mapNotNull { val data = it.data if (data == null) { @@ -48,8 +48,8 @@ class MediumRepository( } else { State.fromGraphQL(data) } - }.transform { - return@transform if (it is State.Error) { + }.transformLatest { + return@transformLatest if (it is State.Error) { emitAll(fallbackQuery) } else { emit(it) diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt index f3b9cf5..4d06cda 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt @@ -174,7 +174,6 @@ data object NetworkModule { MediumRepository( client = instance(Constants.AniList.APOLLO_CLIENT).newBuilder().fetchPolicy(FetchPolicy.NetworkFirst).build(), fallbackClient = instance(Constants.AniList.FALLBACK_APOLLO_CLIENT).newBuilder().fetchPolicy(FetchPolicy.NetworkFirst).build(), - isLoggedIn = instance().isLoggedIn ) } bindSingleton { @@ -190,5 +189,15 @@ data object NetworkModule { nsfw = appSettings.adultContent ) } + bindSingleton { + val appSettings = instance() + + ListRepository( + client = instance(Constants.AniList.APOLLO_CLIENT), + fallbackClient = instance(Constants.AniList.FALLBACK_APOLLO_CLIENT).newBuilder().fetchPolicy(FetchPolicy.NetworkFirst).build(), + user = instance().user, + viewManga = appSettings.viewManga + ) + } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesComponent.kt index b577263..8aa9493 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesComponent.kt @@ -1,8 +1,14 @@ package dev.datlag.aniflow.ui.navigation.screen.favorites +import dev.datlag.aniflow.anilist.ListRepository +import dev.datlag.aniflow.settings.model.TitleLanguage import dev.datlag.aniflow.ui.navigation.Component +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow interface FavoritesComponent : Component { + val listState: StateFlow + val titleLanguage: Flow fun viewDiscover() fun viewHome() diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreen.kt index 4716ce9..cd823c6 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreen.kt @@ -1,17 +1,83 @@ package dev.datlag.aniflow.ui.navigation.screen.favorites -import androidx.compose.material3.Scaffold +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.SwapVert +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.haze +import dev.chrisbanes.haze.hazeChild +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.datlag.aniflow.LocalHaze +import dev.datlag.aniflow.SharedRes +import dev.datlag.aniflow.anilist.ListRepository +import dev.datlag.aniflow.common.isScrollingUp +import dev.datlag.aniflow.common.merge +import dev.datlag.aniflow.common.plus +import dev.datlag.aniflow.common.preferred import dev.datlag.aniflow.ui.navigation.screen.component.HidingNavigationBar import dev.datlag.aniflow.ui.navigation.screen.component.NavigationBarState +import dev.datlag.aniflow.ui.navigation.screen.favorites.component.ListCard +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.flowOf +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable fun FavoritesScreen(component: FavoritesComponent) { + val listState = rememberLazyListState() + Scaffold( + topBar = { + TopAppBar( + title = { + Text(text = stringResource(SharedRes.strings.app_name)) + }, + actions = { + + }, + colors = TopAppBarDefaults.largeTopAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), + modifier = Modifier.hazeChild( + state = LocalHaze.current, + style = HazeMaterials.thin( + containerColor = MaterialTheme.colorScheme.surface, + ) + ).fillMaxWidth() + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = {}, + expanded = listState.isScrollingUp() && listState.canScrollForward, + icon = { + Icon( + imageVector = Icons.Rounded.SwapVert, + contentDescription = null + ) + }, + text = { + Text(text = "Sort") + } + ) + }, bottomBar = { HidingNavigationBar( - visible = true, + visible = listState.isScrollingUp() && listState.canScrollForward, selected = NavigationBarState.Favorite, loggedIn = flowOf(true), onDiscover = component::viewDiscover, @@ -19,7 +85,41 @@ fun FavoritesScreen(component: FavoritesComponent) { onFavorites = { } ) } - ) { + ) { padding -> + val state by component.listState.collectAsStateWithLifecycle() + + when (val current = state) { + is ListRepository.State.None -> { + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(0.2F).clip(CircleShape) + ) + } + } + is ListRepository.State.Error -> { + Text(text = "Error") + } + is ListRepository.State.Success -> { + val titleLanguage by component.titleLanguage.collectAsStateWithLifecycle(null) + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().haze(state = LocalHaze.current), + contentPadding = padding.plus(PaddingValues(8.dp)), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(current.medium.toList()) { + ListCard( + medium = it, + titleLanguage = titleLanguage, + modifier = Modifier.fillParentMaxWidth().height(150.dp) + ) + } + } + } + } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreenComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreenComponent.kt index 4e9d7e8..fb8b6f9 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreenComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreenComponent.kt @@ -6,10 +6,15 @@ import androidx.compose.runtime.remember import com.arkivanov.decompose.ComponentContext import dev.chrisbanes.haze.HazeState import dev.datlag.aniflow.LocalHaze +import dev.datlag.aniflow.anilist.ListRepository import dev.datlag.aniflow.common.onRender import dev.datlag.aniflow.other.UserHelper +import dev.datlag.aniflow.settings.Settings +import dev.datlag.aniflow.settings.model.TitleLanguage +import dev.datlag.tooling.compose.ioDispatcher import dev.datlag.tooling.compose.withMainContext -import kotlinx.coroutines.flow.collectLatest +import dev.datlag.tooling.decompose.ioScope +import kotlinx.coroutines.flow.* import org.kodein.di.DI import org.kodein.di.instance @@ -20,8 +25,17 @@ class FavoritesScreenComponent( private val onHome: () -> Unit, ) : FavoritesComponent, ComponentContext by componentContext { - private val userHelper by instance() - val user = userHelper.user + private val appSettings by instance() + override val titleLanguage: Flow = appSettings.titleLanguage + + private val listRepository by instance() + override val listState: StateFlow = listRepository.list.flowOn( + context = ioDispatcher() + ).stateIn( + scope = ioScope(), + started = SharingStarted.WhileSubscribed(), + initialValue = ListRepository.State.None + ) @Composable override fun render() { diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/component/ListCard.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/component/ListCard.kt new file mode 100644 index 0000000..80f9632 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/component/ListCard.kt @@ -0,0 +1,95 @@ +package dev.datlag.aniflow.ui.navigation.screen.favorites.component + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import coil3.compose.AsyncImage +import coil3.compose.rememberAsyncImagePainter +import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.aniflow.common.preferred +import dev.datlag.aniflow.settings.model.TitleLanguage +import kotlin.math.max +import kotlin.math.min + +@Composable +fun ListCard( + medium: Medium, + titleLanguage: TitleLanguage?, + modifier: Modifier = Modifier +) { + ElevatedCard( + onClick = { }, + modifier = modifier + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + AsyncImage( + modifier = Modifier.widthIn(min = 100.dp, max = 120.dp).fillMaxHeight().clip(MaterialTheme.shapes.medium), + model = medium.coverImage.extraLarge, + contentDescription = medium.preferred(titleLanguage), + contentScale = ContentScale.Crop, + error = rememberAsyncImagePainter( + model = medium.coverImage.large, + contentScale = ContentScale.Crop, + error = rememberAsyncImagePainter( + model = medium.coverImage.medium, + contentScale = ContentScale.Crop, + onSuccess = { state -> + } + ), + onSuccess = { state -> + } + ), + onSuccess = { state -> + } + ) + Column( + modifier = Modifier.weight(1F).padding(top = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val progress = medium.entry?.progress ?: 0 + val maxProgress = max(progress, medium.episodes) + + Text( + text = medium.preferred(titleLanguage), + style = MaterialTheme.typography.titleMedium, + overflow = TextOverflow.Ellipsis, + softWrap = true, + modifier = Modifier.fillMaxWidth().weight(1F), + fontWeight = FontWeight.SemiBold + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LinearProgressIndicator( + progress = { 1F }, + modifier = Modifier + .fillMaxWidth( + fraction = (progress.toFloat() / maxProgress.toFloat()) + ) + .clip(CircleShape) + ) + LinearProgressIndicator( + progress = { 0F }, + modifier = Modifier.weight(1F).clip(CircleShape) + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/HomeComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/HomeComponent.kt index 8acf1f3..681dcd5 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/HomeComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/HomeComponent.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.Flow interface HomeComponent : Component { val viewing: Flow val user: Flow + val loggedIn: Flow val titleLanguage: Flow val airing: Flow diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/HomeScreen.kt index 4fd4969..d2753ac 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/HomeScreen.kt @@ -170,7 +170,7 @@ fun HomeScreen(component: HomeComponent) { HidingNavigationBar( visible = listState.isScrollingUp() && listState.canScrollForward, selected = NavigationBarState.Home, - loggedIn = component.user.mapLatest { it != null }, + loggedIn = component.loggedIn, onDiscover = component::viewDiscover, onHome = { }, onFavorites = component::viewFavorites diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/HomeScreenComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/HomeScreenComponent.kt index 68a4e30..fe794aa 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/HomeScreenComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/home/HomeScreenComponent.kt @@ -55,6 +55,7 @@ class HomeScreenComponent( private val userHelper by instance() override val user: Flow = userHelper.user + override val loggedIn: Flow = userHelper.isLoggedIn private val stateScope = ioScope() private val airingTodayRepository by instance()