diff --git a/i18n/src/commonMain/resources/MR/values/base/strings.xml b/i18n/src/commonMain/resources/MR/values/base/strings.xml index 17badfad..0d4eb98d 100644 --- a/i18n/src/commonMain/resources/MR/values/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/values/base/strings.xml @@ -49,6 +49,7 @@ Library Updates Sources + Global search Extensions Downloads Settings @@ -91,6 +92,7 @@ Reset Filter In library + No results found Default diff --git a/presentation/src/androidMain/kotlin/ca/gosyer/ui/base/vm/ViewModelFactory.kt b/presentation/src/androidMain/kotlin/ca/gosyer/ui/base/vm/ViewModelFactory.kt index c1f262f1..b84b4a1c 100644 --- a/presentation/src/androidMain/kotlin/ca/gosyer/ui/base/vm/ViewModelFactory.kt +++ b/presentation/src/androidMain/kotlin/ca/gosyer/ui/base/vm/ViewModelFactory.kt @@ -26,6 +26,7 @@ import ca.gosyer.ui.settings.ThemesViewModel import ca.gosyer.ui.sources.SourcesScreenViewModel import ca.gosyer.ui.sources.browse.SourceScreenViewModel import ca.gosyer.ui.sources.browse.filter.SourceFiltersViewModel +import ca.gosyer.ui.sources.globalsearch.GlobalSearchViewModel import ca.gosyer.ui.sources.home.SourceHomeScreenViewModel import ca.gosyer.ui.sources.settings.SourceSettingsScreenViewModel import ca.gosyer.ui.updates.UpdatesScreenViewModel @@ -56,6 +57,7 @@ actual class ViewModelFactoryImpl( private val sourceFiltersFactory: (params: SourceFiltersViewModel.Params) -> SourceFiltersViewModel, private val sourceSettingsFactory: (params: SourceSettingsScreenViewModel.Params) -> SourceSettingsScreenViewModel, private val sourceHomeFactory: () -> SourceHomeScreenViewModel, + private val globalSearchFactory: (params: GlobalSearchViewModel.Params) -> GlobalSearchViewModel, private val sourceFactory: (params: SourceScreenViewModel.Params) -> SourceScreenViewModel, private val sourcesFactory: () -> SourcesScreenViewModel, private val updatesFactory: () -> UpdatesScreenViewModel @@ -84,6 +86,7 @@ actual class ViewModelFactoryImpl( SourceFiltersViewModel::class -> sourceFiltersFactory(arg1 as SourceFiltersViewModel.Params) SourceSettingsScreenViewModel::class -> sourceSettingsFactory(arg1 as SourceSettingsScreenViewModel.Params) SourceHomeScreenViewModel::class -> sourceHomeFactory() + GlobalSearchViewModel::class -> globalSearchFactory(arg1 as GlobalSearchViewModel.Params) SourceScreenViewModel::class -> sourceFactory(arg1 as SourceScreenViewModel.Params) SourcesScreenViewModel::class -> sourcesFactory() UpdatesScreenViewModel::class -> updatesFactory() diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/vm/ViewModelFactory.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/vm/ViewModelFactory.kt index c24e6e67..29411207 100644 --- a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/vm/ViewModelFactory.kt +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/vm/ViewModelFactory.kt @@ -27,6 +27,7 @@ import ca.gosyer.ui.settings.ThemesViewModel import ca.gosyer.ui.sources.SourcesScreenViewModel import ca.gosyer.ui.sources.browse.SourceScreenViewModel import ca.gosyer.ui.sources.browse.filter.SourceFiltersViewModel +import ca.gosyer.ui.sources.globalsearch.GlobalSearchViewModel import ca.gosyer.ui.sources.home.SourceHomeScreenViewModel import ca.gosyer.ui.sources.settings.SourceSettingsScreenViewModel import ca.gosyer.ui.updates.UpdatesScreenViewModel @@ -58,6 +59,7 @@ actual class ViewModelFactoryImpl( private val sourceFiltersFactory: (params: SourceFiltersViewModel.Params) -> SourceFiltersViewModel, private val sourceSettingsFactory: (params: SourceSettingsScreenViewModel.Params) -> SourceSettingsScreenViewModel, private val sourceHomeFactory: () -> SourceHomeScreenViewModel, + private val globalSearchFactory: (params: GlobalSearchViewModel.Params) -> GlobalSearchViewModel, private val sourceFactory: (params: SourceScreenViewModel.Params) -> SourceScreenViewModel, private val sourcesFactory: () -> SourcesScreenViewModel, private val updatesFactory: () -> UpdatesScreenViewModel @@ -87,6 +89,7 @@ actual class ViewModelFactoryImpl( SourceFiltersViewModel::class -> sourceFiltersFactory(arg1 as SourceFiltersViewModel.Params) SourceSettingsScreenViewModel::class -> sourceSettingsFactory(arg1 as SourceSettingsScreenViewModel.Params) SourceHomeScreenViewModel::class -> sourceHomeFactory() + GlobalSearchViewModel::class -> globalSearchFactory(arg1 as GlobalSearchViewModel.Params) SourceScreenViewModel::class -> sourceFactory(arg1 as SourceScreenViewModel.Params) SourcesScreenViewModel::class -> sourcesFactory() UpdatesScreenViewModel::class -> updatesFactory() diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/browse/SourceScreen.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/browse/SourceScreen.kt index 31649c53..999d2af0 100644 --- a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/browse/SourceScreen.kt +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/browse/SourceScreen.kt @@ -20,14 +20,14 @@ import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow -class SourceScreen(val source: Source) : Screen { +class SourceScreen(val source: Source, val initialQuery: String? = null) : Screen { override val key: ScreenKey = source.id.toString() @Composable override fun Content() { val sourceVM = viewModel { - instantiate(SourceScreenViewModel.Params(source)) + instantiate(SourceScreenViewModel.Params(source, initialQuery)) } val filterVM = viewModel { instantiate(SourceFiltersViewModel.Params(source.id)) diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/browse/SourceScreenViewModel.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/browse/SourceScreenViewModel.kt index 3af7d448..6e63e315 100644 --- a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/browse/SourceScreenViewModel.kt +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/browse/SourceScreenViewModel.kt @@ -29,7 +29,8 @@ class SourceScreenViewModel( private val sourceHandler: SourceInteractionHandler, private val catalogPreferences: CatalogPreferences, private val libraryPreferences: LibraryPreferences, - contextWrapper: ContextWrapper + contextWrapper: ContextWrapper, + initialQuery: String? ) : ViewModel(contextWrapper) { @Inject constructor( @@ -43,7 +44,8 @@ class SourceScreenViewModel( sourceHandler, catalogPreferences, libraryPreferences, - contextWrapper + contextWrapper, + params.initialQuery ) val displayMode = catalogPreferences.displayMode().stateIn(scope) @@ -67,10 +69,10 @@ class SourceScreenViewModel( private val _usingFilters = MutableStateFlow(false) - private val _sourceSearchQuery = MutableStateFlow(null) + private val _sourceSearchQuery = MutableStateFlow(initialQuery) val sourceSearchQuery = _sourceSearchQuery.asStateFlow() - private val _query = MutableStateFlow(null) + private val _query = MutableStateFlow(sourceSearchQuery.value) private val _pageNum = MutableStateFlow(1) val pageNum = _pageNum.asStateFlow() @@ -156,7 +158,7 @@ class SourceScreenViewModel( catalogPreferences.displayMode().set(displayMode) } - data class Params(val source: Source) + data class Params(val source: Source, val initialQuery: String?) private companion object : CKLogger({}) } diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/GlobalSearchScreen.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/GlobalSearchScreen.kt new file mode 100644 index 00000000..2ad226f4 --- /dev/null +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/GlobalSearchScreen.kt @@ -0,0 +1,53 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.sources.globalsearch + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import ca.gosyer.ui.manga.MangaScreen +import ca.gosyer.ui.sources.browse.SourceScreen +import ca.gosyer.ui.sources.components.LocalSourcesNavigator +import ca.gosyer.ui.sources.globalsearch.components.GlobalSearchScreenContent +import ca.gosyer.uicore.vm.viewModel +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow + +class GlobalSearchScreen(private val initialQuery: String) : Screen { + + override val key: ScreenKey = uniqueScreenKey + + @Composable + override fun Content() { + val vm = viewModel { + instantiate(GlobalSearchViewModel.Params(initialQuery)) + } + val sourcesNavigator = LocalSourcesNavigator.current + val navigator = LocalNavigator.currentOrThrow + + GlobalSearchScreenContent( + sources = vm.sources.collectAsState().value, + results = vm.results, + displayMode = vm.displayMode.collectAsState().value, + query = vm.query.collectAsState().value, + setQuery = vm::setQuery, + submitSearch = vm::startSearch, + onSourceClick = { + if (sourcesNavigator != null) { + sourcesNavigator.select(it, vm.query.value) + } else { + navigator push SourceScreen(it, vm.query.value) + } + }, + onMangaClick = { + navigator push MangaScreen(it.id) + } + ) + } +} \ No newline at end of file diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/GlobalSearchViewModel.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/GlobalSearchViewModel.kt new file mode 100644 index 00000000..73edae4f --- /dev/null +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/GlobalSearchViewModel.kt @@ -0,0 +1,149 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.sources.globalsearch + +import androidx.compose.runtime.snapshots.SnapshotStateMap +import ca.gosyer.core.logging.CKLogger +import ca.gosyer.data.catalog.CatalogPreferences +import ca.gosyer.data.models.MangaPage +import ca.gosyer.data.models.Source +import ca.gosyer.data.server.interactions.SourceInteractionHandler +import ca.gosyer.i18n.MR +import ca.gosyer.uicore.vm.ContextWrapper +import ca.gosyer.uicore.vm.ViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import me.tatarka.inject.annotations.Inject + +class GlobalSearchViewModel @Inject constructor( + private val sourceHandler: SourceInteractionHandler, + catalogPreferences: CatalogPreferences, + contextWrapper: ContextWrapper, + params: Params +) : ViewModel(contextWrapper) { + private val _query = MutableStateFlow(params.initialQuery) + val query = _query.asStateFlow() + + private val installedSources = MutableStateFlow(emptyList()) + + private val languages = catalogPreferences.languages().stateIn(scope) + val displayMode = catalogPreferences.displayMode().stateIn(scope) + + private val _isLoading = MutableStateFlow(true) + val isLoading = _isLoading.asStateFlow() + + val sources = combine(installedSources, languages) { installedSources, languages -> + installedSources.filter { + it.lang in languages || it.id == Source.LOCAL_SOURCE_ID + } + }.stateIn(scope, SharingStarted.Eagerly, emptyList()) + + private val search = MutableStateFlow(params.initialQuery) + + val results = SnapshotStateMap() + + init { + getSources() + readySearch() + } + + private fun getSources() { + sourceHandler.getSourceList() + .onEach { sources -> + installedSources.value = sources.sortedWith( + compareBy(String.CASE_INSENSITIVE_ORDER) { it.lang } + .thenBy(String.CASE_INSENSITIVE_ORDER) { + it.name + } + ) + _isLoading.value = false + } + .catch { + info(it) { "Error getting sources" } + _isLoading.value = false + } + .launchIn(scope) + } + + private val semaphore = Semaphore(5) + + private fun readySearch() { + search + .combine(sources) { query, sources -> + query to sources + } + .mapLatest { (query, sources) -> + results.clear() + supervisorScope { + sources.map { source -> + async { + semaphore.withPermit { + sourceHandler + .getSearchResults(source, query, 1) + .map { + if (it.mangaList.isEmpty()) { + Search.Failure(MR.strings.no_results_found.toPlatformString()) + } else { + Search.Success(it) + } + } + .catch { + info(it) { "Error getting search from ${source.displayName}" } + emit(Search.Failure(it)) + } + .onEach { + results[source.id] = it + } + .collect() + } + } + }.awaitAll() + } + } + .catch { + info(it) { "Error getting sources" } + } + .flowOn(Dispatchers.IO) + .launchIn(scope) + } + + fun setQuery(query: String) { + _query.value = query + } + + fun startSearch(query: String) { + search.value = query + } + + data class Params(val initialQuery: String) + + sealed class Search { + object Searching : Search() + data class Success(val mangaPage: MangaPage) : Search() + data class Failure(val e: String?) : Search() { + constructor(e: Throwable) : this(e.message) + } + } + + private companion object : CKLogger({}) +} diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/components/GlobalSearchMangaComfortableGrid.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/components/GlobalSearchMangaComfortableGrid.kt new file mode 100644 index 00000000..76c4da83 --- /dev/null +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/components/GlobalSearchMangaComfortableGrid.kt @@ -0,0 +1,73 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.sources.globalsearch.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times +import ca.gosyer.data.models.Manga +import ca.gosyer.ui.sources.browse.components.SourceMangaBadges +import ca.gosyer.uicore.components.mangaAspectRatio +import ca.gosyer.uicore.image.KamelImage +import io.kamel.image.lazyPainterResource + +@Composable +fun GlobalSearchMangaComfortableGridItem( + modifier: Modifier, + manga: Manga, + inLibrary: Boolean +) { + val cover = lazyPainterResource(manga, filterQuality = FilterQuality.Medium) + val fontStyle = LocalTextStyle.current.merge( + TextStyle(letterSpacing = 0.sp, fontFamily = FontFamily.SansSerif, fontSize = 14.sp) + ) + + Box( + modifier = Modifier + .padding(4.dp) + .width((mangaAspectRatio * 200.dp)) + .clip(MaterialTheme.shapes.medium) then modifier + ) { + Column { + KamelImage( + cover, + contentDescription = manga.title, + modifier = Modifier + .height(200.dp) + .aspectRatio(mangaAspectRatio, true) + .clip(MaterialTheme.shapes.medium), + contentScale = ContentScale.Crop + ) + Text( + text = manga.title, + style = fontStyle, + maxLines = 3, + modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp) + ) + } + SourceMangaBadges( + inLibrary = inLibrary, + modifier = Modifier.padding(4.dp) + ) + } +} diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/components/GlobalSearchMangaCompactGrid.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/components/GlobalSearchMangaCompactGrid.kt new file mode 100644 index 00000000..74e09e34 --- /dev/null +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/components/GlobalSearchMangaCompactGrid.kt @@ -0,0 +1,85 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.sources.globalsearch.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ca.gosyer.data.models.Manga +import ca.gosyer.ui.sources.browse.components.SourceMangaBadges +import ca.gosyer.uicore.components.mangaAspectRatio +import ca.gosyer.uicore.image.KamelImage +import io.kamel.image.lazyPainterResource + +@Composable +fun GlobalSearchMangaCompactGridItem( + modifier: Modifier, + manga: Manga, + inLibrary: Boolean +) { + val cover = lazyPainterResource(manga, filterQuality = FilterQuality.Medium) + val fontStyle = LocalTextStyle.current.merge( + TextStyle(letterSpacing = 0.sp, fontFamily = FontFamily.SansSerif, fontSize = 14.sp) + ) + + Box( + modifier = Modifier.padding(4.dp) + .height(200.dp) + .aspectRatio(mangaAspectRatio, true) + .clip(MaterialTheme.shapes.medium) then modifier + ) { + KamelImage( + cover, + manga.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + Box(modifier = Modifier.fillMaxSize().then(shadowGradient)) + Text( + text = manga.title, + color = Color.White, + style = fontStyle, + maxLines = 2, + modifier = Modifier.align(Alignment.BottomStart).padding(8.dp) + ) + SourceMangaBadges( + inLibrary = inLibrary, + modifier = Modifier.padding(4.dp) + ) + } +} + +private val shadowGradient = Modifier.drawWithCache { + val gradient = Brush.linearGradient( + 0.75f to Color.Transparent, + 1.0f to Color(0xAA000000), + start = Offset(0f, 0f), + end = Offset(0f, size.height) + ) + onDrawBehind { + drawRect(gradient) + } +} diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/components/GlobalSearchScreenContent.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/components/GlobalSearchScreenContent.kt new file mode 100644 index 00000000..b5805390 --- /dev/null +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/globalsearch/components/GlobalSearchScreenContent.kt @@ -0,0 +1,185 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.sources.globalsearch.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowForward +import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ca.gosyer.data.library.model.DisplayMode +import ca.gosyer.data.models.Manga +import ca.gosyer.data.models.Source +import ca.gosyer.i18n.MR +import ca.gosyer.ui.base.components.HorizontalScrollbar +import ca.gosyer.ui.base.components.VerticalScrollbar +import ca.gosyer.ui.base.components.localeToString +import ca.gosyer.ui.base.components.rememberScrollbarAdapter +import ca.gosyer.ui.base.navigation.Toolbar +import ca.gosyer.ui.sources.globalsearch.GlobalSearchViewModel.Search +import ca.gosyer.uicore.components.ErrorScreen +import ca.gosyer.uicore.resources.stringResource + +@Composable +fun GlobalSearchScreenContent( + sources: List, + results: SnapshotStateMap, + displayMode: DisplayMode, + query: String, + setQuery: (String) -> Unit, + submitSearch: (String) -> Unit, + onSourceClick: (Source) -> Unit, + onMangaClick: (Manga) -> Unit +) { + Scaffold( + topBar = { + Toolbar( + name = stringResource(MR.strings.location_global_search), + searchText = query, + search = setQuery, + searchSubmit = { submitSearch(query) } + ) + } + ) { padding -> + Box(Modifier.padding(padding)) { + val state = rememberLazyListState() + LazyColumn(state = state) { + val sourcesSuccess = sources.filter { results[it.id] is Search.Success } + val loadingSources = sources.filter { results[it.id] == null } + val failedSources = sources.filter { results[it.id] is Search.Failure } + items(sourcesSuccess) { + GlobalSearchItem( + source = it, + search = results[it.id] ?: Search.Searching, + displayMode = displayMode, + onSourceClick = onSourceClick, + onMangaClick = onMangaClick + ) + } + items(loadingSources) { + GlobalSearchItem( + source = it, + search = results[it.id] ?: Search.Searching, + displayMode = displayMode, + onSourceClick = onSourceClick, + onMangaClick = onMangaClick + ) + } + items(failedSources) { + GlobalSearchItem( + source = it, + search = results[it.id] ?: Search.Searching, + displayMode = displayMode, + onSourceClick = onSourceClick, + onMangaClick = onMangaClick + ) + } + } + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd) + .fillMaxHeight() + .padding(horizontal = 4.dp, vertical = 8.dp), + adapter = rememberScrollbarAdapter(state) + ) + } + } +} + +@Composable +fun GlobalSearchItem( + source: Source, + search: Search, + displayMode: DisplayMode, + onSourceClick: (Source) -> Unit, + onMangaClick: (Manga) -> Unit +) { + Column { + Row( + Modifier.fillMaxWidth() + .clickable { onSourceClick(source) } + .padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + source.name, + maxLines = 1, + fontSize = 16.sp + ) + Text( + localeToString(source.displayLang), + maxLines = 1, + fontSize = 12.sp + ) + } + Icon(Icons.Rounded.ArrowForward, stringResource(MR.strings.action_search)) + } + + Spacer(Modifier.height(4.dp)) + when (search) { + is Search.Failure -> Box(Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 16.dp), contentAlignment = Alignment.Center) { + ErrorScreen(search.e) + } + Search.Searching -> Box(Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + is Search.Success -> Box( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + contentAlignment = Alignment.Center + ) { + val state = rememberLazyListState() + LazyRow(Modifier.fillMaxSize(), state) { + items(search.mangaPage.mangaList) { + if (displayMode == DisplayMode.ComfortableGrid) { + GlobalSearchMangaComfortableGridItem( + Modifier.clickable { onMangaClick(it) }, + it, + it.inLibrary + ) + } else { + GlobalSearchMangaCompactGridItem( + Modifier.clickable { onMangaClick(it) }, + it, + it.inLibrary + ) + } + } + } + HorizontalScrollbar( + rememberScrollbarAdapter(state), + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + ) + } + } + } +} \ No newline at end of file diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/home/SourceHomeScreen.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/home/SourceHomeScreen.kt index cb43a7cb..1af9cdfe 100644 --- a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/home/SourceHomeScreen.kt +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/home/SourceHomeScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import ca.gosyer.ui.sources.browse.SourceScreen import ca.gosyer.ui.sources.components.LocalSourcesNavigator +import ca.gosyer.ui.sources.globalsearch.GlobalSearchScreen import ca.gosyer.ui.sources.home.components.SourceHomeScreenContent import ca.gosyer.uicore.vm.viewModel import cafe.adriel.voyager.core.screen.Screen @@ -37,7 +38,12 @@ class SourceHomeScreen : Screen { sources = vm.sources.collectAsState().value, languages = vm.languages.collectAsState().value, sourceLanguages = vm.sourceLanguages.collectAsState().value, - setEnabledLanguages = vm::setEnabledLanguages + setEnabledLanguages = vm::setEnabledLanguages, + query = vm.query.collectAsState().value, + setQuery = vm::setQuery, + submitSearch = { + navigator push GlobalSearchScreen(it) + } ) } } diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/home/SourceHomeScreenViewModel.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/home/SourceHomeScreenViewModel.kt index 0fd50ad8..b1cb4671 100644 --- a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/home/SourceHomeScreenViewModel.kt +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/home/SourceHomeScreenViewModel.kt @@ -46,6 +46,9 @@ class SourceHomeScreenViewModel @Inject constructor( sources.map { it.lang }.toSet() - setOf(Source.LOCAL_SOURCE_LANG) }.stateIn(scope, SharingStarted.Eagerly, emptySet()) + private val _query = MutableStateFlow("") + val query = _query.asStateFlow() + init { getSources() } @@ -73,5 +76,9 @@ class SourceHomeScreenViewModel @Inject constructor( _languages.value = langs } + fun setQuery(query: String) { + _query.value = query + } + private companion object : CKLogger({}) } diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/home/components/SourceHomeScreenContent.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/home/components/SourceHomeScreenContent.kt index bc5f4c0c..13070b0b 100644 --- a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/home/components/SourceHomeScreenContent.kt +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/sources/home/components/SourceHomeScreenContent.kt @@ -64,13 +64,19 @@ fun SourceHomeScreenContent( sources: List, languages: Set, sourceLanguages: Set, - setEnabledLanguages: (Set) -> Unit + setEnabledLanguages: (Set) -> Unit, + query: String, + setQuery: (String) -> Unit, + submitSearch: (String) -> Unit ) { val languageDialogState = rememberMaterialDialogState() Scaffold( topBar = { SourceHomeScreenToolbar( - languageDialogState::show + openEnabledLanguagesClick = languageDialogState::show, + query = query, + setQuery = setQuery, + submitSearch = submitSearch ) } ) { @@ -106,7 +112,10 @@ fun SourceHomeScreenContent( @Composable fun SourceHomeScreenToolbar( - openEnabledLanguagesClick: () -> Unit + openEnabledLanguagesClick: () -> Unit, + query: String, + setQuery: (String) -> Unit, + submitSearch: (String) -> Unit ) { Toolbar( stringResource(MR.strings.location_sources), @@ -114,6 +123,11 @@ fun SourceHomeScreenToolbar( getActionItems( openEnabledLanguagesClick = openEnabledLanguagesClick ) + }, + searchText = query, + search = setQuery, + searchSubmit = { + submitSearch(query) } ) }