[Manatoki] Drawer highlight
This commit is contained in:
10
.idea/deploymentTargetDropDown.xml
generated
10
.idea/deploymentTargetDropDown.xml
generated
@@ -1,17 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetDropDown">
|
||||
<runningDeviceTargetSelectedWithDropDown>
|
||||
<targetSelectedWithDropDown>
|
||||
<Target>
|
||||
<type value="RUNNING_DEVICE_TARGET" />
|
||||
<type value="QUICK_BOOT_TARGET" />
|
||||
<deviceKey>
|
||||
<Key>
|
||||
<type value="VIRTUAL_DEVICE_PATH" />
|
||||
<value value="$USER_HOME$/.android/avd/Pixel_3a_API_30_x86.avd" />
|
||||
<value value="$USER_HOME$/.android/avd/Pixel_2_API_30.avd" />
|
||||
</Key>
|
||||
</deviceKey>
|
||||
</Target>
|
||||
</runningDeviceTargetSelectedWithDropDown>
|
||||
<timeTargetWasSelectedWithDropDown value="2021-12-20T09:02:43.106748Z" />
|
||||
</targetSelectedWithDropDown>
|
||||
<timeTargetWasSelectedWithDropDown value="2021-12-21T07:51:21.968371Z" />
|
||||
</component>
|
||||
</project>
|
||||
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
@@ -31,6 +31,8 @@
|
||||
<entry key="../../../../layout/compose-model-1639625734547.xml" value="0.1" />
|
||||
<entry key="../../../../layout/compose-model-1639629588722.xml" value="0.3472222222222222" />
|
||||
<entry key="../../../../layout/compose-model-1639809297022.xml" value="0.1" />
|
||||
<entry key="../../../../layout/compose-model-1639997860317.xml" value="0.35555555555555557" />
|
||||
<entry key="../../../../layout/compose-model-1639997910182.xml" value="0.35555555555555557" />
|
||||
<entry key="../../../../layout/custom_preview.xml" value="0.518974358974359" />
|
||||
<entry key="app/src/main/res/drawable/avd_star.xml" value="0.2722222222222222" />
|
||||
<entry key="app/src/main/res/drawable/close.xml" value="0.31614583333333335" />
|
||||
|
||||
@@ -82,6 +82,7 @@ dependencies {
|
||||
implementation("androidx.compose.material:material-icons-extended:1.0.5")
|
||||
implementation("androidx.compose.runtime:runtime-livedata:1.0.5")
|
||||
implementation("androidx.compose.ui:ui-util:1.0.5")
|
||||
implementation("androidx.compose.animation:animation:1.1.0-rc01")
|
||||
implementation("androidx.activity:activity-compose:1.4.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.4.0-rc01")
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
tools:replace="android:theme"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
|
||||
@@ -76,10 +76,7 @@ class Pupil : Application(), DIAware {
|
||||
}
|
||||
install(HttpCache)
|
||||
|
||||
install(UserAgent) {
|
||||
agent = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36"
|
||||
}
|
||||
//BrowserUserAgent()
|
||||
BrowserUserAgent()
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.composable
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.forEachGesture
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.NavigateBefore
|
||||
import androidx.compose.material.icons.filled.NavigateNext
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.consumePositionChange
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import com.google.accompanist.insets.LocalWindowInsets
|
||||
import com.google.accompanist.insets.rememberInsetsPaddingValues
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.ui.theme.LightBlue300
|
||||
import kotlin.math.*
|
||||
|
||||
@Composable
|
||||
fun OverscrollPager(
|
||||
currentPage: Int,
|
||||
prevPageAvailable: Boolean,
|
||||
nextPageAvailable: Boolean,
|
||||
onPageTurn: (Int) -> Unit,
|
||||
prevPageTurnIndicatorOffset: Dp = 0.dp,
|
||||
nextPageTurnIndicatorOffset: Dp = 0.dp,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() }
|
||||
|
||||
var overscroll: Float? by remember { mutableStateOf(null) }
|
||||
|
||||
val topCircleRadius by animateFloatAsState(if (overscroll?.let { it >= pageTurnIndicatorHeight } == true) 1000f else 0f)
|
||||
val bottomCircleRadius by animateFloatAsState(if (overscroll?.let { it <= -pageTurnIndicatorHeight } == true) 1000f else 0f)
|
||||
|
||||
val prevPageTurnIndicatorOffsetPx = LocalDensity.current.run { prevPageTurnIndicatorOffset.toPx() }
|
||||
val nextPageTurnIndicatorOffsetPx = LocalDensity.current.run { nextPageTurnIndicatorOffset.toPx() }
|
||||
|
||||
if (topCircleRadius != 0f || bottomCircleRadius != 0f)
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawCircle(
|
||||
LightBlue300.copy(alpha = 0.6f),
|
||||
center = Offset(this.center.x, prevPageTurnIndicatorOffsetPx),
|
||||
radius = topCircleRadius
|
||||
)
|
||||
drawCircle(
|
||||
LightBlue300.copy(alpha = 0.6f),
|
||||
center = Offset(this.center.x, this.size.height-pageTurnIndicatorHeight-nextPageTurnIndicatorOffsetPx),
|
||||
radius = bottomCircleRadius
|
||||
)
|
||||
}
|
||||
|
||||
val isOverscrollOverHeight = overscroll?.let { abs(it) >= pageTurnIndicatorHeight } == true
|
||||
LaunchedEffect(isOverscrollOverHeight) {
|
||||
if (isOverscrollOverHeight) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
|
||||
Box {
|
||||
overscroll?.let { overscroll ->
|
||||
if (overscroll > 0f)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(0.dp, prevPageTurnIndicatorOffset),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.NavigateBefore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.main_move_to_page, currentPage - 1))
|
||||
}
|
||||
|
||||
if (overscroll < 0f)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.offset(0.dp, -nextPageTurnIndicatorOffset),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.main_move_to_page, currentPage + 1))
|
||||
Icon(
|
||||
Icons.Default.NavigateNext,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
0.dp,
|
||||
overscroll
|
||||
?.coerceIn(-pageTurnIndicatorHeight, pageTurnIndicatorHeight)
|
||||
?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } }
|
||||
?: 0.dp)
|
||||
.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
val overscrollSnapshot = overscroll
|
||||
|
||||
return if (overscrollSnapshot == null || overscrollSnapshot == 0f) {
|
||||
Offset.Zero
|
||||
} else {
|
||||
val newOverscroll =
|
||||
if (overscrollSnapshot > 0f && available.y < 0f)
|
||||
max(overscrollSnapshot + available.y, 0f)
|
||||
else if (overscrollSnapshot < 0f && available.y > 0f)
|
||||
min(overscrollSnapshot + available.y, 0f)
|
||||
else
|
||||
overscrollSnapshot
|
||||
|
||||
Offset(0f, newOverscroll - overscrollSnapshot).also {
|
||||
overscroll = newOverscroll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
if (
|
||||
available.y == 0f ||
|
||||
!prevPageAvailable && available.y > 0f ||
|
||||
!nextPageAvailable && available.y < 0f
|
||||
) return Offset.Zero
|
||||
|
||||
return overscroll?.let {
|
||||
overscroll = it + available.y
|
||||
Offset(0f, available.y)
|
||||
} ?: Offset.Zero
|
||||
}
|
||||
})
|
||||
.pointerInput(currentPage) {
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var pointer = down.id
|
||||
overscroll = 0f
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val dragEvent =
|
||||
event.changes.fastFirstOrNull { it.id == pointer }!!
|
||||
|
||||
if (dragEvent.changedToUpIgnoreConsumed()) {
|
||||
val otherDown = event.changes.fastFirstOrNull { it.pressed }
|
||||
if (otherDown == null) {
|
||||
dragEvent.consumePositionChange()
|
||||
overscroll?.let {
|
||||
if (abs(it) > pageTurnIndicatorHeight)
|
||||
onPageTurn(currentPage - it.sign.toInt())
|
||||
}
|
||||
overscroll = null
|
||||
break
|
||||
} else
|
||||
pointer = otherDown.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
@@ -101,7 +102,6 @@ fun <T> SearchBase(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
var isFabExpanded by remember { mutableStateOf(FloatingActionButtonState.COLLAPSED) }
|
||||
|
||||
@@ -119,13 +119,9 @@ fun <T> SearchBase(
|
||||
|
||||
val statusBarsPaddingValues = rememberInsetsPaddingValues(insets = LocalWindowInsets.current.statusBars)
|
||||
|
||||
val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() }
|
||||
|
||||
val searchBarDefaultOffset = statusBarsPaddingValues.calculateTopPadding() + 64.dp
|
||||
val searchBarDefaultOffsetPx = LocalDensity.current.run { searchBarDefaultOffset.roundToPx() }
|
||||
|
||||
var overscroll: Float? by remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(navigationIconProgress) {
|
||||
navigationIcon.progress = navigationIconProgress
|
||||
}
|
||||
@@ -144,76 +140,21 @@ fun <T> SearchBase(
|
||||
}
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
val topCircleRadius by animateFloatAsState(if (overscroll?.let { it >= pageTurnIndicatorHeight } == true) 1000f else 0f)
|
||||
val bottomCircleRadius by animateFloatAsState(if (overscroll?.let { it <= -pageTurnIndicatorHeight } == true) 1000f else 0f)
|
||||
|
||||
if (topCircleRadius != 0f || bottomCircleRadius != 0f)
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawCircle(
|
||||
LightBlue300.copy(alpha = 0.6f),
|
||||
center = Offset(this.center.x, searchBarDefaultOffsetPx.toFloat()),
|
||||
radius = topCircleRadius
|
||||
)
|
||||
drawCircle(
|
||||
LightBlue300.copy(alpha = 0.6f),
|
||||
center = Offset(this.center.x, this.size.height-pageTurnIndicatorHeight),
|
||||
radius = bottomCircleRadius
|
||||
)
|
||||
}
|
||||
|
||||
val isOverscrollOverHeight = overscroll?.let { abs(it) >= pageTurnIndicatorHeight } == true
|
||||
LaunchedEffect(isOverscrollOverHeight) {
|
||||
if (isOverscrollOverHeight) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
|
||||
overscroll?.let { overscroll ->
|
||||
if (overscroll > 0f)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(0.dp, 64.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.NavigateBefore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.main_move_to_page, model.currentPage-1))
|
||||
}
|
||||
|
||||
if (overscroll < 0f)
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.main_move_to_page, model.currentPage+1))
|
||||
Icon(
|
||||
Icons.Default.NavigateNext,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
0.dp,
|
||||
overscroll
|
||||
?.coerceIn(-pageTurnIndicatorHeight, pageTurnIndicatorHeight)
|
||||
?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } }
|
||||
?: 0.dp)
|
||||
.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
val overscrollSnapshot = overscroll
|
||||
|
||||
if (overscrollSnapshot == null || overscrollSnapshot == 0f) {
|
||||
OverscrollPager(
|
||||
currentPage = model.currentPage,
|
||||
prevPageAvailable = model.prevPageAvailable,
|
||||
nextPageAvailable = model.nextPageAvailable,
|
||||
onPageTurn = { model.currentPage = it },
|
||||
prevPageTurnIndicatorOffset = searchBarDefaultOffset,
|
||||
nextPageTurnIndicatorOffset = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars).calculateBottomPadding()
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.nestedScroll(object: NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
model.searchBarOffset =
|
||||
(model.searchBarOffset + available.y.roundToInt()).coerceIn(
|
||||
-searchBarDefaultOffsetPx,
|
||||
@@ -223,72 +164,14 @@ fun <T> SearchBase(
|
||||
model.isFabVisible = available.y > 0f
|
||||
|
||||
return Offset.Zero
|
||||
} else {
|
||||
val newOverscroll =
|
||||
if (overscrollSnapshot > 0f && available.y < 0f)
|
||||
max(overscrollSnapshot + available.y, 0f)
|
||||
else if (overscrollSnapshot < 0f && available.y > 0f)
|
||||
min(overscrollSnapshot + available.y, 0f)
|
||||
else
|
||||
overscrollSnapshot
|
||||
|
||||
return Offset(0f, newOverscroll - overscrollSnapshot).also {
|
||||
overscroll = newOverscroll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
if (
|
||||
available.y == 0f ||
|
||||
!model.prevPageAvailable && available.y > 0f ||
|
||||
!model.nextPageAvailable && available.y < 0f
|
||||
) return Offset.Zero
|
||||
|
||||
return overscroll?.let {
|
||||
overscroll = it + available.y
|
||||
Offset(0f, available.y)
|
||||
} ?: Offset.Zero
|
||||
}
|
||||
})
|
||||
.pointerInput(Unit) {
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var pointer = down.id
|
||||
overscroll = 0f
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val dragEvent =
|
||||
event.changes.fastFirstOrNull { it.id == pointer }!!
|
||||
|
||||
if (dragEvent.changedToUpIgnoreConsumed()) {
|
||||
val otherDown = event.changes.fastFirstOrNull { it.pressed }
|
||||
if (otherDown == null) {
|
||||
dragEvent.consumePositionChange()
|
||||
overscroll?.let {
|
||||
model.currentPage -= it.sign.toInt()
|
||||
}
|
||||
overscroll = null
|
||||
break
|
||||
} else
|
||||
pointer = otherDown.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
content = {
|
||||
this.content(
|
||||
PaddingValues(0.dp, searchBarDefaultOffset, 0.dp, 0.dp)
|
||||
)
|
||||
})
|
||||
) {
|
||||
content(PaddingValues(0.dp, searchBarDefaultOffset, 0.dp, rememberInsetsPaddingValues(
|
||||
insets = LocalWindowInsets.current.navigationBars
|
||||
).calculateBottomPadding()))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (model.loading)
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
|
||||
@@ -36,11 +36,11 @@ import xyz.quaver.pupil.sources.Source
|
||||
import xyz.quaver.pupil.sources.SourceEntries
|
||||
|
||||
@Composable
|
||||
fun SourceSelectDialog(navController: NavController, currentSource: String, onDismissRequest: () -> Unit = { }) {
|
||||
fun SourceSelectDialog(navController: NavController, currentSource: String? = null, onDismissRequest: () -> Unit = { }) {
|
||||
SourceSelectDialog(currentSource = currentSource, onDismissRequest = onDismissRequest) {
|
||||
onDismissRequest()
|
||||
navController.navigate(it.name) {
|
||||
popUpTo(currentSource) { inclusive = true }
|
||||
currentSource?.let { popUpTo(currentSource) { inclusive = true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ fun SourceSelectDialogItem(source: Source, isSelected: Boolean, onSelected: (Sou
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourceSelectDialog(currentSource: String, onDismissRequest: () -> Unit = { }, onSelected: (Source) -> Unit = { }) {
|
||||
fun SourceSelectDialog(currentSource: String? = null, onDismissRequest: () -> Unit = { }, onSelected: (Source) -> Unit = { }) {
|
||||
val sourceEntries: SourceEntries by rememberInstance()
|
||||
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -55,6 +56,7 @@ import org.kodein.log.LoggerFactory
|
||||
import org.kodein.log.newLogger
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.db.AppDatabase
|
||||
import xyz.quaver.pupil.proto.settingsDataStore
|
||||
import xyz.quaver.pupil.sources.Source
|
||||
import xyz.quaver.pupil.sources.composable.*
|
||||
import xyz.quaver.pupil.sources.hitomi.composable.DetailedSearchResult
|
||||
@@ -96,6 +98,15 @@ class Hitomi(app: Application) : Source(), DIAware {
|
||||
bookmarks?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
context.settingsDataStore.updateData {
|
||||
it.toBuilder()
|
||||
.setRecentSource(name)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
var sourceSelectDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (sourceSelectDialog)
|
||||
|
||||
@@ -20,25 +20,41 @@ package xyz.quaver.pupil.sources.manatoki
|
||||
import android.app.Application
|
||||
import android.util.LruCache
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarOutline
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -47,12 +63,15 @@ import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navigation
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.google.accompanist.insets.LocalWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsPadding
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import com.google.accompanist.insets.rememberInsetsPaddingValues
|
||||
import com.google.accompanist.insets.ui.Scaffold
|
||||
import com.google.accompanist.insets.ui.TopAppBar
|
||||
import io.ktor.client.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -64,16 +83,20 @@ import org.kodein.log.LoggerFactory
|
||||
import org.kodein.log.newLogger
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.db.AppDatabase
|
||||
import xyz.quaver.pupil.proto.settingsDataStore
|
||||
import xyz.quaver.pupil.sources.Source
|
||||
import xyz.quaver.pupil.sources.composable.OverscrollPager
|
||||
import xyz.quaver.pupil.sources.composable.ReaderBase
|
||||
import xyz.quaver.pupil.sources.composable.ReaderBaseViewModel
|
||||
import xyz.quaver.pupil.sources.composable.SourceSelectDialog
|
||||
import xyz.quaver.pupil.sources.manatoki.composable.BoardButton
|
||||
import xyz.quaver.pupil.sources.manatoki.composable.MangaListingBottomSheet
|
||||
import xyz.quaver.pupil.sources.manatoki.composable.Thumbnail
|
||||
import xyz.quaver.pupil.sources.manatoki.viewmodel.MainViewModel
|
||||
import xyz.quaver.pupil.sources.manatoki.composable.*
|
||||
import xyz.quaver.pupil.sources.manatoki.viewmodel.*
|
||||
import xyz.quaver.pupil.ui.theme.Orange500
|
||||
import kotlin.math.max
|
||||
|
||||
private val imageUserAgent = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36"
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class, ExperimentalAnimationApi::class)
|
||||
class Manatoki(app: Application) : Source(), DIAware {
|
||||
override val di by closestDI(app)
|
||||
|
||||
@@ -91,10 +114,11 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
navigation(route = name, startDestination = "manatoki.net/") {
|
||||
composable("manatoki.net/") { Main(navController) }
|
||||
composable("manatoki.net/reader/{itemID}") { Reader(navController) }
|
||||
composable("manatoki.net/search") { Search(navController) }
|
||||
composable("manatoki.net/recent") { Recent(navController) }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun Main(navController: NavController) {
|
||||
val model: MainViewModel = viewModel()
|
||||
@@ -108,6 +132,15 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
mangaListing = it
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
context.settingsDataStore.updateData {
|
||||
it.toBuilder()
|
||||
.setRecentSource(name)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
val onReader: (ReaderInfo) -> Unit = { readerInfo ->
|
||||
coroutineScope.launch {
|
||||
readerInfoMutex.withLock {
|
||||
@@ -151,7 +184,7 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("박사장 게섯거라")
|
||||
Text("마나토끼")
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { sourceSelectDialog = true }) {
|
||||
@@ -171,6 +204,19 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
applyBottom = false
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
modifier = Modifier.navigationBarsPadding(),
|
||||
onClick = {
|
||||
navController.navigate("manatoki.net/search")
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
@@ -180,10 +226,23 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
"최신화",
|
||||
style = MaterialTheme.typography.h5
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
"최신화",
|
||||
style = MaterialTheme.typography.h5
|
||||
)
|
||||
|
||||
IconButton(onClick = { navController.navigate("manatoki.net/recent") }) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
@@ -192,7 +251,10 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(model.recentUpload) { item ->
|
||||
Thumbnail(item) {
|
||||
Thumbnail(item,
|
||||
Modifier
|
||||
.width(180.dp)
|
||||
.aspectRatio(6 / 7f)) {
|
||||
coroutineScope.launch {
|
||||
mangaListing = null
|
||||
sheetState.show()
|
||||
@@ -227,7 +289,20 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
}
|
||||
}
|
||||
|
||||
Text("만화 목록", style = MaterialTheme.typography.h5)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("만화 목록", style = MaterialTheme.typography.h5)
|
||||
|
||||
IconButton(onClick = { navController.navigate("manatoki.net/search") }) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -235,7 +310,10 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(model.mangaList) { item ->
|
||||
Thumbnail(item) {
|
||||
Thumbnail(item,
|
||||
Modifier
|
||||
.width(180.dp)
|
||||
.aspectRatio(6f / 7)) {
|
||||
coroutineScope.launch {
|
||||
mangaListing = null
|
||||
sheetState.show()
|
||||
@@ -286,7 +364,9 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
|
||||
Text(
|
||||
item.title,
|
||||
modifier = Modifier.weight(1f).padding(0.dp, 4.dp),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(0.dp, 4.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -307,7 +387,6 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun Reader(navController: NavController) {
|
||||
val model: ReaderBaseViewModel = viewModel()
|
||||
@@ -320,12 +399,14 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
val itemID = navController.currentBackStackEntry?.arguments?.getString("itemID")
|
||||
var readerInfo: ReaderInfo? by rememberSaveable { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(itemID) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (itemID != null)
|
||||
readerInfoMutex.withLock {
|
||||
readerInfoCache.get(itemID)?.let {
|
||||
readerInfo = it
|
||||
model.load(it.urls)
|
||||
model.load(it.urls) {
|
||||
set("User-Agent", imageUserAgent)
|
||||
}
|
||||
} ?: run {
|
||||
model.error = true
|
||||
}
|
||||
@@ -336,6 +417,14 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
|
||||
var mangaListing: MangaListing? by rememberSaveable { mutableStateOf(null) }
|
||||
val mangaListingRippleInteractionSource = remember { mutableStateListOf<MutableInteractionSource>() }
|
||||
val navigationBarsPadding = LocalDensity.current.run {
|
||||
rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.navigationBars
|
||||
).calculateBottomPadding().toPx()
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
@@ -345,11 +434,20 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
}
|
||||
}
|
||||
|
||||
var mangaListingListSize: Size? by remember { mutableStateOf(null) }
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
sheetState = sheetState,
|
||||
sheetShape = RoundedCornerShape(32.dp, 32.dp, 0.dp, 0.dp),
|
||||
sheetContent = {
|
||||
MangaListingBottomSheet(mangaListing) {
|
||||
MangaListingBottomSheet(
|
||||
mangaListing,
|
||||
onListSize = {
|
||||
mangaListingListSize = it
|
||||
},
|
||||
rippleInteractionSource = mangaListingRippleInteractionSource,
|
||||
listState = listState
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
client.getItem(
|
||||
it,
|
||||
@@ -359,7 +457,7 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
readerInfoCache.put(it.itemID, it)
|
||||
}
|
||||
navController.navigate("manatoki.net/reader/${it.itemID}") {
|
||||
popUpTo("manatoki.net/reader/$itemID") { inclusive = true }
|
||||
popUpTo("manatoki.net/")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,27 +508,73 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
modifier = Modifier.navigationBarsPadding(),
|
||||
onClick = {
|
||||
readerInfo?.let {
|
||||
coroutineScope.launch {
|
||||
sheetState.show()
|
||||
}
|
||||
AnimatedVisibility(
|
||||
!model.fullscreen,
|
||||
enter = scaleIn(),
|
||||
exit = scaleOut()
|
||||
) {
|
||||
FloatingActionButton(
|
||||
modifier = Modifier.navigationBarsPadding(),
|
||||
onClick = {
|
||||
readerInfo?.let {
|
||||
coroutineScope.launch {
|
||||
sheetState.show()
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
if (mangaListing?.itemID != it.listingItemID)
|
||||
client.getItem(it.listingItemID, onListing = {
|
||||
mangaListing = it
|
||||
|
||||
mangaListingRippleInteractionSource.addAll(
|
||||
List(max(it.entries.size - mangaListingRippleInteractionSource.size, 0)) {
|
||||
MutableInteractionSource()
|
||||
}
|
||||
)
|
||||
|
||||
coroutineScope.launch {
|
||||
while (listState.layoutInfo.totalItemsCount != it.entries.size) {
|
||||
delay(100)
|
||||
}
|
||||
|
||||
val targetIndex = it.entries.indexOfFirst { it.itemID == itemID }
|
||||
|
||||
listState.animateScrollToItem(targetIndex)
|
||||
|
||||
mangaListingListSize?.let { sheetSize ->
|
||||
val targetItem = listState.layoutInfo.visibleItemsInfo.first {
|
||||
it.key == itemID
|
||||
}
|
||||
|
||||
if (targetItem.offset == 0) {
|
||||
listState.animateScrollBy(
|
||||
-(sheetSize.height - navigationBarsPadding - targetItem.size)
|
||||
)
|
||||
}
|
||||
|
||||
delay(200)
|
||||
|
||||
with (mangaListingRippleInteractionSource[targetIndex]) {
|
||||
val interaction = PressInteraction.Press(
|
||||
Offset(sheetSize.width/2, targetItem.size/2f)
|
||||
)
|
||||
|
||||
emit(interaction)
|
||||
emit(PressInteraction.Release(interaction))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
if (mangaListing?.itemID != it.listingItemID)
|
||||
client.getItem(it.listingItemID, onListing = {
|
||||
mangaListing = it
|
||||
})
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.List,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.List,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
) { contentPadding ->
|
||||
@@ -441,4 +585,275 @@ class Manatoki(app: Application) : Source(), DIAware {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Recent(navController: NavController) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("최신 업데이트")
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
Icons.Default.NavigateBefore,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
contentPadding = rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.statusBars,
|
||||
applyBottom = false
|
||||
)
|
||||
)
|
||||
}
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Search(navController: NavController) {
|
||||
val model: SearchViewModel = viewModel()
|
||||
|
||||
var searchFocused by remember { mutableStateOf(false) }
|
||||
val handleOffset by animateDpAsState(if (searchFocused) 0.dp else (-36).dp)
|
||||
|
||||
val drawerState = rememberSwipeableState(SearchOptionDrawerStates.Hidden)
|
||||
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
|
||||
|
||||
var mangaListing: MangaListing? by rememberSaveable { mutableStateOf(null) }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
model.search()
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
sheetState.isVisible -> coroutineScope.launch { sheetState.hide() }
|
||||
drawerState.currentValue != SearchOptionDrawerStates.Hidden ->
|
||||
coroutineScope.launch { drawerState.animateTo(SearchOptionDrawerStates.Hidden) }
|
||||
else -> navController.popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
sheetState = sheetState,
|
||||
sheetShape = RoundedCornerShape(32.dp, 32.dp, 0.dp, 0.dp),
|
||||
sheetContent = {
|
||||
MangaListingBottomSheet(mangaListing) {
|
||||
coroutineScope.launch {
|
||||
client.getItem(it, onReader = {
|
||||
launch {
|
||||
readerInfoMutex.withLock {
|
||||
readerInfoCache.put(it.itemID, it)
|
||||
}
|
||||
sheetState.snapTo(ModalBottomSheetValue.Hidden)
|
||||
navController.navigate("manatoki.net/reader/${it.itemID}")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { focusManager.clearFocus() }
|
||||
},
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
TextField(
|
||||
model.stx,
|
||||
modifier = Modifier
|
||||
.onFocusChanged {
|
||||
searchFocused = it.isFocused
|
||||
},
|
||||
onValueChange = { model.stx = it },
|
||||
placeholder = { Text("제목") },
|
||||
textStyle = MaterialTheme.typography.subtitle1,
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (model.stx != "" && searchFocused)
|
||||
IconButton(onClick = { model.stx = "" }) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = contentColorFor(MaterialTheme.colors.primarySurface)
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = {
|
||||
focusManager.clearFocus()
|
||||
coroutineScope.launch {
|
||||
drawerState.animateTo(SearchOptionDrawerStates.Hidden)
|
||||
}
|
||||
coroutineScope.launch {
|
||||
model.search()
|
||||
}
|
||||
}
|
||||
),
|
||||
colors = TextFieldDefaults.textFieldColors(
|
||||
textColor = contentColorFor(MaterialTheme.colors.primarySurface),
|
||||
placeholderColor = contentColorFor(MaterialTheme.colors.primarySurface).copy(alpha = 0.75f),
|
||||
backgroundColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colors.secondary,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
Icons.Default.NavigateBefore,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
contentPadding = rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.statusBars,
|
||||
applyBottom = false
|
||||
)
|
||||
)
|
||||
}
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
SearchOptionDrawer(
|
||||
modifier = Modifier.run {
|
||||
if (drawerState.currentValue == SearchOptionDrawerStates.Hidden)
|
||||
offset(0.dp, handleOffset)
|
||||
else
|
||||
navigationBarsWithImePadding()
|
||||
},
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp, 0.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text("작가")
|
||||
TextField(model.artist, onValueChange = { model.artist = it })
|
||||
|
||||
Text("발행")
|
||||
FlowRow(mainAxisSpacing = 4.dp, crossAxisSpacing = 4.dp) {
|
||||
Chip("전체", model.publish.isEmpty()) {
|
||||
model.publish = ""
|
||||
}
|
||||
availablePublish.forEach {
|
||||
Chip(it, model.publish == it) {
|
||||
model.publish = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("초성")
|
||||
FlowRow(mainAxisSpacing = 4.dp, crossAxisSpacing = 4.dp) {
|
||||
Chip("전체", model.jaum.isEmpty()) {
|
||||
model.jaum = ""
|
||||
}
|
||||
availableJaum.forEach {
|
||||
Chip(it, model.jaum == it) {
|
||||
model.jaum = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("장르")
|
||||
FlowRow(mainAxisSpacing = 4.dp, crossAxisSpacing = 4.dp) {
|
||||
Chip("전체", model.tag.isEmpty()) {
|
||||
model.tag.clear()
|
||||
}
|
||||
availableTag.forEach {
|
||||
Chip(it, model.tag.contains(it)) {
|
||||
if (model.tag.contains(it))
|
||||
model.tag.remove(it)
|
||||
else
|
||||
model.tag[it] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("정렬")
|
||||
FlowRow(mainAxisSpacing = 4.dp, crossAxisSpacing = 4.dp) {
|
||||
Chip("기본", model.sst.isEmpty()) {
|
||||
model.sst = ""
|
||||
}
|
||||
availableSst.entries.forEach { (k, v) ->
|
||||
Chip(v, model.sst == k) {
|
||||
model.sst = k
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp))
|
||||
}
|
||||
}
|
||||
) {
|
||||
OverscrollPager(
|
||||
currentPage = model.page,
|
||||
prevPageAvailable = model.page > 1,
|
||||
nextPageAvailable = model.page < model.maxPage,
|
||||
onPageTurn = {
|
||||
model.page = it
|
||||
coroutineScope.launch {
|
||||
model.search(resetPage = false)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
GridCells.Adaptive(minSize = 200.dp),
|
||||
contentPadding = rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.navigationBars
|
||||
)
|
||||
) {
|
||||
items(model.result) { item ->
|
||||
Thumbnail(
|
||||
Thumbnail(item.itemID, item.title, item.thumbnail),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(3f / 4)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
mangaListing = null
|
||||
sheetState.show()
|
||||
}
|
||||
coroutineScope.launch {
|
||||
client.getItem(it, onListing = {
|
||||
mangaListing = it
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (model.loading)
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,24 +18,35 @@
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.composable
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.indication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import coil.compose.rememberImagePainter
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.google.accompanist.insets.LocalWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsPadding
|
||||
import com.google.accompanist.insets.rememberInsetsPaddingValues
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.quaver.pupil.sources.manatoki.MangaListing
|
||||
|
||||
private val FabSpacing = 8.dp
|
||||
@@ -44,7 +55,6 @@ private enum class MangaListingBottomSheetLayoutContent { Top, Bottom, Fab }
|
||||
|
||||
@Composable
|
||||
fun MangaListingBottomSheetLayout(
|
||||
modifier: Modifier = Modifier,
|
||||
floatingActionButton: @Composable () -> Unit,
|
||||
top: @Composable () -> Unit,
|
||||
bottom: @Composable () -> Unit
|
||||
@@ -93,13 +103,30 @@ fun MangaListingBottomSheetLayout(
|
||||
@Composable
|
||||
fun MangaListingBottomSheet(
|
||||
mangaListing: MangaListing? = null,
|
||||
onOpenItem: (String) -> Unit = { }
|
||||
onListSize: (Size) -> Unit = { },
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
rippleInteractionSource: List<MutableInteractionSource> = emptyList(),
|
||||
onOpenItem: (String) -> Unit = { },
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
rippleInteractionSource.forEach {
|
||||
coroutineScope.launch {
|
||||
it.interactions.collect {
|
||||
Log.d("PUPILD", it.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (mangaListing == null)
|
||||
CircularProgressIndicator(Modifier.navigationBarsPadding().padding(16.dp).align(Alignment.Center))
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(16.dp)
|
||||
.align(Alignment.Center))
|
||||
else
|
||||
MangaListingBottomSheetLayout(
|
||||
floatingActionButton = {
|
||||
@@ -157,7 +184,8 @@ fun MangaListingBottomSheet(
|
||||
) {
|
||||
mangaListing.tags.forEach {
|
||||
Card(
|
||||
elevation = 4.dp
|
||||
elevation = 4.dp,
|
||||
backgroundColor = Color.White
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
@@ -177,16 +205,27 @@ fun MangaListingBottomSheet(
|
||||
},
|
||||
bottom = {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned {
|
||||
onListSize(it.size.toSize())
|
||||
},
|
||||
state = listState,
|
||||
contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars)
|
||||
) {
|
||||
items(mangaListing.entries) { entry ->
|
||||
itemsIndexed(mangaListing.entries, key = { _, entry -> entry.itemID }) { index, entry ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
onOpenItem(entry.itemID)
|
||||
}
|
||||
.run {
|
||||
rippleInteractionSource
|
||||
.getOrNull(index)
|
||||
?.let {
|
||||
indication(it, rememberRipple())
|
||||
} ?: this
|
||||
}
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.composable
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.*
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.quaver.pupil.sources.manatoki.composable.SearchOptionDrawerStates.Hidden
|
||||
import xyz.quaver.pupil.sources.manatoki.composable.SearchOptionDrawerStates.Expanded
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class SearchOptionDrawerShape(
|
||||
private val cornerRadius: Dp,
|
||||
private val handleRadius: Dp
|
||||
): Shape {
|
||||
|
||||
private fun drawDrawerPath(
|
||||
size: Size,
|
||||
cornerRadius: Float,
|
||||
handleRadius: Float
|
||||
) = Path().apply {
|
||||
reset()
|
||||
|
||||
lineTo(x = size.width, y = 0f)
|
||||
|
||||
lineTo(x = size.width, y = size.height - cornerRadius)
|
||||
|
||||
arcTo(
|
||||
Rect(
|
||||
left = size.width - 2*cornerRadius,
|
||||
top = size.height - 2*cornerRadius,
|
||||
right = size.width,
|
||||
bottom = size.height
|
||||
),
|
||||
startAngleDegrees = 0f,
|
||||
sweepAngleDegrees = 90f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
|
||||
lineTo(x = size.width / 2 + handleRadius, y = size.height)
|
||||
|
||||
arcTo(
|
||||
Rect(
|
||||
left = size.width/2 - handleRadius,
|
||||
top = size.height - handleRadius,
|
||||
right = size.width/2 + handleRadius,
|
||||
bottom = size.height + handleRadius
|
||||
),
|
||||
startAngleDegrees = 0f,
|
||||
sweepAngleDegrees = 180f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
|
||||
lineTo(x = cornerRadius, y = size.height)
|
||||
|
||||
arcTo(
|
||||
Rect(
|
||||
left = 0f,
|
||||
top = size.height - 2*cornerRadius,
|
||||
right = 2*cornerRadius,
|
||||
bottom = size.height
|
||||
),
|
||||
startAngleDegrees = 90f,
|
||||
sweepAngleDegrees = 90f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
|
||||
close()
|
||||
}
|
||||
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density
|
||||
): Outline = Outline.Generic(
|
||||
path = drawDrawerPath(
|
||||
size,
|
||||
density.run { cornerRadius.toPx() },
|
||||
density.run { handleRadius.toPx() }
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
enum class SearchOptionDrawerStates {
|
||||
Hidden,
|
||||
Expanded
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Scrim(
|
||||
color: Color,
|
||||
onDismiss: () -> Unit,
|
||||
visible: Boolean
|
||||
) {
|
||||
if (color.isSpecified) {
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = TweenSpec()
|
||||
)
|
||||
val dismissModifier = if (visible) {
|
||||
Modifier.pointerInput(onDismiss) { detectTapGestures { onDismiss() } }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.then(dismissModifier)
|
||||
) {
|
||||
drawRect(color = color, alpha = alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ExperimentalMaterialApi
|
||||
fun SearchOptionDrawer(
|
||||
drawerContent: @Composable ColumnScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
drawerCornerRadius: Dp = SearchOptionDrawerDefaults.CornerRadius,
|
||||
drawerHandleRadius: Dp = SearchOptionDrawerDefaults.HandleRadius,
|
||||
drawerState: SwipeableState<SearchOptionDrawerStates> = rememberSwipeableState(Hidden),
|
||||
drawerElevation: Dp = SearchOptionDrawerDefaults.Elevation,
|
||||
drawerBackgroundColor: Color = MaterialTheme.colors.surface,
|
||||
drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
|
||||
scrimColor: Color = SearchOptionDrawerDefaults.scrimColor,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val nestedScrollConnection = remember {
|
||||
object: NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
val delta = available.y
|
||||
return if (delta > 0 && source == NestedScrollSource.Drag)
|
||||
Offset(0f, drawerState.performDrag(delta))
|
||||
else
|
||||
Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
return if (drawerState.offset.value < 0f && source == NestedScrollSource.Drag)
|
||||
Offset(0f, drawerState.performDrag(available.y))
|
||||
else
|
||||
Offset.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
val toFling = available.y
|
||||
return if (toFling > 0 && drawerState.offset.value < 0f) {
|
||||
available
|
||||
} else Velocity.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
drawerState.performFling(available.y)
|
||||
return available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints {
|
||||
var sheetHeight by remember { mutableStateOf<Float?>(null) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
content()
|
||||
Scrim(
|
||||
color = scrimColor,
|
||||
onDismiss = {
|
||||
coroutineScope.launch { drawerState.animateTo(Hidden) }
|
||||
},
|
||||
visible = drawerState.targetValue != Hidden
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.offset {
|
||||
IntOffset(0, drawerState.offset.value.roundToInt())
|
||||
}
|
||||
.drawerSwipeable(drawerState, sheetHeight)
|
||||
.onGloballyPositioned {
|
||||
sheetHeight = it.size.height.toFloat()
|
||||
},
|
||||
shape = SearchOptionDrawerShape(drawerCornerRadius, drawerHandleRadius),
|
||||
elevation = drawerElevation,
|
||||
color = drawerBackgroundColor,
|
||||
contentColor = drawerContentColor
|
||||
) {
|
||||
Column(content = drawerContent)
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.offset(0.dp, drawerHandleRadius)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(2*drawerHandleRadius, drawerHandleRadius)
|
||||
.align(Alignment.TopCenter)
|
||||
.pointerInput(drawerState) {
|
||||
detectTapGestures {
|
||||
coroutineScope.launch {
|
||||
drawerState.animateTo(Expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { }
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
private fun Modifier.drawerSwipeable(
|
||||
drawerState: SwipeableState<SearchOptionDrawerStates>,
|
||||
sheetHeight: Float?
|
||||
) = this.then(
|
||||
if (sheetHeight != null) {
|
||||
val anchors = mapOf(
|
||||
-sheetHeight to Hidden,
|
||||
0f to Expanded
|
||||
)
|
||||
|
||||
Modifier.swipeable(
|
||||
state = drawerState,
|
||||
anchors = anchors,
|
||||
orientation = Orientation.Vertical,
|
||||
enabled = drawerState.currentValue != Hidden,
|
||||
resistance = null
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
|
||||
object SearchOptionDrawerDefaults {
|
||||
val Elevation = 16.dp
|
||||
val scrimColor: Color
|
||||
@Composable
|
||||
get() = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
val CornerRadius = 32.dp
|
||||
val HandleRadius = 32.dp
|
||||
}
|
||||
@@ -47,18 +47,19 @@ data class Thumbnail(
|
||||
@Composable
|
||||
fun Thumbnail(
|
||||
thumbnail: Thumbnail,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (String) -> Unit = { }
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = 8.dp,
|
||||
modifier = Modifier.clickable { onClick(thumbnail.itemID) }
|
||||
modifier = modifier.clickable { onClick(thumbnail.itemID) }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.width(IntrinsicSize.Min)
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier.size(180.dp, 210.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
painter = rememberImagePainter(thumbnail.thumbnail),
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
@@ -19,6 +19,21 @@
|
||||
package xyz.quaver.pupil.sources.manatoki
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.util.concurrent.RateLimiter
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
@@ -26,6 +41,7 @@ import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
@@ -75,6 +91,19 @@ data class ReaderInfo(
|
||||
val listingItemID: String
|
||||
): Parcelable
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun Chip(text: String, selected: Boolean = false, onClick: () -> Unit = { }) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
backgroundColor = if (selected) MaterialTheme.colors.secondary else MaterialTheme.colors.surface,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
elevation = 4.dp
|
||||
) {
|
||||
Text(text, modifier = Modifier.padding(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun HttpClient.getItem(
|
||||
itemID: String,
|
||||
onListing: (MangaListing) -> Unit = { },
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.android.closestDI
|
||||
import org.kodein.di.instance
|
||||
import org.kodein.log.LoggerFactory
|
||||
import org.kodein.log.newLogger
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class SearchResult(
|
||||
val itemID: String,
|
||||
val title: String,
|
||||
val thumbnail: String,
|
||||
val artist: String,
|
||||
val type: String,
|
||||
val lastUpdate: String
|
||||
): Parcelable
|
||||
|
||||
val availablePublish = listOf(
|
||||
"주간",
|
||||
"격주",
|
||||
"월간",
|
||||
"단편",
|
||||
"단행본",
|
||||
"완결"
|
||||
)
|
||||
|
||||
val availableJaum = listOf(
|
||||
"ㄱ",
|
||||
"ㄴ",
|
||||
"ㄷ",
|
||||
"ㄹ",
|
||||
"ㅁ",
|
||||
"ㅂ",
|
||||
"ㅅ",
|
||||
"ㅇ",
|
||||
"ㅈ",
|
||||
"ㅊ",
|
||||
"ㅋ",
|
||||
"ㅌ",
|
||||
"ㅍ",
|
||||
"ㅎ",
|
||||
"0-9",
|
||||
"a-z"
|
||||
)
|
||||
|
||||
val availableTag = listOf(
|
||||
"17",
|
||||
"BL",
|
||||
"SF",
|
||||
"TS",
|
||||
"개그",
|
||||
"게임",
|
||||
"도박",
|
||||
"드라마",
|
||||
"라노벨",
|
||||
"러브코미디",
|
||||
"먹방",
|
||||
"백합",
|
||||
"붕탁",
|
||||
"순정",
|
||||
"스릴러",
|
||||
"스포츠",
|
||||
"시대",
|
||||
"애니화",
|
||||
"액션",
|
||||
"음악",
|
||||
"이세계",
|
||||
"일상",
|
||||
"전생",
|
||||
"추리",
|
||||
"판타지",
|
||||
"학원",
|
||||
"호러"
|
||||
)
|
||||
|
||||
val availableSst = mapOf(
|
||||
"as_view" to "인기순",
|
||||
"as_good" to "추천순",
|
||||
"as_comment" to "댓글순",
|
||||
"as_bookmark" to "북마크순"
|
||||
)
|
||||
|
||||
class SearchViewModel(app: Application) : AndroidViewModel(app), DIAware {
|
||||
override val di by closestDI(app)
|
||||
|
||||
private val logger = newLogger(LoggerFactory.default)
|
||||
|
||||
private val client: HttpClient by instance()
|
||||
|
||||
// 발행
|
||||
var publish by mutableStateOf("")
|
||||
// 초성
|
||||
var jaum by mutableStateOf("")
|
||||
// 장르
|
||||
val tag = mutableStateMapOf<String, String>()
|
||||
// 정렬
|
||||
var sst by mutableStateOf("")
|
||||
// 오름/내림
|
||||
var sod by mutableStateOf("")
|
||||
// 제목
|
||||
var stx by mutableStateOf("")
|
||||
// 작가
|
||||
var artist by mutableStateOf("")
|
||||
|
||||
var page by mutableStateOf(1)
|
||||
var maxPage by mutableStateOf(0)
|
||||
|
||||
var loading by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var error by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val result = mutableStateListOf<SearchResult>()
|
||||
|
||||
private var searchJob: Job? = null
|
||||
suspend fun search(resetPage: Boolean = true) = coroutineScope {
|
||||
searchJob?.cancelAndJoin()
|
||||
|
||||
loading = true
|
||||
result.clear()
|
||||
if (resetPage) page = 1
|
||||
|
||||
searchJob = launch {
|
||||
runCatching {
|
||||
val urlBuilder = StringBuilder("https://manatoki116.net/comic")
|
||||
|
||||
if (page != 1) urlBuilder.append("/p$page")
|
||||
|
||||
val args = mutableListOf<String>()
|
||||
|
||||
if (publish.isNotEmpty()) args.add("publish=$publish")
|
||||
if (jaum.isNotEmpty()) args.add("jaum=$jaum")
|
||||
if (tag.isNotEmpty()) args.add("tag=${tag.keys.joinToString(",")}")
|
||||
if (sst.isNotEmpty()) args.add("sst=$sst")
|
||||
if (stx.isNotEmpty()) args.add("stx=$stx")
|
||||
if (artist.isNotEmpty()) args.add("artist=$artist")
|
||||
|
||||
if (args.isNotEmpty()) urlBuilder.append('?')
|
||||
|
||||
urlBuilder.append(args.joinToString("&"))
|
||||
|
||||
val doc = Jsoup.parse(client.get(urlBuilder.toString()))
|
||||
|
||||
maxPage = doc.getElementsByClass("pagination").first()!!.getElementsByTag("a").maxOf { it.text().toIntOrNull() ?: 0 }
|
||||
|
||||
doc.getElementsByClass("list-item").forEach {
|
||||
val itemID =
|
||||
it.selectFirst(".img-item > a")!!.attr("href").takeLastWhile { it != '/' }
|
||||
val title = it.getElementsByClass("title").first()!!.text()
|
||||
val thumbnail = it.getElementsByTag("img").first()!!.attr("src")
|
||||
val artist = it.getElementsByClass("list-artist").first()!!.text()
|
||||
val type = it.getElementsByClass("list-publish").first()!!.text()
|
||||
val lastUpdate = it.getElementsByClass("list-date").first()!!.text()
|
||||
|
||||
loading = false
|
||||
result.add(
|
||||
SearchResult(
|
||||
itemID,
|
||||
title,
|
||||
thumbnail,
|
||||
artist,
|
||||
type,
|
||||
lastUpdate
|
||||
)
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
loading = false
|
||||
error = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,18 +26,24 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.android.closestDI
|
||||
import org.kodein.di.instance
|
||||
import org.kodein.log.LoggerFactory
|
||||
import org.kodein.log.newLogger
|
||||
import xyz.quaver.pupil.proto.settingsDataStore
|
||||
import xyz.quaver.pupil.sources.SourceEntries
|
||||
import xyz.quaver.pupil.sources.composable.SourceSelectDialog
|
||||
import xyz.quaver.pupil.ui.theme.PupilTheme
|
||||
|
||||
|
||||
@@ -56,7 +62,7 @@ class MainActivity : ComponentActivity(), DIAware {
|
||||
|
||||
setContent {
|
||||
PupilTheme {
|
||||
ProvideWindowInsets {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
val systemUiController = rememberSystemUiController()
|
||||
@@ -72,16 +78,31 @@ class MainActivity : ComponentActivity(), DIAware {
|
||||
NavHost(navController, startDestination = "main") {
|
||||
composable("main") {
|
||||
var launched by rememberSaveable { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
var sourceSelectDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (sourceSelectDialog)
|
||||
SourceSelectDialog(navController, null)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!launched) {
|
||||
val source = it.arguments?.getString("source") ?: "manatoki.net"
|
||||
navController.navigate(source)
|
||||
val recentSource = context.settingsDataStore.data.map { it.recentSource }.first()
|
||||
|
||||
if (recentSource.isEmpty()) {
|
||||
sourceSelectDialog = true
|
||||
launched = true
|
||||
} else {
|
||||
onBackPressed()
|
||||
if (!launched) {
|
||||
navController.navigate(recentSource)
|
||||
launched = true
|
||||
} else {
|
||||
onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
composable("settings") {
|
||||
|
||||
}
|
||||
sources.forEach {
|
||||
it.second.run {
|
||||
|
||||
@@ -20,6 +20,7 @@ package xyz.quaver.pupil.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.contentColorFor
|
||||
import androidx.compose.material.darkColors
|
||||
import androidx.compose.material.lightColors
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -29,6 +30,7 @@ private val DarkColorPalette = darkColors(
|
||||
primary = LightBlue300,
|
||||
primaryVariant = LightBlue700,
|
||||
secondary = Pink600,
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White
|
||||
)
|
||||
private val LightColorPalette = lightColors(
|
||||
|
||||
@@ -160,7 +160,6 @@ class NetworkCache(context: Context) : DIAware {
|
||||
progressFlow.emit(Float.POSITIVE_INFINITY)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.d("PUPILD-NC", it.message.toString())
|
||||
file.delete()
|
||||
FirebaseCrashlytics.getInstance().recordException(it)
|
||||
progressFlow.emit(Float.NEGATIVE_INFINITY)
|
||||
|
||||
@@ -4,5 +4,5 @@ option java_package = "xyz.quaver.pupil.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
message Settings {
|
||||
|
||||
optional string recent_source = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user