From 712f65ade516fa498cb8ebd9c76d7b137afb2004 Mon Sep 17 00:00:00 2001 From: DatLag Date: Sun, 17 Mar 2024 14:34:04 +0100 Subject: [PATCH] added search --- .../burningseries/shared/other/Project.kt | 6 +- .../shared/ui/custom/FloatingSearchButton.kt | 29 ++- .../shared/ui/navigation/NavHostComponent.kt | 4 +- .../screen/initial/InitialScreenComponent.kt | 22 +- .../ui/screen/initial/home/HomeComponent.kt | 8 +- .../ui/screen/initial/home/HomeConfig.kt | 2 + .../ui/screen/initial/home/HomeScreen.kt | 107 ++------- .../initial/home/HomeScreenComponent.kt | 68 +++++- .../initial/home/component/HomeOverview.kt | 109 +++++++++ .../initial/home/component/SearchOverview.kt | 59 +++++ .../initial/home/search/SearchComponent.kt | 25 -- .../initial/home/search/SearchConfig.kt | 15 -- .../initial/home/search/SearchScreen.kt | 221 ------------------ .../home/search/SearchScreenComponent.kt | 134 ----------- 14 files changed, 314 insertions(+), 495 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/component/HomeOverview.kt create mode 100644 app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/component/SearchOverview.kt delete mode 100644 app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchComponent.kt delete mode 100644 app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchConfig.kt delete mode 100644 app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchScreen.kt delete mode 100644 app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchScreenComponent.kt diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/other/Project.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/other/Project.kt index d5dda4ce..f2405117 100644 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/other/Project.kt +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/other/Project.kt @@ -15,13 +15,13 @@ sealed interface Project { val github: String? data object PULZ : Project { - override val icon: ImageResource? = SharedRes.images.PulZ + override val icon: ImageResource = SharedRes.images.PulZ override val title: StringResource = SharedRes.strings.pulz override val subTitle: StringResource = SharedRes.strings.pulz_subtitle override val `package`: String = "dev.datlag.pulz" - override val googlePlay: String? = "https://play.google.com/store/apps/details?id=$`package`" - override val github: String? = "https://github.com/DatL4g/PulZ" + override val googlePlay: String = "https://play.google.com/store/apps/details?id=$`package`" + override val github: String = "https://github.com/DatL4g/PulZ" } data object AniFlow : Project { diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/custom/FloatingSearchButton.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/custom/FloatingSearchButton.kt index ef4737fc..c1cb01fb 100644 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/custom/FloatingSearchButton.kt +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/custom/FloatingSearchButton.kt @@ -1,6 +1,8 @@ package dev.datlag.burningseries.shared.ui.custom import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions @@ -28,27 +30,44 @@ import kotlinx.coroutines.delay fun FloatingSearchButton( icon: ImageVector = Icons.Default.Search, contentDescription: String? = stringResource(SharedRes.strings.search), + enabled: Boolean = true, clearIcon: ImageVector = Icons.Default.Clear, closeIcon: ImageVector = Icons.AutoMirrored.Default.KeyboardArrowRight, modifier: Modifier = Modifier, + onClick: () -> Unit = { }, + overrideOnClick: Boolean = false, onTextChange: (String) -> Unit ) { val focusRequester = remember { FocusRequester() } var opened by remember { mutableStateOf(false) } val textState = remember { mutableStateOf("") } + val enabledColor = if (enabled) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.tertiaryContainer + } + val animatedColor by animateColorAsState( + targetValue = enabledColor, + animationSpec = tween() + ) Surface( - color = MaterialTheme.colorScheme.primaryContainer, + color = animatedColor, modifier = modifier, shape = FloatingActionButtonDefaults.shape, shadowElevation = 6.dp, onClick = { - if (!opened) { - opened = true + if (overrideOnClick) { + onClick() } else { - focusRequester.requestFocus() + if (!opened) { + opened = true + } else { + focusRequester.requestFocus() + } } - } + }, + enabled = enabled ) { AnimatedContent(targetState = opened) { expand -> if (expand) { diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/navigation/NavHostComponent.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/navigation/NavHostComponent.kt index 75f468c2..7e79b168 100644 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/navigation/NavHostComponent.kt +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/navigation/NavHostComponent.kt @@ -13,6 +13,7 @@ import dev.datlag.burningseries.shared.LocalDI import dev.datlag.burningseries.shared.common.backAnimation import dev.datlag.burningseries.shared.ui.screen.initial.InitialScreenComponent import dev.datlag.burningseries.shared.ui.screen.video.VideoScreenComponent +import io.github.aakira.napier.Napier import org.kodein.di.DI class NavHostComponent( @@ -49,7 +50,8 @@ class NavHostComponent( stream.toList() ) ) - } + }, + onBack = navigation::pop ) is ScreenConfig.Video -> VideoScreenComponent( componentContext = componentContext, diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/InitialScreenComponent.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/InitialScreenComponent.kt index 842428d2..e846b275 100644 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/InitialScreenComponent.kt +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/InitialScreenComponent.kt @@ -13,6 +13,7 @@ import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.pages.* import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.operator.map +import com.arkivanov.essenty.backhandler.BackCallback import dev.datlag.burningseries.model.Series import dev.datlag.burningseries.model.Shortcut import dev.datlag.burningseries.shared.LocalDI @@ -20,7 +21,6 @@ import dev.datlag.burningseries.shared.SharedRes import dev.datlag.burningseries.shared.ui.navigation.Component import dev.datlag.burningseries.shared.ui.screen.initial.favorite.FavoriteScreenComponent import dev.datlag.burningseries.shared.ui.screen.initial.home.HomeScreenComponent -import dev.datlag.burningseries.shared.ui.screen.initial.home.search.SearchScreenComponent import dev.datlag.burningseries.shared.ui.screen.initial.sponsor.SponsorScreenComponent import dev.datlag.skeo.Stream import kotlinx.coroutines.flow.MutableStateFlow @@ -30,7 +30,8 @@ class InitialScreenComponent( componentContext: ComponentContext, override val di: DI, shortcutIntent: Shortcut.Intent, - private val watchVideo: (String, Series, Series.Episode, Collection) -> Unit + private val watchVideo: (String, Series, Series.Episode, Collection) -> Unit, + private val onBack: () -> Unit ) : InitialComponent, ComponentContext by componentContext { override val pagerItems: List = listOf( @@ -83,6 +84,14 @@ class InitialScreenComponent( override val homeScrollEnabled = MutableStateFlow(true) override val favoriteScrollEnabled = MutableStateFlow(true) + private val backCallback = BackCallback { + pageBack() + } + + init { + backHandler.register(backCallback) + } + @Composable override fun render() { CompositionLocalProvider( @@ -125,8 +134,15 @@ class InitialScreenComponent( override fun selectPage(index: Int) { pagesNavigation.select(index = index) { new, old -> if (new.items[new.selectedIndex] == old.items[old.selectedIndex]) { - (pages.value.items[pages.value.selectedIndex].instance as? SeriesHolderComponent)?.dismissHoldingSeries() + pageBack() } } } + + @OptIn(ExperimentalDecomposeApi::class) + private fun pageBack() { + (pages.value.items[pages.value.selectedIndex].instance as? SeriesHolderComponent) + ?.dismissHoldingSeries() + ?: onBack() + } } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeComponent.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeComponent.kt index 06f17b10..abf1a797 100644 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeComponent.kt +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeComponent.kt @@ -2,8 +2,10 @@ package dev.datlag.burningseries.shared.ui.screen.initial.home import com.arkivanov.decompose.router.slot.ChildSlot import com.arkivanov.decompose.value.Value +import dev.datlag.burningseries.model.Genre import dev.datlag.burningseries.model.Release import dev.datlag.burningseries.model.state.HomeState +import dev.datlag.burningseries.model.state.SearchState import dev.datlag.burningseries.shared.ui.navigation.Component import dev.datlag.burningseries.shared.ui.navigation.DialogComponent import dev.datlag.burningseries.shared.ui.screen.initial.SeriesHolderComponent @@ -17,10 +19,14 @@ interface HomeComponent : SeriesHolderComponent { val homeState: StateFlow val release: StateFlow + val searchState: StateFlow + val searchItems: StateFlow> + val onDeviceReachable: StateFlow fun retryLoadingHome(): Any? fun itemClicked(config: HomeConfig) - fun showDialog(config: DialogConfig) + fun searchQuery(text: String) + fun retryLoadingSearch() } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeConfig.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeConfig.kt index 35e05c9b..e34f970c 100644 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeConfig.kt +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeConfig.kt @@ -1,5 +1,6 @@ package dev.datlag.burningseries.shared.ui.screen.initial.home +import dev.datlag.burningseries.model.Genre import dev.datlag.burningseries.model.Home import kotlinx.serialization.Serializable @@ -14,5 +15,6 @@ sealed class HomeConfig { ) : HomeConfig() { constructor(series: Home.Series) : this(series.title, series.href, series.coverHref) constructor(episode: Home.Episode) : this(episode.series ?: episode.fullTitle, episode.href, episode.coverHref) + constructor(item: Genre.Item) : this(item.title, item.href, null) } } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeScreen.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeScreen.kt index 7a95c843..f8be4374 100644 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeScreen.kt +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeScreen.kt @@ -6,9 +6,9 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SearchOff +import androidx.compose.material.icons.filled.YoutubeSearchedFor import androidx.compose.material3.* import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass @@ -24,12 +24,12 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import dev.chrisbanes.haze.haze import dev.datlag.burningseries.model.Home import dev.datlag.burningseries.model.state.HomeState +import dev.datlag.burningseries.model.state.SearchState import dev.datlag.burningseries.shared.LocalHaze import dev.datlag.burningseries.shared.SharedRes import dev.datlag.burningseries.shared.common.LocalPadding import dev.datlag.burningseries.shared.common.header import dev.datlag.burningseries.shared.common.lifecycle.collectAsStateWithLifecycle -import dev.datlag.burningseries.shared.common.localPadding import dev.datlag.burningseries.shared.common.mergedLocalPadding import dev.datlag.burningseries.shared.other.StateSaver import dev.datlag.burningseries.shared.rememberIsTv @@ -38,9 +38,7 @@ import dev.datlag.burningseries.shared.ui.custom.VerticalScrollbar import dev.datlag.burningseries.shared.ui.custom.rememberScrollbarAdapter import dev.datlag.burningseries.shared.ui.custom.state.ErrorState import dev.datlag.burningseries.shared.ui.custom.state.LoadingState -import dev.datlag.burningseries.shared.ui.screen.initial.home.component.DeviceContent -import dev.datlag.burningseries.shared.ui.screen.initial.home.component.EpisodeItem -import dev.datlag.burningseries.shared.ui.screen.initial.home.component.SeriesItem +import dev.datlag.burningseries.shared.ui.screen.initial.home.component.* import dev.datlag.burningseries.shared.ui.theme.MaterialSymbols import dev.icerock.moko.resources.compose.stringResource @@ -133,93 +131,36 @@ private fun ExpandedView(home: Home, component: HomeComponent) { @Composable private fun MainView(home: Home, component: HomeComponent, modifier: Modifier = Modifier) { Box(modifier = modifier) { + val searchState by component.searchState.collectAsStateWithLifecycle() + Row( modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - val state = rememberLazyGridState( - initialFirstVisibleItemIndex = StateSaver.homeGridIndex, - initialFirstVisibleItemScrollOffset = StateSaver.homeGridOffset - ) + val searchItems by component.searchItems.collectAsStateWithLifecycle() - LazyVerticalGrid( - columns = GridCells.Adaptive(400.dp), - modifier = Modifier.weight(1F).haze(state = LocalHaze.current), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = LocalPadding(), - state = state - ) { - DeviceContent(component.release, component.onDeviceReachable) - header { - Row( - modifier = Modifier.padding(top = 16.dp).fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - modifier = Modifier.weight(1F), - text = stringResource(SharedRes.strings.newest_episodes), - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold - ) - if (!StateSaver.sekretLibraryLoaded) { - IconButton( - onClick = { - component.showDialog(DialogConfig.Sekret) - }, - colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) - ) { - Icon( - imageVector = MaterialSymbols.rememberDeployedCodeAlert(), - contentDescription = stringResource(SharedRes.strings.sekret_unavailable_title) - ) - } - } - } - } - items(home.episodes, key = { - it.href - }) { episode -> - EpisodeItem(episode) { - component.itemClicked(HomeConfig.Series(episode)) - } - } - header { - Spacer(modifier = Modifier.size(48.dp)) - } - header { - Text( - text = stringResource(SharedRes.strings.newest_series), - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold - ) - } - items(home.series, key = { - it.href - }) { series -> - SeriesItem(series) { - component.itemClicked(HomeConfig.Series(series)) - } - } - } - VerticalScrollbar(rememberScrollbarAdapter(state)) - - DisposableEffect(state) { - onDispose { - StateSaver.homeGridIndex = state.firstVisibleItemIndex - StateSaver.homeGridOffset = state.firstVisibleItemScrollOffset - } + if (searchItems.isEmpty()) { + HomeOverview(home, component) + } else { + SearchOverview(searchItems, component) } } FloatingSearchButton( modifier = Modifier.align(Alignment.BottomEnd).mergedLocalPadding(WindowInsets.ime.asPaddingValues(), 16.dp), onTextChange = { - + component.searchQuery(it) + }, + enabled = searchState !is SearchState.Loading, + icon = when (searchState) { + is SearchState.Loading -> Icons.Default.YoutubeSearchedFor + is SearchState.Success -> Icons.Default.Search + is SearchState.Error -> Icons.Default.SearchOff + }, + overrideOnClick = searchState !is SearchState.Success, + onClick = { + component.retryLoadingSearch() } ) } -} \ No newline at end of file +} + diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeScreenComponent.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeScreenComponent.kt index 9ae0c937..b2dbce72 100644 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeScreenComponent.kt +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/HomeScreenComponent.kt @@ -6,16 +6,18 @@ import androidx.compose.runtime.SideEffect import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.slot.* import com.arkivanov.decompose.value.Value +import dev.datlag.burningseries.model.Genre import dev.datlag.burningseries.model.Release import dev.datlag.burningseries.model.Series import dev.datlag.burningseries.model.Shortcut +import dev.datlag.burningseries.model.algorithm.JaroWinkler import dev.datlag.burningseries.model.common.getDigitsOrNull import dev.datlag.burningseries.model.common.safeCast -import dev.datlag.burningseries.model.state.HomeAction -import dev.datlag.burningseries.model.state.HomeState -import dev.datlag.burningseries.model.state.ReleaseState +import dev.datlag.burningseries.model.common.safeSubList +import dev.datlag.burningseries.model.state.* import dev.datlag.burningseries.network.state.HomeStateMachine import dev.datlag.burningseries.network.state.ReleaseStateMachine +import dev.datlag.burningseries.network.state.SearchStateMachine import dev.datlag.burningseries.shared.LocalDI import dev.datlag.burningseries.shared.common.ioDispatcher import dev.datlag.burningseries.shared.common.ioScope @@ -26,6 +28,9 @@ import dev.datlag.burningseries.shared.ui.navigation.DialogComponent import dev.datlag.burningseries.shared.ui.screen.initial.home.dialog.sekret.SekretDialogComponent import dev.datlag.burningseries.shared.ui.screen.initial.series.SeriesScreenComponent import dev.datlag.skeo.Stream +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* import org.kodein.di.DI import org.kodein.di.instance @@ -45,6 +50,37 @@ class HomeScreenComponent( it.safeCast()?.onDeviceReachable ?: (it is HomeState.Loading) }.flowOn(ioDispatcher()).stateIn(ioScope(), SharingStarted.WhileSubscribed(), true) + private val searchStateMachine by di.instance() + override val searchState = searchStateMachine.state.flowOn( + context = ioDispatcher() + ).stateIn( + scope = ioScope(), + started = SharingStarted.WhileSubscribed(), + initialValue = SearchState.Loading + ) + private val allSearchItems = searchState.mapNotNull { it.safeCast() }.map { it.genres.flatMap { g -> g.items } } + private val searchQuery: MutableStateFlow = MutableStateFlow("") + override val searchItems: StateFlow> = combine(allSearchItems, searchQuery) { t1, t2 -> + if (t2.isBlank()) { + emptyList() + } else { + coroutineScope { + t1.map { + async { + when { + it.title.trim().equals(t2.trim(), true) -> it to 1.0 + it.title.trim().startsWith(t2.trim(), true) -> it to 0.95 + it.title.trim().contains(t2.trim(), true) -> it to 0.9 + else -> it to JaroWinkler.distance(it.title.trim(), t2.trim()) + } + } + }.awaitAll().filter { + it.second > 0.85 + }.sortedByDescending { it.second }.map { it.first }.safeSubList(0, 10) + } + } + }.flowOn(ioDispatcher()).stateIn(ioScope(), SharingStarted.WhileSubscribed(), emptyList()) + private val appVersion: String? by di.instanceOrNull("APP_VERSION") private val releaseStateMachine: ReleaseStateMachine by di.instance() override val release: StateFlow = releaseStateMachine.state.map { state -> @@ -133,6 +169,30 @@ class HomeScreenComponent( override fun dismissHoldingSeries() { shortcutIntent = Shortcut.Intent.NONE - navigation.dismiss(scrollEnabled) + navigation.dismiss { success -> + scrollEnabled(success) + + if (!success) { + searchQuery.getAndUpdate { + if (it.isBlank()) { + it + } else { + "" + } + } + } + } + } + + override fun searchQuery(text: String) { + ioScope().launchIO { + searchQuery.emit(text) + } + } + + override fun retryLoadingSearch() { + ioScope().launchIO { + searchStateMachine.dispatch(SearchAction.Retry) + } } } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/component/HomeOverview.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/component/HomeOverview.kt new file mode 100644 index 00000000..058ee206 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/component/HomeOverview.kt @@ -0,0 +1,109 @@ +package dev.datlag.burningseries.shared.ui.screen.initial.home.component + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.burningseries.model.Home +import dev.datlag.burningseries.shared.LocalHaze +import dev.datlag.burningseries.shared.SharedRes +import dev.datlag.burningseries.shared.common.LocalPadding +import dev.datlag.burningseries.shared.common.header +import dev.datlag.burningseries.shared.other.StateSaver +import dev.datlag.burningseries.shared.ui.custom.VerticalScrollbar +import dev.datlag.burningseries.shared.ui.custom.rememberScrollbarAdapter +import dev.datlag.burningseries.shared.ui.screen.initial.home.DialogConfig +import dev.datlag.burningseries.shared.ui.screen.initial.home.HomeComponent +import dev.datlag.burningseries.shared.ui.screen.initial.home.HomeConfig +import dev.datlag.burningseries.shared.ui.theme.MaterialSymbols +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun RowScope.HomeOverview(home: Home, component: HomeComponent) { + val state = rememberLazyGridState( + initialFirstVisibleItemIndex = StateSaver.homeGridIndex, + initialFirstVisibleItemScrollOffset = StateSaver.homeGridOffset + ) + + LazyVerticalGrid( + columns = GridCells.Adaptive(400.dp), + modifier = Modifier.weight(1F).haze(state = LocalHaze.current), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = LocalPadding(), + state = state + ) { + DeviceContent(component.release, component.onDeviceReachable) + header { + Row( + modifier = Modifier.padding(top = 16.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + modifier = Modifier.weight(1F), + text = stringResource(SharedRes.strings.newest_episodes), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold + ) + if (!StateSaver.sekretLibraryLoaded) { + IconButton( + onClick = { + component.showDialog(DialogConfig.Sekret) + }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Icon( + imageVector = MaterialSymbols.rememberDeployedCodeAlert(), + contentDescription = stringResource(SharedRes.strings.sekret_unavailable_title) + ) + } + } + } + } + items(home.episodes, key = { + it.href + }) { episode -> + EpisodeItem(episode) { + component.itemClicked(HomeConfig.Series(episode)) + } + } + header { + Spacer(modifier = Modifier.size(48.dp)) + } + header { + Text( + text = stringResource(SharedRes.strings.newest_series), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold + ) + } + items(home.series, key = { + it.href + }) { series -> + SeriesItem(series) { + component.itemClicked(HomeConfig.Series(series)) + } + } + } + VerticalScrollbar(rememberScrollbarAdapter(state)) + + DisposableEffect(state) { + onDispose { + StateSaver.homeGridIndex = state.firstVisibleItemIndex + StateSaver.homeGridOffset = state.firstVisibleItemScrollOffset + } + } +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/component/SearchOverview.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/component/SearchOverview.kt new file mode 100644 index 00000000..fcba3ccf --- /dev/null +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/component/SearchOverview.kt @@ -0,0 +1,59 @@ +package dev.datlag.burningseries.shared.ui.screen.initial.home.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.RowScope +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.material3.MaterialTheme +import dev.datlag.burningseries.shared.ui.custom.rememberScrollbarAdapter +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.haze +import dev.datlag.burningseries.model.Genre +import dev.datlag.burningseries.shared.LocalHaze +import dev.datlag.burningseries.shared.SharedRes +import dev.datlag.burningseries.shared.common.LocalPadding +import dev.datlag.burningseries.shared.ui.screen.initial.home.HomeComponent +import dev.datlag.burningseries.shared.common.onClick +import dev.datlag.burningseries.shared.ui.custom.VerticalScrollbar +import dev.datlag.burningseries.shared.ui.screen.initial.home.HomeConfig +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun RowScope.SearchOverview(items: List, component: HomeComponent) { + val listState = rememberLazyListState() + + LazyColumn( + state = listState, + modifier = Modifier.weight(1F).haze(state = LocalHaze.current), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = LocalPadding(), + ) { + item { + Text( + modifier = Modifier.padding(top = 16.dp), + text = stringResource(SharedRes.strings.search), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold + ) + } + items(items) { item -> + Text( + modifier = Modifier.fillParentMaxWidth().clip(MaterialTheme.shapes.small).onClick { + component.itemClicked(HomeConfig.Series(item)) + }.padding(vertical = 16.dp), + text = item.bestTitle, + softWrap = true, + overflow = TextOverflow.Ellipsis + ) + } + } + VerticalScrollbar(rememberScrollbarAdapter(listState)) +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchComponent.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchComponent.kt deleted file mode 100644 index 42e8546b..00000000 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchComponent.kt +++ /dev/null @@ -1,25 +0,0 @@ -package dev.datlag.burningseries.shared.ui.screen.initial.home.search - -import com.arkivanov.decompose.router.slot.ChildSlot -import com.arkivanov.decompose.value.Value -import dev.datlag.burningseries.model.Genre -import dev.datlag.burningseries.model.state.SearchState -import dev.datlag.burningseries.shared.ui.navigation.Component -import dev.datlag.burningseries.shared.ui.screen.initial.SeriesHolderComponent -import kotlinx.coroutines.flow.StateFlow - -interface SearchComponent : SeriesHolderComponent { - - val searchState: StateFlow - val genres: StateFlow> - val canLoadMoreGenres: StateFlow - - val searchItems: StateFlow> - - val child: Value> - - fun retryLoadingSearch(): Any? - fun loadMoreGenres(): Any? - fun searchQuery(text: String) - fun itemClicked(config: SearchConfig) -} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchConfig.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchConfig.kt deleted file mode 100644 index 4ca73e24..00000000 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchConfig.kt +++ /dev/null @@ -1,15 +0,0 @@ -package dev.datlag.burningseries.shared.ui.screen.initial.home.search - -import dev.datlag.burningseries.model.Genre -import kotlinx.serialization.Serializable - -@Serializable -sealed class SearchConfig { - @Serializable - data class Series( - val title: String, - val href: String, - ) : SearchConfig() { - constructor(item: Genre.Item) : this(item.title, item.href) - } -} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchScreen.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchScreen.kt deleted file mode 100644 index 5d40b651..00000000 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchScreen.kt +++ /dev/null @@ -1,221 +0,0 @@ -package dev.datlag.burningseries.shared.ui.screen.initial.home.search - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi -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.material.icons.Icons -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.* -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.arkivanov.decompose.extensions.compose.subscribeAsState -import dev.datlag.burningseries.model.state.SearchState -import dev.datlag.burningseries.shared.SharedRes -import dev.datlag.burningseries.shared.common.OnBottomReached -import dev.datlag.burningseries.shared.common.lifecycle.collectAsStateWithLifecycle -import dev.datlag.burningseries.shared.common.onClick -import dev.datlag.burningseries.shared.rememberIsTv -import dev.datlag.burningseries.shared.ui.custom.VerticalScrollbar -import dev.datlag.burningseries.shared.ui.custom.rememberScrollbarAdapter -import dev.datlag.burningseries.shared.ui.custom.state.ErrorState -import dev.datlag.burningseries.shared.ui.custom.state.LoadingState -import dev.icerock.moko.resources.compose.stringResource - -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) -@Composable -fun SearchScreen(component: SearchComponent) { - val state by component.searchState.collectAsStateWithLifecycle() - - when (state) { - is SearchState.Loading -> { - LoadingState(SharedRes.strings.loading_search) - } - is SearchState.Error -> { - ErrorState(SharedRes.strings.error_loading_search) { - component.retryLoadingSearch() - } - } - is SearchState.Success -> { - when (calculateWindowSizeClass().widthSizeClass) { - WindowWidthSizeClass.Expanded -> { - if (rememberIsTv()) { - DefaultView(component) - } else { - ExpandedView(component) - } - } - else -> DefaultView(component) - } - } - } -} - -@Composable -private fun DefaultView(component: SearchComponent) { - val childState by component.child.subscribeAsState() - childState.child?.also { (_, instance) -> - instance.render() - } ?: MainView(component, Modifier.fillMaxWidth()) -} - -@Composable -private fun ExpandedView(component: SearchComponent) { - val childState by component.child.subscribeAsState() - - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - val modifier = if (childState.child?.configuration != null) { - Modifier.weight(1F).widthIn(min = 100.dp, max = 700.dp) - } else { - Modifier.fillMaxWidth() - } - MainView(component, modifier) - - childState.child?.also { (_, instance) -> - Box( - modifier = Modifier.weight(2F) - ) { - instance.render() - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun MainView(component: SearchComponent, modifier: Modifier = Modifier) { - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - val genres by component.genres.collectAsStateWithLifecycle() - val canLoadMore by component.canLoadMoreGenres.collectAsStateWithLifecycle() - val listState = rememberLazyListState() - - LazyColumn( - modifier = Modifier.weight(1F), - state = listState - ) { - stickyHeader { - SearchBar(component) - } - genres.forEach { genre -> - item { - Text( - modifier = Modifier.fillMaxWidth().animateItemPlacement(), - text = genre.title, - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - } - items(genre.items) { - Text( - modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.extraSmall).onClick { - component.itemClicked(SearchConfig.Series(it)) - }.padding(12.dp).animateItemPlacement(), - text = it.bestTitle, - softWrap = true, - overflow = TextOverflow.Ellipsis - ) - } - } - if (canLoadMore) { - item(key = canLoadMore) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp).animateItemPlacement(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator() - } - } - } - } - - VerticalScrollbar(rememberScrollbarAdapter(listState)) - - listState.OnBottomReached(canLoadMore) { - component.loadMoreGenres() - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SearchBar(component: SearchComponent) { - val items by component.searchItems.collectAsStateWithLifecycle() - var queryComp by remember { mutableStateOf(String()) } - - DockedSearchBar( - query = queryComp, - onQueryChange = { - queryComp = it - }, - onSearch = { - queryComp = it - }, - modifier = Modifier.fillMaxWidth().padding(8.dp).animateContentSize(), - active = queryComp.isNotBlank() && items.isNotEmpty(), - onActiveChange = {}, - placeholder = { - Text(text = stringResource(SharedRes.strings.search)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(SharedRes.strings.search) - ) - }, - trailingIcon = { - AnimatedVisibility( - visible = queryComp.isNotBlank(), - enter = fadeIn(), - exit = fadeOut() - ) { - IconButton( - onClick = { - queryComp = String() - } - ) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = stringResource(SharedRes.strings.clear) - ) - } - } - } - ) { - items.forEach { item -> - Text( - modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.extraSmall).onClick { - component.itemClicked(SearchConfig.Series(item)) - }.padding(12.dp), - text = item.bestTitle, - softWrap = true, - overflow = TextOverflow.Ellipsis - ) - } - } - - LaunchedEffect(queryComp) { - component.searchQuery(queryComp) - } -} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchScreenComponent.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchScreenComponent.kt deleted file mode 100644 index 025baafa..00000000 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/home/search/SearchScreenComponent.kt +++ /dev/null @@ -1,134 +0,0 @@ -package dev.datlag.burningseries.shared.ui.screen.initial.home.search - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.SideEffect -import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.router.slot.* -import com.arkivanov.decompose.value.Value -import dev.datlag.burningseries.model.Genre -import dev.datlag.burningseries.model.Series -import dev.datlag.burningseries.model.algorithm.JaroWinkler -import dev.datlag.burningseries.model.common.safeCast -import dev.datlag.burningseries.model.common.safeSubList -import dev.datlag.burningseries.model.state.SearchAction -import dev.datlag.burningseries.model.state.SearchState -import dev.datlag.burningseries.network.state.SearchStateMachine -import dev.datlag.burningseries.shared.LocalDI -import dev.datlag.burningseries.shared.common.ioDispatcher -import dev.datlag.burningseries.shared.common.ioScope -import dev.datlag.burningseries.shared.common.launchIO -import dev.datlag.burningseries.shared.other.Crashlytics -import dev.datlag.burningseries.shared.ui.navigation.Component -import dev.datlag.burningseries.shared.ui.screen.initial.series.SeriesScreenComponent -import dev.datlag.skeo.Stream -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.* -import org.kodein.di.DI -import org.kodein.di.instance - -class SearchScreenComponent( - componentContext: ComponentContext, - override val di: DI, - private val watchVideo: (String, Series, Series.Episode, Collection) -> Unit, - private val scrollEnabled: (Boolean) -> Unit -) : SearchComponent, ComponentContext by componentContext { - - private val searchStateMachine: SearchStateMachine by di.instance() - override val searchState: StateFlow = searchStateMachine.state.flowOn(ioDispatcher()).stateIn(ioScope(), SharingStarted.WhileSubscribed(), SearchState.Loading) - - private val allGenres = searchState.mapNotNull { it.safeCast() }.map { it.genres }.flowOn(ioDispatcher()).stateIn(ioScope(), SharingStarted.WhileSubscribed(), emptyList()) - private val allItems = allGenres.map { it.flatMap { g -> g.items } }.flowOn(ioDispatcher()) - private val maxGenres = allGenres.map { it.size }.flowOn(ioDispatcher()) - private val loadedGenres = MutableStateFlow(1) - - override val genres: StateFlow> = combine(allGenres, loadedGenres) { t1, t2 -> - t1.safeSubList(0, t2) - }.flowOn(ioDispatcher()).stateIn(ioScope(), SharingStarted.WhileSubscribed(), emptyList()) - - override val canLoadMoreGenres: StateFlow = combine(loadedGenres, maxGenres) { t1, t2 -> - t1 < t2 - }.flowOn(ioDispatcher()).stateIn(ioScope(), SharingStarted.WhileSubscribed(), allGenres.value.size > loadedGenres.value) - - - private val searchQuery: MutableStateFlow = MutableStateFlow(String()) - override val searchItems: StateFlow> = combine(allItems, searchQuery) { t1, t2 -> - if (t2.isBlank()) { - emptyList() - } else { - coroutineScope { - t1.map { - async { - when { - it.title.trim().equals(t2.trim(), true) -> it to 1.0 - it.title.trim().startsWith(t2.trim(), true) -> it to 0.95 - it.title.trim().contains(t2.trim(), true) -> it to 0.9 - else -> it to JaroWinkler.distance(it.title.trim(), t2.trim()) - } - } - }.awaitAll().filter { - it.second > 0.85 - }.sortedByDescending { it.second }.map { it.first }.safeSubList(0, 10) - } - } - }.flowOn(ioDispatcher()).stateIn(ioScope(), SharingStarted.WhileSubscribed(), emptyList()) - - private val navigation = SlotNavigation() - override val child: Value> = childSlot( - source = navigation, - serializer = SearchConfig.serializer(), - handleBackButton = false - ) { config, context -> - when (config) { - is SearchConfig.Series -> SeriesScreenComponent( - componentContext = context, - di = di, - initialTitle = config.title, - initialHref = config.href, - initialCoverHref = null, - onGoBack = { - dismissHoldingSeries() - }, - watchVideo = { schemeKey, series, episode, stream -> - watchVideo(schemeKey, series, episode, stream) - } - ) - } - } - - @Composable - override fun render() { - CompositionLocalProvider( - LocalDI provides di - ) { - SearchScreen(this) - } - SideEffect { - Crashlytics.screen(this) - } - } - - override fun retryLoadingSearch(): Any? = ioScope().launchIO { - searchStateMachine.dispatch(SearchAction.Retry) - } - - override fun loadMoreGenres(): Any? = ioScope().launchIO { - loadedGenres.update { it + 1 } - } - - override fun searchQuery(text: String) { - searchQuery.value = text.trim() - } - - override fun itemClicked(config: SearchConfig) { - navigation.activate(config) { - scrollEnabled(false) - } - } - - override fun dismissHoldingSeries() { - navigation.dismiss(scrollEnabled) - } -} \ No newline at end of file