diff --git a/app/build.gradle b/app/build.gradle index bffcd6d0..faec0d31 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -105,8 +105,6 @@ dependencies { annotationProcessor "androidx.room:room-compiler:$room_version" ksp "androidx.room:room-compiler:$room_version" - implementation "androidx.paging:paging-compose:3.2.1" - implementation "io.ktor:ktor-client-core:2.3.8" implementation "io.ktor:ktor-client-okhttp:2.3.8" diff --git a/app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt b/app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt index 4739fc67..961652b8 100644 --- a/app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt +++ b/app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt @@ -18,7 +18,7 @@ data class Group(val group: String): TagLike { } @Serializable -data class Series(val series: String): TagLike { +data class Series(@SerialName("parody") val series: String): TagLike { override fun toTag() = SearchQuery.Tag("series", series) } diff --git a/app/src/main/java/xyz/quaver/pupil/networking/GallerySearchSource.kt b/app/src/main/java/xyz/quaver/pupil/networking/GallerySearchSource.kt new file mode 100644 index 00000000..43092753 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/networking/GallerySearchSource.kt @@ -0,0 +1,36 @@ +package xyz.quaver.pupil.networking + +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +class GallerySearchSource(val query: SearchQuery?) { + private var searchResult: List? = null + private var job: Job? = null + + suspend fun load(range: IntRange): Result, Int>> = runCatching { + val searchResult = searchResult ?: ( + HitomiHttpClient + .search(query) + .getOrThrow() + .toList() + .also { searchResult = it } + ) + + val galleryResults = coroutineScope { + searchResult.slice(range).map { galleryID -> + async { + HitomiHttpClient.getGalleryInfo(galleryID) + } + } + } + + val galleries = galleryResults.map { result -> + result.await().getOrThrow() + } + + Pair(galleries, searchResult.size) + } + + fun cancel() = job?.cancel() +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt b/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt index 930fd554..95b7a3cc 100644 --- a/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt +++ b/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import xyz.quaver.pupil.hitomi.max_node_size import java.nio.ByteBuffer import java.nio.ByteOrder @@ -36,7 +37,7 @@ data class Suggestion( ) fun IntBuffer.toSet(): Set { - val result = mutableSetOf() + val result = LinkedHashSet() while (this.hasRemaining()) { result.add(this.get()) @@ -45,6 +46,11 @@ fun IntBuffer.toSet(): Set { return result } +private val json = Json { + isLenient = true + ignoreUnknownKeys = true +} + object HitomiHttpClient { private val httpClient = HttpClient(OkHttp) @@ -193,6 +199,15 @@ object HitomiHttpClient { data?.let { getSuggestionsFromData(field, data) } ?: emptyList() } + suspend fun getGalleryInfo(galleryID: Int) = runCatching { + withContext(Dispatchers.IO) { + json.decodeFromString( + httpClient.get("https://$domain/galleries/$galleryID.js").bodyAsText() + .replace("var galleryinfo = ", "") + ) + } + } + suspend fun search(query: SearchQuery?): Result> = runCatching { when (query) { is SearchQuery.Tag -> getGalleryIDsForQuery(query).toSet() @@ -203,7 +218,7 @@ object HitomiHttpClient { val queriedGalleries = search(query.query).getOrThrow() - val result = mutableSetOf() + val result = LinkedHashSet() with (allGalleries.await()) { while (this.hasRemaining()) { @@ -241,7 +256,7 @@ object HitomiHttpClient { } } - val result = mutableSetOf() + val result = LinkedHashSet() queries.forEach { val queryResult = it.await() diff --git a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt index 88705e79..1141524a 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt @@ -30,6 +30,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.adaptive.calculateDisplayFeatures import xyz.quaver.pupil.ui.composable.MainApp import xyz.quaver.pupil.ui.theme.AppTheme +import xyz.quaver.pupil.ui.viewmodel.MainUIState import xyz.quaver.pupil.ui.viewmodel.MainViewModel class MainActivity : BaseActivity() { @@ -54,7 +55,9 @@ class MainActivity : BaseActivity() { displayFeatures = displayFeatures, uiState = uiState, navigateToDestination = viewModel::navigateToDestination, - closeDetailScreen = viewModel::closeDetailScreen + closeDetailScreen = viewModel::closeDetailScreen, + onQueryChange = viewModel::onQueryChange, + loadSearchResult = viewModel::loadSearchResult ) } } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt index 6f9793a9..c0603545 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier import androidx.window.layout.DisplayFeature import androidx.window.layout.FoldingFeature import kotlinx.coroutines.launch +import xyz.quaver.pupil.networking.SearchQuery import xyz.quaver.pupil.ui.viewmodel.MainUIState @Composable @@ -32,7 +33,9 @@ fun MainApp( displayFeatures: List, uiState: MainUIState, navigateToDestination: (MainDestination) -> Unit, - closeDetailScreen: () -> Unit + closeDetailScreen: () -> Unit, + onQueryChange: (SearchQuery?) -> Unit, + loadSearchResult: (IntRange) -> Unit ) { val navigationType: NavigationType val contentType: ContentType @@ -85,7 +88,9 @@ fun MainApp( navigationContentPosition, uiState, navigateToDestination, - closeDetailScreen = closeDetailScreen + closeDetailScreen = closeDetailScreen, + onQueryChange = onQueryChange, + loadSearchResult = loadSearchResult ) } @@ -97,7 +102,9 @@ private fun MainNavigationWrapper( navigationContentPosition: NavigationContentPosition, uiState: MainUIState, navigateToDestination: (MainDestination) -> Unit, - closeDetailScreen: () -> Unit + closeDetailScreen: () -> Unit, + onQueryChange: (SearchQuery?) -> Unit, + loadSearchResult: (IntRange) -> Unit ) { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val coroutineScope = rememberCoroutineScope() @@ -126,7 +133,9 @@ private fun MainNavigationWrapper( uiState = uiState, navigateToDestination = navigateToDestination, onDrawerClicked = openDrawer, - closeDetailScreen = closeDetailScreen + closeDetailScreen = closeDetailScreen, + onQueryChange = onQueryChange, + loadSearchResult = loadSearchResult ) } } else { @@ -153,7 +162,9 @@ private fun MainNavigationWrapper( uiState = uiState, navigateToDestination = navigateToDestination, onDrawerClicked = openDrawer, - closeDetailScreen = closeDetailScreen + closeDetailScreen = closeDetailScreen, + onQueryChange = onQueryChange, + loadSearchResult = loadSearchResult ) } } @@ -168,7 +179,9 @@ fun MainContent( uiState: MainUIState, navigateToDestination: (MainDestination) -> Unit, onDrawerClicked: () -> Unit, - closeDetailScreen: () -> Unit + closeDetailScreen: () -> Unit, + onQueryChange: (SearchQuery?) -> Unit, + loadSearchResult: (IntRange) -> Unit ) { Row(modifier = Modifier.fillMaxSize()) { AnimatedVisibility(visible = navigationType == NavigationType.NAVIGATION_RAIL) { @@ -197,7 +210,9 @@ fun MainContent( contentType = contentType, displayFeatures = displayFeatures, uiState = uiState, - closeDetailScreen = closeDetailScreen + closeDetailScreen = closeDetailScreen, + onQueryChange = onQueryChange, + loadSearchResult = loadSearchResult ) } AnimatedVisibility(visible = navigationType == NavigationType.BOTTOM_NAVIGATION) { diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainScreen.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainScreen.kt index cdbb3b3f..6fbd306c 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainScreen.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainScreen.kt @@ -1,6 +1,7 @@ package xyz.quaver.pupil.ui.composable import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn @@ -14,8 +15,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -23,9 +29,12 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -42,6 +51,7 @@ import androidx.compose.material.icons.filled.Male import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -61,10 +71,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.window.layout.DisplayFeature @@ -73,7 +83,6 @@ import com.google.accompanist.adaptive.TwoPane import xyz.quaver.pupil.R import xyz.quaver.pupil.networking.GalleryInfo import xyz.quaver.pupil.networking.SearchQuery -import xyz.quaver.pupil.networking.SearchQueryPreviewParameterProvider import xyz.quaver.pupil.ui.theme.Blue600 import xyz.quaver.pupil.ui.theme.Pink600 import xyz.quaver.pupil.ui.theme.Yellow400 @@ -217,7 +226,7 @@ fun TagChip( @Composable fun QueryView( - query: SearchQuery, + query: SearchQuery?, topLevel: Boolean = true ) { val modifier = if (topLevel) { @@ -227,6 +236,16 @@ fun QueryView( } when (query) { + null -> { + Text( + modifier = Modifier + .height(60.dp) + .wrapContentHeight() + .padding(horizontal = 16.dp), + text = stringResource(id = R.string.search_hint), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } is SearchQuery.Tag -> { TagChip( query, @@ -250,8 +269,8 @@ fun QueryView( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - query.queries.forEach { subquery -> - QueryView(subquery, topLevel = false) + query.queries.forEach { subQuery -> + QueryView(subQuery, topLevel = false) } } } @@ -272,17 +291,25 @@ fun QueryView( fun SearchBar( contentType: ContentType, query: SearchQuery?, - onQueryChange: (SearchQuery) -> Unit, + onQueryChange: (SearchQuery?) -> Unit, onSearch: () -> Unit, topOffset: Int, onTopOffsetChange: (Int) -> Unit, content: @Composable () -> Unit ) { - var focused by remember { mutableStateOf(true) } - val scrimAlpha: Float by animateFloatAsState(if (focused && contentType == ContentType.SINGLE_PANE) 0.3f else 0f, label = "skrim alpha") + var focused by remember { mutableStateOf(false) } + val scrimAlpha: Float by animateFloatAsState(if (focused && contentType == ContentType.SINGLE_PANE) 0.3f else 0f, label = "scrim alpha") val interactionSource = remember { MutableInteractionSource() } + val state = remember(query) { query.toEditableState() } + + LaunchedEffect(focused) { + if (!focused) { + onQueryChange(state.toSearchQuery()) + } + } + if (focused) { BackHandler { focused = false @@ -299,13 +326,15 @@ fun SearchBar( ) { focused = false } - .safeDrawingPadding() - .padding(16.dp) ) { val height: Dp by animateDpAsState(if (focused) maxHeight else 60.dp, label = "searchbar height") + content() + Card( modifier = Modifier + .safeDrawingPadding() + .padding(16.dp) .fillMaxWidth() .height(height) .clickable( @@ -321,17 +350,7 @@ fun SearchBar( ) else CardDefaults.cardElevation() ) { Box { - androidx.compose.animation.AnimatedVisibility(query == null && !focused, enter = fadeIn(), exit = fadeOut()) { - Text( - modifier = Modifier - .height(60.dp) - .wrapContentHeight() - .padding(horizontal = 16.dp), - text = stringResource(id = R.string.search_hint), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - } - androidx.compose.animation.AnimatedVisibility(query != null && !focused, enter = fadeIn(), exit = fadeOut()) { + androidx.compose.animation.AnimatedVisibility(!focused, enter = fadeIn(), exit = fadeOut()) { Row( modifier = Modifier .heightIn(min = 60.dp) @@ -339,13 +358,11 @@ fun SearchBar( verticalAlignment = Alignment.CenterVertically ) { Box(Modifier.size(8.dp)) - QueryView(query!!) + QueryView(query) Box(Modifier.size(8.dp)) } } androidx.compose.animation.AnimatedVisibility(focused, enter = fadeIn(), exit = fadeOut()) { - val state = remember(query) { query.toEditableState() } - Column( Modifier .fillMaxSize() @@ -373,7 +390,9 @@ fun MainScreen( contentType: ContentType, displayFeatures: List, uiState: MainUIState, - closeDetailScreen: () -> Unit + closeDetailScreen: () -> Unit, + onQueryChange: (SearchQuery?) -> Unit, + loadSearchResult: (IntRange) -> Unit ) { LaunchedEffect(contentType) { if (contentType == ContentType.SINGLE_PANE && !uiState.isDetailOnlyOpen) { @@ -383,12 +402,34 @@ fun MainScreen( val galleryLazyListState = rememberLazyListState() + val itemsPerPage by remember { mutableIntStateOf(20) } + + val currentRange = remember(uiState) { + if (uiState.currentRange != IntRange.EMPTY) { + uiState.currentRange + } else { + 0 ..< itemsPerPage + } + } + + val search = remember(currentRange) {{ loadSearchResult(currentRange) }} + + LaunchedEffect(Unit) { + search() + } + if (contentType == ContentType.DUAL_PANE) { TwoPane( first = { GalleryList( contentType = contentType, - galleryLazyListState = galleryLazyListState + galleries = uiState.galleries, + query = uiState.query, + loading = uiState.loading, + error = uiState.error, + galleryLazyListState = galleryLazyListState, + onQueryChange = onQueryChange, + search = search ) }, second = { @@ -400,7 +441,13 @@ fun MainScreen( } else { GalleryList( contentType = contentType, - galleryLazyListState = galleryLazyListState + galleries = uiState.galleries, + query = uiState.query, + loading = uiState.loading, + error = uiState.error, + galleryLazyListState = galleryLazyListState, + onQueryChange = onQueryChange, + search = search ) } } @@ -408,11 +455,13 @@ fun MainScreen( @Composable fun GalleryList( contentType: ContentType, - galleries: List = emptyList(), + galleries: List, + query: SearchQuery?, + loading: Boolean = false, + error: Boolean = false, openedGallery: GalleryInfo? = null, - query: SearchQuery? = SearchQueryPreviewParameterProvider().values.first(), - onQueryChange: (SearchQuery) -> Unit = {}, - onSearch: () -> Unit = { }, + onQueryChange: (SearchQuery?) -> Unit = {}, + search: () -> Unit = {}, selectedGalleryIds: Set = emptySet(), toggleGallerySelection: (Int) -> Unit = {}, galleryLazyListState: LazyListState, @@ -424,10 +473,43 @@ fun GalleryList( contentType = contentType, query = query, onQueryChange = onQueryChange, - onSearch = onSearch, + onSearch = search, topOffset = topOffset, onTopOffsetChange = { topOffset = it }, ) { - + AnimatedVisibility (loading) { + Box(Modifier.fillMaxSize()) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + } + AnimatedVisibility(error) { + Box(Modifier.fillMaxSize()) { + Column( + Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("(´∇`)", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)) + Text("No sources found!\nLet's go download one.", textAlign = TextAlign.Center) + } + } + } + AnimatedVisibility(!loading && !error) { + LazyColumn( + contentPadding = WindowInsets.systemBars.asPaddingValues().let { systemBarPaddingValues -> + val layoutDirection = LocalLayoutDirection.current + PaddingValues( + top = systemBarPaddingValues.calculateTopPadding() + 96.dp, + bottom = systemBarPaddingValues.calculateBottomPadding(), + start = systemBarPaddingValues.calculateStartPadding(layoutDirection), + end = systemBarPaddingValues.calculateEndPadding(layoutDirection), + ) + } + ) { + items(galleries) {galleryInfo -> + Text(galleryInfo.title) + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt index e62a2de1..2fe2a0e7 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt @@ -1,6 +1,5 @@ package xyz.quaver.pupil.ui.composable -import android.util.Log import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring @@ -10,7 +9,6 @@ import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer @@ -92,11 +90,23 @@ private fun SearchQuery.toEditableStateInternal(): EditableSearchQueryState = wh fun SearchQuery?.toEditableState(): EditableSearchQueryState.Root = EditableSearchQueryState.Root(this?.toEditableStateInternal()) +private fun EditableSearchQueryState.Tag.toSearchQueryInternal(): SearchQuery.Tag? = + if (namespace.value != null || tag.value.isNotBlank()) SearchQuery.Tag(namespace.value, tag.value.lowercase().trim()) else null + +private fun EditableSearchQueryState.And.toSearchQueryInternal(): SearchQuery.And? = + queries.mapNotNull { it.toSearchQueryInternal() }.let { if (it.isNotEmpty()) SearchQuery.And(it) else null } + +private fun EditableSearchQueryState.Or.toSearchQueryInternal(): SearchQuery.Or? = + queries.mapNotNull { it.toSearchQueryInternal() }.let { if (it.isNotEmpty()) SearchQuery.Or(it) else null } + +private fun EditableSearchQueryState.Not.toSearchQueryInternal(): SearchQuery.Not? = + query.value?.toSearchQueryInternal()?.let { SearchQuery.Not(it) } + private fun EditableSearchQueryState.toSearchQueryInternal(): SearchQuery? = when (this) { - is EditableSearchQueryState.Tag -> SearchQuery.Tag(namespace.value, tag.value) - is EditableSearchQueryState.And -> SearchQuery.And(queries.mapNotNull { it.toSearchQueryInternal() }) - is EditableSearchQueryState.Or -> SearchQuery.Or(queries.mapNotNull { it.toSearchQueryInternal() }) - is EditableSearchQueryState.Not -> query.value?.toSearchQueryInternal()?.let { SearchQuery.Not(it) } + is EditableSearchQueryState.Tag -> this.toSearchQueryInternal() + is EditableSearchQueryState.And -> this.toSearchQueryInternal() + is EditableSearchQueryState.Or -> this.toSearchQueryInternal() + is EditableSearchQueryState.Not -> this.toSearchQueryInternal() } fun EditableSearchQueryState.Root.toSearchQuery(): SearchQuery? @@ -162,9 +172,16 @@ fun TagSuggestionList( LaunchedEffect(namespace, tag) { suggestionList = null - suggestionList = HitomiHttpClient.getSuggestionsForQuery(SearchQuery.Tag(namespace, tag)) - .getOrDefault(emptyList()) - .filterNot { it.tag == SearchQuery.Tag(namespace, tag) } + + val searchQuery = state.toSearchQueryInternal() + + suggestionList = if (searchQuery != null) { + HitomiHttpClient.getSuggestionsForQuery(searchQuery) + .getOrDefault(emptyList()) + .filterNot { it.tag == SearchQuery.Tag(namespace, tag) } + } else { + emptyList() + } } val suggestionListSnapshot = suggestionList @@ -576,18 +593,6 @@ fun QueryEditorQueryView( EditableTagChip( newSearchQuery, requestScrollTo = requestScrollTo, - rightIcon = { - Icon( - modifier = Modifier - .padding(8.dp) - .size(16.dp) - .clickable { - onQueryRemove(state) - }, - imageVector = Icons.Default.RemoveCircleOutline, - contentDescription = stringResource(R.string.search_remove_query_item_description) - ) - } ) NewQueryChip(state) { newQueryState -> state.queries.add(newQueryState) @@ -677,26 +682,60 @@ fun QueryEditor( verticalArrangement = Arrangement.spacedBy(4.dp) ) { val rootQuerySnapshot = rootQuery + + val requestScrollTo: (Float) -> Unit = { target -> + val topYSnapshot = topY + + coroutineScope.launch { + scrollState.animateScrollBy(target - topYSnapshot - scrollOffset, spring(stiffness = Spring.StiffnessLow)) + } + } + + val requestScrollBy: (Float) -> Unit = { value -> + coroutineScope.launch { + scrollState.animateScrollBy(value) + } + } + if (rootQuerySnapshot != null) { QueryEditorQueryView( state = rootQuerySnapshot, onQueryRemove = { rootQuery = null }, - requestScrollTo = { target -> - val topYSnapshot = topY - - coroutineScope.launch { - scrollState.animateScrollBy(target - topYSnapshot - scrollOffset, spring(stiffness = Spring.StiffnessLow)) - } - }, - requestScrollBy = { value -> - coroutineScope.launch { - scrollState.animateScrollBy(value) - } - } + requestScrollTo = requestScrollTo, + requestScrollBy = requestScrollBy ) } if (rootQuerySnapshot is EditableSearchQueryState.Tag?) { + val newSearchQuery = remember { EditableSearchQueryState.Tag(expanded = true) } + + var newQueryNamespace by newSearchQuery.namespace + var newQueryTag by newSearchQuery.tag + var newQueryExpanded by newSearchQuery.expanded + + val offset = with (LocalDensity.current) { 40.dp.toPx() } + + LaunchedEffect(newQueryExpanded) { + if (!newQueryExpanded && (newQueryNamespace != null || newQueryTag.isNotBlank())) { + rootQuery = if (rootQuerySnapshot == null) { + EditableSearchQueryState.Tag(newQueryNamespace, newQueryTag) + } else { + EditableSearchQueryState.And(listOf( + rootQuerySnapshot, + EditableSearchQueryState.Tag(newQueryNamespace, newQueryTag) + )) + } + newQueryNamespace = null + newQueryTag = "" + newQueryExpanded = true + requestScrollBy(offset) + } + } + + EditableTagChip( + newSearchQuery, + requestScrollTo = requestScrollTo + ) NewQueryChip(rootQuerySnapshot) { newState -> rootQuery = coalesceTags(rootQuerySnapshot, newState) } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt index 381f5af8..66592ddb 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt @@ -1,9 +1,16 @@ package xyz.quaver.pupil.ui.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.observeOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import xyz.quaver.pupil.networking.GalleryInfo +import xyz.quaver.pupil.networking.GallerySearchSource import xyz.quaver.pupil.networking.SearchQuery import xyz.quaver.pupil.ui.composable.MainDestination import xyz.quaver.pupil.ui.composable.mainDestinations @@ -11,6 +18,8 @@ import xyz.quaver.pupil.ui.composable.mainDestinations class MainViewModel : ViewModel() { private val _uiState = MutableStateFlow(MainUIState()) val uiState: StateFlow = _uiState + private var searchSource: GallerySearchSource = GallerySearchSource(null) + private var job: Job? = null fun navigateToDestination(destination: MainDestination) { _uiState.value = MainUIState( @@ -24,6 +33,40 @@ class MainViewModel : ViewModel() { ) } + fun onQueryChange(query: SearchQuery?) { + _uiState.value = _uiState.value.copy( + query = query, + validRange = IntRange.EMPTY, + currentRange = IntRange.EMPTY + ) + + searchSource = GallerySearchSource(query) + } + + fun loadSearchResult(range: IntRange) { + job?.cancel() + job = viewModelScope.launch { + _uiState.value = _uiState.value.copy( + loading = true, + currentRange = range + ) + + var error = false + val (galleries, galleryCount) = searchSource.load(range).getOrElse { + error = true + it.printStackTrace() + emptyList() to 0 + } + + _uiState.value = _uiState.value.copy( + galleries = galleries, + validRange = IntRange(1, galleryCount), + error = error, + loading = false + ) + } + } + fun navigateToDetail() { } @@ -32,7 +75,11 @@ class MainViewModel : ViewModel() { data class MainUIState( val currentDestination: MainDestination = mainDestinations.first(), val query: SearchQuery? = null, - val loading: Boolean = true, + val galleries: List = emptyList(), + val loading: Boolean = false, + val error: Boolean = false, + val validRange: IntRange = IntRange.EMPTY, + val currentRange: IntRange = IntRange.EMPTY, val openedGallery: GalleryInfo? = null, val isDetailOnlyOpen: Boolean = false ) \ No newline at end of file