diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml index 4ed21896..140d9cd6 100644 --- a/.idea/deploymentTargetDropDown.xml +++ b/.idea/deploymentTargetDropDown.xml @@ -1,17 +1,17 @@ - + - + - + - - + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index ea0fb3d8..f0d2b58a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -31,6 +31,8 @@ + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7161bf0f..f742a297 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 15ee3fb5..116352f1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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"> diff --git a/app/src/main/java/xyz/quaver/pupil/Pupil.kt b/app/src/main/java/xyz/quaver/pupil/Pupil.kt index 72d1fe71..05278bde 100644 --- a/app/src/main/java/xyz/quaver/pupil/Pupil.kt +++ b/app/src/main/java/xyz/quaver/pupil/Pupil.kt @@ -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() } } } } diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/OverscrollPager.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/OverscrollPager.kt new file mode 100644 index 00000000..3d7dda72 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/sources/composable/OverscrollPager.kt @@ -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 . + */ + +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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/SearchBase.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/SearchBase.kt index 111a2c8e..95c5b46e 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/composable/SearchBase.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/composable/SearchBase.kt @@ -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 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 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 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 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)) diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/SourceSelectDialog.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/SourceSelectDialog.kt index c1360d33..e68fcfa3 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/composable/SourceSelectDialog.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/composable/SourceSelectDialog.kt @@ -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) { diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt index 8594ac4f..942a2625 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt @@ -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) diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt index 047b4b73..b8a7b421 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt @@ -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() } + 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)) + } + } + } + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt index c7e90ec0..3e9c6d5a 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt @@ -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 = 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) diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/SearchOptionDrawer.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/SearchOptionDrawer.kt new file mode 100644 index 00000000..57b1bf80 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/SearchOptionDrawer.kt @@ -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 . + */ + +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 = 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(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, + 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 +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Thumbnail.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Thumbnail.kt index e8dfdc52..5194f571 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Thumbnail.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Thumbnail.kt @@ -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 ) diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt index 854f3f71..bf8ff685 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt @@ -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 = { }, diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/SearchViewModel.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/SearchViewModel.kt new file mode 100644 index 00000000..d9b45ac3 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/SearchViewModel.kt @@ -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 . + */ + +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() + // 정렬 + 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() + + 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() + + 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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt index 26a62379..c205ec6e 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt @@ -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 { diff --git a/app/src/main/java/xyz/quaver/pupil/ui/theme/Theme.kt b/app/src/main/java/xyz/quaver/pupil/ui/theme/Theme.kt index 3d6ef1cb..516a0089 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/theme/Theme.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/theme/Theme.kt @@ -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( diff --git a/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt b/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt index 49973fa9..f7c8e45e 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt @@ -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) diff --git a/app/src/main/proto/settings.proto b/app/src/main/proto/settings.proto index 431f0f90..d20062e4 100644 --- a/app/src/main/proto/settings.proto +++ b/app/src/main/proto/settings.proto @@ -4,5 +4,5 @@ option java_package = "xyz.quaver.pupil.proto"; option java_multiple_files = true; message Settings { - + optional string recent_source = 1; } \ No newline at end of file