Initial facade of manga list
This commit is contained in:
@@ -3,6 +3,7 @@ plugins {
|
|||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
id("org.jetbrains.kotlin.plugin.serialization")
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
id("org.jetbrains.kotlin.kapt")
|
id("org.jetbrains.kotlin.kapt")
|
||||||
|
id("org.jetbrains.kotlin.plugin.parcelize")
|
||||||
id("com.google.devtools.ksp")
|
id("com.google.devtools.ksp")
|
||||||
id("dagger.hilt.android.plugin")
|
id("dagger.hilt.android.plugin")
|
||||||
}
|
}
|
||||||
@@ -25,7 +26,8 @@ android {
|
|||||||
javaCompileOptions {
|
javaCompileOptions {
|
||||||
annotationProcessorOptions {
|
annotationProcessorOptions {
|
||||||
arguments += mapOf(
|
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.lifecycle:lifecycle-process:2.7.0")
|
||||||
implementation("androidx.activity:activity-compose:1.8.2")
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
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")
|
||||||
implementation("androidx.compose.ui:ui-graphics")
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class App : Application() {
|
|||||||
}
|
}
|
||||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||||
|
|
||||||
processLifecycleScope.launch((Dispatchers.IO)) {
|
processLifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Updater.deleteOutdatedApk(this@App)
|
Updater.deleteOutdatedApk(this@App)
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package org.xtimms.tokusho
|
package org.xtimms.tokusho
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
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.WindowWidthSizeClass
|
||||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
@@ -43,6 +46,7 @@ import kotlinx.coroutines.launch
|
|||||||
import org.xtimms.tokusho.core.Navigation
|
import org.xtimms.tokusho.core.Navigation
|
||||||
import org.xtimms.tokusho.core.components.BottomNavBar
|
import org.xtimms.tokusho.core.components.BottomNavBar
|
||||||
import org.xtimms.tokusho.core.components.TopAppBar
|
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.ui.theme.TokushoTheme
|
||||||
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||||
import javax.inject.Inject
|
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 {
|
companion object {
|
||||||
private const val TAG = "MainActivity"
|
private const val TAG = "MainActivity"
|
||||||
|
const val EXTRA_DATA = "data"
|
||||||
|
|
||||||
fun setLanguage(locale: String) {
|
fun setLanguage(locale: String) {
|
||||||
Log.d(TAG, "setLanguage: $locale")
|
Log.d(TAG, "setLanguage: $locale")
|
||||||
@@ -122,6 +137,7 @@ fun MainView(
|
|||||||
BottomNavBar(
|
BottomNavBar(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
bottomBarState = bottomBarState,
|
bottomBarState = bottomBarState,
|
||||||
|
topBarOffsetY = topBarOffsetY
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,18 +11,23 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.xtimms.tokusho.core.model.ShelfCategory
|
import org.xtimms.tokusho.core.model.ShelfCategory
|
||||||
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
|
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
|
||||||
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
|
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
|
||||||
import org.xtimms.tokusho.sections.details.DETAILS_DESTINATION
|
import org.xtimms.tokusho.sections.details.DETAILS_DESTINATION
|
||||||
import org.xtimms.tokusho.sections.details.DetailsView
|
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.explore.ExploreView
|
||||||
import org.xtimms.tokusho.sections.history.HistoryView
|
import org.xtimms.tokusho.sections.history.HistoryView
|
||||||
import org.xtimms.tokusho.sections.list.LIST_DESTINATION
|
import org.xtimms.tokusho.sections.list.LIST_DESTINATION
|
||||||
import org.xtimms.tokusho.sections.list.MangaListView
|
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.SEARCH_DESTINATION
|
||||||
import org.xtimms.tokusho.sections.search.SearchHostView
|
import org.xtimms.tokusho.sections.search.SearchHostView
|
||||||
import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION
|
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.settings.appearance.LanguagesView
|
||||||
import org.xtimms.tokusho.sections.shelf.ShelfMap
|
import org.xtimms.tokusho.sections.shelf.ShelfMap
|
||||||
import org.xtimms.tokusho.sections.shelf.ShelfView
|
import org.xtimms.tokusho.sections.shelf.ShelfView
|
||||||
|
import org.xtimms.tokusho.utils.lang.removeFirstAndLast
|
||||||
|
|
||||||
const val DURATION_ENTER = 400
|
const val DURATION_ENTER = 400
|
||||||
const val DURATION_EXIT = 200
|
const val DURATION_EXIT = 200
|
||||||
@@ -88,7 +94,10 @@ fun Navigation(
|
|||||||
composable(BottomNavDestination.Shelf.route) {
|
composable(BottomNavDestination.Shelf.route) {
|
||||||
val library: ShelfMap = emptyMap()
|
val library: ShelfMap = emptyMap()
|
||||||
ShelfView(
|
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 },
|
currentPage = { 0 },
|
||||||
showPageTabs = true,
|
showPageTabs = true,
|
||||||
getNumberOfMangaForCategory = { 2 },
|
getNumberOfMangaForCategory = { 2 },
|
||||||
@@ -108,7 +117,11 @@ fun Navigation(
|
|||||||
composable(BottomNavDestination.Explore.route) {
|
composable(BottomNavDestination.Explore.route) {
|
||||||
ExploreView(
|
ExploreView(
|
||||||
coil = coil,
|
coil = coil,
|
||||||
navController = navController,
|
navigateToSource = {
|
||||||
|
navController.navigate(
|
||||||
|
LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
|
||||||
|
)
|
||||||
|
},
|
||||||
padding = padding,
|
padding = padding,
|
||||||
topBarHeightPx = topBarHeightPx,
|
topBarHeightPx = topBarHeightPx,
|
||||||
topBarOffsetY = topBarOffsetY
|
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(
|
MangaListView(
|
||||||
sourceName = "Source",
|
coil = coil,
|
||||||
|
source = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast())
|
||||||
|
?.let { source -> MangaSource.valueOf(source) } ?: MangaSource.DUMMY,
|
||||||
navigateBack = navigateBack,
|
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(
|
DetailsView(
|
||||||
|
coil = coil,
|
||||||
|
mangaId = 0L,
|
||||||
navigateBack = navigateBack,
|
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
|
package org.xtimms.tokusho.core.base.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.xtimms.tokusho.core.base.event.UiEvent
|
import org.xtimms.tokusho.core.base.event.UiEvent
|
||||||
import org.xtimms.tokusho.core.base.state.UiState
|
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 {
|
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>
|
protected abstract val mutableUiState: MutableStateFlow<S>
|
||||||
val uiState: StateFlow<S> by lazy { mutableUiState.asStateFlow() }
|
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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun setLoading(value: Boolean) {
|
fun setLoading(value: Boolean) {
|
||||||
mutableUiState.update { it.setLoading(value) as S }
|
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 }
|
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 {
|
companion object {
|
||||||
private const val GENERIC_ERROR = "Generic Error"
|
private const val GENERIC_ERROR = "Generic Error"
|
||||||
const val FLOW_TIMEOUT = 5_000L
|
const val FLOW_TIMEOUT = 5_000L
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.xtimms.tokusho.core.components
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
@@ -11,10 +13,12 @@ import androidx.compose.runtime.State
|
|||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.xtimms.tokusho.core.BottomNavDestination
|
import org.xtimms.tokusho.core.BottomNavDestination
|
||||||
import org.xtimms.tokusho.core.BottomNavDestination.Companion.Icon
|
import org.xtimms.tokusho.core.BottomNavDestination.Companion.Icon
|
||||||
import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION
|
import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION
|
||||||
@@ -25,7 +29,9 @@ import org.xtimms.tokusho.sections.shelf.SHELF_DESTINATION
|
|||||||
fun BottomNavBar(
|
fun BottomNavBar(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
bottomBarState: State<Boolean>,
|
bottomBarState: State<Boolean>,
|
||||||
|
topBarOffsetY: Animatable<Float, AnimationVector1D>,
|
||||||
) {
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val isVisible by remember {
|
val isVisible by remember {
|
||||||
@@ -49,6 +55,10 @@ fun BottomNavBar(
|
|||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
topBarOffsetY.animateTo(0f)
|
||||||
|
}
|
||||||
|
|
||||||
navController.navigate(dest.route) {
|
navController.navigate(dest.route) {
|
||||||
popUpTo(navController.graph.findStartDestination().id) {
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
saveState = true
|
saveState = true
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package org.xtimms.tokusho.core.components
|
|||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||||
|
import androidx.compose.material.icons.outlined.OpenInBrowser
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BackIconButton(
|
fun BackIconButton(
|
||||||
@@ -16,4 +19,16 @@ fun BackIconButton(
|
|||||||
contentDescription = "arrow_back"
|
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.graphics.painter.Painter
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.semantics.Role
|
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) {
|
enum class MangaCover(val ratio: Float) {
|
||||||
Square(1f / 1f),
|
Square(1f / 1f),
|
||||||
@@ -20,14 +23,16 @@ enum class MangaCover(val ratio: Float) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
operator fun invoke(
|
operator fun invoke(
|
||||||
data: Painter,
|
coil: ImageLoader,
|
||||||
|
data: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentDescription: String = "",
|
contentDescription: String = "",
|
||||||
shape: Shape = MaterialTheme.shapes.small,
|
shape: Shape = MaterialTheme.shapes.small,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Image(
|
AsyncImageImpl(
|
||||||
painter = data,
|
coil = coil,
|
||||||
|
model = data,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.aspectRatio(ratio)
|
.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
|
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"
|
const val TABLE_SOURCES = "sources"
|
||||||
@@ -4,20 +4,28 @@ import android.content.Context
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
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.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.MangaSourceEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.MangaTagsEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.TagEntity
|
||||||
|
|
||||||
const val DATABASE_VERSION = 1
|
const val DATABASE_VERSION = 1
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
|
MangaEntity::class,
|
||||||
|
TagEntity::class,
|
||||||
|
MangaTagsEntity::class,
|
||||||
MangaSourceEntity::class
|
MangaSourceEntity::class
|
||||||
],
|
],
|
||||||
version = DATABASE_VERSION,
|
version = DATABASE_VERSION
|
||||||
exportSchema = false
|
|
||||||
)
|
)
|
||||||
abstract class MangaDatabase : RoomDatabase() {
|
abstract class MangaDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun getMangaDao(): MangaDao
|
||||||
|
|
||||||
abstract fun getSourcesDao(): MangaSourcesDao
|
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 androidx.room.PrimaryKey
|
||||||
import org.xtimms.tokusho.core.database.TABLE_SOURCES
|
import org.xtimms.tokusho.core.database.TABLE_SOURCES
|
||||||
|
|
||||||
@Entity(
|
@Entity(tableName = TABLE_SOURCES)
|
||||||
tableName = TABLE_SOURCES,
|
|
||||||
)
|
|
||||||
data class MangaSourceEntity(
|
data class MangaSourceEntity(
|
||||||
@PrimaryKey(autoGenerate = false)
|
@PrimaryKey(autoGenerate = false)
|
||||||
@ColumnInfo(name = "source")
|
@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 kotlinx.coroutines.currentCoroutineContext
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
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 org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
@OptIn(InternalParsersApi::class)
|
||||||
class RemoteMangaRepository(
|
class RemoteMangaRepository(
|
||||||
private val parser: MangaParser,
|
private val parser: MangaParser,
|
||||||
private val cache: ContentCache,
|
private val cache: ContentCache,
|
||||||
@@ -55,6 +57,9 @@ class RemoteMangaRepository(
|
|||||||
override val isTagsExclusionSupported: Boolean
|
override val isTagsExclusionSupported: Boolean
|
||||||
get() = parser.isTagsExclusionSupported
|
get() = parser.isTagsExclusionSupported
|
||||||
|
|
||||||
|
val domains: Array<out String>
|
||||||
|
get() = parser.configKeyDomain.presetValues
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
return if (parser is Interceptor) {
|
return if (parser is Interceptor) {
|
||||||
parser.intercept(chain)
|
parser.intercept(chain)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.util.fastForEach
|
import androidx.compose.ui.util.fastForEach
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import org.xtimms.tokusho.core.components.ActionButton
|
import org.xtimms.tokusho.core.components.ActionButton
|
||||||
import org.xtimms.tokusho.utils.secondaryItemAlpha
|
import org.xtimms.tokusho.utils.composable.secondaryItemAlpha
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
data class EmptyScreenAction(
|
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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import org.xtimms.tokusho.utils.secondaryItemAlpha
|
import org.xtimms.tokusho.utils.composable.secondaryItemAlpha
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InfoScreen(
|
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
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
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.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.size
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
import androidx.compose.foundation.layout.wrapContentSize
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.MenuBook
|
import androidx.compose.material.icons.automirrored.outlined.MenuBook
|
||||||
import androidx.compose.material.icons.outlined.Block
|
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.Close
|
||||||
import androidx.compose.material.icons.outlined.DoneAll
|
import androidx.compose.material.icons.outlined.DoneAll
|
||||||
import androidx.compose.material.icons.outlined.Language
|
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.Pause
|
||||||
import androidx.compose.material.icons.outlined.Person
|
import androidx.compose.material.icons.outlined.Person
|
||||||
import androidx.compose.material.icons.outlined.Schedule
|
import androidx.compose.material.icons.outlined.Schedule
|
||||||
import androidx.compose.material.icons.outlined.Upcoming
|
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.Icon
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ProvideTextStyle
|
import androidx.compose.material3.ProvideTextStyle
|
||||||
|
import androidx.compose.material3.SuggestionChip
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.blur
|
import androidx.compose.ui.draw.blur
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
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 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.MangaState
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.core.components.MangaCover
|
import org.xtimms.tokusho.core.components.MangaCover
|
||||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
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
|
@Composable
|
||||||
fun DetailsInfoBox(
|
fun DetailsInfoBox(
|
||||||
|
coil: ImageLoader,
|
||||||
|
imageUrl: String,
|
||||||
|
title: String,
|
||||||
|
author: String?,
|
||||||
|
artist: String?,
|
||||||
|
state: MangaState?,
|
||||||
isTabletUi: Boolean,
|
isTabletUi: Boolean,
|
||||||
appBarPadding: Dp,
|
appBarPadding: Dp,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -67,8 +100,8 @@ fun DetailsInfoBox(
|
|||||||
Color.Transparent,
|
Color.Transparent,
|
||||||
MaterialTheme.colorScheme.background,
|
MaterialTheme.colorScheme.background,
|
||||||
)
|
)
|
||||||
Image(
|
AsyncImage(
|
||||||
painterResource(id = R.drawable.ookami),
|
model = imageUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -79,28 +112,30 @@ fun DetailsInfoBox(
|
|||||||
brush = Brush.verticalGradient(colors = backdropGradientColors),
|
brush = Brush.verticalGradient(colors = backdropGradientColors),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.blur(8.dp)
|
.blur(2.dp)
|
||||||
.alpha(0.2f)
|
.alpha(0.2f)
|
||||||
)
|
)
|
||||||
|
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||||
if (!isTabletUi) {
|
if (!isTabletUi) {
|
||||||
MangaAndSourceTitlesSmall(
|
MangaAndSourceTitlesSmall(
|
||||||
|
coil = coil,
|
||||||
appBarPadding = appBarPadding,
|
appBarPadding = appBarPadding,
|
||||||
onCoverClick = { },
|
imageUrl = imageUrl,
|
||||||
title = "Ookami to Koushinryou",
|
title = title,
|
||||||
author = "Hasekura Isuna",
|
author = author,
|
||||||
artist = "Koume Keito",
|
artist = artist,
|
||||||
state = MangaState.FINISHED
|
state = state
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
MangaAndSourceTitlesLarge(
|
MangaAndSourceTitlesLarge(
|
||||||
|
coil = coil,
|
||||||
appBarPadding = appBarPadding,
|
appBarPadding = appBarPadding,
|
||||||
onCoverClick = { },
|
imageUrl = imageUrl,
|
||||||
title = "Ookami to Koushinryou",
|
title = title,
|
||||||
author = "Hasekura Isuna",
|
author = author,
|
||||||
artist = "Koume Keito",
|
artist = artist,
|
||||||
state = MangaState.FINISHED
|
state = state
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,8 +144,9 @@ fun DetailsInfoBox(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MangaAndSourceTitlesLarge(
|
private fun MangaAndSourceTitlesLarge(
|
||||||
|
coil: ImageLoader,
|
||||||
appBarPadding: Dp,
|
appBarPadding: Dp,
|
||||||
onCoverClick: () -> Unit,
|
imageUrl: String,
|
||||||
title: String,
|
title: String,
|
||||||
author: String?,
|
author: String?,
|
||||||
artist: String?,
|
artist: String?,
|
||||||
@@ -123,10 +159,10 @@ private fun MangaAndSourceTitlesLarge(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
MangaCover.Book(
|
MangaCover.Book(
|
||||||
|
coil = coil,
|
||||||
modifier = Modifier.fillMaxWidth(0.65f),
|
modifier = Modifier.fillMaxWidth(0.65f),
|
||||||
data = painterResource(id = R.drawable.ookami),
|
data = imageUrl,
|
||||||
contentDescription = stringResource(R.string.manga_cover),
|
contentDescription = stringResource(R.string.manga_cover),
|
||||||
onClick = onCoverClick,
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
DetailsContentInfo(
|
DetailsContentInfo(
|
||||||
@@ -139,8 +175,9 @@ private fun MangaAndSourceTitlesLarge(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MangaAndSourceTitlesSmall(
|
private fun MangaAndSourceTitlesSmall(
|
||||||
|
coil: ImageLoader,
|
||||||
appBarPadding: Dp,
|
appBarPadding: Dp,
|
||||||
onCoverClick: () -> Unit,
|
imageUrl: String,
|
||||||
title: String,
|
title: String,
|
||||||
author: String?,
|
author: String?,
|
||||||
artist: String?,
|
artist: String?,
|
||||||
@@ -156,12 +193,12 @@ private fun MangaAndSourceTitlesSmall(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
MangaCover.Book(
|
MangaCover.Book(
|
||||||
|
coil = coil,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.sizeIn(maxWidth = 100.dp)
|
.sizeIn(maxWidth = 100.dp)
|
||||||
.align(Alignment.Top),
|
.align(Alignment.Top),
|
||||||
data = painterResource(id = R.drawable.ookami),
|
data = imageUrl,
|
||||||
contentDescription = stringResource(R.string.manga_cover),
|
contentDescription = stringResource(R.string.manga_cover),
|
||||||
onClick = onCoverClick,
|
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
@@ -246,7 +283,11 @@ private fun RowScope.DetailsRow(
|
|||||||
textAlign: TextAlign? = LocalTextStyle.current.textAlign,
|
textAlign: TextAlign? = LocalTextStyle.current.textAlign,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f).wrapContentSize().secondaryItemAlpha(),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.wrapContentSize()
|
||||||
|
.secondaryItemAlpha()
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
|
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
@@ -279,7 +320,10 @@ private fun RowScope.DetailsRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f).wrapContentSize().secondaryItemAlpha(),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.wrapContentSize()
|
||||||
|
.secondaryItemAlpha(),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
|
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
@@ -296,7 +340,10 @@ private fun RowScope.DetailsRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f).wrapContentSize().secondaryItemAlpha(),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.wrapContentSize()
|
||||||
|
.secondaryItemAlpha(),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
|
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
@@ -314,13 +361,177 @@ private fun RowScope.DetailsRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewLightDark
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailsInfoBoxPreview() {
|
fun ExpandableMangaDescription(
|
||||||
TokushoTheme {
|
defaultExpandState: Boolean,
|
||||||
DetailsInfoBox(
|
description: String?,
|
||||||
isTabletUi = false,
|
tagsProvider: () -> List<MangaTag>?,
|
||||||
appBarPadding = 72.dp,
|
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.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
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
|
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
|
@Composable
|
||||||
fun DetailsView(
|
fun DetailsView(
|
||||||
|
coil: ImageLoader,
|
||||||
|
mangaId: Long,
|
||||||
navigateBack: () -> Unit,
|
navigateBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val viewModel: DetailsViewModel = hiltViewModel()
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val chapterListState = rememberLazyListState()
|
val chapterListState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(mangaId) {
|
||||||
|
viewModel.getDetails(mangaId)
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
val isFirstItemVisible by remember {
|
val isFirstItemVisible by remember {
|
||||||
@@ -68,10 +83,29 @@ fun DetailsView(
|
|||||||
contentType = DetailsViewItem.INFO_BOX
|
contentType = DetailsViewItem.INFO_BOX
|
||||||
) {
|
) {
|
||||||
DetailsInfoBox(
|
DetailsInfoBox(
|
||||||
|
coil = coil,
|
||||||
|
imageUrl = uiState.manga?.largeCoverUrl ?: "",
|
||||||
|
title = uiState.manga?.title ?: "",
|
||||||
|
author = uiState.manga?.author ?: "",
|
||||||
|
artist = "",
|
||||||
|
state = uiState.manga?.state ?: MangaState.FINISHED,
|
||||||
isTabletUi = false,
|
isTabletUi = false,
|
||||||
appBarPadding = topPadding,
|
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
|
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.core.collapsable
|
import org.xtimms.tokusho.core.collapsable
|
||||||
import org.xtimms.tokusho.core.components.ExploreButton
|
import org.xtimms.tokusho.core.components.ExploreButton
|
||||||
@@ -50,7 +51,7 @@ const val EXPLORE_DESTINATION = "explore"
|
|||||||
@Composable
|
@Composable
|
||||||
fun ExploreView(
|
fun ExploreView(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
navController: NavController,
|
navigateToSource: (MangaSource) -> Unit,
|
||||||
topBarHeightPx: Float,
|
topBarHeightPx: Float,
|
||||||
topBarOffsetY: Animatable<Float, AnimationVector1D>,
|
topBarOffsetY: Animatable<Float, AnimationVector1D>,
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
@@ -60,7 +61,7 @@ fun ExploreView(
|
|||||||
|
|
||||||
ExploreViewContent(
|
ExploreViewContent(
|
||||||
coil = coil,
|
coil = coil,
|
||||||
navController = navController,
|
navigateToSource = navigateToSource,
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
event = viewModel,
|
event = viewModel,
|
||||||
topBarHeightPx = topBarHeightPx,
|
topBarHeightPx = topBarHeightPx,
|
||||||
@@ -72,7 +73,7 @@ fun ExploreView(
|
|||||||
@Composable
|
@Composable
|
||||||
fun ExploreViewContent(
|
fun ExploreViewContent(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
navController: NavController,
|
navigateToSource: (MangaSource) -> Unit,
|
||||||
uiState: ExploreUiState,
|
uiState: ExploreUiState,
|
||||||
event: ExploreEvent?,
|
event: ExploreEvent?,
|
||||||
nestedScrollConnection: NestedScrollConnection? = null,
|
nestedScrollConnection: NestedScrollConnection? = null,
|
||||||
@@ -166,7 +167,7 @@ fun ExploreViewContent(
|
|||||||
}
|
}
|
||||||
items(
|
items(
|
||||||
items = uiState.sources,
|
items = uiState.sources,
|
||||||
key = { it.ordinal },
|
key = { it.name },
|
||||||
contentType = { it }
|
contentType = { it }
|
||||||
) { item ->
|
) { item ->
|
||||||
Box(
|
Box(
|
||||||
@@ -176,11 +177,10 @@ fun ExploreViewContent(
|
|||||||
SourceItem(
|
SourceItem(
|
||||||
coil = coil,
|
coil = coil,
|
||||||
faviconUrl = item.faviconUri(),
|
faviconUrl = item.faviconUri(),
|
||||||
title = item.title,
|
title = item.title
|
||||||
onClick = {
|
) {
|
||||||
navController.navigate(LIST_DESTINATION)
|
navigateToSource(item)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class ExploreViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
launchJob(Dispatchers.Default) {
|
||||||
val result = mangaSourcesRepository.allMangaSources
|
val result = mangaSourcesRepository.allMangaSources
|
||||||
mutableUiState.update {
|
mutableUiState.update {
|
||||||
it.copy(
|
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
|
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.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.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.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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
|
@Composable
|
||||||
fun MangaListView(
|
fun MangaListView(
|
||||||
sourceName: String,
|
coil: ImageLoader,
|
||||||
|
source: MangaSource,
|
||||||
navigateBack: () -> Unit,
|
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()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
ScaffoldWithSmallTopAppBar(
|
if (uiState.message != null) {
|
||||||
title = sourceName,
|
LaunchedEffect(uiState.message) {
|
||||||
navigateBack = navigateBack
|
context.toast(uiState.message)
|
||||||
) { padding ->
|
event?.onMessageDisplayed()
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.padding(padding),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Button(onClick = { navigateToDetails() }) {
|
|
||||||
Text(text = "Click")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -45,8 +46,9 @@ fun SearchHostView(
|
|||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
val performSearch = remember { mutableStateOf(false) }
|
val performSearch = remember { mutableStateOf(false) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(focusRequester) {
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +70,9 @@ fun SearchHostView(
|
|||||||
if (isCompactScreen) BackIconButton(onClick = navigateBack)
|
if (isCompactScreen) BackIconButton(onClick = navigateBack)
|
||||||
},
|
},
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(
|
||||||
onSearch = { performSearch.value = true }
|
onSearch = {
|
||||||
|
keyboardController?.hide()
|
||||||
|
}
|
||||||
),
|
),
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import org.xtimms.tokusho.core.collapsable
|
|||||||
import org.xtimms.tokusho.core.model.ShelfCategory
|
import org.xtimms.tokusho.core.model.ShelfCategory
|
||||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||||
|
|
||||||
const val SHELF_DESTINATION = "stub"
|
const val SHELF_DESTINATION = "shelf"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShelfView(
|
fun ShelfView(
|
||||||
@@ -100,8 +100,11 @@ fun ShelfPreview() {
|
|||||||
TokushoTheme {
|
TokushoTheme {
|
||||||
Surface {
|
Surface {
|
||||||
ShelfViewContent(
|
ShelfViewContent(
|
||||||
categories = emptyList(),
|
categories = listOf(
|
||||||
currentPage = { 2 },
|
ShelfCategory(1, "Test 1", 1L, 1L),
|
||||||
|
ShelfCategory(2, "Test 2", 2L, 2L)
|
||||||
|
),
|
||||||
|
currentPage = { 0 },
|
||||||
showPageTabs = true,
|
showPageTabs = true,
|
||||||
getNumberOfMangaForCategory = { 2 },
|
getNumberOfMangaForCategory = { 2 },
|
||||||
getLibraryForPage = { library.values.toTypedArray().getOrNull(0).orEmpty() },
|
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
|
import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes
|
||||||
|
|
||||||
val LocalTonalPalettes = staticCompositionLocalOf {
|
val LocalTonalPalettes = staticCompositionLocalOf {
|
||||||
Color(0xFF0057C9).toTonalPalettes()
|
Color(0xFF1978D2).toTonalPalettes()
|
||||||
}
|
}
|
||||||
|
|
||||||
inline val Number.a1: Color
|
inline val Number.a1: Color
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ object FixedAccentColors {
|
|||||||
@Composable get() = 30.a3
|
@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
|
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 {
|
inline fun <C : CharSequence?> C?.ifNullOrEmpty(defaultValue: () -> C): C {
|
||||||
return if (this.isNullOrEmpty()) defaultValue() else this
|
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="paused">Paused</string>
|
||||||
<string name="upcoming">Upcoming</string>
|
<string name="upcoming">Upcoming</string>
|
||||||
<string name="unknown">Unknown</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>
|
</resources>
|
||||||
Reference in New Issue
Block a user