[Manatoki] Drawer highlight

This commit is contained in:
tom5079
2021-12-21 18:04:54 +09:00
parent f6f0ed40c1
commit c34b0f6f0f
19 changed files with 1326 additions and 210 deletions

View File

@@ -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
View File

@@ -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" />

View File

@@ -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")

View File

@@ -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">

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = { },

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

@@ -4,5 +4,5 @@ option java_package = "xyz.quaver.pupil.proto";
option java_multiple_files = true;
message Settings {
optional string recent_source = 1;
}