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)
}
)
}