This commit is contained in:
tom5079
2024-03-17 15:02:35 -07:00
parent f34876ca93
commit 9e9a5998cd
9 changed files with 317 additions and 82 deletions

View File

@@ -105,8 +105,6 @@ dependencies {
annotationProcessor "androidx.room:room-compiler:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version"
ksp "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-core:2.3.8"
implementation "io.ktor:ktor-client-okhttp:2.3.8" implementation "io.ktor:ktor-client-okhttp:2.3.8"

View File

@@ -18,7 +18,7 @@ data class Group(val group: String): TagLike {
} }
@Serializable @Serializable
data class Series(val series: String): TagLike { data class Series(@SerialName("parody") val series: String): TagLike {
override fun toTag() = SearchQuery.Tag("series", series) override fun toTag() = SearchQuery.Tag("series", series)
} }

View File

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

View File

@@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import xyz.quaver.pupil.hitomi.max_node_size import xyz.quaver.pupil.hitomi.max_node_size
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
@@ -36,7 +37,7 @@ data class Suggestion(
) )
fun IntBuffer.toSet(): Set<Int> { fun IntBuffer.toSet(): Set<Int> {
val result = mutableSetOf<Int>() val result = LinkedHashSet<Int>()
while (this.hasRemaining()) { while (this.hasRemaining()) {
result.add(this.get()) result.add(this.get())
@@ -45,6 +46,11 @@ fun IntBuffer.toSet(): Set<Int> {
return result return result
} }
private val json = Json {
isLenient = true
ignoreUnknownKeys = true
}
object HitomiHttpClient { object HitomiHttpClient {
private val httpClient = HttpClient(OkHttp) private val httpClient = HttpClient(OkHttp)
@@ -193,6 +199,15 @@ object HitomiHttpClient {
data?.let { getSuggestionsFromData(field, data) } ?: emptyList() 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 { suspend fun search(query: SearchQuery?): Result<Set<Int>> = runCatching {
when (query) { when (query) {
is SearchQuery.Tag -> getGalleryIDsForQuery(query).toSet() is SearchQuery.Tag -> getGalleryIDsForQuery(query).toSet()
@@ -203,7 +218,7 @@ object HitomiHttpClient {
val queriedGalleries = search(query.query).getOrThrow() val queriedGalleries = search(query.query).getOrThrow()
val result = mutableSetOf<Int>() val result = LinkedHashSet<Int>()
with (allGalleries.await()) { with (allGalleries.await()) {
while (this.hasRemaining()) { while (this.hasRemaining()) {
@@ -241,7 +256,7 @@ object HitomiHttpClient {
} }
} }
val result = mutableSetOf<Int>() val result = LinkedHashSet<Int>()
queries.forEach { queries.forEach {
val queryResult = it.await() val queryResult = it.await()

View File

@@ -30,6 +30,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.adaptive.calculateDisplayFeatures import com.google.accompanist.adaptive.calculateDisplayFeatures
import xyz.quaver.pupil.ui.composable.MainApp import xyz.quaver.pupil.ui.composable.MainApp
import xyz.quaver.pupil.ui.theme.AppTheme import xyz.quaver.pupil.ui.theme.AppTheme
import xyz.quaver.pupil.ui.viewmodel.MainUIState
import xyz.quaver.pupil.ui.viewmodel.MainViewModel import xyz.quaver.pupil.ui.viewmodel.MainViewModel
class MainActivity : BaseActivity() { class MainActivity : BaseActivity() {
@@ -54,7 +55,9 @@ class MainActivity : BaseActivity() {
displayFeatures = displayFeatures, displayFeatures = displayFeatures,
uiState = uiState, uiState = uiState,
navigateToDestination = viewModel::navigateToDestination, navigateToDestination = viewModel::navigateToDestination,
closeDetailScreen = viewModel::closeDetailScreen closeDetailScreen = viewModel::closeDetailScreen,
onQueryChange = viewModel::onQueryChange,
loadSearchResult = viewModel::loadSearchResult
) )
} }
} }

View File

@@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier
import androidx.window.layout.DisplayFeature import androidx.window.layout.DisplayFeature
import androidx.window.layout.FoldingFeature import androidx.window.layout.FoldingFeature
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import xyz.quaver.pupil.networking.SearchQuery
import xyz.quaver.pupil.ui.viewmodel.MainUIState import xyz.quaver.pupil.ui.viewmodel.MainUIState
@Composable @Composable
@@ -32,7 +33,9 @@ fun MainApp(
displayFeatures: List<DisplayFeature>, displayFeatures: List<DisplayFeature>,
uiState: MainUIState, uiState: MainUIState,
navigateToDestination: (MainDestination) -> Unit, navigateToDestination: (MainDestination) -> Unit,
closeDetailScreen: () -> Unit closeDetailScreen: () -> Unit,
onQueryChange: (SearchQuery?) -> Unit,
loadSearchResult: (IntRange) -> Unit
) { ) {
val navigationType: NavigationType val navigationType: NavigationType
val contentType: ContentType val contentType: ContentType
@@ -85,7 +88,9 @@ fun MainApp(
navigationContentPosition, navigationContentPosition,
uiState, uiState,
navigateToDestination, navigateToDestination,
closeDetailScreen = closeDetailScreen closeDetailScreen = closeDetailScreen,
onQueryChange = onQueryChange,
loadSearchResult = loadSearchResult
) )
} }
@@ -97,7 +102,9 @@ private fun MainNavigationWrapper(
navigationContentPosition: NavigationContentPosition, navigationContentPosition: NavigationContentPosition,
uiState: MainUIState, uiState: MainUIState,
navigateToDestination: (MainDestination) -> Unit, navigateToDestination: (MainDestination) -> Unit,
closeDetailScreen: () -> Unit closeDetailScreen: () -> Unit,
onQueryChange: (SearchQuery?) -> Unit,
loadSearchResult: (IntRange) -> Unit
) { ) {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -126,7 +133,9 @@ private fun MainNavigationWrapper(
uiState = uiState, uiState = uiState,
navigateToDestination = navigateToDestination, navigateToDestination = navigateToDestination,
onDrawerClicked = openDrawer, onDrawerClicked = openDrawer,
closeDetailScreen = closeDetailScreen closeDetailScreen = closeDetailScreen,
onQueryChange = onQueryChange,
loadSearchResult = loadSearchResult
) )
} }
} else { } else {
@@ -153,7 +162,9 @@ private fun MainNavigationWrapper(
uiState = uiState, uiState = uiState,
navigateToDestination = navigateToDestination, navigateToDestination = navigateToDestination,
onDrawerClicked = openDrawer, onDrawerClicked = openDrawer,
closeDetailScreen = closeDetailScreen closeDetailScreen = closeDetailScreen,
onQueryChange = onQueryChange,
loadSearchResult = loadSearchResult
) )
} }
} }
@@ -168,7 +179,9 @@ fun MainContent(
uiState: MainUIState, uiState: MainUIState,
navigateToDestination: (MainDestination) -> Unit, navigateToDestination: (MainDestination) -> Unit,
onDrawerClicked: () -> Unit, onDrawerClicked: () -> Unit,
closeDetailScreen: () -> Unit closeDetailScreen: () -> Unit,
onQueryChange: (SearchQuery?) -> Unit,
loadSearchResult: (IntRange) -> Unit
) { ) {
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
AnimatedVisibility(visible = navigationType == NavigationType.NAVIGATION_RAIL) { AnimatedVisibility(visible = navigationType == NavigationType.NAVIGATION_RAIL) {
@@ -197,7 +210,9 @@ fun MainContent(
contentType = contentType, contentType = contentType,
displayFeatures = displayFeatures, displayFeatures = displayFeatures,
uiState = uiState, uiState = uiState,
closeDetailScreen = closeDetailScreen closeDetailScreen = closeDetailScreen,
onQueryChange = onQueryChange,
loadSearchResult = loadSearchResult
) )
} }
AnimatedVisibility(visible = navigationType == NavigationType.BOTTOM_NAVIGATION) { AnimatedVisibility(visible = navigationType == NavigationType.BOTTOM_NAVIGATION) {

View File

@@ -1,6 +1,7 @@
package xyz.quaver.pupil.ui.composable package xyz.quaver.pupil.ui.composable
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn 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.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape 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.material.icons.filled.Translate
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
@@ -61,10 +71,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.window.layout.DisplayFeature import androidx.window.layout.DisplayFeature
@@ -73,7 +83,6 @@ import com.google.accompanist.adaptive.TwoPane
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.networking.GalleryInfo import xyz.quaver.pupil.networking.GalleryInfo
import xyz.quaver.pupil.networking.SearchQuery 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.Blue600
import xyz.quaver.pupil.ui.theme.Pink600 import xyz.quaver.pupil.ui.theme.Pink600
import xyz.quaver.pupil.ui.theme.Yellow400 import xyz.quaver.pupil.ui.theme.Yellow400
@@ -217,7 +226,7 @@ fun TagChip(
@Composable @Composable
fun QueryView( fun QueryView(
query: SearchQuery, query: SearchQuery?,
topLevel: Boolean = true topLevel: Boolean = true
) { ) {
val modifier = if (topLevel) { val modifier = if (topLevel) {
@@ -227,6 +236,16 @@ fun QueryView(
} }
when (query) { 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 -> { is SearchQuery.Tag -> {
TagChip( TagChip(
query, query,
@@ -250,8 +269,8 @@ fun QueryView(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
query.queries.forEach { subquery -> query.queries.forEach { subQuery ->
QueryView(subquery, topLevel = false) QueryView(subQuery, topLevel = false)
} }
} }
} }
@@ -272,17 +291,25 @@ fun QueryView(
fun SearchBar( fun SearchBar(
contentType: ContentType, contentType: ContentType,
query: SearchQuery?, query: SearchQuery?,
onQueryChange: (SearchQuery) -> Unit, onQueryChange: (SearchQuery?) -> Unit,
onSearch: () -> Unit, onSearch: () -> Unit,
topOffset: Int, topOffset: Int,
onTopOffsetChange: (Int) -> Unit, onTopOffsetChange: (Int) -> Unit,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
var focused by remember { mutableStateOf(true) } var focused by remember { mutableStateOf(false) }
val scrimAlpha: Float by animateFloatAsState(if (focused && contentType == ContentType.SINGLE_PANE) 0.3f else 0f, label = "skrim alpha") val scrimAlpha: Float by animateFloatAsState(if (focused && contentType == ContentType.SINGLE_PANE) 0.3f else 0f, label = "scrim alpha")
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val state = remember(query) { query.toEditableState() }
LaunchedEffect(focused) {
if (!focused) {
onQueryChange(state.toSearchQuery())
}
}
if (focused) { if (focused) {
BackHandler { BackHandler {
focused = false focused = false
@@ -299,13 +326,15 @@ fun SearchBar(
) { ) {
focused = false focused = false
} }
.safeDrawingPadding()
.padding(16.dp)
) { ) {
val height: Dp by animateDpAsState(if (focused) maxHeight else 60.dp, label = "searchbar height") val height: Dp by animateDpAsState(if (focused) maxHeight else 60.dp, label = "searchbar height")
content()
Card( Card(
modifier = Modifier modifier = Modifier
.safeDrawingPadding()
.padding(16.dp)
.fillMaxWidth() .fillMaxWidth()
.height(height) .height(height)
.clickable( .clickable(
@@ -321,17 +350,7 @@ fun SearchBar(
) else CardDefaults.cardElevation() ) else CardDefaults.cardElevation()
) { ) {
Box { Box {
androidx.compose.animation.AnimatedVisibility(query == null && !focused, enter = fadeIn(), exit = fadeOut()) { androidx.compose.animation.AnimatedVisibility(!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()) {
Row( Row(
modifier = Modifier modifier = Modifier
.heightIn(min = 60.dp) .heightIn(min = 60.dp)
@@ -339,13 +358,11 @@ fun SearchBar(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box(Modifier.size(8.dp)) Box(Modifier.size(8.dp))
QueryView(query!!) QueryView(query)
Box(Modifier.size(8.dp)) Box(Modifier.size(8.dp))
} }
} }
androidx.compose.animation.AnimatedVisibility(focused, enter = fadeIn(), exit = fadeOut()) { androidx.compose.animation.AnimatedVisibility(focused, enter = fadeIn(), exit = fadeOut()) {
val state = remember(query) { query.toEditableState() }
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -373,7 +390,9 @@ fun MainScreen(
contentType: ContentType, contentType: ContentType,
displayFeatures: List<DisplayFeature>, displayFeatures: List<DisplayFeature>,
uiState: MainUIState, uiState: MainUIState,
closeDetailScreen: () -> Unit closeDetailScreen: () -> Unit,
onQueryChange: (SearchQuery?) -> Unit,
loadSearchResult: (IntRange) -> Unit
) { ) {
LaunchedEffect(contentType) { LaunchedEffect(contentType) {
if (contentType == ContentType.SINGLE_PANE && !uiState.isDetailOnlyOpen) { if (contentType == ContentType.SINGLE_PANE && !uiState.isDetailOnlyOpen) {
@@ -383,12 +402,34 @@ fun MainScreen(
val galleryLazyListState = rememberLazyListState() 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) { if (contentType == ContentType.DUAL_PANE) {
TwoPane( TwoPane(
first = { first = {
GalleryList( GalleryList(
contentType = contentType, contentType = contentType,
galleryLazyListState = galleryLazyListState galleries = uiState.galleries,
query = uiState.query,
loading = uiState.loading,
error = uiState.error,
galleryLazyListState = galleryLazyListState,
onQueryChange = onQueryChange,
search = search
) )
}, },
second = { second = {
@@ -400,7 +441,13 @@ fun MainScreen(
} else { } else {
GalleryList( GalleryList(
contentType = contentType, 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 @Composable
fun GalleryList( fun GalleryList(
contentType: ContentType, contentType: ContentType,
galleries: List<GalleryInfo> = emptyList(), galleries: List<GalleryInfo>,
query: SearchQuery?,
loading: Boolean = false,
error: Boolean = false,
openedGallery: GalleryInfo? = null, openedGallery: GalleryInfo? = null,
query: SearchQuery? = SearchQueryPreviewParameterProvider().values.first(), onQueryChange: (SearchQuery?) -> Unit = {},
onQueryChange: (SearchQuery) -> Unit = {}, search: () -> Unit = {},
onSearch: () -> Unit = { },
selectedGalleryIds: Set<Int> = emptySet(), selectedGalleryIds: Set<Int> = emptySet(),
toggleGallerySelection: (Int) -> Unit = {}, toggleGallerySelection: (Int) -> Unit = {},
galleryLazyListState: LazyListState, galleryLazyListState: LazyListState,
@@ -424,10 +473,43 @@ fun GalleryList(
contentType = contentType, contentType = contentType,
query = query, query = query,
onQueryChange = onQueryChange, onQueryChange = onQueryChange,
onSearch = onSearch, onSearch = search,
topOffset = topOffset, topOffset = topOffset,
onTopOffsetChange = { topOffset = it }, 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)
}
}
}
} }
} }

View File

@@ -1,6 +1,5 @@
package xyz.quaver.pupil.ui.composable package xyz.quaver.pupil.ui.composable
import android.util.Log
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring 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.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -92,11 +90,23 @@ private fun SearchQuery.toEditableStateInternal(): EditableSearchQueryState = wh
fun SearchQuery?.toEditableState(): EditableSearchQueryState.Root fun SearchQuery?.toEditableState(): EditableSearchQueryState.Root
= EditableSearchQueryState.Root(this?.toEditableStateInternal()) = 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) { private fun EditableSearchQueryState.toSearchQueryInternal(): SearchQuery? = when (this) {
is EditableSearchQueryState.Tag -> SearchQuery.Tag(namespace.value, tag.value) is EditableSearchQueryState.Tag -> this.toSearchQueryInternal()
is EditableSearchQueryState.And -> SearchQuery.And(queries.mapNotNull { it.toSearchQueryInternal() }) is EditableSearchQueryState.And -> this.toSearchQueryInternal()
is EditableSearchQueryState.Or -> SearchQuery.Or(queries.mapNotNull { it.toSearchQueryInternal() }) is EditableSearchQueryState.Or -> this.toSearchQueryInternal()
is EditableSearchQueryState.Not -> query.value?.toSearchQueryInternal()?.let { SearchQuery.Not(it) } is EditableSearchQueryState.Not -> this.toSearchQueryInternal()
} }
fun EditableSearchQueryState.Root.toSearchQuery(): SearchQuery? fun EditableSearchQueryState.Root.toSearchQuery(): SearchQuery?
@@ -162,9 +172,16 @@ fun TagSuggestionList(
LaunchedEffect(namespace, tag) { LaunchedEffect(namespace, tag) {
suggestionList = null suggestionList = null
suggestionList = HitomiHttpClient.getSuggestionsForQuery(SearchQuery.Tag(namespace, tag))
val searchQuery = state.toSearchQueryInternal()
suggestionList = if (searchQuery != null) {
HitomiHttpClient.getSuggestionsForQuery(searchQuery)
.getOrDefault(emptyList()) .getOrDefault(emptyList())
.filterNot { it.tag == SearchQuery.Tag(namespace, tag) } .filterNot { it.tag == SearchQuery.Tag(namespace, tag) }
} else {
emptyList()
}
} }
val suggestionListSnapshot = suggestionList val suggestionListSnapshot = suggestionList
@@ -576,18 +593,6 @@ fun QueryEditorQueryView(
EditableTagChip( EditableTagChip(
newSearchQuery, newSearchQuery,
requestScrollTo = requestScrollTo, 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 -> NewQueryChip(state) { newQueryState ->
state.queries.add(newQueryState) state.queries.add(newQueryState)
@@ -677,26 +682,60 @@ fun QueryEditor(
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
val rootQuerySnapshot = rootQuery val rootQuerySnapshot = rootQuery
if (rootQuerySnapshot != null) {
QueryEditorQueryView( val requestScrollTo: (Float) -> Unit = { target ->
state = rootQuerySnapshot,
onQueryRemove = { rootQuery = null },
requestScrollTo = { target ->
val topYSnapshot = topY val topYSnapshot = topY
coroutineScope.launch { coroutineScope.launch {
scrollState.animateScrollBy(target - topYSnapshot - scrollOffset, spring(stiffness = Spring.StiffnessLow)) scrollState.animateScrollBy(target - topYSnapshot - scrollOffset, spring(stiffness = Spring.StiffnessLow))
} }
}, }
requestScrollBy = { value ->
val requestScrollBy: (Float) -> Unit = { value ->
coroutineScope.launch { coroutineScope.launch {
scrollState.animateScrollBy(value) scrollState.animateScrollBy(value)
} }
} }
if (rootQuerySnapshot != null) {
QueryEditorQueryView(
state = rootQuerySnapshot,
onQueryRemove = { rootQuery = null },
requestScrollTo = requestScrollTo,
requestScrollBy = requestScrollBy
) )
} }
if (rootQuerySnapshot is EditableSearchQueryState.Tag?) { 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 -> NewQueryChip(rootQuerySnapshot) { newState ->
rootQuery = coalesceTags(rootQuerySnapshot, newState) rootQuery = coalesceTags(rootQuerySnapshot, newState)
} }

View File

@@ -1,9 +1,16 @@
package xyz.quaver.pupil.ui.viewmodel package xyz.quaver.pupil.ui.viewmodel
import androidx.lifecycle.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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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.GalleryInfo
import xyz.quaver.pupil.networking.GallerySearchSource
import xyz.quaver.pupil.networking.SearchQuery import xyz.quaver.pupil.networking.SearchQuery
import xyz.quaver.pupil.ui.composable.MainDestination import xyz.quaver.pupil.ui.composable.MainDestination
import xyz.quaver.pupil.ui.composable.mainDestinations import xyz.quaver.pupil.ui.composable.mainDestinations
@@ -11,6 +18,8 @@ import xyz.quaver.pupil.ui.composable.mainDestinations
class MainViewModel : ViewModel() { class MainViewModel : ViewModel() {
private val _uiState = MutableStateFlow(MainUIState()) private val _uiState = MutableStateFlow(MainUIState())
val uiState: StateFlow<MainUIState> = _uiState val uiState: StateFlow<MainUIState> = _uiState
private var searchSource: GallerySearchSource = GallerySearchSource(null)
private var job: Job? = null
fun navigateToDestination(destination: MainDestination) { fun navigateToDestination(destination: MainDestination) {
_uiState.value = MainUIState( _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() { fun navigateToDetail() {
} }
@@ -32,7 +75,11 @@ class MainViewModel : ViewModel() {
data class MainUIState( data class MainUIState(
val currentDestination: MainDestination = mainDestinations.first(), val currentDestination: MainDestination = mainDestinations.first(),
val query: SearchQuery? = null, 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 openedGallery: GalleryInfo? = null,
val isDetailOnlyOpen: Boolean = false val isDetailOnlyOpen: Boolean = false
) )