From 5dcb5613056a83deb016f9c323b70c44ea19769d Mon Sep 17 00:00:00 2001 From: DatLag Date: Sun, 19 May 2024 19:33:43 +0200 Subject: [PATCH] prepare searching --- .../commonMain/graphql/SearchQuery.graphql | 108 +++++++++++ .../aniflow/anilist/SearchRepository.kt | 169 ++++++++++++++++++ .../aniflow/anilist/common/ExtendDateTime.kt | 16 ++ .../datlag/aniflow/anilist/model/Character.kt | 43 +++++ .../datlag/aniflow/anilist/model/Medium.kt | 73 ++++++++ .../aniflow/anilist/state/CollectionState.kt | 11 ++ .../datlag/aniflow/module/NetworkModule.kt | 10 ++ .../datlag/aniflow/ui/custom/ErrorContent.kt | 7 +- .../aniflow/ui/navigation/RootComponent.kt | 3 + .../screen/discover/DiscoverComponent.kt | 11 ++ .../screen/discover/DiscoverScreen.kt | 107 ++++++++++- .../discover/DiscoverScreenComponent.kt | 30 ++++ .../screen/discover/component/SearchResult.kt | 96 ++++++++++ .../screen/favorites/FavoritesScreen.kt | 3 +- .../moko-resources/base/strings.xml | 2 + .../moko-resources/de-DE/strings.xml | 2 + 16 files changed, 685 insertions(+), 6 deletions(-) create mode 100644 anilist/src/commonMain/graphql/SearchQuery.graphql create mode 100644 anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/SearchRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/component/SearchResult.kt diff --git a/anilist/src/commonMain/graphql/SearchQuery.graphql b/anilist/src/commonMain/graphql/SearchQuery.graphql new file mode 100644 index 0000000..e556773 --- /dev/null +++ b/anilist/src/commonMain/graphql/SearchQuery.graphql @@ -0,0 +1,108 @@ +query SearchQuery( + $query: String, + $adultContent: Boolean, + $preventGenres: [String], + $type: MediaType +) { + Page { + media( + search: $query, + isAdult: $adultContent, + genre_not_in: $preventGenres, + sort: [POPULARITY_DESC], + type: $type + ) { + id, + idMal, + type, + status(version: 2), + description(asHtml: true), + episodes, + duration, + chapters, + countryOfOrigin, + popularity, + isFavourite, + isFavouriteBlocked, + isAdult, + format, + bannerImage, + coverImage { + extraLarge, + large, + medium, + color + }, + averageScore, + title { + english, + native, + romaji, + userPreferred + }, + nextAiringEpisode { + episode, + airingAt + }, + rankings { + rank, + allTime, + year, + season, + type + }, + genres, + characters(sort: [FAVOURITES_DESC,RELEVANCE]) { + nodes { + id, + name { + first + middle + last + full + native + userPreferred + }, + image { + large + medium + }, + description(asHtml: true) + gender, + dateOfBirth { + year + month + day + }, + bloodType, + isFavourite, + isFavouriteBlocked, + } + }, + mediaListEntry { + score(format: POINT_5), + status, + progress, + repeat, + startedAt { + year, + month, + day + } + }, + trailer { + id, + site, + thumbnail + }, + siteUrl, + chapters, + volumes, + startDate { + year, + month, + day + } + } + } +} \ No newline at end of file diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/SearchRepository.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/SearchRepository.kt new file mode 100644 index 0000000..ba2bb75 --- /dev/null +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/SearchRepository.kt @@ -0,0 +1,169 @@ +package dev.datlag.aniflow.anilist + +import com.apollographql.apollo3.ApolloClient +import com.apollographql.apollo3.api.Optional +import dev.datlag.aniflow.anilist.state.CollectionState +import dev.datlag.aniflow.anilist.type.MediaType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.update + +class SearchRepository( + private val client: ApolloClient, + private val fallbackClient: ApolloClient, + private val nsfw: Flow = flowOf(false), + private val viewManga: Flow = flowOf(false), +) { + + private val search = MutableStateFlow(null) + private val _type = MutableStateFlow(MediaType.UNKNOWN__) + + val searchQuery: String? + get() = search.value?.ifBlank { null } + + @OptIn(FlowPreview::class) + private val searchDebounced = search.debounce { + if (it.isNullOrBlank()) { + 0 + } else { + 100 + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + 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(searchDebounced, type, nsfw.distinctUntilChanged()) { s, t, n -> + if (s.isNullOrBlank()) { + null + } else { + Query( + search = s, + nsfw = n, + type = t + ) + } + }.distinctUntilChanged() + + @OptIn(ExperimentalCoroutinesApi::class) + private val fallbackQuery = query.transformLatest { + return@transformLatest if (it == null) { + emit(it) + } else { + emitAll(fallbackClient.query(it.toGraphQL()).toFlow()) + } + }.mapLatest { + if (it == null) { + return@mapLatest CollectionState.None + } + + val data = it.data + if (data == null) { + if (it.hasErrors()) { + CollectionState.fromSearchGraphQL(data) + } else { + CollectionState.None + } + } else { + CollectionState.fromSearchGraphQL(data) + } + }.distinctUntilChanged() + + @OptIn(ExperimentalCoroutinesApi::class) + val result = query.transformLatest { + return@transformLatest if (it == null) { + emit(it) + } else { + emitAll(client.query(it.toGraphQL()).toFlow()) + } + }.mapLatest { + if (it == null) { + return@mapLatest CollectionState.None + } + + val data = it.data + if (data == null) { + if (it.hasErrors()) { + CollectionState.fromSearchGraphQL(data) + } else { + CollectionState.None + } + } else { + CollectionState.fromSearchGraphQL(data) + } + }.transformLatest { + return@transformLatest if (it.isError) { + emitAll(fallbackQuery) + } else { + emit(it) + } + } + + fun query(query: String) { + search.update { query } + } + + fun setType(type: MediaType) { + _type.update { type } + } + + fun viewAnime() = setType(MediaType.ANIME) + fun viewManga() = setType(MediaType.MANGA) + + fun toggleType() { + _type.update { + if (it == MediaType.MANGA) { + MediaType.ANIME + } else { + MediaType.MANGA + } + } + } + + private data class Query( + val search: String, + val nsfw: Boolean, + val type: MediaType + ) { + fun toGraphQL() = SearchQuery( + query = Optional.present(search), + adultContent = if (nsfw) { + Optional.absent() + } else { + Optional.present(nsfw) + }, + preventGenres = if (nsfw) { + Optional.absent() + } else { + Optional.present(AdultContent.Genre.allTags) + }, + type = if (type == MediaType.UNKNOWN__) { + Optional.absent() + } else { + Optional.present(type) + } + ) + } +} \ No newline at end of file diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/common/ExtendDateTime.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/common/ExtendDateTime.kt index 60df18f..f65439e 100644 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/common/ExtendDateTime.kt +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/common/ExtendDateTime.kt @@ -68,6 +68,14 @@ internal fun ListQuery.StartDate.toLocalDate(): LocalDate? { ) } +internal fun SearchQuery.StartDate.toLocalDate(): LocalDate? { + return LocalDate( + year = year ?: return null, + monthNumber = month ?: return null, + dayOfMonth = day ?: 1 + ) +} + internal fun AiringQuery.StartedAt.toLocalDate(): LocalDate? { return LocalDate( year = year ?: return null, @@ -107,3 +115,11 @@ internal fun ListQuery.StartedAt.toLocalDate(): LocalDate? { dayOfMonth = day ?: 1 ) } + +internal fun SearchQuery.StartedAt.toLocalDate(): LocalDate? { + return LocalDate( + year = year ?: return null, + monthNumber = month ?: return null, + dayOfMonth = day ?: 1 + ) +} diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/Character.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/Character.kt index 061b494..656568c 100644 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/Character.kt +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/Character.kt @@ -138,6 +138,15 @@ data class Character( native = name.native?.ifBlank { null }, userPreferred = name.userPreferred?.ifBlank { null } ) + + constructor(name: SearchQuery.Name) : this( + first = name.first?.ifBlank { null }, + middle = name.middle?.ifBlank { null }, + last = name.last?.ifBlank { null }, + full = name.full?.ifBlank { null }, + native = name.native?.ifBlank { null }, + userPreferred = name.userPreferred?.ifBlank { null } + ) } @Serializable @@ -174,6 +183,11 @@ data class Character( large = image.large?.ifBlank { null }, medium = image.medium?.ifBlank { null }, ) + + constructor(image: SearchQuery.Image) : this( + large = image.large?.ifBlank { null }, + medium = image.medium?.ifBlank { null }, + ) } @Serializable @@ -292,6 +306,18 @@ data class Character( year = birth.year ) } + + operator fun invoke(birth: SearchQuery.DateOfBirth): BirthDate? { + if (birth.day == null && birth.month == null && birth.year == null) { + return null + } + + return BirthDate( + day = birth.day, + month = birth.month, + year = birth.year + ) + } } } @@ -397,5 +423,22 @@ data class Character( isFavoriteBlocked = character.isFavouriteBlocked ) } + + operator fun invoke(character: SearchQuery.Node) : Character? { + val name = character.name?.let(::Name) ?: return null + val image = character.image?.let(::Image) ?: return null + + return Character( + id = character.id, + name = name, + image = image, + gender = character.gender?.ifBlank { null }, + bloodType = character.bloodType?.ifBlank { null }, + birthDate = character.dateOfBirth?.let { BirthDate(it) }, + description = character.description?.ifBlank { null }, + isFavorite = character.isFavourite, + isFavoriteBlocked = character.isFavouriteBlocked + ) + } } } \ No newline at end of file diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/Medium.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/Medium.kt index c91c233..b725e84 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 @@ -313,6 +313,58 @@ data class Medium( startDate = media.startDate?.toLocalDate() ) + constructor(search: SearchQuery.Medium) : this( + id = search.id, + idMal = search.idMal, + type = search.type ?: MediaType.UNKNOWN__, + status = search.status ?: MediaStatus.UNKNOWN__, + description = search.description?.ifBlank { null }, + _episodes = search.episodes ?: -1, + avgEpisodeDurationInMin = search.duration ?: -1, + format = search.format ?: MediaFormat.UNKNOWN__, + _isAdult = search.isAdult ?: false, + genres = search.genresFilterNotNull()?.toSet() ?: emptySet(), + countryOfOrigin = search.countryOfOrigin?.toString()?.ifBlank { null }, + averageScore = search.averageScore ?: -1, + title = Title( + english = search.title?.english?.ifBlank { null }, + native = search.title?.native?.ifBlank { null }, + romaji = search.title?.romaji?.ifBlank { null }, + userPreferred = search.title?.userPreferred?.ifBlank { null } + ), + bannerImage = search.bannerImage?.ifBlank { null }, + coverImage = CoverImage( + color = search.coverImage?.color?.ifBlank { null }, + medium = search.coverImage?.medium?.ifBlank { null }, + large = search.coverImage?.large?.ifBlank { null }, + extraLarge = search.coverImage?.extraLarge?.ifBlank { null } + ), + nextAiringEpisode = search.nextAiringEpisode?.let(::NextAiring), + ranking = search.rankingsFilterNotNull()?.map(::Ranking)?.toSet() ?: emptySet(), + _characters = search.characters?.nodesFilterNotNull()?.mapNotNull(Character::invoke)?.toSet() ?: emptySet(), + entry = search.mediaListEntry?.let(::Entry), + trailer = search.trailer?.let { + val site = it.site?.ifBlank { null } + val thumbnail = it.thumbnail?.ifBlank { null } + + if (site == null || thumbnail == null) { + null + } else { + Trailer( + id = it.id?.ifBlank { null }, + site = site, + thumbnail = thumbnail + ) + } + }, + isFavorite = search.isFavourite, + _isFavoriteBlocked = search.isFavouriteBlocked, + siteUrl = search.siteUrl?.ifBlank { null } ?: "$SITE_URL${search.id}", + chapters = search.chapters ?: -1, + volumes = search.volumes ?: -1, + startDate = search.startDate?.toLocalDate() + ) + @Transient val isAdult: Boolean = _isAdult || genres.any { AdultContent.Genre.exists(it) @@ -455,6 +507,14 @@ data class Medium( season = ranking.season?.lastMonth(), type = ranking.type ) + + constructor(ranking: SearchQuery.Ranking) : this( + rank = ranking.rank, + allTime = ranking.allTime ?: (ranking.season?.lastMonth() == null && ranking.year == null), + year = ranking.year ?: -1, + season = ranking.season?.lastMonth(), + type = ranking.type + ) } @Serializable @@ -504,6 +564,14 @@ data class Medium( repeatCount = entry.repeat, startDate = entry.startedAt?.toLocalDate() ) + + constructor(entry: SearchQuery.MediaListEntry) : this( + score = entry.score, + status = entry.status ?: MediaListStatus.UNKNOWN__, + progress = entry.progress, + repeatCount = entry.repeat, + startDate = entry.startedAt?.toLocalDate() + ) } @Serializable @@ -594,6 +662,11 @@ data class Medium( episodes = nextAiringEpisode.episode, airingAt = nextAiringEpisode.airingAt ) + + constructor(nextAiringEpisode: SearchQuery.NextAiringEpisode) : this( + episodes = nextAiringEpisode.episode, + airingAt = nextAiringEpisode.airingAt + ) } companion object { diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/CollectionState.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/CollectionState.kt index 7653629..4bcb51a 100644 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/CollectionState.kt +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/CollectionState.kt @@ -1,5 +1,6 @@ package dev.datlag.aniflow.anilist.state +import dev.datlag.aniflow.anilist.SearchQuery import dev.datlag.aniflow.anilist.SeasonQuery import dev.datlag.aniflow.anilist.TrendingQuery import dev.datlag.aniflow.anilist.model.Medium @@ -45,6 +46,16 @@ sealed interface CollectionState { return Success(mediaList.map { Medium(it) }) } + + fun fromSearchGraphQL(data: SearchQuery.Data?): CollectionState { + val mediaList = data?.Page?.mediaFilterNotNull() + + if (mediaList.isNullOrEmpty()) { + return Error + } + + return Success(mediaList.map { Medium(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 724f753..5a458b1 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt @@ -202,5 +202,15 @@ data object NetworkModule { viewManga = appSettings.viewManga ) } + bindSingleton { + val appSettings = instance() + + SearchRepository( + client = instance(Constants.AniList.APOLLO_CLIENT), + fallbackClient = instance(Constants.AniList.FALLBACK_APOLLO_CLIENT).newBuilder().fetchPolicy(FetchPolicy.NetworkFirst).build(), + nsfw = appSettings.adultContent, + viewManga = appSettings.viewManga + ) + } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/ErrorContent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/ErrorContent.kt index 9f221f0..62fbe03 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/ErrorContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/ErrorContent.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter 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.icerock.moko.resources.ImageResource @@ -60,11 +61,13 @@ fun ErrorContent( modifier = Modifier.padding(horizontal = 16.dp), text = stringResource(title), style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center ) Text( modifier = Modifier.padding(horizontal = 16.dp), - text = stringResource(text) + text = stringResource(text), + textAlign = TextAlign.Center ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/RootComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/RootComponent.kt index acbb648..1ccc42c 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/RootComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/RootComponent.kt @@ -86,6 +86,9 @@ class RootComponent( }, onList = { navigation.replaceCurrent(RootConfig.Favorites) + }, + onMedium = { + navigation.bringToFront(RootConfig.Details(it)) } ) } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverComponent.kt index b62e9db..995d4f3 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverComponent.kt @@ -1,11 +1,22 @@ package dev.datlag.aniflow.ui.navigation.screen.discover +import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.aniflow.anilist.state.CollectionState +import dev.datlag.aniflow.anilist.type.MediaType import dev.datlag.aniflow.ui.navigation.Component import kotlinx.coroutines.flow.Flow interface DiscoverComponent : Component { val loggedIn: Flow + val initialSearchValue: String? + val type: Flow + val searchResult: Flow + fun viewHome() fun viewList() + fun details(medium: Medium) + + fun search(query: String) + fun toggleView() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverScreen.kt index f107964..c12e327 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverScreen.kt @@ -1,34 +1,52 @@ package dev.datlag.aniflow.ui.navigation.screen.discover +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.MenuBook +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.PlayCircle import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SearchBar import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.chrisbanes.haze.haze import dev.datlag.aniflow.LocalHaze import dev.datlag.aniflow.SharedRes +import dev.datlag.aniflow.anilist.state.CollectionState +import dev.datlag.aniflow.anilist.type.MediaType import dev.datlag.aniflow.common.merge +import dev.datlag.aniflow.common.preferred import dev.datlag.aniflow.common.scrollUpVisible +import dev.datlag.aniflow.ui.custom.ErrorContent 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.discover.component.SearchResult +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle import dev.icerock.moko.resources.compose.stringResource @OptIn(ExperimentalMaterial3Api::class) @@ -38,13 +56,18 @@ fun DiscoverScreen(component: DiscoverComponent) { Scaffold( topBar = { - var query by remember { mutableStateOf("") } + val type by component.type.collectAsStateWithLifecycle(MediaType.UNKNOWN__) + var query by remember { mutableStateOf(component.initialSearchValue ?: "") } var active by remember { mutableStateOf(false) } val activePadding by animateDpAsState( targetValue = if (active) 0.dp else 16.dp ) + LaunchedEffect(query) { + component.search(query) + } + SearchBar( modifier = Modifier .fillMaxWidth() @@ -68,9 +91,87 @@ fun DiscoverScreen(component: DiscoverComponent) { ) }, placeholder = { - Text(text = stringResource(SharedRes.strings.search)) + Text( + text = stringResource( + if (type == MediaType.MANGA) { + SharedRes.strings.search_manga + } else { + SharedRes.strings.search_anime + } + ) + ) }, - content = { } + trailingIcon = { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility( + visible = query.isNotBlank(), + enter = fadeIn(), + exit = fadeOut() + ) { + IconButton( + onClick = { + query = "" + active = false + }, + enabled = query.isNotBlank() + ) { + Icon( + imageVector = Icons.Rounded.Clear, + contentDescription = null + ) + } + } + IconButton( + onClick = { + active = true + component.toggleView() + }, + enabled = type != MediaType.UNKNOWN__ + ) { + if (type == MediaType.ANIME) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.MenuBook, + contentDescription = null + ) + } else { + Icon( + imageVector = Icons.Rounded.PlayCircle, + contentDescription = null + ) + } + } + } + }, + content = { + val resultState by component.searchResult.collectAsStateWithLifecycle(CollectionState.None) + + when (val current = resultState) { + is CollectionState.None -> {} + is CollectionState.Error -> { + ErrorContent( + modifier = Modifier.fillMaxSize() + ) + } + is CollectionState.Success -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(current.collection.toList(), key = { it.id }) { + SearchResult( + medium = it, + modifier = Modifier.fillParentMaxWidth(), + onClick = component::details + ) + } + } + } + } + } ) }, bottomBar = { diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverScreenComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverScreenComponent.kt index 59c2d85..09a9c8d 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverScreenComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverScreenComponent.kt @@ -6,9 +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.SearchRepository +import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.aniflow.anilist.state.CollectionState +import dev.datlag.aniflow.anilist.type.MediaType import dev.datlag.aniflow.common.onRender import dev.datlag.aniflow.other.UserHelper +import dev.datlag.tooling.compose.ioDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn import org.kodein.di.DI import org.kodein.di.instance @@ -17,11 +23,23 @@ class DiscoverScreenComponent( override val di: DI, private val onHome: () -> Unit, private val onList: () -> Unit, + private val onMedium: (Medium) -> Unit ) : DiscoverComponent, ComponentContext by componentContext { private val userHelper by instance() override val loggedIn: Flow = userHelper.isLoggedIn + private val searchRepository by instance() + + override val initialSearchValue: String? + get() = searchRepository.searchQuery + + override val type: Flow = searchRepository.type + + override val searchResult: Flow = searchRepository.result.flowOn( + context = ioDispatcher() + ) + @Composable override fun render() { val haze = remember { HazeState() } @@ -42,4 +60,16 @@ class DiscoverScreenComponent( override fun viewList() { onList() } + + override fun details(medium: Medium) { + onMedium(medium) + } + + override fun search(query: String) { + searchRepository.query(query) + } + + override fun toggleView() { + searchRepository.toggleType() + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/component/SearchResult.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/component/SearchResult.kt new file mode 100644 index 0000000..bdde3c8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/component/SearchResult.kt @@ -0,0 +1,96 @@ +package dev.datlag.aniflow.ui.navigation.screen.discover.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +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 coil3.compose.AsyncImage +import coil3.compose.rememberAsyncImagePainter +import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.aniflow.common.preferred +import dev.datlag.aniflow.ui.theme.SchemeTheme +import dev.datlag.tooling.compose.onClick + +@OptIn(ExperimentalStdlibApi::class) +@Composable +fun SearchResult( + medium: Medium, + modifier: Modifier = Modifier, + onClick: (Medium) -> Unit +) { + Row( + modifier = modifier.clip(CardDefaults.shape).onClick { onClick(medium) }, + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + val defaultColor = remember(medium.coverImage.color) { + medium.coverImage.color?.substringAfter('#')?.let { + val colorValue = it.hexToLong() or 0x00000000FF000000 + Color(colorValue) + } + } + + val updater = SchemeTheme.create( + key = medium.id, + defaultColor = defaultColor, + ) + + AsyncImage( + modifier = Modifier + .width(100.dp) + .height(140.dp) + .clip(MaterialTheme.shapes.medium), + model = medium.coverImage.extraLarge, + contentDescription = medium.preferred(null), + contentScale = ContentScale.Crop, + error = rememberAsyncImagePainter( + model = medium.coverImage.large, + contentScale = ContentScale.Crop, + error = rememberAsyncImagePainter( + model = medium.coverImage.medium, + contentScale = ContentScale.Crop, + onSuccess = { state -> + updater?.update(state.painter) + } + ), + onSuccess = { state -> + updater?.update(state.painter) + } + ), + onSuccess = { state -> + updater?.update(state.painter) + } + ) + Column( + modifier = Modifier.weight(1F).padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = medium.preferred(null), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + softWrap = true, + overflow = TextOverflow.Ellipsis + ) + } + } +} \ No newline at end of file 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 5d24ce5..9ed279a 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 @@ -10,6 +10,7 @@ import androidx.compose.material.icons.automirrored.rounded.MenuBook import androidx.compose.material.icons.rounded.ClearAll import androidx.compose.material.icons.rounded.FilterList import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.PlayCircle import androidx.compose.material.icons.rounded.SwapVert import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -71,7 +72,7 @@ fun FavoritesScreen(component: FavoritesComponent) { ) } else { Icon( - imageVector = Icons.Rounded.PlayArrow, + imageVector = Icons.Rounded.PlayCircle, contentDescription = null ) } diff --git a/composeApp/src/commonMain/moko-resources/base/strings.xml b/composeApp/src/commonMain/moko-resources/base/strings.xml index 1174650..cd99336 100644 --- a/composeApp/src/commonMain/moko-resources/base/strings.xml +++ b/composeApp/src/commonMain/moko-resources/base/strings.xml @@ -110,4 +110,6 @@ Chapter Watched Episode Read Chapter + Search Anime + Search Manga diff --git a/composeApp/src/commonMain/moko-resources/de-DE/strings.xml b/composeApp/src/commonMain/moko-resources/de-DE/strings.xml index b923f2e..91f5f73 100644 --- a/composeApp/src/commonMain/moko-resources/de-DE/strings.xml +++ b/composeApp/src/commonMain/moko-resources/de-DE/strings.xml @@ -110,4 +110,6 @@ Kapitel Gesehene Folge Gelesenes Kapitel + Anime suchen + Manga suchen