Initial facade of manga list
This commit is contained in:
@@ -3,6 +3,7 @@ plugins {
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("org.jetbrains.kotlin.kapt")
|
||||
id("org.jetbrains.kotlin.plugin.parcelize")
|
||||
id("com.google.devtools.ksp")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
@@ -25,7 +26,8 @@ android {
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
arguments += mapOf(
|
||||
"room.generateKotlin" to "true"
|
||||
"room.generateKotlin" to "true",
|
||||
"room.schemaLocation" to "$projectDir/schemas"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -68,6 +70,7 @@ dependencies {
|
||||
implementation("androidx.lifecycle:lifecycle-process:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
||||
implementation("androidx.compose.animation:animation-graphics")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
|
||||
@@ -36,7 +36,7 @@ class App : Application() {
|
||||
}
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
|
||||
processLifecycleScope.launch((Dispatchers.IO)) {
|
||||
processLifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Updater.deleteOutdatedApk(this@App)
|
||||
} catch (_: Throwable) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package org.xtimms.tokusho
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
@@ -22,6 +24,7 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
@@ -43,6 +46,7 @@ import kotlinx.coroutines.launch
|
||||
import org.xtimms.tokusho.core.Navigation
|
||||
import org.xtimms.tokusho.core.components.BottomNavBar
|
||||
import org.xtimms.tokusho.core.components.TopAppBar
|
||||
import org.xtimms.tokusho.sections.list.LIST_DESTINATION
|
||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||
import javax.inject.Inject
|
||||
@@ -74,10 +78,21 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
putDataToExtras(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
putDataToExtras(intent)
|
||||
super.onNewIntent(intent)
|
||||
}
|
||||
|
||||
private fun putDataToExtras(intent: Intent?) {
|
||||
intent?.putExtra(EXTRA_DATA, intent.data)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
const val EXTRA_DATA = "data"
|
||||
|
||||
fun setLanguage(locale: String) {
|
||||
Log.d(TAG, "setLanguage: $locale")
|
||||
@@ -122,6 +137,7 @@ fun MainView(
|
||||
BottomNavBar(
|
||||
navController = navController,
|
||||
bottomBarState = bottomBarState,
|
||||
topBarOffsetY = topBarOffsetY
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,18 +11,23 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.tokusho.core.model.ShelfCategory
|
||||
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
|
||||
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
|
||||
import org.xtimms.tokusho.sections.details.DETAILS_DESTINATION
|
||||
import org.xtimms.tokusho.sections.details.DetailsView
|
||||
import org.xtimms.tokusho.sections.details.MANGA_ID_ARGUMENT
|
||||
import org.xtimms.tokusho.sections.explore.ExploreView
|
||||
import org.xtimms.tokusho.sections.history.HistoryView
|
||||
import org.xtimms.tokusho.sections.list.LIST_DESTINATION
|
||||
import org.xtimms.tokusho.sections.list.MangaListView
|
||||
import org.xtimms.tokusho.sections.list.PROVIDER_ARGUMENT
|
||||
import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION
|
||||
import org.xtimms.tokusho.sections.search.SearchHostView
|
||||
import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION
|
||||
@@ -39,6 +44,7 @@ import org.xtimms.tokusho.sections.settings.appearance.LANGUAGES_DESTINATION
|
||||
import org.xtimms.tokusho.sections.settings.appearance.LanguagesView
|
||||
import org.xtimms.tokusho.sections.shelf.ShelfMap
|
||||
import org.xtimms.tokusho.sections.shelf.ShelfView
|
||||
import org.xtimms.tokusho.utils.lang.removeFirstAndLast
|
||||
|
||||
const val DURATION_ENTER = 400
|
||||
const val DURATION_EXIT = 200
|
||||
@@ -88,7 +94,10 @@ fun Navigation(
|
||||
composable(BottomNavDestination.Shelf.route) {
|
||||
val library: ShelfMap = emptyMap()
|
||||
ShelfView(
|
||||
categories = listOf(ShelfCategory(1, "Test 1", 1L, 1L), ShelfCategory(2, "Test 2", 2L, 2L)),
|
||||
categories = listOf(
|
||||
ShelfCategory(1, "Test 1", 1L, 1L),
|
||||
ShelfCategory(2, "Test 2", 2L, 2L)
|
||||
),
|
||||
currentPage = { 0 },
|
||||
showPageTabs = true,
|
||||
getNumberOfMangaForCategory = { 2 },
|
||||
@@ -108,7 +117,11 @@ fun Navigation(
|
||||
composable(BottomNavDestination.Explore.route) {
|
||||
ExploreView(
|
||||
coil = coil,
|
||||
navController = navController,
|
||||
navigateToSource = {
|
||||
navController.navigate(
|
||||
LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
|
||||
)
|
||||
},
|
||||
padding = padding,
|
||||
topBarHeightPx = topBarHeightPx,
|
||||
topBarOffsetY = topBarOffsetY
|
||||
@@ -152,11 +165,26 @@ fun Navigation(
|
||||
)
|
||||
}
|
||||
|
||||
composable(LIST_DESTINATION) {
|
||||
composable(
|
||||
route = LIST_DESTINATION,
|
||||
arguments = listOf(
|
||||
navArgument(PROVIDER_ARGUMENT.removeFirstAndLast()) {
|
||||
type = NavType.StringType
|
||||
}
|
||||
)
|
||||
) { navEntry ->
|
||||
MangaListView(
|
||||
sourceName = "Source",
|
||||
coil = coil,
|
||||
source = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast())
|
||||
?.let { source -> MangaSource.valueOf(source) } ?: MangaSource.DUMMY,
|
||||
navigateBack = navigateBack,
|
||||
navigateToDetails = { navController.navigate(DETAILS_DESTINATION) }
|
||||
navigateToDetails = {
|
||||
navController.navigate(
|
||||
DETAILS_DESTINATION.replace(
|
||||
MANGA_ID_ARGUMENT, it.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -173,8 +201,13 @@ fun Navigation(
|
||||
)
|
||||
}
|
||||
|
||||
composable(DETAILS_DESTINATION) {
|
||||
// TODO
|
||||
composable(
|
||||
route = DETAILS_DESTINATION
|
||||
) { navEntry ->
|
||||
DetailsView(
|
||||
coil = coil,
|
||||
mangaId = 0L,
|
||||
navigateBack = navigateBack,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.xtimms.tokusho.core.base.event
|
||||
|
||||
interface PagedUiEvent : UiEvent {
|
||||
fun loadMore()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.xtimms.tokusho.core.base.state
|
||||
|
||||
abstract class PagedUiState : UiState() {
|
||||
|
||||
abstract val nextPage: String?
|
||||
|
||||
/**
|
||||
* Trigger variable to load more items, be careful to set it to false after loading more
|
||||
*/
|
||||
abstract val loadMore: Boolean
|
||||
|
||||
val canLoadMore get() = nextPage != null && !isLoading
|
||||
}
|
||||
@@ -1,18 +1,45 @@
|
||||
package org.xtimms.tokusho.core.base.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.tokusho.core.base.event.UiEvent
|
||||
import org.xtimms.tokusho.core.base.state.UiState
|
||||
import org.xtimms.tokusho.utils.lang.EventFlow
|
||||
import org.xtimms.tokusho.utils.lang.MutableEventFlow
|
||||
import org.xtimms.tokusho.utils.lang.call
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
abstract class BaseViewModel<S : UiState> : ViewModel(), UiEvent {
|
||||
|
||||
@JvmField
|
||||
protected val loadingCounter = MutableStateFlow(0)
|
||||
|
||||
@JvmField
|
||||
protected val errorEvent = MutableEventFlow<Throwable>()
|
||||
|
||||
val onError: EventFlow<Throwable>
|
||||
get() = errorEvent
|
||||
|
||||
protected abstract val mutableUiState: MutableStateFlow<S>
|
||||
val uiState: StateFlow<S> by lazy { mutableUiState.asStateFlow() }
|
||||
|
||||
protected fun launchJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun setLoading(value: Boolean) {
|
||||
mutableUiState.update { it.setLoading(value) as S }
|
||||
@@ -28,6 +55,29 @@ abstract class BaseViewModel<S : UiState> : ViewModel(), UiEvent {
|
||||
mutableUiState.update { it.setMessage(null) as S }
|
||||
}
|
||||
|
||||
protected fun launchLoadingJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
|
||||
loadingCounter.increment()
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
loadingCounter.decrement()
|
||||
}
|
||||
}
|
||||
|
||||
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
|
||||
|
||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||
|
||||
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
||||
if (throwable !is CancellationException) {
|
||||
errorEvent.call(throwable)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val GENERIC_ERROR = "Generic Error"
|
||||
const val FLOW_TIMEOUT = 5_000L
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.xtimms.tokusho.core.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.material3.NavigationBar
|
||||
@@ -11,10 +13,12 @@ import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.tokusho.core.BottomNavDestination
|
||||
import org.xtimms.tokusho.core.BottomNavDestination.Companion.Icon
|
||||
import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION
|
||||
@@ -25,7 +29,9 @@ import org.xtimms.tokusho.sections.shelf.SHELF_DESTINATION
|
||||
fun BottomNavBar(
|
||||
navController: NavController,
|
||||
bottomBarState: State<Boolean>,
|
||||
topBarOffsetY: Animatable<Float, AnimationVector1D>,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val isVisible by remember {
|
||||
@@ -49,6 +55,10 @@ fun BottomNavBar(
|
||||
NavigationBarItem(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
topBarOffsetY.animateTo(0f)
|
||||
}
|
||||
|
||||
navController.navigate(dest.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
|
||||
@@ -2,9 +2,12 @@ package org.xtimms.tokusho.core.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.OpenInBrowser
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.xtimms.tokusho.R
|
||||
|
||||
@Composable
|
||||
fun BackIconButton(
|
||||
@@ -16,4 +19,16 @@ fun BackIconButton(
|
||||
contentDescription = "arrow_back"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ViewInBrowserButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.OpenInBrowser,
|
||||
contentDescription = stringResource(R.string.open_in_browser)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import org.xtimms.tokusho.core.AsyncImageImpl
|
||||
|
||||
enum class MangaCover(val ratio: Float) {
|
||||
Square(1f / 1f),
|
||||
@@ -20,14 +23,16 @@ enum class MangaCover(val ratio: Float) {
|
||||
|
||||
@Composable
|
||||
operator fun invoke(
|
||||
data: Painter,
|
||||
coil: ImageLoader,
|
||||
data: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String = "",
|
||||
shape: Shape = MaterialTheme.shapes.small,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Image(
|
||||
painter = data,
|
||||
AsyncImageImpl(
|
||||
coil = coil,
|
||||
model = data,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier
|
||||
.aspectRatio(ratio)
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package org.xtimms.tokusho.core.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.ImageLoader
|
||||
import org.xtimms.tokusho.core.AsyncImageImpl
|
||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||
|
||||
private const val GridSelectedCoverAlpha = 0.76f
|
||||
|
||||
/**
|
||||
* Layout of grid list item with title overlaying the cover.
|
||||
* Accepts null [title] for a cover-only view.
|
||||
*/
|
||||
@Composable
|
||||
fun MangaCompactGridItem(
|
||||
coil: ImageLoader,
|
||||
imageUrl: String,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
isSelected: Boolean = false,
|
||||
title: String? = null,
|
||||
onClickContinueReading: (() -> Unit)? = null,
|
||||
coverAlpha: Float = 1f,
|
||||
) {
|
||||
GridItemSelectable(
|
||||
isSelected = isSelected,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Box {
|
||||
AsyncImageImpl(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.aspectRatio(10F / 16F),
|
||||
coil = coil,
|
||||
model = imageUrl,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title!!,
|
||||
modifier = Modifier.padding(4.dp),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common cover layout to add contents to be drawn on top of the cover.
|
||||
*/
|
||||
@Composable
|
||||
private fun MangaGridCover(
|
||||
modifier: Modifier = Modifier,
|
||||
cover: @Composable BoxScope.() -> Unit = {},
|
||||
content: @Composable (BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(MangaCover.Book.ratio),
|
||||
) {
|
||||
cover()
|
||||
content?.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title overlay for [MangaCompactGridItem]
|
||||
*/
|
||||
@Composable
|
||||
private fun BoxScope.CoverTextOverlay(
|
||||
title: String,
|
||||
onClickContinueReading: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
1f to Color(0xAA000000),
|
||||
),
|
||||
)
|
||||
.fillMaxHeight(0.33f)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
GridItemTitle(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(8.dp),
|
||||
title = title,
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
color = Color.White,
|
||||
shadow = Shadow(
|
||||
color = Color.Black,
|
||||
blurRadius = 4f,
|
||||
),
|
||||
),
|
||||
minLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridItemTitle(
|
||||
title: String,
|
||||
style: TextStyle,
|
||||
minLines: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
maxLines: Int = 2,
|
||||
) {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = title,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 18.sp,
|
||||
minLines = minLines,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for grid items to handle selection state, click and long click.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun GridItemSelectable(
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.selectedOutline(isSelected = isSelected, color = MaterialTheme.colorScheme.secondary)
|
||||
.padding(4.dp),
|
||||
) {
|
||||
val contentColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onSecondary
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
}
|
||||
CompositionLocalProvider(LocalContentColor provides contentColor) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see GridItemSelectable
|
||||
*/
|
||||
private fun Modifier.selectedOutline(
|
||||
isSelected: Boolean,
|
||||
color: Color,
|
||||
) = this then drawBehind { if (isSelected) drawRect(color = color) }
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun MangaGridItemPreview() {
|
||||
TokushoTheme {
|
||||
MangaCompactGridItem(
|
||||
coil = ImageLoader(LocalContext.current),
|
||||
imageUrl = "https://cdn.myanimelist.net/images/manga/2/170594l.jpg",
|
||||
title = "Stub",
|
||||
onClick = { },
|
||||
onLongClick = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
package org.xtimms.tokusho.core.database
|
||||
|
||||
const val TABLE_MANGA = "manga"
|
||||
const val TABLE_TAGS = "tags"
|
||||
const val TABLE_MANGA_TAGS = "manga_tags"
|
||||
const val TABLE_SOURCES = "sources"
|
||||
@@ -4,20 +4,28 @@ import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import org.xtimms.tokusho.core.database.dao.MangaDao
|
||||
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
|
||||
import org.xtimms.tokusho.core.database.entity.MangaEntity
|
||||
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
|
||||
import org.xtimms.tokusho.core.database.entity.MangaTagsEntity
|
||||
import org.xtimms.tokusho.core.database.entity.TagEntity
|
||||
|
||||
const val DATABASE_VERSION = 1
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MangaEntity::class,
|
||||
TagEntity::class,
|
||||
MangaTagsEntity::class,
|
||||
MangaSourceEntity::class
|
||||
],
|
||||
version = DATABASE_VERSION,
|
||||
exportSchema = false
|
||||
version = DATABASE_VERSION
|
||||
)
|
||||
abstract class MangaDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun getMangaDao(): MangaDao
|
||||
|
||||
abstract fun getSourcesDao(): MangaSourcesDao
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.xtimms.tokusho.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import androidx.room.Upsert
|
||||
import org.xtimms.tokusho.core.database.entity.MangaEntity
|
||||
import org.xtimms.tokusho.core.database.entity.MangaTagsEntity
|
||||
import org.xtimms.tokusho.core.database.entity.MangaWithTags
|
||||
import org.xtimms.tokusho.core.database.entity.TagEntity
|
||||
|
||||
@Dao
|
||||
abstract class MangaDao {
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE manga_id = :id")
|
||||
abstract suspend fun find(id: Long): MangaWithTags?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
|
||||
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE source = :source")
|
||||
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(manga: MangaEntity)
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun update(manga: MangaEntity): Int
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insertTagRelation(tag: MangaTagsEntity): Long
|
||||
|
||||
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
|
||||
abstract suspend fun clearTagRelation(mangaId: Long)
|
||||
|
||||
@Transaction
|
||||
@Delete
|
||||
abstract suspend fun delete(subjects: Collection<MangaEntity>)
|
||||
|
||||
@Transaction
|
||||
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
||||
upsert(manga)
|
||||
if (tags != null) {
|
||||
clearTagRelation(manga.id)
|
||||
tags.map {
|
||||
MangaTagsEntity(manga.id, it.id)
|
||||
}.forEach {
|
||||
insertTagRelation(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.xtimms.tokusho.core.database.entity
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.xtimms.tokusho.core.model.MangaSource
|
||||
import org.xtimms.tokusho.utils.lang.longHashCode
|
||||
|
||||
// Entity to model
|
||||
|
||||
fun TagEntity.toMangaTag() = MangaTag(
|
||||
key = this.key,
|
||||
title = this.title.toTitleCase(),
|
||||
source = MangaSource(this.source),
|
||||
)
|
||||
|
||||
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
|
||||
|
||||
fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
|
||||
|
||||
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
||||
id = this.id,
|
||||
title = this.title,
|
||||
altTitle = this.altTitle,
|
||||
state = this.state?.let { MangaState(it) },
|
||||
rating = this.rating,
|
||||
isNsfw = this.isNsfw,
|
||||
url = this.url,
|
||||
publicUrl = this.publicUrl,
|
||||
coverUrl = this.coverUrl,
|
||||
largeCoverUrl = this.largeCoverUrl,
|
||||
author = this.author,
|
||||
source = MangaSource(this.source),
|
||||
tags = tags,
|
||||
)
|
||||
|
||||
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
||||
|
||||
// Model to entity
|
||||
|
||||
fun Manga.toEntity() = MangaEntity(
|
||||
id = id,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
source = source.name,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
coverUrl = coverUrl,
|
||||
altTitle = altTitle,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
state = state?.name,
|
||||
title = title,
|
||||
author = author,
|
||||
)
|
||||
|
||||
fun MangaTag.toEntity() = TagEntity(
|
||||
title = title,
|
||||
key = key,
|
||||
source = source.name,
|
||||
id = "${key}_${source.name}".longHashCode(),
|
||||
)
|
||||
|
||||
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
|
||||
|
||||
// Other
|
||||
|
||||
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
||||
SortOrder.valueOf(name)
|
||||
}.getOrDefault(fallback)
|
||||
|
||||
fun MangaState(name: String): MangaState? = runCatching {
|
||||
MangaState.valueOf(name)
|
||||
}.getOrNull()
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.xtimms.tokusho.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.xtimms.tokusho.core.database.TABLE_MANGA
|
||||
|
||||
@Entity(tableName = TABLE_MANGA)
|
||||
data class MangaEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "manga_id") val id: Long,
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "alt_title") val altTitle: String?,
|
||||
@ColumnInfo(name = "url") val url: String,
|
||||
@ColumnInfo(name = "public_url") val publicUrl: String,
|
||||
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
|
||||
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
|
||||
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
||||
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
||||
@ColumnInfo(name = "state") val state: String?,
|
||||
@ColumnInfo(name = "author") val author: String?,
|
||||
@ColumnInfo(name = "source") val source: String,
|
||||
)
|
||||
@@ -5,9 +5,7 @@ import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.xtimms.tokusho.core.database.TABLE_SOURCES
|
||||
|
||||
@Entity(
|
||||
tableName = TABLE_SOURCES,
|
||||
)
|
||||
@Entity(tableName = TABLE_SOURCES)
|
||||
data class MangaSourceEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "source")
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.xtimms.tokusho.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import org.xtimms.tokusho.core.database.TABLE_MANGA_TAGS
|
||||
|
||||
@Entity(
|
||||
tableName = TABLE_MANGA_TAGS,
|
||||
primaryKeys = ["manga_id", "tag_id"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = TagEntity::class,
|
||||
parentColumns = ["tag_id"],
|
||||
childColumns = ["tag_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
]
|
||||
)
|
||||
class MangaTagsEntity(
|
||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||
@ColumnInfo(name = "tag_id", index = true) val tagId: Long,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.xtimms.tokusho.core.database.entity
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
|
||||
data class MangaWithTags(
|
||||
@Embedded val manga: MangaEntity,
|
||||
@Relation(
|
||||
parentColumn = "manga_id",
|
||||
entityColumn = "tag_id",
|
||||
associateBy = Junction(MangaTagsEntity::class)
|
||||
)
|
||||
val tags: List<TagEntity>,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.xtimms.tokusho.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.xtimms.tokusho.core.database.TABLE_TAGS
|
||||
|
||||
@Entity(tableName = TABLE_TAGS)
|
||||
data class TagEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "tag_id") val id: Long,
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "key") val key: String,
|
||||
@ColumnInfo(name = "source") val source: String,
|
||||
)
|
||||
5
app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt
Normal file
5
app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt
Normal file
@@ -0,0 +1,5 @@
|
||||
package org.xtimms.tokusho.core.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.xtimms.tokusho.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parceler
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.TypeParceler
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.xtimms.tokusho.utils.lang.readSerializableCompat
|
||||
|
||||
object MangaTagParceler : Parceler<MangaTag> {
|
||||
override fun create(parcel: Parcel) = MangaTag(
|
||||
title = requireNotNull(parcel.readString()),
|
||||
key = requireNotNull(parcel.readString()),
|
||||
source = requireNotNull(parcel.readSerializableCompat()),
|
||||
)
|
||||
|
||||
override fun MangaTag.write(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(title)
|
||||
parcel.writeString(key)
|
||||
parcel.writeSerializable(source)
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@TypeParceler<MangaTag, MangaTagParceler>
|
||||
data class ParcelableMangaTags(val tags: Set<MangaTag>) : Parcelable
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.xtimms.tokusho.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.core.os.ParcelCompat
|
||||
import kotlinx.parcelize.Parceler
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.utils.lang.readParcelableCompat
|
||||
import org.xtimms.tokusho.utils.lang.readSerializableCompat
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableManga(
|
||||
val manga: Manga,
|
||||
) : Parcelable {
|
||||
|
||||
companion object : Parceler<ParcelableManga> {
|
||||
|
||||
override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) {
|
||||
parcel.writeLong(id)
|
||||
parcel.writeString(title)
|
||||
parcel.writeString(altTitle)
|
||||
parcel.writeString(url)
|
||||
parcel.writeString(publicUrl)
|
||||
parcel.writeFloat(rating)
|
||||
ParcelCompat.writeBoolean(parcel, isNsfw)
|
||||
parcel.writeString(coverUrl)
|
||||
parcel.writeString(largeCoverUrl)
|
||||
parcel.writeString(description)
|
||||
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||
parcel.writeSerializable(state)
|
||||
parcel.writeString(author)
|
||||
parcel.writeSerializable(source)
|
||||
}
|
||||
|
||||
override fun create(parcel: Parcel) = ParcelableManga(
|
||||
Manga(
|
||||
id = parcel.readLong(),
|
||||
title = requireNotNull(parcel.readString()),
|
||||
altTitle = parcel.readString(),
|
||||
url = requireNotNull(parcel.readString()),
|
||||
publicUrl = requireNotNull(parcel.readString()),
|
||||
rating = parcel.readFloat(),
|
||||
isNsfw = ParcelCompat.readBoolean(parcel),
|
||||
coverUrl = requireNotNull(parcel.readString()),
|
||||
largeCoverUrl = parcel.readString(),
|
||||
description = parcel.readString(),
|
||||
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
|
||||
state = parcel.readSerializableCompat(),
|
||||
author = parcel.readString(),
|
||||
chapters = null,
|
||||
source = requireNotNull(parcel.readSerializableCompat()),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.xtimms.tokusho.core.parser
|
||||
|
||||
import dagger.Reusable
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.core.database.MangaDatabase
|
||||
import org.xtimms.tokusho.core.database.entity.toManga
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
@Reusable
|
||||
class MangaDataRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
private val resolverProvider: Provider<MangaLinkResolver>,
|
||||
) {
|
||||
|
||||
suspend fun findMangaById(mangaId: Long): Manga? {
|
||||
return db.getMangaDao().find(mangaId)?.toManga()
|
||||
}
|
||||
|
||||
suspend fun findMangaByPublicUrl(publicUrl: String): Manga? {
|
||||
return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga()
|
||||
}
|
||||
|
||||
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
|
||||
intent.manga != null -> intent.manga
|
||||
intent.mangaId != 0L -> findMangaById(intent.mangaId)
|
||||
intent.uri != null -> resolverProvider.get().resolve(intent.uri)
|
||||
else -> null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.xtimms.tokusho.core.parser
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.MainActivity
|
||||
import org.xtimms.tokusho.core.model.parcelable.ParcelableManga
|
||||
import org.xtimms.tokusho.utils.lang.getParcelableCompat
|
||||
import org.xtimms.tokusho.utils.lang.getParcelableExtraCompat
|
||||
|
||||
class MangaIntent private constructor(
|
||||
@JvmField val manga: Manga?,
|
||||
@JvmField val id: Long,
|
||||
@JvmField val uri: Uri?,
|
||||
) {
|
||||
|
||||
constructor(intent: Intent?) : this(
|
||||
manga = intent?.getParcelableExtraCompat<ParcelableManga>(KEY_MANGA)?.manga,
|
||||
id = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE,
|
||||
uri = intent?.data,
|
||||
)
|
||||
|
||||
constructor(savedStateHandle: SavedStateHandle) : this(
|
||||
manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga,
|
||||
id = savedStateHandle[KEY_ID] ?: ID_NONE,
|
||||
uri = savedStateHandle[MainActivity.EXTRA_DATA],
|
||||
)
|
||||
|
||||
constructor(args: Bundle?) : this(
|
||||
manga = args?.getParcelableCompat<ParcelableManga>(KEY_MANGA)?.manga,
|
||||
id = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,
|
||||
uri = null,
|
||||
)
|
||||
|
||||
val mangaId: Long
|
||||
get() = if (id != ID_NONE) id else manga?.id ?: uri?.lastPathSegment?.toLongOrNull() ?: ID_NONE
|
||||
|
||||
companion object {
|
||||
|
||||
const val ID_NONE = 0L
|
||||
|
||||
const val KEY_MANGA = "manga"
|
||||
const val KEY_ID = "id"
|
||||
|
||||
fun of(manga: Manga) = MangaIntent(manga, manga.id, null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package org.xtimms.tokusho.core.parser
|
||||
|
||||
import android.net.Uri
|
||||
import coil.request.CachePolicy
|
||||
import dagger.Reusable
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.almostEquals
|
||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.toRelativeUrl
|
||||
import org.xtimms.tokusho.core.model.MangaSource
|
||||
import org.xtimms.tokusho.data.repository.MangaSourcesRepository
|
||||
import org.xtimms.tokusho.utils.lang.ifNullOrEmpty
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class MangaLinkResolver @Inject constructor(
|
||||
private val repositoryFactory: MangaRepository.Factory,
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
private val dataRepository: MangaDataRepository,
|
||||
) {
|
||||
|
||||
suspend fun resolve(uri: Uri): Manga {
|
||||
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
|
||||
resolveAppLink(uri)
|
||||
} else {
|
||||
resolveExternalLink(uri)
|
||||
} ?: throw NotFoundException("Cannot resolve link", uri.toString())
|
||||
}
|
||||
|
||||
private suspend fun resolveAppLink(uri: Uri): Manga? {
|
||||
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
|
||||
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
|
||||
val source = MangaSource(sourceName)
|
||||
require(source != MangaSource.DUMMY) { "Manga source $sourceName is not supported" }
|
||||
val repo = repositoryFactory.create(source)
|
||||
return repo.findExact(
|
||||
url = uri.getQueryParameter("url"),
|
||||
title = uri.getQueryParameter("name"),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun resolveExternalLink(uri: Uri): Manga? {
|
||||
dataRepository.findMangaByPublicUrl(uri.toString())?.let {
|
||||
return it
|
||||
}
|
||||
val host = uri.host ?: return null
|
||||
val repo = sourcesRepository.allMangaSources.asSequence()
|
||||
.map { source ->
|
||||
repositoryFactory.create(source) as RemoteMangaRepository
|
||||
}.find { repo ->
|
||||
host in repo.domains
|
||||
} ?: return null
|
||||
return repo.findExact(uri.toString().toRelativeUrl(host), null)
|
||||
}
|
||||
|
||||
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
|
||||
if (!title.isNullOrEmpty()) {
|
||||
val list = getList(0, MangaListFilter.Search(title))
|
||||
if (url != null) {
|
||||
list.find { it.url == url }?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
list.minByOrNull { it.title.levenshteinDistance(title) }
|
||||
?.takeIf { it.title.almostEquals(title, 0.2f) }
|
||||
?.let { return it }
|
||||
}
|
||||
val seed = getDetailsNoCache(
|
||||
getSeedManga(source, url ?: return null, title),
|
||||
)
|
||||
return runCatchingCancellable {
|
||||
val seedTitle = seed.title.ifEmpty {
|
||||
seed.altTitle
|
||||
}.ifNullOrEmpty {
|
||||
seed.author
|
||||
} ?: return@runCatchingCancellable null
|
||||
val seedList = getList(0, MangaListFilter.Search(seedTitle))
|
||||
seedList.first { x -> x.url == url }
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
|
||||
return if (this is RemoteMangaRepository) {
|
||||
getDetails(manga, CachePolicy.READ_ONLY)
|
||||
} else {
|
||||
getDetails(manga)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(
|
||||
id = run {
|
||||
var h = 1125899906842597L
|
||||
source.name.forEach { c ->
|
||||
h = 31 * h + c.code
|
||||
}
|
||||
url.forEach { c ->
|
||||
h = 31 * h + c.code
|
||||
}
|
||||
h
|
||||
},
|
||||
title = title.orEmpty(),
|
||||
altTitle = null,
|
||||
url = url,
|
||||
publicUrl = "",
|
||||
rating = 0.0f,
|
||||
isNsfw = source.contentType == ContentType.HENTAI,
|
||||
coverUrl = "",
|
||||
tags = emptySet(),
|
||||
state = null,
|
||||
author = null,
|
||||
largeCoverUrl = null,
|
||||
description = null,
|
||||
chapters = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
@@ -29,6 +30,7 @@ import org.xtimms.tokusho.core.cache.SafeDeferred
|
||||
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(InternalParsersApi::class)
|
||||
class RemoteMangaRepository(
|
||||
private val parser: MangaParser,
|
||||
private val cache: ContentCache,
|
||||
@@ -55,6 +57,9 @@ class RemoteMangaRepository(
|
||||
override val isTagsExclusionSupported: Boolean
|
||||
get() = parser.isTagsExclusionSupported
|
||||
|
||||
val domains: Array<out String>
|
||||
get() = parser.configKeyDomain.presetValues
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return if (parser is Interceptor) {
|
||||
parser.intercept(chain)
|
||||
|
||||
@@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import org.xtimms.tokusho.core.components.ActionButton
|
||||
import org.xtimms.tokusho.utils.secondaryItemAlpha
|
||||
import org.xtimms.tokusho.utils.composable.secondaryItemAlpha
|
||||
import kotlin.random.Random
|
||||
|
||||
data class EmptyScreenAction(
|
||||
|
||||
@@ -29,7 +29,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import org.xtimms.tokusho.utils.secondaryItemAlpha
|
||||
import org.xtimms.tokusho.utils.composable.secondaryItemAlpha
|
||||
|
||||
@Composable
|
||||
fun InfoScreen(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.xtimms.tokusho.sections.details
|
||||
|
||||
import org.xtimms.tokusho.core.base.event.UiEvent
|
||||
|
||||
interface DetailsEvent : UiEvent
|
||||
@@ -1,10 +1,18 @@
|
||||
package org.xtimms.tokusho.sections.details
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
|
||||
import androidx.compose.animation.graphics.res.animatedVectorResource
|
||||
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
|
||||
import androidx.compose.animation.graphics.vector.AnimatedImageVector
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -14,6 +22,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.MenuBook
|
||||
import androidx.compose.material.icons.outlined.Block
|
||||
@@ -21,43 +30,67 @@ import androidx.compose.material.icons.outlined.Brush
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.DoneAll
|
||||
import androidx.compose.material.icons.outlined.Language
|
||||
import androidx.compose.material.icons.outlined.MenuBook
|
||||
import androidx.compose.material.icons.outlined.Pause
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material.icons.outlined.Upcoming
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.xtimms.tokusho.R
|
||||
import org.xtimms.tokusho.core.components.MangaCover
|
||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||
import org.xtimms.tokusho.utils.secondaryItemAlpha
|
||||
import org.xtimms.tokusho.utils.composable.clickableNoIndication
|
||||
import org.xtimms.tokusho.utils.composable.secondaryItemAlpha
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE))
|
||||
|
||||
@Composable
|
||||
fun DetailsInfoBox(
|
||||
coil: ImageLoader,
|
||||
imageUrl: String,
|
||||
title: String,
|
||||
author: String?,
|
||||
artist: String?,
|
||||
state: MangaState?,
|
||||
isTabletUi: Boolean,
|
||||
appBarPadding: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -67,8 +100,8 @@ fun DetailsInfoBox(
|
||||
Color.Transparent,
|
||||
MaterialTheme.colorScheme.background,
|
||||
)
|
||||
Image(
|
||||
painterResource(id = R.drawable.ookami),
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
@@ -79,28 +112,30 @@ fun DetailsInfoBox(
|
||||
brush = Brush.verticalGradient(colors = backdropGradientColors),
|
||||
)
|
||||
}
|
||||
.blur(8.dp)
|
||||
.blur(2.dp)
|
||||
.alpha(0.2f)
|
||||
)
|
||||
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||
if (!isTabletUi) {
|
||||
MangaAndSourceTitlesSmall(
|
||||
coil = coil,
|
||||
appBarPadding = appBarPadding,
|
||||
onCoverClick = { },
|
||||
title = "Ookami to Koushinryou",
|
||||
author = "Hasekura Isuna",
|
||||
artist = "Koume Keito",
|
||||
state = MangaState.FINISHED
|
||||
imageUrl = imageUrl,
|
||||
title = title,
|
||||
author = author,
|
||||
artist = artist,
|
||||
state = state
|
||||
)
|
||||
} else {
|
||||
MangaAndSourceTitlesLarge(
|
||||
coil = coil,
|
||||
appBarPadding = appBarPadding,
|
||||
onCoverClick = { },
|
||||
title = "Ookami to Koushinryou",
|
||||
author = "Hasekura Isuna",
|
||||
artist = "Koume Keito",
|
||||
state = MangaState.FINISHED
|
||||
imageUrl = imageUrl,
|
||||
title = title,
|
||||
author = author,
|
||||
artist = artist,
|
||||
state = state
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -109,8 +144,9 @@ fun DetailsInfoBox(
|
||||
|
||||
@Composable
|
||||
private fun MangaAndSourceTitlesLarge(
|
||||
coil: ImageLoader,
|
||||
appBarPadding: Dp,
|
||||
onCoverClick: () -> Unit,
|
||||
imageUrl: String,
|
||||
title: String,
|
||||
author: String?,
|
||||
artist: String?,
|
||||
@@ -123,10 +159,10 @@ private fun MangaAndSourceTitlesLarge(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
MangaCover.Book(
|
||||
coil = coil,
|
||||
modifier = Modifier.fillMaxWidth(0.65f),
|
||||
data = painterResource(id = R.drawable.ookami),
|
||||
data = imageUrl,
|
||||
contentDescription = stringResource(R.string.manga_cover),
|
||||
onClick = onCoverClick,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
DetailsContentInfo(
|
||||
@@ -139,8 +175,9 @@ private fun MangaAndSourceTitlesLarge(
|
||||
|
||||
@Composable
|
||||
private fun MangaAndSourceTitlesSmall(
|
||||
coil: ImageLoader,
|
||||
appBarPadding: Dp,
|
||||
onCoverClick: () -> Unit,
|
||||
imageUrl: String,
|
||||
title: String,
|
||||
author: String?,
|
||||
artist: String?,
|
||||
@@ -156,12 +193,12 @@ private fun MangaAndSourceTitlesSmall(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
MangaCover.Book(
|
||||
coil = coil,
|
||||
modifier = Modifier
|
||||
.sizeIn(maxWidth = 100.dp)
|
||||
.align(Alignment.Top),
|
||||
data = painterResource(id = R.drawable.ookami),
|
||||
data = imageUrl,
|
||||
contentDescription = stringResource(R.string.manga_cover),
|
||||
onClick = onCoverClick,
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
@@ -246,7 +283,11 @@ private fun RowScope.DetailsRow(
|
||||
textAlign: TextAlign? = LocalTextStyle.current.textAlign,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).wrapContentSize().secondaryItemAlpha(),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.wrapContentSize()
|
||||
.secondaryItemAlpha()
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
@@ -279,7 +320,10 @@ private fun RowScope.DetailsRow(
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).wrapContentSize().secondaryItemAlpha(),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.wrapContentSize()
|
||||
.secondaryItemAlpha(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
@@ -296,7 +340,10 @@ private fun RowScope.DetailsRow(
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).wrapContentSize().secondaryItemAlpha(),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.wrapContentSize()
|
||||
.secondaryItemAlpha(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
@@ -314,13 +361,177 @@ private fun RowScope.DetailsRow(
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DetailsInfoBoxPreview() {
|
||||
TokushoTheme {
|
||||
DetailsInfoBox(
|
||||
isTabletUi = false,
|
||||
appBarPadding = 72.dp,
|
||||
fun ExpandableMangaDescription(
|
||||
defaultExpandState: Boolean,
|
||||
description: String?,
|
||||
tagsProvider: () -> List<MangaTag>?,
|
||||
onTagSearch: (String) -> Unit,
|
||||
onCopyTagToClipboard: (tag: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
val (expanded, onExpanded) = rememberSaveable {
|
||||
mutableStateOf(defaultExpandState)
|
||||
}
|
||||
val desc =
|
||||
description.takeIf { !it.isNullOrBlank() } ?: stringResource(R.string.description_placeholder)
|
||||
val trimmedDescription = remember(desc) {
|
||||
desc
|
||||
.replace(whitespaceLineRegex, "\n")
|
||||
.trimEnd()
|
||||
}
|
||||
val tags = tagsProvider()
|
||||
if (!tags.isNullOrEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.animateContentSize(),
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var tagSelected by remember { mutableStateOf("") }
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = stringResource(R.string.search)) },
|
||||
onClick = {
|
||||
onTagSearch(tagSelected)
|
||||
showMenu = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = stringResource(R.string.action_copy_to_clipboard)) },
|
||||
onClick = {
|
||||
onCopyTagToClipboard(tagSelected)
|
||||
showMenu = false
|
||||
},
|
||||
)
|
||||
}
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
tags.forEach {
|
||||
TagsChip(
|
||||
modifier = DefaultTagChipModifier,
|
||||
text = it.title,
|
||||
onClick = {
|
||||
tagSelected = it.title
|
||||
showMenu = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MangaSummary(
|
||||
expandedDescription = desc,
|
||||
shrunkDescription = trimmedDescription,
|
||||
expanded = expanded,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.clickableNoIndication { onExpanded(!expanded) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationGraphicsApi::class)
|
||||
@Composable
|
||||
private fun MangaSummary(
|
||||
expandedDescription: String,
|
||||
shrunkDescription: String,
|
||||
expanded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val animProgress by animateFloatAsState(
|
||||
targetValue = if (expanded) 1f else 0f,
|
||||
label = "summary",
|
||||
)
|
||||
Layout(
|
||||
modifier = modifier.clipToBounds(),
|
||||
contents = listOf(
|
||||
{
|
||||
Text(
|
||||
text = "\n\n", // Shows at least 3 lines
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
{
|
||||
Text(
|
||||
text = expandedDescription,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
{
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = if (expanded) expandedDescription else shrunkDescription,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.secondaryItemAlpha(),
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
val colors = listOf(Color.Transparent, MaterialTheme.colorScheme.background)
|
||||
Box(
|
||||
modifier = Modifier.background(Brush.verticalGradient(colors = colors)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down)
|
||||
Icon(
|
||||
painter = rememberAnimatedVectorPainter(image, !expanded),
|
||||
contentDescription = stringResource(
|
||||
if (expanded) R.string.manga_info_collapse else R.string.manga_info_expand,
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.background(Brush.radialGradient(colors = colors.asReversed())),
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
) { (shrunk, expanded, actual, scrim), constraints ->
|
||||
val shrunkHeight = shrunk.single()
|
||||
.measure(constraints)
|
||||
.height
|
||||
val expandedHeight = expanded.single()
|
||||
.measure(constraints)
|
||||
.height
|
||||
val heightDelta = expandedHeight - shrunkHeight
|
||||
val scrimHeight = 24.dp.roundToPx()
|
||||
|
||||
val actualPlaceable = actual.single()
|
||||
.measure(constraints)
|
||||
val scrimPlaceable = scrim.single()
|
||||
.measure(Constraints.fixed(width = constraints.maxWidth, height = scrimHeight))
|
||||
|
||||
val currentHeight = shrunkHeight + ((heightDelta + scrimHeight) * animProgress).roundToInt()
|
||||
layout(constraints.maxWidth, currentHeight) {
|
||||
actualPlaceable.place(0, 0)
|
||||
|
||||
val scrimY = currentHeight - scrimHeight
|
||||
scrimPlaceable.place(0, scrimY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DefaultTagChipModifier = Modifier.padding(vertical = 4.dp)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TagsChip(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
|
||||
SuggestionChip(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
label = { Text(text = text) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.xtimms.tokusho.sections.details
|
||||
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.core.base.state.UiState
|
||||
|
||||
data class DetailsUiState(
|
||||
val manga: Manga? = null,
|
||||
override val isLoading: Boolean = false,
|
||||
override val message: String? = null,
|
||||
) : UiState() {
|
||||
override fun setLoading(value: Boolean) = copy(isLoading = value)
|
||||
override fun setMessage(value: String?) = copy(message = value)
|
||||
}
|
||||
@@ -9,22 +9,37 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.xtimms.tokusho.core.components.DetailsToolbar
|
||||
|
||||
const val DETAILS_DESTINATION = "details"
|
||||
const val MANGA_ID_ARGUMENT = "{mangaId}"
|
||||
const val DETAILS_DESTINATION = "details/$MANGA_ID_ARGUMENT"
|
||||
|
||||
@Composable
|
||||
fun DetailsView(
|
||||
coil: ImageLoader,
|
||||
mangaId: Long,
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
val viewModel: DetailsViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
val chapterListState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(mangaId) {
|
||||
viewModel.getDetails(mangaId)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
val isFirstItemVisible by remember {
|
||||
@@ -68,10 +83,29 @@ fun DetailsView(
|
||||
contentType = DetailsViewItem.INFO_BOX
|
||||
) {
|
||||
DetailsInfoBox(
|
||||
coil = coil,
|
||||
imageUrl = uiState.manga?.largeCoverUrl ?: "",
|
||||
title = uiState.manga?.title ?: "",
|
||||
author = uiState.manga?.author ?: "",
|
||||
artist = "",
|
||||
state = uiState.manga?.state ?: MangaState.FINISHED,
|
||||
isTabletUi = false,
|
||||
appBarPadding = topPadding,
|
||||
)
|
||||
}
|
||||
|
||||
item(
|
||||
key = DetailsViewItem.DESCRIPTION_WITH_TAG,
|
||||
contentType = DetailsViewItem.DESCRIPTION_WITH_TAG,
|
||||
) {
|
||||
ExpandableMangaDescription(
|
||||
defaultExpandState = true,
|
||||
description = uiState.manga?.description ?: "",
|
||||
tagsProvider = { uiState.manga?.tags?.toList() },
|
||||
onTagSearch = { },
|
||||
onCopyTagToClipboard = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.xtimms.tokusho.sections.details
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
|
||||
import org.xtimms.tokusho.core.parser.MangaIntent
|
||||
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||
import org.xtimms.tokusho.sections.details.domain.DetailsLoadUseCase
|
||||
import org.xtimms.tokusho.utils.lang.onEachWhile
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DetailsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val detailsLoadUseCase: DetailsLoadUseCase,
|
||||
) : BaseViewModel<DetailsUiState>(), DetailsEvent {
|
||||
|
||||
private val intent = MangaIntent(savedStateHandle)
|
||||
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, false) })
|
||||
|
||||
val manga = details.map { x -> x?.toManga() }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
override val mutableUiState = MutableStateFlow(DetailsUiState())
|
||||
|
||||
fun getDetails(mangaId: Long) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
detailsLoadUseCase.invoke(intent)
|
||||
.onEachWhile {
|
||||
if (it.allChapters.isEmpty()) {
|
||||
return@onEachWhile false
|
||||
}
|
||||
true
|
||||
}.collect {
|
||||
mutableUiState.update {
|
||||
val manga = details.firstOrNull { it != null } ?: return@collect
|
||||
it.copy(
|
||||
manga = manga.toManga()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package org.xtimms.tokusho.sections.details
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.tokusho.core.components.BackIconButton
|
||||
import org.xtimms.tokusho.core.components.ViewInBrowserButton
|
||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||
|
||||
const val PICTURES_ARGUMENT = "{pictures}"
|
||||
const val FULL_POSTER_DESTINATION = "full_poster/$PICTURES_ARGUMENT"
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FullImageView(
|
||||
pictures: Array<String>,
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState { pictures.size }
|
||||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
fun openUrl(url: String) {
|
||||
uriHandler.openUri(url)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { },
|
||||
navigationIcon = {
|
||||
BackIconButton(onClick = navigateBack)
|
||||
},
|
||||
actions = {
|
||||
ViewInBrowserButton(
|
||||
onClick = {
|
||||
pictures.getOrNull(pagerState.currentPage)?.let { url ->
|
||||
openUrl(url)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(it)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
HorizontalPager(
|
||||
modifier = Modifier.weight(1f),
|
||||
state = pagerState,
|
||||
pageSpacing = 16.dp,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) { page ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AsyncImage(
|
||||
model = pictures[page],
|
||||
contentDescription = "image$page",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(50.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
pictures.forEachIndexed { index, _ ->
|
||||
val color =
|
||||
if (pagerState.currentPage == index)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.primaryContainer
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.size(8.dp)
|
||||
.clickable {
|
||||
coroutineScope.launch { pagerState.animateScrollToPage(index) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun FullPosterPreview() {
|
||||
TokushoTheme {
|
||||
FullImageView(
|
||||
pictures = arrayOf("", ""),
|
||||
navigateBack = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.xtimms.tokusho.sections.details.data
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
|
||||
data class MangaDetails(
|
||||
private val manga: Manga,
|
||||
val description: CharSequence?,
|
||||
val isLoaded: Boolean,
|
||||
) {
|
||||
|
||||
val id: Long
|
||||
get() = manga.id
|
||||
|
||||
val chapters: Map<String?, List<MangaChapter>> = manga.chapters?.groupBy { it.branch }.orEmpty()
|
||||
|
||||
val branches: Set<String?>
|
||||
get() = chapters.keys
|
||||
|
||||
val allChapters: List<MangaChapter> by lazy { listOf() }
|
||||
|
||||
fun toManga() = manga
|
||||
|
||||
fun filterChapters(branch: String?) = MangaDetails(
|
||||
manga = manga.filterChapters(branch),
|
||||
description = description,
|
||||
isLoaded = isLoaded,
|
||||
)
|
||||
}
|
||||
|
||||
fun Manga.filterChapters(branch: String?): Manga {
|
||||
if (chapters.isNullOrEmpty()) return this
|
||||
return withChapters(chapters = chapters?.filter { it.branch == branch })
|
||||
}
|
||||
|
||||
private fun Manga.withChapters(chapters: List<MangaChapter>?) = Manga(
|
||||
id = id,
|
||||
title = title,
|
||||
altTitle = altTitle,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
coverUrl = coverUrl,
|
||||
tags = tags,
|
||||
state = state,
|
||||
author = author,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
description = description,
|
||||
chapters = chapters,
|
||||
source = source,
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.xtimms.tokusho.sections.details.domain
|
||||
|
||||
import android.text.Html
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.core.text.parseAsHtml
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.recoverNotNull
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xtimms.tokusho.core.parser.MangaDataRepository
|
||||
import org.xtimms.tokusho.core.parser.MangaIntent
|
||||
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||
import org.xtimms.tokusho.utils.lang.sanitize
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class DetailsLoadUseCase @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val imageGetter: Html.ImageGetter,
|
||||
) {
|
||||
|
||||
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
|
||||
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent)) {
|
||||
"Cannot resolve intent $intent"
|
||||
}
|
||||
send(MangaDetails(manga, null, false))
|
||||
try {
|
||||
val details = getDetails(manga)
|
||||
send(MangaDetails(details, details.description?.parseAsHtml(withImages = false), false))
|
||||
send(MangaDetails(details, details.description?.parseAsHtml(withImages = true), true))
|
||||
} catch (e: IOException) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getDetails(seed: Manga) = runCatchingCancellable {
|
||||
val repository = mangaRepositoryFactory.create(seed.source)
|
||||
repository.getDetails(seed)
|
||||
}.getOrThrow()
|
||||
|
||||
private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? {
|
||||
return if (withImages) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
parseAsHtml(imageGetter = imageGetter)
|
||||
}.filterSpans()
|
||||
} else {
|
||||
runInterruptible(Dispatchers.Default) {
|
||||
parseAsHtml()
|
||||
}.filterSpans().sanitize()
|
||||
}.takeUnless { it.isBlank() }
|
||||
}
|
||||
|
||||
private fun Spanned.filterSpans(): Spanned {
|
||||
val spannable = SpannableString.valueOf(this)
|
||||
val spans = spannable.getSpans<ForegroundColorSpan>()
|
||||
for (span in spans) {
|
||||
spannable.removeSpan(span)
|
||||
}
|
||||
return spannable
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,4 @@ package org.xtimms.tokusho.sections.explore
|
||||
|
||||
import org.xtimms.tokusho.core.base.event.UiEvent
|
||||
|
||||
interface ExploreEvent : UiEvent {
|
||||
|
||||
}
|
||||
interface ExploreEvent : UiEvent
|
||||
@@ -36,6 +36,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.tokusho.R
|
||||
import org.xtimms.tokusho.core.collapsable
|
||||
import org.xtimms.tokusho.core.components.ExploreButton
|
||||
@@ -50,7 +51,7 @@ const val EXPLORE_DESTINATION = "explore"
|
||||
@Composable
|
||||
fun ExploreView(
|
||||
coil: ImageLoader,
|
||||
navController: NavController,
|
||||
navigateToSource: (MangaSource) -> Unit,
|
||||
topBarHeightPx: Float,
|
||||
topBarOffsetY: Animatable<Float, AnimationVector1D>,
|
||||
padding: PaddingValues,
|
||||
@@ -60,7 +61,7 @@ fun ExploreView(
|
||||
|
||||
ExploreViewContent(
|
||||
coil = coil,
|
||||
navController = navController,
|
||||
navigateToSource = navigateToSource,
|
||||
uiState = uiState,
|
||||
event = viewModel,
|
||||
topBarHeightPx = topBarHeightPx,
|
||||
@@ -72,7 +73,7 @@ fun ExploreView(
|
||||
@Composable
|
||||
fun ExploreViewContent(
|
||||
coil: ImageLoader,
|
||||
navController: NavController,
|
||||
navigateToSource: (MangaSource) -> Unit,
|
||||
uiState: ExploreUiState,
|
||||
event: ExploreEvent?,
|
||||
nestedScrollConnection: NestedScrollConnection? = null,
|
||||
@@ -166,7 +167,7 @@ fun ExploreViewContent(
|
||||
}
|
||||
items(
|
||||
items = uiState.sources,
|
||||
key = { it.ordinal },
|
||||
key = { it.name },
|
||||
contentType = { it }
|
||||
) { item ->
|
||||
Box(
|
||||
@@ -176,11 +177,10 @@ fun ExploreViewContent(
|
||||
SourceItem(
|
||||
coil = coil,
|
||||
faviconUrl = item.faviconUri(),
|
||||
title = item.title,
|
||||
onClick = {
|
||||
navController.navigate(LIST_DESTINATION)
|
||||
}
|
||||
)
|
||||
title = item.title
|
||||
) {
|
||||
navigateToSource(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class ExploreViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val result = mangaSourcesRepository.allMangaSources
|
||||
mutableUiState.update {
|
||||
it.copy(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.xtimms.tokusho.sections.list
|
||||
|
||||
import org.xtimms.tokusho.core.base.event.PagedUiEvent
|
||||
|
||||
interface MangaListEvent : PagedUiEvent
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.xtimms.tokusho.sections.list
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.core.base.state.PagedUiState
|
||||
|
||||
data class MangaListUiState(
|
||||
val manga: List<Manga> = listOf(),
|
||||
override val nextPage: String? = null,
|
||||
override val loadMore: Boolean = true,
|
||||
override val isLoading: Boolean = false,
|
||||
override val message: String? = null,
|
||||
) : PagedUiState() {
|
||||
|
||||
override fun setLoading(value: Boolean) = copy(isLoading = value)
|
||||
override fun setMessage(value: String?) = copy(message = value)
|
||||
}
|
||||
@@ -1,41 +1,133 @@
|
||||
package org.xtimms.tokusho.sections.list
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.tokusho.core.components.MangaCompactGridItem
|
||||
import org.xtimms.tokusho.core.components.ScaffoldWithSmallTopAppBar
|
||||
import org.xtimms.tokusho.utils.composable.onBottomReached
|
||||
import org.xtimms.tokusho.utils.system.toast
|
||||
|
||||
const val LIST_DESTINATION = "list"
|
||||
const val PROVIDER_ARGUMENT = "{source}"
|
||||
const val LIST_DESTINATION = "provider/${PROVIDER_ARGUMENT}"
|
||||
|
||||
@Composable
|
||||
fun MangaListView(
|
||||
sourceName: String,
|
||||
coil: ImageLoader,
|
||||
source: MangaSource,
|
||||
navigateBack: () -> Unit,
|
||||
navigateToDetails: () -> Unit,
|
||||
navigateToDetails: (Long) -> Unit,
|
||||
) {
|
||||
val viewModel: MangaListViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
MangaListView(
|
||||
coil = coil,
|
||||
source = source,
|
||||
uiState = uiState,
|
||||
event = viewModel,
|
||||
navigateBack = navigateBack,
|
||||
navigateToDetails = navigateToDetails
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MangaListView(
|
||||
coil: ImageLoader,
|
||||
source: MangaSource,
|
||||
uiState: MangaListUiState,
|
||||
event: MangaListEvent?,
|
||||
navigateBack: () -> Unit,
|
||||
navigateToDetails: (Long) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
ScaffoldWithSmallTopAppBar(
|
||||
title = sourceName,
|
||||
navigateBack = navigateBack
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Button(onClick = { navigateToDetails() }) {
|
||||
Text(text = "Click")
|
||||
}
|
||||
if (uiState.message != null) {
|
||||
LaunchedEffect(uiState.message) {
|
||||
context.toast(uiState.message)
|
||||
event?.onMessageDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
ScaffoldWithSmallTopAppBar(
|
||||
title = source.title,
|
||||
navigateBack = navigateBack,
|
||||
contentWindowInsets = WindowInsets.systemBars
|
||||
.only(WindowInsetsSides.Horizontal)
|
||||
) { padding ->
|
||||
val listState = rememberLazyGridState()
|
||||
listState.onBottomReached(buffer = 3) {
|
||||
event?.loadMore()
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (!uiState.isLoading) LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 100.dp),
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp,
|
||||
top = 8.dp,
|
||||
end = 8.dp,
|
||||
bottom = WindowInsets.navigationBars.asPaddingValues()
|
||||
.calculateBottomPadding()
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
items(
|
||||
items = uiState.manga,
|
||||
key = { it.id },
|
||||
contentType = { it }
|
||||
) { item ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
MangaCompactGridItem(
|
||||
coil = coil,
|
||||
imageUrl = item.coverUrl,
|
||||
title = item.title,
|
||||
onClick = { navigateToDetails(item.id) },
|
||||
onLongClick = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package org.xtimms.tokusho.sections.list
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
|
||||
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||
import org.xtimms.tokusho.utils.lang.call
|
||||
import org.xtimms.tokusho.utils.lang.removeFirstAndLast
|
||||
import org.xtimms.tokusho.utils.lang.require
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@HiltViewModel
|
||||
class MangaListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) : BaseViewModel<MangaListUiState>(), MangaListEvent {
|
||||
|
||||
private var loadingJob: Job? = null
|
||||
|
||||
val source = MangaSource.valueOf(savedStateHandle.get<String>(PROVIDER_ARGUMENT.removeFirstAndLast())!!)
|
||||
private val repository = mangaRepositoryFactory.create(source)
|
||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||
private val listError = MutableStateFlow<Throwable?>(null)
|
||||
private val hasNextPage = MutableStateFlow(false)
|
||||
|
||||
override val mutableUiState = MutableStateFlow(MangaListUiState())
|
||||
|
||||
init {
|
||||
setLoading(true)
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
mutableUiState
|
||||
.distinctUntilChangedBy { it.loadMore }
|
||||
.filter { it.loadMore }
|
||||
.collectLatest { uiState ->
|
||||
val list = repository.getList(
|
||||
offset = mangaList.value?.size ?: 0,
|
||||
filter = null,
|
||||
)
|
||||
val oldList = mangaList.getAndUpdate { oldList ->
|
||||
if (oldList.isNullOrEmpty()) {
|
||||
list
|
||||
} else {
|
||||
oldList + list
|
||||
}
|
||||
}.orEmpty()
|
||||
hasNextPage.value = list.size > oldList.size || hasNextPage.value
|
||||
mutableUiState.update {
|
||||
it.copy(
|
||||
manga = list,
|
||||
nextPage = "2",
|
||||
loadMore = hasNextPage.value,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun loadList(append: Boolean): Job {
|
||||
loadingJob?.let {
|
||||
if (it.isActive) return it
|
||||
}
|
||||
return launchLoadingJob(Dispatchers.Default) {
|
||||
try {
|
||||
listError.value = null
|
||||
val list = repository.getList(
|
||||
offset = if (append) mangaList.value?.size ?: 0 else 0,
|
||||
filter = null,
|
||||
)
|
||||
val oldList = mangaList.getAndUpdate { oldList ->
|
||||
if (!append || oldList.isNullOrEmpty()) {
|
||||
list
|
||||
} else {
|
||||
oldList + list
|
||||
}
|
||||
}.orEmpty()
|
||||
hasNextPage.value = if (append) {
|
||||
list.isNotEmpty()
|
||||
} else {
|
||||
list.size > oldList.size || hasNextPage.value
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
listError.value = e
|
||||
if (!mangaList.value.isNullOrEmpty()) {
|
||||
errorEvent.call(e)
|
||||
}
|
||||
hasNextPage.value = false
|
||||
}
|
||||
}.also { loadingJob = it }
|
||||
}
|
||||
|
||||
fun loadNextPage() {
|
||||
if (hasNextPage.value && listError.value == null) {
|
||||
loadList(append = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadMore() {
|
||||
if (mutableUiState.value.canLoadMore) {
|
||||
mutableUiState.update { it.copy(loadMore = true) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun showMessage(message: String?) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun onMessageDisplayed() {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -45,8 +46,9 @@ fun SearchHostView(
|
||||
var query by remember { mutableStateOf("") }
|
||||
val performSearch = remember { mutableStateOf(false) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(focusRequester) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
@@ -68,7 +70,9 @@ fun SearchHostView(
|
||||
if (isCompactScreen) BackIconButton(onClick = navigateBack)
|
||||
},
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = { performSearch.value = true }
|
||||
onSearch = {
|
||||
keyboardController?.hide()
|
||||
}
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
singleLine = true,
|
||||
|
||||
@@ -20,7 +20,7 @@ import org.xtimms.tokusho.core.collapsable
|
||||
import org.xtimms.tokusho.core.model.ShelfCategory
|
||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||
|
||||
const val SHELF_DESTINATION = "stub"
|
||||
const val SHELF_DESTINATION = "shelf"
|
||||
|
||||
@Composable
|
||||
fun ShelfView(
|
||||
@@ -100,8 +100,11 @@ fun ShelfPreview() {
|
||||
TokushoTheme {
|
||||
Surface {
|
||||
ShelfViewContent(
|
||||
categories = emptyList(),
|
||||
currentPage = { 2 },
|
||||
categories = listOf(
|
||||
ShelfCategory(1, "Test 1", 1L, 1L),
|
||||
ShelfCategory(2, "Test 2", 2L, 2L)
|
||||
),
|
||||
currentPage = { 0 },
|
||||
showPageTabs = true,
|
||||
getNumberOfMangaForCategory = { 2 },
|
||||
getLibraryForPage = { library.values.toTypedArray().getOrNull(0).orEmpty() },
|
||||
|
||||
@@ -10,7 +10,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes
|
||||
|
||||
val LocalTonalPalettes = staticCompositionLocalOf {
|
||||
Color(0xFF0057C9).toTonalPalettes()
|
||||
Color(0xFF1978D2).toTonalPalettes()
|
||||
}
|
||||
|
||||
inline val Number.a1: Color
|
||||
|
||||
@@ -33,4 +33,4 @@ object FixedAccentColors {
|
||||
@Composable get() = 30.a3
|
||||
}
|
||||
|
||||
const val SEED = 0x0057c9
|
||||
const val SEED = 0x1978D2
|
||||
36
app/src/main/java/org/xtimms/tokusho/utils/Event.kt
Normal file
36
app/src/main/java/org/xtimms/tokusho/utils/Event.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package org.xtimms.tokusho.utils
|
||||
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
|
||||
class Event<T>(
|
||||
private val data: T,
|
||||
) {
|
||||
private var isConsumed = false
|
||||
|
||||
suspend fun consume(collector: FlowCollector<T>) {
|
||||
if (!isConsumed) {
|
||||
collector.emit(data)
|
||||
isConsumed = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Event<*>
|
||||
|
||||
if (data != other.data) return false
|
||||
return isConsumed == other.isConsumed
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = data?.hashCode() ?: 0
|
||||
result = 31 * result + isConsumed.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "Event(data=$data, isConsumed=$isConsumed)"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.xtimms.tokusho.utils
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import org.xtimms.tokusho.utils.material.SecondaryItemAlpha
|
||||
|
||||
fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha)
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.xtimms.tokusho.utils.composable
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
|
||||
/**
|
||||
* Extension function to load more items when the bottom is reached
|
||||
* @param buffer Tells how many items before it reaches the bottom of the list to call `onLoadMore`. This value should be >= 0
|
||||
* @param onLoadMore The code to execute when it reaches the bottom of the list
|
||||
* @author Manav Tamboli
|
||||
*/
|
||||
@Composable
|
||||
fun LazyListState.onBottomReached(
|
||||
buffer: Int = 0,
|
||||
onLoadMore: suspend () -> Unit
|
||||
) {
|
||||
// Buffer must be positive.
|
||||
// Or our list will never reach the bottom.
|
||||
require(buffer >= 0) { "buffer cannot be negative, but was $buffer" }
|
||||
|
||||
val shouldLoadMore = remember {
|
||||
derivedStateOf {
|
||||
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
|
||||
?: return@derivedStateOf true
|
||||
|
||||
// subtract buffer from the total items
|
||||
lastVisibleItem.index >= layoutInfo.totalItemsCount - 1 - buffer
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldLoadMore) {
|
||||
snapshotFlow { shouldLoadMore.value }
|
||||
.collect { if (it) onLoadMore() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to load more items when the bottom is reached
|
||||
* @param buffer Tells how many items before it reaches the bottom of the list to call `onLoadMore`. This value should be >= 0
|
||||
* @param onLoadMore The code to execute when it reaches the bottom of the list
|
||||
* @author Manav Tamboli
|
||||
*/
|
||||
@Composable
|
||||
fun LazyGridState.onBottomReached(
|
||||
buffer: Int = 0,
|
||||
onLoadMore: suspend () -> Unit
|
||||
) {
|
||||
// Buffer must be positive.
|
||||
// Or our list will never reach the bottom.
|
||||
require(buffer >= 0) { "buffer cannot be negative, but was $buffer" }
|
||||
|
||||
val shouldLoadMore = remember {
|
||||
derivedStateOf {
|
||||
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
|
||||
?: return@derivedStateOf true
|
||||
|
||||
// subtract buffer from the total items
|
||||
lastVisibleItem.index >= layoutInfo.totalItemsCount - 1 - buffer
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldLoadMore) {
|
||||
snapshotFlow { shouldLoadMore.value }
|
||||
.collect { if (it) onLoadMore() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.xtimms.tokusho.utils.composable
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import org.xtimms.tokusho.utils.material.SecondaryItemAlpha
|
||||
|
||||
fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun Modifier.clickableNoIndication(
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
): Modifier = composed {
|
||||
Modifier.combinedClickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
52
app/src/main/java/org/xtimms/tokusho/utils/lang/Bundle.kt
Normal file
52
app/src/main/java/org/xtimms/tokusho/utils/lang/Bundle.kt
Normal file
@@ -0,0 +1,52 @@
|
||||
package org.xtimms.tokusho.utils.lang
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.ParcelCompat
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import java.io.Serializable
|
||||
|
||||
inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? {
|
||||
return BundleCompat.getParcelable(this, key, T::class.java)
|
||||
}
|
||||
|
||||
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? {
|
||||
return IntentCompat.getParcelableExtra(this, key, T::class.java)
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Intent.getSerializableExtraCompat(key: String): T? {
|
||||
return getSerializableExtra(key) as T?
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String): T? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getSerializable(key, T::class.java)
|
||||
} else {
|
||||
getSerializable(key) as T?
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(): T? {
|
||||
return ParcelCompat.readParcelable(this, T::class.java.classLoader, T::class.java)
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Parcel.readSerializableCompat(): T? {
|
||||
return ParcelCompat.readSerializable(this, T::class.java.classLoader, T::class.java)
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T {
|
||||
return checkNotNull(getSerializableCompat(key)) {
|
||||
"Serializable of type \"${T::class.java.name}\" not found at \"$key\""
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> SavedStateHandle.require(key: String): T {
|
||||
return checkNotNull(get(key)) {
|
||||
"Value $key not found in SavedStateHandle or has a wrong type"
|
||||
}
|
||||
}
|
||||
18
app/src/main/java/org/xtimms/tokusho/utils/lang/EventFlow.kt
Normal file
18
app/src/main/java/org/xtimms/tokusho/utils/lang/EventFlow.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package org.xtimms.tokusho.utils.lang
|
||||
|
||||
import androidx.annotation.AnyThread
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.xtimms.tokusho.utils.Event
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun <T> MutableEventFlow() = MutableStateFlow<Event<T>?>(null)
|
||||
|
||||
typealias EventFlow<T> = StateFlow<Event<T>?>
|
||||
|
||||
typealias MutableEventFlow<T> = MutableStateFlow<Event<T>?>
|
||||
|
||||
@AnyThread
|
||||
fun <T> MutableEventFlow<T>.call(data: T) {
|
||||
value = Event(data)
|
||||
}
|
||||
16
app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt
Normal file
16
app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package org.xtimms.tokusho.utils.lang
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
fun <T> Flow<T>.onEachWhile(action: suspend (T) -> Boolean): Flow<T> {
|
||||
var isCalled = false
|
||||
return onEach {
|
||||
if (!isCalled) {
|
||||
isCalled = action(it)
|
||||
}
|
||||
}.onCompletion {
|
||||
isCalled = false
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,28 @@
|
||||
package org.xtimms.tokusho.utils.lang
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
inline fun <C : CharSequence?> C?.ifNullOrEmpty(defaultValue: () -> C): C {
|
||||
return if (this.isNullOrEmpty()) defaultValue() else this
|
||||
}
|
||||
}
|
||||
|
||||
fun String.removeFirstAndLast() = substring(1, length - 1)
|
||||
|
||||
fun Array<String>.toNavArgument(): String = Uri.encode(Json.encodeToString(this))
|
||||
|
||||
fun String.longHashCode(): Long {
|
||||
var h = 1125899906842597L
|
||||
val len: Int = this.length
|
||||
for (i in 0 until len) {
|
||||
h = 31 * h + this[i].code
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
fun CharSequence.sanitize(): CharSequence {
|
||||
return filterNot { c -> c.isReplacement() }
|
||||
}
|
||||
|
||||
fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF'
|
||||
85
app/src/main/res/drawable/anim_caret_down.xml
Normal file
85
app/src/main/res/drawable/anim_caret_down.xml
Normal file
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt">
|
||||
<aapt:attr name="android:drawable">
|
||||
<vector
|
||||
android:name="caret_up"
|
||||
android:width="24.0dip"
|
||||
android:height="24.0dip"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<group
|
||||
android:name="caret01"
|
||||
android:rotation="90.0"
|
||||
android:translateX="12.0"
|
||||
android:translateY="15.0">
|
||||
<group
|
||||
android:name="caret_l"
|
||||
android:rotation="45.0">
|
||||
<group
|
||||
android:name="caret_l_pivot"
|
||||
android:translateY="4.0">
|
||||
<group
|
||||
android:name="caret_l_rect_position"
|
||||
android:translateY="-1.0">
|
||||
<path
|
||||
android:name="caret_l_rect"
|
||||
android:fillColor="#000"
|
||||
android:pathData="M -1.0,-4.0 l 2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l -2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,-8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 Z" />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
<group
|
||||
android:name="caret_r"
|
||||
android:rotation="-45.0">
|
||||
<group
|
||||
android:name="caret_r_pivot"
|
||||
android:translateY="-4.0">
|
||||
<group
|
||||
android:name="caret_r_rect_position"
|
||||
android:translateY="1.0">
|
||||
<path
|
||||
android:name="caret_r_rect"
|
||||
android:fillColor="#000"
|
||||
android:pathData="M -1.0,-4.0 l 2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l -2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,-8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 Z" />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
</aapt:attr>
|
||||
|
||||
<target android:name="caret01">
|
||||
<aapt:attr name="android:animation">
|
||||
<objectAnimator
|
||||
android:duration="300"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"
|
||||
android:pathData="M 12.0,9.0 c 0.0,0.66667 0.0,5.0 0.0,6.0"
|
||||
android:propertyXName="translateX"
|
||||
android:propertyYName="translateY" />
|
||||
</aapt:attr>
|
||||
</target>
|
||||
<target android:name="caret_l">
|
||||
<aapt:attr name="android:animation">
|
||||
<objectAnimator
|
||||
android:duration="300"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"
|
||||
android:propertyName="rotation"
|
||||
android:valueFrom="-45.0"
|
||||
android:valueTo="45.0"
|
||||
android:valueType="floatType" />
|
||||
</aapt:attr>
|
||||
</target>
|
||||
<target
|
||||
android:name="caret_r">
|
||||
<aapt:attr name="android:animation">
|
||||
<objectAnimator
|
||||
android:duration="300"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"
|
||||
android:propertyName="rotation"
|
||||
android:valueFrom="45.0"
|
||||
android:valueTo="-45.0"
|
||||
android:valueType="floatType" />
|
||||
</aapt:attr>
|
||||
</target>
|
||||
</animated-vector>
|
||||
@@ -68,4 +68,9 @@
|
||||
<string name="paused">Paused</string>
|
||||
<string name="upcoming">Upcoming</string>
|
||||
<string name="unknown">Unknown</string>
|
||||
<string name="open_in_browser">Open in browser</string>
|
||||
<string name="manga_info_collapse">Less</string>
|
||||
<string name="manga_info_expand">More</string>
|
||||
<string name="action_copy_to_clipboard">Copy to clipboard</string>
|
||||
<string name="description_placeholder">No description</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user