Search
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Int>? = null
|
||||
private var job: Job? = null
|
||||
|
||||
suspend fun load(range: IntRange): Result<Pair<List<GalleryInfo>, 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()
|
||||
}
|
||||
@@ -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<Int> {
|
||||
val result = mutableSetOf<Int>()
|
||||
val result = LinkedHashSet<Int>()
|
||||
|
||||
while (this.hasRemaining()) {
|
||||
result.add(this.get())
|
||||
@@ -45,6 +46,11 @@ fun IntBuffer.toSet(): Set<Int> {
|
||||
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<GalleryInfo>(
|
||||
httpClient.get("https://$domain/galleries/$galleryID.js").bodyAsText()
|
||||
.replace("var galleryinfo = ", "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: SearchQuery?): Result<Set<Int>> = runCatching {
|
||||
when (query) {
|
||||
is SearchQuery.Tag -> getGalleryIDsForQuery(query).toSet()
|
||||
@@ -203,7 +218,7 @@ object HitomiHttpClient {
|
||||
|
||||
val queriedGalleries = search(query.query).getOrThrow()
|
||||
|
||||
val result = mutableSetOf<Int>()
|
||||
val result = LinkedHashSet<Int>()
|
||||
|
||||
with (allGalleries.await()) {
|
||||
while (this.hasRemaining()) {
|
||||
@@ -241,7 +256,7 @@ object HitomiHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
val result = mutableSetOf<Int>()
|
||||
val result = LinkedHashSet<Int>()
|
||||
|
||||
queries.forEach {
|
||||
val queryResult = it.await()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DisplayFeature>,
|
||||
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) {
|
||||
|
||||
@@ -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<DisplayFeature>,
|
||||
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<GalleryInfo> = emptyList(),
|
||||
galleries: List<GalleryInfo>,
|
||||
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<Int> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<MainUIState> = _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<GalleryInfo>() 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<GalleryInfo> = 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
|
||||
)
|
||||
Reference in New Issue
Block a user