Nested navigation
Hitomi main actions
This commit is contained in:
tom5079
2021-12-19 12:33:47 +09:00
parent 7befa24aff
commit 20ddf04614
11 changed files with 192 additions and 131 deletions

View File

@@ -12,6 +12,6 @@
</deviceKey>
</Target>
</runningDeviceTargetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2021-12-18T14:48:54.587703Z" />
<timeTargetWasSelectedWithDropDown value="2021-12-19T03:31:58.153375Z" />
</component>
</project>

View File

@@ -19,8 +19,8 @@
package xyz.quaver.pupil.sources
import android.app.Application
import androidx.compose.runtime.Composable
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import org.kodein.di.*
import xyz.quaver.pupil.sources.hitomi.Hitomi
@@ -28,14 +28,7 @@ abstract class Source {
abstract val name: String
abstract val iconResID: Int
@Composable
open fun MainScreen(navController: NavController) { }
@Composable
open fun Search(navController: NavController) { }
@Composable
open fun Reader(navController: NavController) { }
open fun NavGraphBuilder.navGraph(navController: NavController) { }
}
typealias SourceEntry = Pair<String, Source>

View File

@@ -24,10 +24,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.*
@@ -80,7 +77,8 @@ fun FloatingSearchBar(
elevation = 8.dp
) {
Row(
modifier = Modifier.fillMaxSize().padding(16.dp, 0.dp),
modifier = Modifier
.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
navigationIcon()
@@ -91,14 +89,15 @@ fun FloatingSearchBar(
.padding(16.dp, 0.dp)
.onFocusChanged {
if (it.isFocused) onTextFieldFocused()
else onTextFieldUnfocused()
else onTextFieldUnfocused()
isFocused = it.isFocused
},
value = query,
onValueChange = onQueryChange,
singleLine = true,
cursorBrush = SolidColor(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface),
cursorBrush = SolidColor(MaterialTheme.colors.secondary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = {
@@ -129,11 +128,14 @@ fun FloatingSearchBar(
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp),
content = actions
)
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Row(
Modifier.fillMaxHeight(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
content = actions
)
}
}
}
}

View File

@@ -28,10 +28,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun <T> ListSearchResult(searchResults: List<T>, content: @Composable (T) -> Unit) {
fun <T> ListSearchResult(searchResults: List<T>, contentPadding: PaddingValues = PaddingValues(0.dp), content: @Composable (T) -> Unit) {
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(0.dp, 64.dp, 0.dp, 0.dp),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(searchResults) { itemInfo ->

View File

@@ -126,6 +126,7 @@ private class FloatingActionButtonItemProvider : PreviewParameterProvider<SubFab
@Composable
fun MultipleFloatingActionButton(
@PreviewParameter(provider = FloatingActionButtonItemProvider::class) items: List<SubFabItem>,
modifier: Modifier = Modifier,
fabIcon: ImageVector = Icons.Default.Add,
visible: Boolean = true,
targetState: FloatingActionButtonState = FloatingActionButtonState.COLLAPSED,
@@ -150,6 +151,7 @@ fun MultipleFloatingActionButton(
if (!visible) onStateChanged?.invoke(FloatingActionButtonState.COLLAPSED)
Column(
modifier = modifier,
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {

View File

@@ -41,6 +41,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.accompanist.insets.LocalWindowInsets
import com.google.accompanist.insets.rememberInsetsPaddingValues
import com.google.accompanist.insets.ui.Scaffold
import com.google.accompanist.insets.ui.TopAppBar
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -172,6 +177,16 @@ fun ReaderBase(
}
}
val systemUiController = rememberSystemUiController()
val useDarkIcons = MaterialTheme.colors.isLight
SideEffect {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = useDarkIcons
)
}
Scaffold(
topBar = {
if (!model.isFullscreen)
@@ -186,7 +201,11 @@ fun ReaderBase(
},
actions = {
//TODO
}
},
contentPadding = rememberInsetsPaddingValues(
LocalWindowInsets.current.statusBars,
applyBottom = false
)
)
},
floatingActionButton = {
@@ -208,8 +227,8 @@ fun ReaderBase(
},
scaffoldState = scaffoldState,
snackbarHost = { scaffoldState.snackbarHostState }
) {
Box {
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
LazyColumn(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)

View File

@@ -24,20 +24,18 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NavigateBefore
import androidx.compose.material.icons.filled.NavigateNext
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
@@ -55,6 +53,12 @@ import androidx.compose.ui.util.fastFirstOrNull
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import com.google.accompanist.insets.LocalWindowInsets
import com.google.accompanist.insets.navigationBarsPadding
import com.google.accompanist.insets.rememberInsetsPaddingValues
import com.google.accompanist.insets.systemBarsPadding
import com.google.accompanist.insets.ui.Scaffold
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import xyz.quaver.pupil.R
import xyz.quaver.pupil.ui.theme.LightBlue300
import kotlin.math.*
@@ -95,7 +99,7 @@ fun <T> SearchBase(
fabSubMenu: List<SubFabItem> = emptyList(),
actions: @Composable RowScope.() -> Unit = { },
onSearch: () -> Unit = { },
content: @Composable BoxScope.() -> Unit
content: @Composable BoxScope.(contentPadding: PaddingValues) -> Unit
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
@@ -115,11 +119,25 @@ fun <T> SearchBase(
}
}
val systemBarsPaddingValues = rememberInsetsPaddingValues(insets = LocalWindowInsets.current.systemBars)
val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() }
val searchBarHeight = LocalDensity.current.run { 64.dp.roundToPx() }
val searchBarDefaultOffset = systemBarsPaddingValues.calculateTopPadding() + 64.dp
val searchBarDefaultOffsetPx = LocalDensity.current.run { searchBarDefaultOffset.roundToPx() }
var overscroll: Float? by remember { mutableStateOf(null) }
val systemUiController = rememberSystemUiController()
val useDarkIcons = MaterialTheme.colors.isLight
SideEffect {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = useDarkIcons
)
}
LaunchedEffect(navigationIconProgress) {
navigationIcon.progress = navigationIconProgress
}
@@ -127,6 +145,7 @@ fun <T> SearchBase(
Scaffold(
floatingActionButton = {
MultipleFloatingActionButton(
modifier = Modifier.navigationBarsPadding(),
items = fabSubMenu,
visible = model.isFabVisible,
targetState = isFabExpanded,
@@ -135,8 +154,8 @@ fun <T> SearchBase(
}
)
}
) {
Box(Modifier.fillMaxSize()) {
) { 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)
@@ -144,7 +163,7 @@ fun <T> SearchBase(
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
LightBlue300.copy(alpha = 0.6f),
center = Offset(this.center.x, searchBarHeight.toFloat()),
center = Offset(this.center.x, searchBarDefaultOffsetPx.toFloat()),
radius = topCircleRadius
)
drawCircle(
@@ -195,7 +214,9 @@ fun <T> SearchBase(
modifier = Modifier
.offset(
0.dp,
overscroll?.coerceIn(-pageTurnIndicatorHeight, pageTurnIndicatorHeight)?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } }
overscroll
?.coerceIn(-pageTurnIndicatorHeight, pageTurnIndicatorHeight)
?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } }
?: 0.dp)
.nestedScroll(object : NestedScrollConnection {
override fun onPreScroll(
@@ -207,7 +228,7 @@ fun <T> SearchBase(
if (overscrollSnapshot == null || overscrollSnapshot == 0f) {
model.searchBarOffset =
(model.searchBarOffset + available.y.roundToInt()).coerceIn(
-searchBarHeight,
-searchBarDefaultOffsetPx,
0
)
@@ -275,29 +296,29 @@ fun <T> SearchBase(
}
}
},
content = content
content = {
this.content(
PaddingValues(0.dp, searchBarDefaultOffset, 0.dp, 0.dp)
)
}
)
if (model.loading)
CircularProgressIndicator(Modifier.align(Alignment.Center))
FloatingSearchBar(
modifier = Modifier.offset(0.dp, LocalDensity.current.run { model.searchBarOffset.toDp() }),
modifier = Modifier
.systemBarsPadding()
.offset(0.dp, LocalDensity.current.run { model.searchBarOffset.toDp() }),
query = model.query,
onQueryChange = { model.query = it },
navigationIcon = {
Icon(
painter = rememberDrawablePainter(navigationIcon),
contentDescription = null,
modifier = Modifier
.size(24.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false)
) {
focusManager.clearFocus()
}
)
IconButton(onClick = { focusManager.clearFocus() }) {
Icon(
painter = rememberDrawablePainter(navigationIcon),
contentDescription = null
)
}
},
actions = actions,
onTextFieldFocused = { navigationIconState = NavigationIconState.ARROW },

View File

@@ -19,14 +19,27 @@
package xyz.quaver.pupil.sources.hitomi
import android.app.Application
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.Sort
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import io.ktor.client.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -44,6 +57,7 @@ import xyz.quaver.pupil.sources.hitomi.composable.DetailedSearchResult
import xyz.quaver.pupil.sources.hitomi.lib.getGalleryInfo
import xyz.quaver.pupil.sources.hitomi.lib.getReferer
import xyz.quaver.pupil.sources.hitomi.lib.imageUrlFromImage
import xyz.quaver.pupil.ui.dialog.SourceSelectDialog
class Hitomi(app: Application) : Source(), DIAware {
override val di by closestDI(app)
@@ -58,16 +72,15 @@ class Hitomi(app: Application) : Source(), DIAware {
override val name: String = "hitomi.la"
override val iconResID: Int = R.drawable.hitomi
@Composable
override fun MainScreen(navController: NavController) {
navController.navigate("search/hitomi.la") {
launchSingleTop = true
navController.popBackStack()
override fun NavGraphBuilder.navGraph(navController: NavController) {
navigation(startDestination = "search", route = name) {
composable("search") { Search(navController) }
composable("reader/{itemID}") { Reader(navController) }
}
}
@Composable
override fun Search(navController: NavController) {
fun Search(navController: NavController) {
val model: HitomiSearchResultViewModel = viewModel()
val database: AppDatabase by rememberInstance()
val bookmarkDao = remember { database.bookmarkDao() }
@@ -78,7 +91,21 @@ class Hitomi(app: Application) : Source(), DIAware {
bookmarks?.toSet() ?: emptySet()
}
LaunchedEffect(model.currentPage) {
var sourceSelectDialog by remember { mutableStateOf(false) }
if (sourceSelectDialog)
SourceSelectDialog(
currentSource = name,
onDismissRequest = { sourceSelectDialog = false }
) {
sourceSelectDialog = false
navController.navigate("main/${it.name}") {
launchSingleTop = true
popUpTo("main/{source}") { inclusive = true }
}
}
LaunchedEffect(model.currentPage, model.sortByPopularity) {
model.search()
}
@@ -99,11 +126,58 @@ class Hitomi(app: Application) : Source(), DIAware {
)
),
actions = {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { sourceSelectDialog = true }) {
Image(
painter = painterResource(id = R.drawable.hitomi),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
}
IconButton(onClick = { expanded = true }) {
Icon(Icons.Default.Sort, contentDescription = null)
}
IconButton(onClick = { navController.navigate("settings") }) {
Icon(Icons.Default.Settings, contentDescription = null)
}
val onClick: (Boolean?) -> Unit = {
expanded = false
it?.let {
model.sortByPopularity = it
}
}
DropdownMenu(expanded, onDismissRequest = { onClick(null) }) {
DropdownMenuItem(onClick = { onClick(false) }) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(stringResource(R.string.main_menu_sort_newest))
RadioButton(selected = !model.sortByPopularity, onClick = { onClick(false) })
}
}
Divider()
DropdownMenuItem(onClick = { onClick(true) }){
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(stringResource(R.string.main_menu_sort_popular))
RadioButton(selected = model.sortByPopularity, onClick = { onClick(true) })
}
}
}
},
onSearch = { model.search() }
) {
ListSearchResult(model.searchResults) {
) { contentPadding ->
ListSearchResult(model.searchResults, contentPadding = contentPadding) {
DetailedSearchResult(
it,
bookmarks = bookmarkSet,
@@ -114,14 +188,14 @@ class Hitomi(app: Application) : Source(), DIAware {
}
}
) { result ->
navController.navigate("reader/$name/${result.itemID}")
navController.navigate("reader/${result.itemID}")
}
}
}
}
@Composable
override fun Reader(navController: NavController) {
fun Reader(navController: NavController) {
val model: ReaderBaseViewModel = viewModel()
val database: AppDatabase by rememberInstance()
@@ -148,7 +222,6 @@ class Hitomi(app: Application) : Source(), DIAware {
append("Referer", getReferer(galleryID))
}
}.onFailure {
logger.warning(it)
model.error = true
}
}

View File

@@ -31,6 +31,8 @@ import kotlinx.coroutines.yield
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.db.AppDatabase
import xyz.quaver.pupil.sources.composable.SearchBaseViewModel
import xyz.quaver.pupil.sources.hitomi.lib.GalleryBlock
@@ -41,6 +43,8 @@ import kotlin.math.ceil
class HitomiSearchResultViewModel(app: Application) : SearchBaseViewModel<HitomiSearchResult>(app), DIAware {
override val di by closestDI(app)
private val logger = newLogger(LoggerFactory.default)
private val client: HttpClient by instance()
private val database: AppDatabase by instance()
@@ -80,9 +84,9 @@ class HitomiSearchResultViewModel(app: Application) : SearchBaseViewModel<Hitomi
maxPage = ceil(result.size / resultsPerPage.toDouble()).toInt()
}
yield()
cache.slice((currentPage-1)*resultsPerPage until currentPage*resultsPerPage).forEach { galleryID ->
yield()
loading = false
searchResults.add(transform(getGalleryBlock(client, galleryID)))
}
}

View File

@@ -21,25 +21,24 @@ package xyz.quaver.pupil.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.ExperimentalAnimationApi
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 org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.direct
import org.kodein.di.instance
import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger
import xyz.quaver.pupil.sources.SourceEntries
import xyz.quaver.pupil.ui.theme.PupilTheme
import xyz.quaver.pupil.ui.viewmodel.MainViewModel
import xyz.quaver.pupil.util.source
class MainActivity : ComponentActivity(), DIAware {
override val di by closestDI()
private val model: MainViewModel by viewModels()
private val sources: SourceEntries by instance()
private val logger = newLogger(LoggerFactory.default)
@@ -47,24 +46,17 @@ class MainActivity : ComponentActivity(), DIAware {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
PupilTheme {
val navController = rememberNavController()
ProvideWindowInsets {
val navController = rememberNavController()
NavHost(navController, startDestination = "main/{source}") {
composable("main/{source}") {
direct.source(it.arguments?.getString("source") ?: "hitomi.la")
.MainScreen(navController)
}
composable("search/{source}") {
direct.source(it.arguments?.getString("source") ?: "hitomi.la")
.Search(navController)
}
composable("reader/{source}/{itemID}") {
direct.source(it.arguments?.getString("source") ?: "hitomi.la")
.Reader(navController)
NavHost(navController, startDestination = "hitomi.la") {
sources.forEach {
it.second.run { navGraph(navController) }
}
}
}
}

View File

@@ -1,45 +0,0 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.viewmodel
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import org.kodein.di.DIAware
import org.kodein.di.android.x.closestDI
import org.kodein.di.direct
import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger
import xyz.quaver.pupil.sources.Source
import xyz.quaver.pupil.util.source
@Suppress("UNCHECKED_CAST")
class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
override val di by closestDI()
private val logger = newLogger(LoggerFactory.default)
private val defaultSourceFactory: (String) -> Source = {
direct.source(it)
}
private var sourceFactory: (String) -> Source = defaultSourceFactory
var source by mutableStateOf(sourceFactory("hitomi.la"))
}