From 2aa6732e1db9f8b97d15fe7047cdb68925b67e17 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Fri, 9 Feb 2024 01:44:42 +0300 Subject: [PATCH] Initial facade of manga list --- app/build.gradle.kts | 5 +- app/src/main/java/org/xtimms/tokusho/App.kt | 2 +- .../java/org/xtimms/tokusho/MainActivity.kt | 16 + .../org/xtimms/tokusho/core/Navigation.kt | 45 ++- .../tokusho/core/base/event/PagedUiEvent.kt | 5 + .../tokusho/core/base/state/PagedUiState.kt | 13 + .../core/base/viewmodel/BaseViewModel.kt | 50 ++++ .../tokusho/core/components/BottomNavBar.kt | 10 + .../tokusho/core/components/IconButton.kt | 15 + .../tokusho/core/components/MangaCover.kt | 11 +- .../tokusho/core/components/MangaGridItem.kt | 223 ++++++++++++++ .../xtimms/tokusho/core/database/Tables.kt | 3 + .../tokusho/core/database/TokushoDatabase.kt | 12 +- .../tokusho/core/database/dao/MangaDao.kt | 59 ++++ .../core/database/entity/EntityMapping.kt | 76 +++++ .../core/database/entity/MangaEntity.kt | 23 ++ .../core/database/entity/MangaSourceEntity.kt | 4 +- .../core/database/entity/MangaTagsEntity.kt | 29 ++ .../core/database/entity/MangaWithTags.kt | 15 + .../tokusho/core/database/entity/TagEntity.kt | 15 + .../org/xtimms/tokusho/core/model/Manga.kt | 5 + .../model/parcelable/ParcelableMangaTags.kt | 27 ++ .../core/model/parcelable/ParcerableManga.kt | 56 ++++ .../core/parser/MangaDataRepository.kt | 31 ++ .../xtimms/tokusho/core/parser/MangaIntent.kt | 49 ++++ .../tokusho/core/parser/MangaLinkResolver.kt | 121 ++++++++ .../core/parser/RemoteMangaRepository.kt | 5 + .../tokusho/core/screens/EmptyScreen.kt | 2 +- .../xtimms/tokusho/core/screens/InfoScreen.kt | 2 +- .../tokusho/sections/details/DetailsEvent.kt | 5 + .../sections/details/DetailsInfoHeader.kt | 275 ++++++++++++++++-- .../sections/details/DetailsUiState.kt | 14 + .../tokusho/sections/details/DetailsView.kt | 36 ++- .../sections/details/DetailsViewModel.kt | 55 ++++ .../tokusho/sections/details/FullImageView.kt | 134 +++++++++ .../sections/details/data/MangaDetails.kt | 52 ++++ .../details/domain/DetailsLoadUseCase.kt | 71 +++++ .../tokusho/sections/explore/ExploreEvent.kt | 4 +- .../tokusho/sections/explore/ExploreView.kt | 18 +- .../sections/explore/ExploreViewModel.kt | 2 +- .../tokusho/sections/list/MangaListEvent.kt | 5 + .../tokusho/sections/list/MangaListUiState.kt | 16 + .../tokusho/sections/list/MangaListView.kt | 116 +++++++- .../sections/list/MangaListViewModel.kt | 125 ++++++++ .../tokusho/sections/search/SearchView.kt | 8 +- .../tokusho/sections/shelf/ShelfView.kt | 9 +- .../java/org/xtimms/tokusho/ui/monet/Monet.kt | 2 +- .../java/org/xtimms/tokusho/ui/theme/Color.kt | 2 +- .../java/org/xtimms/tokusho/utils/Event.kt | 36 +++ .../java/org/xtimms/tokusho/utils/Modifier.kt | 7 - .../tokusho/utils/composable/LazyListState.kt | 71 +++++ .../tokusho/utils/composable/Modifier.kt | 25 ++ .../org/xtimms/tokusho/utils/lang/Bundle.kt | 52 ++++ .../xtimms/tokusho/utils/lang/EventFlow.kt | 18 ++ .../org/xtimms/tokusho/utils/lang/Flow.kt | 16 + .../org/xtimms/tokusho/utils/lang/String.kt | 25 +- app/src/main/res/drawable/anim_caret_down.xml | 85 ++++++ app/src/main/res/values/strings.xml | 5 + 58 files changed, 2127 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/org/xtimms/tokusho/core/base/event/PagedUiEvent.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/base/state/PagedUiState.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaDao.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaEntity.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaTagsEntity.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaWithTags.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/entity/TagEntity.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/model/parcelable/ParcelableMangaTags.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/model/parcelable/ParcerableManga.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/MangaDataRepository.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/MangaIntent.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/MangaLinkResolver.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/DetailsEvent.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/DetailsUiState.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/data/MangaDetails.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/list/MangaListEvent.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/list/MangaListUiState.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/list/MangaListViewModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/Event.kt delete mode 100644 app/src/main/java/org/xtimms/tokusho/utils/Modifier.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/composable/LazyListState.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/composable/Modifier.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/lang/Bundle.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/lang/EventFlow.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt create mode 100644 app/src/main/res/drawable/anim_caret_down.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 14fdce7..9dee60e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlin.kapt") + id("org.jetbrains.kotlin.plugin.parcelize") id("com.google.devtools.ksp") id("dagger.hilt.android.plugin") } @@ -25,7 +26,8 @@ android { javaCompileOptions { annotationProcessorOptions { arguments += mapOf( - "room.generateKotlin" to "true" + "room.generateKotlin" to "true", + "room.schemaLocation" to "$projectDir/schemas" ) } } @@ -68,6 +70,7 @@ dependencies { implementation("androidx.lifecycle:lifecycle-process:2.7.0") implementation("androidx.activity:activity-compose:1.8.2") implementation(platform("androidx.compose:compose-bom:2024.01.00")) + implementation("androidx.compose.animation:animation-graphics") implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") diff --git a/app/src/main/java/org/xtimms/tokusho/App.kt b/app/src/main/java/org/xtimms/tokusho/App.kt index 6d94a7f..add6052 100644 --- a/app/src/main/java/org/xtimms/tokusho/App.kt +++ b/app/src/main/java/org/xtimms/tokusho/App.kt @@ -36,7 +36,7 @@ class App : Application() { } DynamicColors.applyToActivitiesIfAvailable(this) - processLifecycleScope.launch((Dispatchers.IO)) { + processLifecycleScope.launch(Dispatchers.IO) { try { Updater.deleteOutdatedApk(this@App) } catch (_: Throwable) { diff --git a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt index c9810b3..7af6976 100644 --- a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt +++ b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt @@ -1,8 +1,10 @@ package org.xtimms.tokusho +import android.content.Intent import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatDelegate @@ -22,6 +24,7 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -43,6 +46,7 @@ import kotlinx.coroutines.launch import org.xtimms.tokusho.core.Navigation import org.xtimms.tokusho.core.components.BottomNavBar import org.xtimms.tokusho.core.components.TopAppBar +import org.xtimms.tokusho.sections.list.LIST_DESTINATION import org.xtimms.tokusho.ui.theme.TokushoTheme import org.xtimms.tokusho.utils.lang.processLifecycleScope import javax.inject.Inject @@ -74,10 +78,21 @@ class MainActivity : ComponentActivity() { } } } + putDataToExtras(intent) + } + + override fun onNewIntent(intent: Intent?) { + putDataToExtras(intent) + super.onNewIntent(intent) + } + + private fun putDataToExtras(intent: Intent?) { + intent?.putExtra(EXTRA_DATA, intent.data) } companion object { private const val TAG = "MainActivity" + const val EXTRA_DATA = "data" fun setLanguage(locale: String) { Log.d(TAG, "setLanguage: $locale") @@ -122,6 +137,7 @@ fun MainView( BottomNavBar( navController = navController, bottomBarState = bottomBarState, + topBarOffsetY = topBarOffsetY ) } }, diff --git a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt index d4e32f3..57333de 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt @@ -11,18 +11,23 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.navArgument import coil.ImageLoader +import org.koitharu.kotatsu.parsers.model.MangaSource import org.xtimms.tokusho.core.model.ShelfCategory import org.xtimms.tokusho.core.motion.materialSharedAxisXIn import org.xtimms.tokusho.core.motion.materialSharedAxisXOut import org.xtimms.tokusho.sections.details.DETAILS_DESTINATION import org.xtimms.tokusho.sections.details.DetailsView +import org.xtimms.tokusho.sections.details.MANGA_ID_ARGUMENT import org.xtimms.tokusho.sections.explore.ExploreView import org.xtimms.tokusho.sections.history.HistoryView import org.xtimms.tokusho.sections.list.LIST_DESTINATION import org.xtimms.tokusho.sections.list.MangaListView +import org.xtimms.tokusho.sections.list.PROVIDER_ARGUMENT import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION import org.xtimms.tokusho.sections.search.SearchHostView import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION @@ -39,6 +44,7 @@ import org.xtimms.tokusho.sections.settings.appearance.LANGUAGES_DESTINATION import org.xtimms.tokusho.sections.settings.appearance.LanguagesView import org.xtimms.tokusho.sections.shelf.ShelfMap import org.xtimms.tokusho.sections.shelf.ShelfView +import org.xtimms.tokusho.utils.lang.removeFirstAndLast const val DURATION_ENTER = 400 const val DURATION_EXIT = 200 @@ -88,7 +94,10 @@ fun Navigation( composable(BottomNavDestination.Shelf.route) { val library: ShelfMap = emptyMap() ShelfView( - categories = listOf(ShelfCategory(1, "Test 1", 1L, 1L), ShelfCategory(2, "Test 2", 2L, 2L)), + categories = listOf( + ShelfCategory(1, "Test 1", 1L, 1L), + ShelfCategory(2, "Test 2", 2L, 2L) + ), currentPage = { 0 }, showPageTabs = true, getNumberOfMangaForCategory = { 2 }, @@ -108,7 +117,11 @@ fun Navigation( composable(BottomNavDestination.Explore.route) { ExploreView( coil = coil, - navController = navController, + navigateToSource = { + navController.navigate( + LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name) + ) + }, padding = padding, topBarHeightPx = topBarHeightPx, topBarOffsetY = topBarOffsetY @@ -152,11 +165,26 @@ fun Navigation( ) } - composable(LIST_DESTINATION) { + composable( + route = LIST_DESTINATION, + arguments = listOf( + navArgument(PROVIDER_ARGUMENT.removeFirstAndLast()) { + type = NavType.StringType + } + ) + ) { navEntry -> MangaListView( - sourceName = "Source", + coil = coil, + source = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast()) + ?.let { source -> MangaSource.valueOf(source) } ?: MangaSource.DUMMY, navigateBack = navigateBack, - navigateToDetails = { navController.navigate(DETAILS_DESTINATION) } + navigateToDetails = { + navController.navigate( + DETAILS_DESTINATION.replace( + MANGA_ID_ARGUMENT, it.toString() + ) + ) + } ) } @@ -173,8 +201,13 @@ fun Navigation( ) } - composable(DETAILS_DESTINATION) { + // TODO + composable( + route = DETAILS_DESTINATION + ) { navEntry -> DetailsView( + coil = coil, + mangaId = 0L, navigateBack = navigateBack, ) } diff --git a/app/src/main/java/org/xtimms/tokusho/core/base/event/PagedUiEvent.kt b/app/src/main/java/org/xtimms/tokusho/core/base/event/PagedUiEvent.kt new file mode 100644 index 0000000..7c6b778 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/base/event/PagedUiEvent.kt @@ -0,0 +1,5 @@ +package org.xtimms.tokusho.core.base.event + +interface PagedUiEvent : UiEvent { + fun loadMore() +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/base/state/PagedUiState.kt b/app/src/main/java/org/xtimms/tokusho/core/base/state/PagedUiState.kt new file mode 100644 index 0000000..02c0ad7 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/base/state/PagedUiState.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/BaseViewModel.kt b/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/BaseViewModel.kt index 1759076..9b0d940 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/BaseViewModel.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/BaseViewModel.kt @@ -1,18 +1,45 @@ package org.xtimms.tokusho.core.base.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.xtimms.tokusho.core.base.event.UiEvent import org.xtimms.tokusho.core.base.state.UiState +import org.xtimms.tokusho.utils.lang.EventFlow +import org.xtimms.tokusho.utils.lang.MutableEventFlow +import org.xtimms.tokusho.utils.lang.call +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.cancellation.CancellationException abstract class BaseViewModel : ViewModel(), UiEvent { + @JvmField + protected val loadingCounter = MutableStateFlow(0) + + @JvmField + protected val errorEvent = MutableEventFlow() + + val onError: EventFlow + get() = errorEvent + protected abstract val mutableUiState: MutableStateFlow val uiState: StateFlow by lazy { mutableUiState.asStateFlow() } + protected fun launchJob( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit + ): Job = viewModelScope.launch(context + createErrorHandler(), start, block) + @Suppress("UNCHECKED_CAST") fun setLoading(value: Boolean) { mutableUiState.update { it.setLoading(value) as S } @@ -28,6 +55,29 @@ abstract class BaseViewModel : ViewModel(), UiEvent { mutableUiState.update { it.setMessage(null) as S } } + protected fun launchLoadingJob( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit + ): Job = viewModelScope.launch(context + createErrorHandler(), start) { + loadingCounter.increment() + try { + block() + } finally { + loadingCounter.decrement() + } + } + + protected fun MutableStateFlow.increment() = update { it + 1 } + + protected fun MutableStateFlow.decrement() = update { it - 1 } + + private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> + if (throwable !is CancellationException) { + errorEvent.call(throwable) + } + } + companion object { private const val GENERIC_ERROR = "Generic Error" const val FLOW_TIMEOUT = 5_000L diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/BottomNavBar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/BottomNavBar.kt index 88d15fa..b2329f9 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/BottomNavBar.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/BottomNavBar.kt @@ -1,6 +1,8 @@ package org.xtimms.tokusho.core.components import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.material3.NavigationBar @@ -11,10 +13,12 @@ import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.stringResource import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.currentBackStackEntryAsState +import kotlinx.coroutines.launch import org.xtimms.tokusho.core.BottomNavDestination import org.xtimms.tokusho.core.BottomNavDestination.Companion.Icon import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION @@ -25,7 +29,9 @@ import org.xtimms.tokusho.sections.shelf.SHELF_DESTINATION fun BottomNavBar( navController: NavController, bottomBarState: State, + topBarOffsetY: Animatable, ) { + val scope = rememberCoroutineScope() val navBackStackEntry by navController.currentBackStackEntryAsState() val isVisible by remember { @@ -49,6 +55,10 @@ fun BottomNavBar( NavigationBarItem( selected = isSelected, onClick = { + scope.launch { + topBarOffsetY.animateTo(0f) + } + navController.navigate(dest.route) { popUpTo(navController.graph.findStartDestination().id) { saveState = true diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt index 8c54853..502aced 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt @@ -2,9 +2,12 @@ package org.xtimms.tokusho.core.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.OpenInBrowser import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import org.xtimms.tokusho.R @Composable fun BackIconButton( @@ -16,4 +19,16 @@ fun BackIconButton( contentDescription = "arrow_back" ) } +} + +@Composable +fun ViewInBrowserButton( + onClick: () -> Unit +) { + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Outlined.OpenInBrowser, + contentDescription = stringResource(R.string.open_in_browser) + ) + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/MangaCover.kt b/app/src/main/java/org/xtimms/tokusho/core/components/MangaCover.kt index 2df1f62..a0a9648 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/MangaCover.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/MangaCover.kt @@ -12,6 +12,9 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.semantics.Role +import coil.ImageLoader +import coil.compose.AsyncImage +import org.xtimms.tokusho.core.AsyncImageImpl enum class MangaCover(val ratio: Float) { Square(1f / 1f), @@ -20,14 +23,16 @@ enum class MangaCover(val ratio: Float) { @Composable operator fun invoke( - data: Painter, + coil: ImageLoader, + data: String, modifier: Modifier = Modifier, contentDescription: String = "", shape: Shape = MaterialTheme.shapes.small, onClick: (() -> Unit)? = null, ) { - Image( - painter = data, + AsyncImageImpl( + coil = coil, + model = data, contentDescription = contentDescription, modifier = modifier .aspectRatio(ratio) diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt new file mode 100644 index 0000000..627ac1b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt @@ -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 = { } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt b/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt index 6f8f25d..88649c9 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt @@ -1,3 +1,6 @@ package org.xtimms.tokusho.core.database +const val TABLE_MANGA = "manga" +const val TABLE_TAGS = "tags" +const val TABLE_MANGA_TAGS = "manga_tags" const val TABLE_SOURCES = "sources" \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt index 164961d..f641725 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt @@ -4,20 +4,28 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import org.xtimms.tokusho.core.database.dao.MangaDao import org.xtimms.tokusho.core.database.dao.MangaSourcesDao +import org.xtimms.tokusho.core.database.entity.MangaEntity import org.xtimms.tokusho.core.database.entity.MangaSourceEntity +import org.xtimms.tokusho.core.database.entity.MangaTagsEntity +import org.xtimms.tokusho.core.database.entity.TagEntity const val DATABASE_VERSION = 1 @Database( entities = [ + MangaEntity::class, + TagEntity::class, + MangaTagsEntity::class, MangaSourceEntity::class ], - version = DATABASE_VERSION, - exportSchema = false + version = DATABASE_VERSION ) abstract class MangaDatabase : RoomDatabase() { + abstract fun getMangaDao(): MangaDao + abstract fun getSourcesDao(): MangaSourcesDao } diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaDao.kt new file mode 100644 index 0000000..fabb76d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaDao.kt @@ -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 + + @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) + + @Transaction + open suspend fun upsert(manga: MangaEntity, tags: Iterable? = null) { + upsert(manga) + if (tags != null) { + clearTagRelation(manga.id) + tags.map { + MangaTagsEntity(manga.id, it.id) + }.forEach { + insertTagRelation(it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt new file mode 100644 index 0000000..56126ef --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt @@ -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.toMangaTags() = mapToSet(TagEntity::toMangaTag) + +fun Collection.toMangaTagsList() = map(TagEntity::toMangaTag) + +fun MangaEntity.toManga(tags: Set) = 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.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() \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaEntity.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaEntity.kt new file mode 100644 index 0000000..9054365 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaEntity.kt @@ -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, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaSourceEntity.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaSourceEntity.kt index c1ea648..a396394 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaSourceEntity.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaSourceEntity.kt @@ -5,9 +5,7 @@ import androidx.room.Entity import androidx.room.PrimaryKey import org.xtimms.tokusho.core.database.TABLE_SOURCES -@Entity( - tableName = TABLE_SOURCES, -) +@Entity(tableName = TABLE_SOURCES) data class MangaSourceEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "source") diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaTagsEntity.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaTagsEntity.kt new file mode 100644 index 0000000..be88d2d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaTagsEntity.kt @@ -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, +) diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaWithTags.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaWithTags.kt new file mode 100644 index 0000000..afeb68f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaWithTags.kt @@ -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, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/TagEntity.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/TagEntity.kt new file mode 100644 index 0000000..bc8c062 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/TagEntity.kt @@ -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, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt b/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt new file mode 100644 index 0000000..3029d33 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt @@ -0,0 +1,5 @@ +package org.xtimms.tokusho.core.model + +import org.koitharu.kotatsu.parsers.model.Manga + +fun Collection.distinctById() = distinctBy { it.id } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/parcelable/ParcelableMangaTags.kt b/app/src/main/java/org/xtimms/tokusho/core/model/parcelable/ParcelableMangaTags.kt new file mode 100644 index 0000000..68d5844 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/parcelable/ParcelableMangaTags.kt @@ -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 { + 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 +data class ParcelableMangaTags(val tags: Set) : Parcelable diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/parcelable/ParcerableManga.kt b/app/src/main/java/org/xtimms/tokusho/core/model/parcelable/ParcerableManga.kt new file mode 100644 index 0000000..25f4995 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/parcelable/ParcerableManga.kt @@ -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 { + + 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()).tags, + state = parcel.readSerializableCompat(), + author = parcel.readString(), + chapters = null, + source = requireNotNull(parcel.readSerializableCompat()), + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaDataRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaDataRepository.kt new file mode 100644 index 0000000..244906c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaDataRepository.kt @@ -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, +) { + + 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 + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaIntent.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaIntent.kt new file mode 100644 index 0000000..4e57f39 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaIntent.kt @@ -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(KEY_MANGA)?.manga, + id = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE, + uri = intent?.data, + ) + + constructor(savedStateHandle: SavedStateHandle) : this( + manga = savedStateHandle.get(KEY_MANGA)?.manga, + id = savedStateHandle[KEY_ID] ?: ID_NONE, + uri = savedStateHandle[MainActivity.EXTRA_DATA], + ) + + constructor(args: Bundle?) : this( + manga = args?.getParcelableCompat(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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaLinkResolver.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaLinkResolver.kt new file mode 100644 index 0000000..94393bf --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaLinkResolver.kt @@ -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, + ) +} diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt index e1b77d2..e746119 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.currentCoroutineContext import okhttp3.Interceptor import okhttp3.Response +import org.koitharu.kotatsu.parsers.InternalParsersApi import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.model.ContentRating @@ -29,6 +30,7 @@ import org.xtimms.tokusho.core.cache.SafeDeferred import org.xtimms.tokusho.utils.lang.processLifecycleScope import java.util.Locale +@OptIn(InternalParsersApi::class) class RemoteMangaRepository( private val parser: MangaParser, private val cache: ContentCache, @@ -55,6 +57,9 @@ class RemoteMangaRepository( override val isTagsExclusionSupported: Boolean get() = parser.isTagsExclusionSupported + val domains: Array + get() = parser.configKeyDomain.presetValues + override fun intercept(chain: Interceptor.Chain): Response { return if (parser is Interceptor) { parser.intercept(chain) diff --git a/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt b/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt index 20c8307..60f2c94 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import kotlinx.collections.immutable.ImmutableList import org.xtimms.tokusho.core.components.ActionButton -import org.xtimms.tokusho.utils.secondaryItemAlpha +import org.xtimms.tokusho.utils.composable.secondaryItemAlpha import kotlin.random.Random data class EmptyScreenAction( diff --git a/app/src/main/java/org/xtimms/tokusho/core/screens/InfoScreen.kt b/app/src/main/java/org/xtimms/tokusho/core/screens/InfoScreen.kt index 2959bf4..401d9ca 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/screens/InfoScreen.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/screens/InfoScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import org.xtimms.tokusho.utils.secondaryItemAlpha +import org.xtimms.tokusho.utils.composable.secondaryItemAlpha @Composable fun InfoScreen( diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsEvent.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsEvent.kt new file mode 100644 index 0000000..b3b460f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsEvent.kt @@ -0,0 +1,5 @@ +package org.xtimms.tokusho.sections.details + +import org.xtimms.tokusho.core.base.event.UiEvent + +interface DetailsEvent : UiEvent \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt index d5bd723..60f0cdd 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt @@ -1,10 +1,18 @@ package org.xtimms.tokusho.sections.details -import androidx.compose.foundation.Image +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer @@ -14,6 +22,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.MenuBook import androidx.compose.material.icons.outlined.Block @@ -21,43 +30,67 @@ import androidx.compose.material.icons.outlined.Brush import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.Language -import androidx.compose.material.icons.outlined.MenuBook import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Upcoming +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil.ImageLoader +import coil.compose.AsyncImage import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag import org.xtimms.tokusho.R import org.xtimms.tokusho.core.components.MangaCover import org.xtimms.tokusho.ui.theme.TokushoTheme -import org.xtimms.tokusho.utils.secondaryItemAlpha +import org.xtimms.tokusho.utils.composable.clickableNoIndication +import org.xtimms.tokusho.utils.composable.secondaryItemAlpha +import kotlin.math.roundToInt + +private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)) @Composable fun DetailsInfoBox( + coil: ImageLoader, + imageUrl: String, + title: String, + author: String?, + artist: String?, + state: MangaState?, isTabletUi: Boolean, appBarPadding: Dp, modifier: Modifier = Modifier, @@ -67,8 +100,8 @@ fun DetailsInfoBox( Color.Transparent, MaterialTheme.colorScheme.background, ) - Image( - painterResource(id = R.drawable.ookami), + AsyncImage( + model = imageUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -79,28 +112,30 @@ fun DetailsInfoBox( brush = Brush.verticalGradient(colors = backdropGradientColors), ) } - .blur(8.dp) + .blur(2.dp) .alpha(0.2f) ) CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { if (!isTabletUi) { MangaAndSourceTitlesSmall( + coil = coil, appBarPadding = appBarPadding, - onCoverClick = { }, - title = "Ookami to Koushinryou", - author = "Hasekura Isuna", - artist = "Koume Keito", - state = MangaState.FINISHED + imageUrl = imageUrl, + title = title, + author = author, + artist = artist, + state = state ) } else { MangaAndSourceTitlesLarge( + coil = coil, appBarPadding = appBarPadding, - onCoverClick = { }, - title = "Ookami to Koushinryou", - author = "Hasekura Isuna", - artist = "Koume Keito", - state = MangaState.FINISHED + imageUrl = imageUrl, + title = title, + author = author, + artist = artist, + state = state ) } } @@ -109,8 +144,9 @@ fun DetailsInfoBox( @Composable private fun MangaAndSourceTitlesLarge( + coil: ImageLoader, appBarPadding: Dp, - onCoverClick: () -> Unit, + imageUrl: String, title: String, author: String?, artist: String?, @@ -123,10 +159,10 @@ private fun MangaAndSourceTitlesLarge( horizontalAlignment = Alignment.CenterHorizontally, ) { MangaCover.Book( + coil = coil, modifier = Modifier.fillMaxWidth(0.65f), - data = painterResource(id = R.drawable.ookami), + data = imageUrl, contentDescription = stringResource(R.string.manga_cover), - onClick = onCoverClick, ) Spacer(modifier = Modifier.height(16.dp)) DetailsContentInfo( @@ -139,8 +175,9 @@ private fun MangaAndSourceTitlesLarge( @Composable private fun MangaAndSourceTitlesSmall( + coil: ImageLoader, appBarPadding: Dp, - onCoverClick: () -> Unit, + imageUrl: String, title: String, author: String?, artist: String?, @@ -156,12 +193,12 @@ private fun MangaAndSourceTitlesSmall( horizontalArrangement = Arrangement.spacedBy(16.dp), ) { MangaCover.Book( + coil = coil, modifier = Modifier .sizeIn(maxWidth = 100.dp) .align(Alignment.Top), - data = painterResource(id = R.drawable.ookami), + data = imageUrl, contentDescription = stringResource(R.string.manga_cover), - onClick = onCoverClick, ) Column( verticalArrangement = Arrangement.spacedBy(2.dp), @@ -246,7 +283,11 @@ private fun RowScope.DetailsRow( textAlign: TextAlign? = LocalTextStyle.current.textAlign, ) { Column( - modifier = Modifier.weight(1f).wrapContentSize().secondaryItemAlpha(), + modifier = Modifier + .weight(1f) + .wrapContentSize() + .secondaryItemAlpha() + .padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -279,7 +320,10 @@ private fun RowScope.DetailsRow( } } Column( - modifier = Modifier.weight(1f).wrapContentSize().secondaryItemAlpha(), + modifier = Modifier + .weight(1f) + .wrapContentSize() + .secondaryItemAlpha(), verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -296,7 +340,10 @@ private fun RowScope.DetailsRow( ) } Column( - modifier = Modifier.weight(1f).wrapContentSize().secondaryItemAlpha(), + modifier = Modifier + .weight(1f) + .wrapContentSize() + .secondaryItemAlpha(), verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -314,13 +361,177 @@ private fun RowScope.DetailsRow( } } -@PreviewLightDark +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ExpandableMangaDescription( + defaultExpandState: Boolean, + description: String?, + tagsProvider: () -> List?, + 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 -fun DetailsInfoBoxPreview() { - TokushoTheme { - DetailsInfoBox( - isTabletUi = false, - appBarPadding = 72.dp, +private fun TagsChip( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + SuggestionChip( + modifier = modifier, + onClick = onClick, + label = { Text(text = text) }, ) } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsUiState.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsUiState.kt new file mode 100644 index 0000000..1eef7c2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsUiState.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt index 151222f..c282e60 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt @@ -9,22 +9,37 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.ImageLoader +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaState import org.xtimms.tokusho.core.components.DetailsToolbar -const val DETAILS_DESTINATION = "details" +const val MANGA_ID_ARGUMENT = "{mangaId}" +const val DETAILS_DESTINATION = "details/$MANGA_ID_ARGUMENT" @Composable fun DetailsView( + coil: ImageLoader, + mangaId: Long, navigateBack: () -> Unit, ) { + val viewModel: DetailsViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val chapterListState = rememberLazyListState() + LaunchedEffect(mangaId) { + viewModel.getDetails(mangaId) + } + Scaffold( topBar = { val isFirstItemVisible by remember { @@ -68,10 +83,29 @@ fun DetailsView( contentType = DetailsViewItem.INFO_BOX ) { DetailsInfoBox( + coil = coil, + imageUrl = uiState.manga?.largeCoverUrl ?: "", + title = uiState.manga?.title ?: "", + author = uiState.manga?.author ?: "", + artist = "", + state = uiState.manga?.state ?: MangaState.FINISHED, isTabletUi = false, appBarPadding = topPadding, ) } + + item( + key = DetailsViewItem.DESCRIPTION_WITH_TAG, + contentType = DetailsViewItem.DESCRIPTION_WITH_TAG, + ) { + ExpandableMangaDescription( + defaultExpandState = true, + description = uiState.manga?.description ?: "", + tagsProvider = { uiState.manga?.tags?.toList() }, + onTagSearch = { }, + onCopyTagToClipboard = { }, + ) + } } } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt new file mode 100644 index 0000000..095485e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt @@ -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(), 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() + ) + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt new file mode 100644 index 0000000..0d39b3a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt @@ -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, + 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 = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/data/MangaDetails.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/data/MangaDetails.kt new file mode 100644 index 0000000..8739961 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/data/MangaDetails.kt @@ -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> = manga.chapters?.groupBy { it.branch }.orEmpty() + + val branches: Set + get() = chapters.keys + + val allChapters: List 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?) = 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, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt new file mode 100644 index 0000000..d676a87 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt @@ -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 = 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() + for (span in spans) { + spannable.removeSpan(span) + } + return spannable + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreEvent.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreEvent.kt index eba7a3c..28e5265 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreEvent.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreEvent.kt @@ -2,6 +2,4 @@ package org.xtimms.tokusho.sections.explore import org.xtimms.tokusho.core.base.event.UiEvent -interface ExploreEvent : UiEvent { - -} \ No newline at end of file +interface ExploreEvent : UiEvent \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt index 82b9c94..ebbd7e6 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt @@ -36,6 +36,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import coil.ImageLoader +import org.koitharu.kotatsu.parsers.model.MangaSource import org.xtimms.tokusho.R import org.xtimms.tokusho.core.collapsable import org.xtimms.tokusho.core.components.ExploreButton @@ -50,7 +51,7 @@ const val EXPLORE_DESTINATION = "explore" @Composable fun ExploreView( coil: ImageLoader, - navController: NavController, + navigateToSource: (MangaSource) -> Unit, topBarHeightPx: Float, topBarOffsetY: Animatable, padding: PaddingValues, @@ -60,7 +61,7 @@ fun ExploreView( ExploreViewContent( coil = coil, - navController = navController, + navigateToSource = navigateToSource, uiState = uiState, event = viewModel, topBarHeightPx = topBarHeightPx, @@ -72,7 +73,7 @@ fun ExploreView( @Composable fun ExploreViewContent( coil: ImageLoader, - navController: NavController, + navigateToSource: (MangaSource) -> Unit, uiState: ExploreUiState, event: ExploreEvent?, nestedScrollConnection: NestedScrollConnection? = null, @@ -166,7 +167,7 @@ fun ExploreViewContent( } items( items = uiState.sources, - key = { it.ordinal }, + key = { it.name }, contentType = { it } ) { item -> Box( @@ -176,11 +177,10 @@ fun ExploreViewContent( SourceItem( coil = coil, faviconUrl = item.faviconUri(), - title = item.title, - onClick = { - navController.navigate(LIST_DESTINATION) - } - ) + title = item.title + ) { + navigateToSource(item) + } } } } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt index 93e64a8..0c10a17 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt @@ -22,7 +22,7 @@ class ExploreViewModel @Inject constructor( ) init { - viewModelScope.launch(Dispatchers.IO) { + launchJob(Dispatchers.Default) { val result = mangaSourcesRepository.allMangaSources mutableUiState.update { it.copy( diff --git a/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListEvent.kt b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListEvent.kt new file mode 100644 index 0000000..a3ee9ed --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListEvent.kt @@ -0,0 +1,5 @@ +package org.xtimms.tokusho.sections.list + +import org.xtimms.tokusho.core.base.event.PagedUiEvent + +interface MangaListEvent : PagedUiEvent \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListUiState.kt b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListUiState.kt new file mode 100644 index 0000000..4ebad61 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListUiState.kt @@ -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 = 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) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt index 040d276..b9894b7 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt @@ -1,41 +1,133 @@ package org.xtimms.tokusho.sections.list +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.Text +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.ImageLoader +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.xtimms.tokusho.core.components.MangaCompactGridItem import org.xtimms.tokusho.core.components.ScaffoldWithSmallTopAppBar +import org.xtimms.tokusho.utils.composable.onBottomReached +import org.xtimms.tokusho.utils.system.toast -const val LIST_DESTINATION = "list" +const val PROVIDER_ARGUMENT = "{source}" +const val LIST_DESTINATION = "provider/${PROVIDER_ARGUMENT}" @Composable fun MangaListView( - sourceName: String, + coil: ImageLoader, + source: MangaSource, navigateBack: () -> Unit, - navigateToDetails: () -> Unit, + navigateToDetails: (Long) -> Unit, ) { + val viewModel: MangaListViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + MangaListView( + coil = coil, + source = source, + uiState = uiState, + event = viewModel, + navigateBack = navigateBack, + navigateToDetails = navigateToDetails + ) +} + +@Composable +private fun MangaListView( + coil: ImageLoader, + source: MangaSource, + uiState: MangaListUiState, + event: MangaListEvent?, + navigateBack: () -> Unit, + navigateToDetails: (Long) -> Unit, +) { + val context = LocalContext.current val scrollState = rememberScrollState() + if (uiState.message != null) { + LaunchedEffect(uiState.message) { + context.toast(uiState.message) + event?.onMessageDisplayed() + } + } + ScaffoldWithSmallTopAppBar( - title = sourceName, - navigateBack = navigateBack + title = source.title, + navigateBack = navigateBack, + contentWindowInsets = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) ) { padding -> + val listState = rememberLazyGridState() + listState.onBottomReached(buffer = 3) { + event?.loadMore() + } Column( modifier = Modifier - .verticalScroll(scrollState) .padding(padding), horizontalAlignment = Alignment.CenterHorizontally ) { - Button(onClick = { navigateToDetails() }) { - Text(text = "Click") + 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() } } } - } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListViewModel.kt new file mode 100644 index 0000000..848f888 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListViewModel.kt @@ -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(), MangaListEvent { + + private var loadingJob: Job? = null + + val source = MangaSource.valueOf(savedStateHandle.get(PROVIDER_ARGUMENT.removeFirstAndLast())!!) + private val repository = mangaRepositoryFactory.create(source) + private val mangaList = MutableStateFlow?>(null) + private val listError = MutableStateFlow(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") + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt b/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt index a0d8dec..be7269b 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview @@ -45,8 +46,9 @@ fun SearchHostView( var query by remember { mutableStateOf("") } val performSearch = remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current - LaunchedEffect(Unit) { + LaunchedEffect(focusRequester) { focusRequester.requestFocus() } @@ -68,7 +70,9 @@ fun SearchHostView( if (isCompactScreen) BackIconButton(onClick = navigateBack) }, keyboardActions = KeyboardActions( - onSearch = { performSearch.value = true } + onSearch = { + keyboardController?.hide() + } ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), singleLine = true, diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt index ea9deae..fb8750d 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt @@ -20,7 +20,7 @@ import org.xtimms.tokusho.core.collapsable import org.xtimms.tokusho.core.model.ShelfCategory import org.xtimms.tokusho.ui.theme.TokushoTheme -const val SHELF_DESTINATION = "stub" +const val SHELF_DESTINATION = "shelf" @Composable fun ShelfView( @@ -100,8 +100,11 @@ fun ShelfPreview() { TokushoTheme { Surface { ShelfViewContent( - categories = emptyList(), - currentPage = { 2 }, + categories = listOf( + ShelfCategory(1, "Test 1", 1L, 1L), + ShelfCategory(2, "Test 2", 2L, 2L) + ), + currentPage = { 0 }, showPageTabs = true, getNumberOfMangaForCategory = { 2 }, getLibraryForPage = { library.values.toTypedArray().getOrNull(0).orEmpty() }, diff --git a/app/src/main/java/org/xtimms/tokusho/ui/monet/Monet.kt b/app/src/main/java/org/xtimms/tokusho/ui/monet/Monet.kt index a3358ef..ac148c6 100644 --- a/app/src/main/java/org/xtimms/tokusho/ui/monet/Monet.kt +++ b/app/src/main/java/org/xtimms/tokusho/ui/monet/Monet.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.graphics.Color import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes val LocalTonalPalettes = staticCompositionLocalOf { - Color(0xFF0057C9).toTonalPalettes() + Color(0xFF1978D2).toTonalPalettes() } inline val Number.a1: Color diff --git a/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt b/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt index 430a8d1..4cf7248 100644 --- a/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt +++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt @@ -33,4 +33,4 @@ object FixedAccentColors { @Composable get() = 30.a3 } -const val SEED = 0x0057c9 \ No newline at end of file +const val SEED = 0x1978D2 \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/Event.kt b/app/src/main/java/org/xtimms/tokusho/utils/Event.kt new file mode 100644 index 0000000..bf100f8 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/Event.kt @@ -0,0 +1,36 @@ +package org.xtimms.tokusho.utils + +import kotlinx.coroutines.flow.FlowCollector + +class Event( + private val data: T, +) { + private var isConsumed = false + + suspend fun consume(collector: FlowCollector) { + 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)" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/Modifier.kt b/app/src/main/java/org/xtimms/tokusho/utils/Modifier.kt deleted file mode 100644 index 1e62a14..0000000 --- a/app/src/main/java/org/xtimms/tokusho/utils/Modifier.kt +++ /dev/null @@ -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) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/composable/LazyListState.kt b/app/src/main/java/org/xtimms/tokusho/utils/composable/LazyListState.kt new file mode 100644 index 0000000..2fe6b9e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/composable/LazyListState.kt @@ -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() } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/composable/Modifier.kt b/app/src/main/java/org/xtimms/tokusho/utils/composable/Modifier.kt new file mode 100644 index 0000000..3a342d2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/composable/Modifier.kt @@ -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, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Bundle.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Bundle.kt new file mode 100644 index 0000000..2a6031a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Bundle.kt @@ -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 Bundle.getParcelableCompat(key: String): T? { + return BundleCompat.getParcelable(this, key, T::class.java) +} + +inline fun Intent.getParcelableExtraCompat(key: String): T? { + return IntentCompat.getParcelableExtra(this, key, T::class.java) +} + +inline fun Intent.getSerializableExtraCompat(key: String): T? { + return getSerializableExtra(key) as T? +} + +inline fun 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 Parcel.readParcelableCompat(): T? { + return ParcelCompat.readParcelable(this, T::class.java.classLoader, T::class.java) +} + +inline fun Parcel.readSerializableCompat(): T? { + return ParcelCompat.readSerializable(this, T::class.java.classLoader, T::class.java) +} + +inline fun Bundle.requireSerializable(key: String): T { + return checkNotNull(getSerializableCompat(key)) { + "Serializable of type \"${T::class.java.name}\" not found at \"$key\"" + } +} + +fun SavedStateHandle.require(key: String): T { + return checkNotNull(get(key)) { + "Value $key not found in SavedStateHandle or has a wrong type" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/EventFlow.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/EventFlow.kt new file mode 100644 index 0000000..0b2cb30 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/EventFlow.kt @@ -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 MutableEventFlow() = MutableStateFlow?>(null) + +typealias EventFlow = StateFlow?> + +typealias MutableEventFlow = MutableStateFlow?> + +@AnyThread +fun MutableEventFlow.call(data: T) { + value = Event(data) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt new file mode 100644 index 0000000..287c15f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt @@ -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 Flow.onEachWhile(action: suspend (T) -> Boolean): Flow { + var isCalled = false + return onEach { + if (!isCalled) { + isCalled = action(it) + } + }.onCompletion { + isCalled = false + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt index b060491..39efea2 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt @@ -1,5 +1,28 @@ package org.xtimms.tokusho.utils.lang +import android.net.Uri +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + inline fun C?.ifNullOrEmpty(defaultValue: () -> C): C { return if (this.isNullOrEmpty()) defaultValue() else this -} \ No newline at end of file +} + +fun String.removeFirstAndLast() = substring(1, length - 1) + +fun Array.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' \ No newline at end of file diff --git a/app/src/main/res/drawable/anim_caret_down.xml b/app/src/main/res/drawable/anim_caret_down.xml new file mode 100644 index 0000000..b1dfa17 --- /dev/null +++ b/app/src/main/res/drawable/anim_caret_down.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bcaa6f9..8091ebf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,4 +68,9 @@ Paused Upcoming Unknown + Open in browser + Less + More + Copy to clipboard + No description \ No newline at end of file