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