Initial facade of manga list

master
Zakhar Timoshenko 2 years ago
parent 1c24b7d0b2
commit 2aa6732e1d
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -3,6 +3,7 @@ plugins {
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.kapt")
id("org.jetbrains.kotlin.plugin.parcelize")
id("com.google.devtools.ksp")
id("dagger.hilt.android.plugin")
}
@ -25,7 +26,8 @@ android {
javaCompileOptions {
annotationProcessorOptions {
arguments += mapOf(
"room.generateKotlin" to "true"
"room.generateKotlin" to "true",
"room.schemaLocation" to "$projectDir/schemas"
)
}
}
@ -68,6 +70,7 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-process:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
implementation("androidx.compose.animation:animation-graphics")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")

@ -36,7 +36,7 @@ class App : Application() {
}
DynamicColors.applyToActivitiesIfAvailable(this)
processLifecycleScope.launch((Dispatchers.IO)) {
processLifecycleScope.launch(Dispatchers.IO) {
try {
Updater.deleteOutdatedApk(this@App)
} catch (_: Throwable) {

@ -1,8 +1,10 @@
package org.xtimms.tokusho
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatDelegate
@ -22,6 +24,7 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@ -43,6 +46,7 @@ import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.Navigation
import org.xtimms.tokusho.core.components.BottomNavBar
import org.xtimms.tokusho.core.components.TopAppBar
import org.xtimms.tokusho.sections.list.LIST_DESTINATION
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.utils.lang.processLifecycleScope
import javax.inject.Inject
@ -74,10 +78,21 @@ class MainActivity : ComponentActivity() {
}
}
}
putDataToExtras(intent)
}
override fun onNewIntent(intent: Intent?) {
putDataToExtras(intent)
super.onNewIntent(intent)
}
private fun putDataToExtras(intent: Intent?) {
intent?.putExtra(EXTRA_DATA, intent.data)
}
companion object {
private const val TAG = "MainActivity"
const val EXTRA_DATA = "data"
fun setLanguage(locale: String) {
Log.d(TAG, "setLanguage: $locale")
@ -122,6 +137,7 @@ fun MainView(
BottomNavBar(
navController = navController,
bottomBarState = bottomBarState,
topBarOffsetY = topBarOffsetY
)
}
},

@ -11,18 +11,23 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.core.model.ShelfCategory
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
import org.xtimms.tokusho.sections.details.DETAILS_DESTINATION
import org.xtimms.tokusho.sections.details.DetailsView
import org.xtimms.tokusho.sections.details.MANGA_ID_ARGUMENT
import org.xtimms.tokusho.sections.explore.ExploreView
import org.xtimms.tokusho.sections.history.HistoryView
import org.xtimms.tokusho.sections.list.LIST_DESTINATION
import org.xtimms.tokusho.sections.list.MangaListView
import org.xtimms.tokusho.sections.list.PROVIDER_ARGUMENT
import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION
import org.xtimms.tokusho.sections.search.SearchHostView
import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION
@ -39,6 +44,7 @@ import org.xtimms.tokusho.sections.settings.appearance.LANGUAGES_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.LanguagesView
import org.xtimms.tokusho.sections.shelf.ShelfMap
import org.xtimms.tokusho.sections.shelf.ShelfView
import org.xtimms.tokusho.utils.lang.removeFirstAndLast
const val DURATION_ENTER = 400
const val DURATION_EXIT = 200
@ -88,7 +94,10 @@ fun Navigation(
composable(BottomNavDestination.Shelf.route) {
val library: ShelfMap = emptyMap()
ShelfView(
categories = listOf(ShelfCategory(1, "Test 1", 1L, 1L), ShelfCategory(2, "Test 2", 2L, 2L)),
categories = listOf(
ShelfCategory(1, "Test 1", 1L, 1L),
ShelfCategory(2, "Test 2", 2L, 2L)
),
currentPage = { 0 },
showPageTabs = true,
getNumberOfMangaForCategory = { 2 },
@ -108,7 +117,11 @@ fun Navigation(
composable(BottomNavDestination.Explore.route) {
ExploreView(
coil = coil,
navController = navController,
navigateToSource = {
navController.navigate(
LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
)
},
padding = padding,
topBarHeightPx = topBarHeightPx,
topBarOffsetY = topBarOffsetY
@ -152,11 +165,26 @@ fun Navigation(
)
}
composable(LIST_DESTINATION) {
composable(
route = LIST_DESTINATION,
arguments = listOf(
navArgument(PROVIDER_ARGUMENT.removeFirstAndLast()) {
type = NavType.StringType
}
)
) { navEntry ->
MangaListView(
sourceName = "Source",
coil = coil,
source = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast())
?.let { source -> MangaSource.valueOf(source) } ?: MangaSource.DUMMY,
navigateBack = navigateBack,
navigateToDetails = { navController.navigate(DETAILS_DESTINATION) }
navigateToDetails = {
navController.navigate(
DETAILS_DESTINATION.replace(
MANGA_ID_ARGUMENT, it.toString()
)
)
}
)
}
@ -173,8 +201,13 @@ fun Navigation(
)
}
composable(DETAILS_DESTINATION) {
// TODO
composable(
route = DETAILS_DESTINATION
) { navEntry ->
DetailsView(
coil = coil,
mangaId = 0L,
navigateBack = navigateBack,
)
}

@ -0,0 +1,5 @@
package org.xtimms.tokusho.core.base.event
interface PagedUiEvent : UiEvent {
fun loadMore()
}

@ -0,0 +1,13 @@
package org.xtimms.tokusho.core.base.state
abstract class PagedUiState : UiState() {
abstract val nextPage: String?
/**
* Trigger variable to load more items, be careful to set it to false after loading more
*/
abstract val loadMore: Boolean
val canLoadMore get() = nextPage != null && !isLoading
}

@ -1,18 +1,45 @@
package org.xtimms.tokusho.core.base.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.base.event.UiEvent
import org.xtimms.tokusho.core.base.state.UiState
import org.xtimms.tokusho.utils.lang.EventFlow
import org.xtimms.tokusho.utils.lang.MutableEventFlow
import org.xtimms.tokusho.utils.lang.call
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.cancellation.CancellationException
abstract class BaseViewModel<S : UiState> : ViewModel(), UiEvent {
@JvmField
protected val loadingCounter = MutableStateFlow(0)
@JvmField
protected val errorEvent = MutableEventFlow<Throwable>()
val onError: EventFlow<Throwable>
get() = errorEvent
protected abstract val mutableUiState: MutableStateFlow<S>
val uiState: StateFlow<S> by lazy { mutableUiState.asStateFlow() }
protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
@Suppress("UNCHECKED_CAST")
fun setLoading(value: Boolean) {
mutableUiState.update { it.setLoading(value) as S }
@ -28,6 +55,29 @@ abstract class BaseViewModel<S : UiState> : ViewModel(), UiEvent {
mutableUiState.update { it.setMessage(null) as S }
}
protected fun launchLoadingJob(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
loadingCounter.increment()
try {
block()
} finally {
loadingCounter.decrement()
}
}
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
if (throwable !is CancellationException) {
errorEvent.call(throwable)
}
}
companion object {
private const val GENERIC_ERROR = "Generic Error"
const val FLOW_TIMEOUT = 5_000L

@ -1,6 +1,8 @@
package org.xtimms.tokusho.core.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.material3.NavigationBar
@ -11,10 +13,12 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.BottomNavDestination
import org.xtimms.tokusho.core.BottomNavDestination.Companion.Icon
import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION
@ -25,7 +29,9 @@ import org.xtimms.tokusho.sections.shelf.SHELF_DESTINATION
fun BottomNavBar(
navController: NavController,
bottomBarState: State<Boolean>,
topBarOffsetY: Animatable<Float, AnimationVector1D>,
) {
val scope = rememberCoroutineScope()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val isVisible by remember {
@ -49,6 +55,10 @@ fun BottomNavBar(
NavigationBarItem(
selected = isSelected,
onClick = {
scope.launch {
topBarOffsetY.animateTo(0f)
}
navController.navigate(dest.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true

@ -2,9 +2,12 @@ package org.xtimms.tokusho.core.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.OpenInBrowser
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import org.xtimms.tokusho.R
@Composable
fun BackIconButton(
@ -17,3 +20,15 @@ fun BackIconButton(
)
}
}
@Composable
fun ViewInBrowserButton(
onClick: () -> Unit
) {
IconButton(onClick = onClick) {
Icon(
imageVector = Icons.Outlined.OpenInBrowser,
contentDescription = stringResource(R.string.open_in_browser)
)
}
}

@ -12,6 +12,9 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.Role
import coil.ImageLoader
import coil.compose.AsyncImage
import org.xtimms.tokusho.core.AsyncImageImpl
enum class MangaCover(val ratio: Float) {
Square(1f / 1f),
@ -20,14 +23,16 @@ enum class MangaCover(val ratio: Float) {
@Composable
operator fun invoke(
data: Painter,
coil: ImageLoader,
data: String,
modifier: Modifier = Modifier,
contentDescription: String = "",
shape: Shape = MaterialTheme.shapes.small,
onClick: (() -> Unit)? = null,
) {
Image(
painter = data,
AsyncImageImpl(
coil = coil,
model = data,
contentDescription = contentDescription,
modifier = modifier
.aspectRatio(ratio)

@ -0,0 +1,223 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.ImageLoader
import org.xtimms.tokusho.core.AsyncImageImpl
import org.xtimms.tokusho.ui.theme.TokushoTheme
private const val GridSelectedCoverAlpha = 0.76f
/**
* Layout of grid list item with title overlaying the cover.
* Accepts null [title] for a cover-only view.
*/
@Composable
fun MangaCompactGridItem(
coil: ImageLoader,
imageUrl: String,
onClick: () -> Unit,
onLongClick: () -> Unit,
isSelected: Boolean = false,
title: String? = null,
onClickContinueReading: (() -> Unit)? = null,
coverAlpha: Float = 1f,
) {
GridItemSelectable(
isSelected = isSelected,
onClick = onClick,
onLongClick = onLongClick,
) {
Column(
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Box {
AsyncImageImpl(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
.clip(MaterialTheme.shapes.medium)
.aspectRatio(10F / 16F),
coil = coil,
model = imageUrl,
contentDescription = null
)
}
Text(
text = title!!,
modifier = Modifier.padding(4.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
style = MaterialTheme.typography.titleSmall,
)
}
}
}
/**
* Common cover layout to add contents to be drawn on top of the cover.
*/
@Composable
private fun MangaGridCover(
modifier: Modifier = Modifier,
cover: @Composable BoxScope.() -> Unit = {},
content: @Composable (BoxScope.() -> Unit)? = null,
) {
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio),
) {
cover()
content?.invoke(this)
}
}
/**
* Title overlay for [MangaCompactGridItem]
*/
@Composable
private fun BoxScope.CoverTextOverlay(
title: String,
onClickContinueReading: (() -> Unit)? = null,
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
.background(
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color(0xAA000000),
),
)
.fillMaxHeight(0.33f)
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
Row(
modifier = Modifier.align(Alignment.BottomStart),
verticalAlignment = Alignment.Bottom,
) {
GridItemTitle(
modifier = Modifier
.weight(1f)
.padding(8.dp),
title = title,
style = MaterialTheme.typography.titleSmall.copy(
color = Color.White,
shadow = Shadow(
color = Color.Black,
blurRadius = 4f,
),
),
minLines = 1,
)
}
}
@Composable
private fun GridItemTitle(
title: String,
style: TextStyle,
minLines: Int,
modifier: Modifier = Modifier,
maxLines: Int = 2,
) {
Text(
modifier = modifier,
text = title,
fontSize = 12.sp,
lineHeight = 18.sp,
minLines = minLines,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
style = style,
)
}
/**
* Wrapper for grid items to handle selection state, click and long click.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun GridItemSelectable(
isSelected: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.clip(MaterialTheme.shapes.small)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.selectedOutline(isSelected = isSelected, color = MaterialTheme.colorScheme.secondary)
.padding(4.dp),
) {
val contentColor = if (isSelected) {
MaterialTheme.colorScheme.onSecondary
} else {
LocalContentColor.current
}
CompositionLocalProvider(LocalContentColor provides contentColor) {
content()
}
}
}
/**
* @see GridItemSelectable
*/
private fun Modifier.selectedOutline(
isSelected: Boolean,
color: Color,
) = this then drawBehind { if (isSelected) drawRect(color = color) }
@PreviewLightDark
@Composable
fun MangaGridItemPreview() {
TokushoTheme {
MangaCompactGridItem(
coil = ImageLoader(LocalContext.current),
imageUrl = "https://cdn.myanimelist.net/images/manga/2/170594l.jpg",
title = "Stub",
onClick = { },
onLongClick = { }
)
}
}

@ -1,3 +1,6 @@
package org.xtimms.tokusho.core.database
const val TABLE_MANGA = "manga"
const val TABLE_TAGS = "tags"
const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources"

@ -4,20 +4,28 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import org.xtimms.tokusho.core.database.dao.MangaDao
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
import org.xtimms.tokusho.core.database.entity.MangaEntity
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
import org.xtimms.tokusho.core.database.entity.MangaTagsEntity
import org.xtimms.tokusho.core.database.entity.TagEntity
const val DATABASE_VERSION = 1
@Database(
entities = [
MangaEntity::class,
TagEntity::class,
MangaTagsEntity::class,
MangaSourceEntity::class
],
version = DATABASE_VERSION,
exportSchema = false
version = DATABASE_VERSION
)
abstract class MangaDatabase : RoomDatabase() {
abstract fun getMangaDao(): MangaDao
abstract fun getSourcesDao(): MangaSourcesDao
}

@ -0,0 +1,59 @@
package org.xtimms.tokusho.core.database.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import org.xtimms.tokusho.core.database.entity.MangaEntity
import org.xtimms.tokusho.core.database.entity.MangaTagsEntity
import org.xtimms.tokusho.core.database.entity.MangaWithTags
import org.xtimms.tokusho.core.database.entity.TagEntity
@Dao
abstract class MangaDao {
@Transaction
@Query("SELECT * FROM manga WHERE manga_id = :id")
abstract suspend fun find(id: Long): MangaWithTags?
@Transaction
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
@Transaction
@Query("SELECT * FROM manga WHERE source = :source")
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
@Upsert
abstract suspend fun upsert(manga: MangaEntity)
@Update(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun update(manga: MangaEntity): Int
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insertTagRelation(tag: MangaTagsEntity): Long
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
abstract suspend fun clearTagRelation(mangaId: Long)
@Transaction
@Delete
abstract suspend fun delete(subjects: Collection<MangaEntity>)
@Transaction
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
upsert(manga)
if (tags != null) {
clearTagRelation(manga.id)
tags.map {
MangaTagsEntity(manga.id, it.id)
}.forEach {
insertTagRelation(it)
}
}
}
}

@ -0,0 +1,76 @@
package org.xtimms.tokusho.core.database.entity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.xtimms.tokusho.core.model.MangaSource
import org.xtimms.tokusho.utils.lang.longHashCode
// Entity to model
fun TagEntity.toMangaTag() = MangaTag(
key = this.key,
title = this.title.toTitleCase(),
source = MangaSource(this.source),
)
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
state = this.state?.let { MangaState(it) },
rating = this.rating,
isNsfw = this.isNsfw,
url = this.url,
publicUrl = this.publicUrl,
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
source = MangaSource(this.source),
tags = tags,
)
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
// Model to entity
fun Manga.toEntity() = MangaEntity(
id = id,
url = url,
publicUrl = publicUrl,
source = source.name,
largeCoverUrl = largeCoverUrl,
coverUrl = coverUrl,
altTitle = altTitle,
rating = rating,
isNsfw = isNsfw,
state = state?.name,
title = title,
author = author,
)
fun MangaTag.toEntity() = TagEntity(
title = title,
key = key,
source = source.name,
id = "${key}_${source.name}".longHashCode(),
)
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
// Other
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
SortOrder.valueOf(name)
}.getOrDefault(fallback)
fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name)
}.getOrNull()

@ -0,0 +1,23 @@
package org.xtimms.tokusho.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.xtimms.tokusho.core.database.TABLE_MANGA
@Entity(tableName = TABLE_MANGA)
data class MangaEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "alt_title") val altTitle: String?,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "public_url") val publicUrl: String,
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
@ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String?,
@ColumnInfo(name = "author") val author: String?,
@ColumnInfo(name = "source") val source: String,
)

@ -5,9 +5,7 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
import org.xtimms.tokusho.core.database.TABLE_SOURCES
@Entity(
tableName = TABLE_SOURCES,
)
@Entity(tableName = TABLE_SOURCES)
data class MangaSourceEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "source")

@ -0,0 +1,29 @@
package org.xtimms.tokusho.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import org.xtimms.tokusho.core.database.TABLE_MANGA_TAGS
@Entity(
tableName = TABLE_MANGA_TAGS,
primaryKeys = ["manga_id", "tag_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = TagEntity::class,
parentColumns = ["tag_id"],
childColumns = ["tag_id"],
onDelete = ForeignKey.CASCADE,
)
]
)
class MangaTagsEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "tag_id", index = true) val tagId: Long,
)

@ -0,0 +1,15 @@
package org.xtimms.tokusho.core.database.entity
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
data class MangaWithTags(
@Embedded val manga: MangaEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>,
)

@ -0,0 +1,15 @@
package org.xtimms.tokusho.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.xtimms.tokusho.core.database.TABLE_TAGS
@Entity(tableName = TABLE_TAGS)
data class TagEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "tag_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "key") val key: String,
@ColumnInfo(name = "source") val source: String,
)

@ -0,0 +1,5 @@
package org.xtimms.tokusho.core.model
import org.koitharu.kotatsu.parsers.model.Manga
fun Collection<Manga>.distinctById() = distinctBy { it.id }

@ -0,0 +1,27 @@
package org.xtimms.tokusho.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.xtimms.tokusho.utils.lang.readSerializableCompat
object MangaTagParceler : Parceler<MangaTag> {
override fun create(parcel: Parcel) = MangaTag(
title = requireNotNull(parcel.readString()),
key = requireNotNull(parcel.readString()),
source = requireNotNull(parcel.readSerializableCompat()),
)
override fun MangaTag.write(parcel: Parcel, flags: Int) {
parcel.writeString(title)
parcel.writeString(key)
parcel.writeSerializable(source)
}
}
@Parcelize
@TypeParceler<MangaTag, MangaTagParceler>
data class ParcelableMangaTags(val tags: Set<MangaTag>) : Parcelable

@ -0,0 +1,56 @@
package org.xtimms.tokusho.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import androidx.core.os.ParcelCompat
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.utils.lang.readParcelableCompat
import org.xtimms.tokusho.utils.lang.readSerializableCompat
@Parcelize
data class ParcelableManga(
val manga: Manga,
) : Parcelable {
companion object : Parceler<ParcelableManga> {
override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) {
parcel.writeLong(id)
parcel.writeString(title)
parcel.writeString(altTitle)
parcel.writeString(url)
parcel.writeString(publicUrl)
parcel.writeFloat(rating)
ParcelCompat.writeBoolean(parcel, isNsfw)
parcel.writeString(coverUrl)
parcel.writeString(largeCoverUrl)
parcel.writeString(description)
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state)
parcel.writeString(author)
parcel.writeSerializable(source)
}
override fun create(parcel: Parcel) = ParcelableManga(
Manga(
id = parcel.readLong(),
title = requireNotNull(parcel.readString()),
altTitle = parcel.readString(),
url = requireNotNull(parcel.readString()),
publicUrl = requireNotNull(parcel.readString()),
rating = parcel.readFloat(),
isNsfw = ParcelCompat.readBoolean(parcel),
coverUrl = requireNotNull(parcel.readString()),
largeCoverUrl = parcel.readString(),
description = parcel.readString(),
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
state = parcel.readSerializableCompat(),
author = parcel.readString(),
chapters = null,
source = requireNotNull(parcel.readSerializableCompat()),
)
)
}
}

@ -0,0 +1,31 @@
package org.xtimms.tokusho.core.parser
import dagger.Reusable
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.database.MangaDatabase
import org.xtimms.tokusho.core.database.entity.toManga
import javax.inject.Inject
import javax.inject.Provider
@Reusable
class MangaDataRepository @Inject constructor(
private val db: MangaDatabase,
private val resolverProvider: Provider<MangaLinkResolver>,
) {
suspend fun findMangaById(mangaId: Long): Manga? {
return db.getMangaDao().find(mangaId)?.toManga()
}
suspend fun findMangaByPublicUrl(publicUrl: String): Manga? {
return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga()
}
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
intent.manga != null -> intent.manga
intent.mangaId != 0L -> findMangaById(intent.mangaId)
intent.uri != null -> resolverProvider.get().resolve(intent.uri)
else -> null
}
}

@ -0,0 +1,49 @@
package org.xtimms.tokusho.core.parser
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.lifecycle.SavedStateHandle
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.MainActivity
import org.xtimms.tokusho.core.model.parcelable.ParcelableManga
import org.xtimms.tokusho.utils.lang.getParcelableCompat
import org.xtimms.tokusho.utils.lang.getParcelableExtraCompat
class MangaIntent private constructor(
@JvmField val manga: Manga?,
@JvmField val id: Long,
@JvmField val uri: Uri?,
) {
constructor(intent: Intent?) : this(
manga = intent?.getParcelableExtraCompat<ParcelableManga>(KEY_MANGA)?.manga,
id = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE,
uri = intent?.data,
)
constructor(savedStateHandle: SavedStateHandle) : this(
manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga,
id = savedStateHandle[KEY_ID] ?: ID_NONE,
uri = savedStateHandle[MainActivity.EXTRA_DATA],
)
constructor(args: Bundle?) : this(
manga = args?.getParcelableCompat<ParcelableManga>(KEY_MANGA)?.manga,
id = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,
uri = null,
)
val mangaId: Long
get() = if (id != ID_NONE) id else manga?.id ?: uri?.lastPathSegment?.toLongOrNull() ?: ID_NONE
companion object {
const val ID_NONE = 0L
const val KEY_MANGA = "manga"
const val KEY_ID = "id"
fun of(manga: Manga) = MangaIntent(manga, manga.id, null)
}
}

@ -0,0 +1,121 @@
package org.xtimms.tokusho.core.parser
import android.net.Uri
import coil.request.CachePolicy
import dagger.Reusable
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toRelativeUrl
import org.xtimms.tokusho.core.model.MangaSource
import org.xtimms.tokusho.data.repository.MangaSourcesRepository
import org.xtimms.tokusho.utils.lang.ifNullOrEmpty
import javax.inject.Inject
@Reusable
class MangaLinkResolver @Inject constructor(
private val repositoryFactory: MangaRepository.Factory,
private val sourcesRepository: MangaSourcesRepository,
private val dataRepository: MangaDataRepository,
) {
suspend fun resolve(uri: Uri): Manga {
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
resolveAppLink(uri)
} else {
resolveExternalLink(uri)
} ?: throw NotFoundException("Cannot resolve link", uri.toString())
}
private suspend fun resolveAppLink(uri: Uri): Manga? {
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
val source = MangaSource(sourceName)
require(source != MangaSource.DUMMY) { "Manga source $sourceName is not supported" }
val repo = repositoryFactory.create(source)
return repo.findExact(
url = uri.getQueryParameter("url"),
title = uri.getQueryParameter("name"),
)
}
private suspend fun resolveExternalLink(uri: Uri): Manga? {
dataRepository.findMangaByPublicUrl(uri.toString())?.let {
return it
}
val host = uri.host ?: return null
val repo = sourcesRepository.allMangaSources.asSequence()
.map { source ->
repositoryFactory.create(source) as RemoteMangaRepository
}.find { repo ->
host in repo.domains
} ?: return null
return repo.findExact(uri.toString().toRelativeUrl(host), null)
}
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
if (!title.isNullOrEmpty()) {
val list = getList(0, MangaListFilter.Search(title))
if (url != null) {
list.find { it.url == url }?.let {
return it
}
}
list.minByOrNull { it.title.levenshteinDistance(title) }
?.takeIf { it.title.almostEquals(title, 0.2f) }
?.let { return it }
}
val seed = getDetailsNoCache(
getSeedManga(source, url ?: return null, title),
)
return runCatchingCancellable {
val seedTitle = seed.title.ifEmpty {
seed.altTitle
}.ifNullOrEmpty {
seed.author
} ?: return@runCatchingCancellable null
val seedList = getList(0, MangaListFilter.Search(seedTitle))
seedList.first { x -> x.url == url }
}.getOrThrow()
}
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is RemoteMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY)
} else {
getDetails(manga)
}
}
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(
id = run {
var h = 1125899906842597L
source.name.forEach { c ->
h = 31 * h + c.code
}
url.forEach { c ->
h = 31 * h + c.code
}
h
},
title = title.orEmpty(),
altTitle = null,
url = url,
publicUrl = "",
rating = 0.0f,
isNsfw = source.contentType == ContentType.HENTAI,
coverUrl = "",
tags = emptySet(),
state = null,
author = null,
largeCoverUrl = null,
description = null,
chapters = null,
source = source,
)
}

@ -10,6 +10,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.ContentRating
@ -29,6 +30,7 @@ import org.xtimms.tokusho.core.cache.SafeDeferred
import org.xtimms.tokusho.utils.lang.processLifecycleScope
import java.util.Locale
@OptIn(InternalParsersApi::class)
class RemoteMangaRepository(
private val parser: MangaParser,
private val cache: ContentCache,
@ -55,6 +57,9 @@ class RemoteMangaRepository(
override val isTagsExclusionSupported: Boolean
get() = parser.isTagsExclusionSupported
val domains: Array<out String>
get() = parser.configKeyDomain.presetValues
override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) {
parser.intercept(chain)

@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import kotlinx.collections.immutable.ImmutableList
import org.xtimms.tokusho.core.components.ActionButton
import org.xtimms.tokusho.utils.secondaryItemAlpha
import org.xtimms.tokusho.utils.composable.secondaryItemAlpha
import kotlin.random.Random
data class EmptyScreenAction(

@ -29,7 +29,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import org.xtimms.tokusho.utils.secondaryItemAlpha
import org.xtimms.tokusho.utils.composable.secondaryItemAlpha
@Composable
fun InfoScreen(

@ -0,0 +1,5 @@
package org.xtimms.tokusho.sections.details
import org.xtimms.tokusho.core.base.event.UiEvent
interface DetailsEvent : UiEvent

@ -1,10 +1,18 @@
package org.xtimms.tokusho.sections.details
import androidx.compose.foundation.Image
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
@ -14,6 +22,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.MenuBook
import androidx.compose.material.icons.outlined.Block
@ -21,43 +30,67 @@ import androidx.compose.material.icons.outlined.Brush
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.MenuBook
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Upcoming
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.ImageLoader
import coil.compose.AsyncImage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.MangaCover
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.utils.secondaryItemAlpha
import org.xtimms.tokusho.utils.composable.clickableNoIndication
import org.xtimms.tokusho.utils.composable.secondaryItemAlpha
import kotlin.math.roundToInt
private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE))
@Composable
fun DetailsInfoBox(
coil: ImageLoader,
imageUrl: String,
title: String,
author: String?,
artist: String?,
state: MangaState?,
isTabletUi: Boolean,
appBarPadding: Dp,
modifier: Modifier = Modifier,
@ -67,8 +100,8 @@ fun DetailsInfoBox(
Color.Transparent,
MaterialTheme.colorScheme.background,
)
Image(
painterResource(id = R.drawable.ookami),
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
@ -79,28 +112,30 @@ fun DetailsInfoBox(
brush = Brush.verticalGradient(colors = backdropGradientColors),
)
}
.blur(8.dp)
.blur(2.dp)
.alpha(0.2f)
)
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
if (!isTabletUi) {
MangaAndSourceTitlesSmall(
coil = coil,
appBarPadding = appBarPadding,
onCoverClick = { },
title = "Ookami to Koushinryou",
author = "Hasekura Isuna",
artist = "Koume Keito",
state = MangaState.FINISHED
imageUrl = imageUrl,
title = title,
author = author,
artist = artist,
state = state
)
} else {
MangaAndSourceTitlesLarge(
coil = coil,
appBarPadding = appBarPadding,
onCoverClick = { },
title = "Ookami to Koushinryou",
author = "Hasekura Isuna",
artist = "Koume Keito",
state = MangaState.FINISHED
imageUrl = imageUrl,
title = title,
author = author,
artist = artist,
state = state
)
}
}
@ -109,8 +144,9 @@ fun DetailsInfoBox(
@Composable
private fun MangaAndSourceTitlesLarge(
coil: ImageLoader,
appBarPadding: Dp,
onCoverClick: () -> Unit,
imageUrl: String,
title: String,
author: String?,
artist: String?,
@ -123,10 +159,10 @@ private fun MangaAndSourceTitlesLarge(
horizontalAlignment = Alignment.CenterHorizontally,
) {
MangaCover.Book(
coil = coil,
modifier = Modifier.fillMaxWidth(0.65f),
data = painterResource(id = R.drawable.ookami),
data = imageUrl,
contentDescription = stringResource(R.string.manga_cover),
onClick = onCoverClick,
)
Spacer(modifier = Modifier.height(16.dp))
DetailsContentInfo(
@ -139,8 +175,9 @@ private fun MangaAndSourceTitlesLarge(
@Composable
private fun MangaAndSourceTitlesSmall(
coil: ImageLoader,
appBarPadding: Dp,
onCoverClick: () -> Unit,
imageUrl: String,
title: String,
author: String?,
artist: String?,
@ -156,12 +193,12 @@ private fun MangaAndSourceTitlesSmall(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
MangaCover.Book(
coil = coil,
modifier = Modifier
.sizeIn(maxWidth = 100.dp)
.align(Alignment.Top),
data = painterResource(id = R.drawable.ookami),
data = imageUrl,
contentDescription = stringResource(R.string.manga_cover),
onClick = onCoverClick,
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
@ -246,7 +283,11 @@ private fun RowScope.DetailsRow(
textAlign: TextAlign? = LocalTextStyle.current.textAlign,
) {
Column(
modifier = Modifier.weight(1f).wrapContentSize().secondaryItemAlpha(),
modifier = Modifier
.weight(1f)
.wrapContentSize()
.secondaryItemAlpha()
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally
) {
@ -279,7 +320,10 @@ private fun RowScope.DetailsRow(
}
}
Column(
modifier = Modifier.weight(1f).wrapContentSize().secondaryItemAlpha(),
modifier = Modifier
.weight(1f)
.wrapContentSize()
.secondaryItemAlpha(),
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
@ -296,7 +340,10 @@ private fun RowScope.DetailsRow(
)
}
Column(
modifier = Modifier.weight(1f).wrapContentSize().secondaryItemAlpha(),
modifier = Modifier
.weight(1f)
.wrapContentSize()
.secondaryItemAlpha(),
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally
) {
@ -314,13 +361,177 @@ private fun RowScope.DetailsRow(
}
}
@PreviewLightDark
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ExpandableMangaDescription(
defaultExpandState: Boolean,
description: String?,
tagsProvider: () -> List<MangaTag>?,
onTagSearch: (String) -> Unit,
onCopyTagToClipboard: (tag: String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
val (expanded, onExpanded) = rememberSaveable {
mutableStateOf(defaultExpandState)
}
val desc =
description.takeIf { !it.isNullOrBlank() } ?: stringResource(R.string.description_placeholder)
val trimmedDescription = remember(desc) {
desc
.replace(whitespaceLineRegex, "\n")
.trimEnd()
}
val tags = tagsProvider()
if (!tags.isNullOrEmpty()) {
Box(
modifier = Modifier
.animateContentSize(),
) {
var showMenu by remember { mutableStateOf(false) }
var tagSelected by remember { mutableStateOf("") }
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.search)) },
onClick = {
onTagSearch(tagSelected)
showMenu = false
},
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_copy_to_clipboard)) },
onClick = {
onCopyTagToClipboard(tagSelected)
showMenu = false
},
)
}
FlowRow(
modifier = Modifier.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
tags.forEach {
TagsChip(
modifier = DefaultTagChipModifier,
text = it.title,
onClick = {
tagSelected = it.title
showMenu = true
},
)
}
}
}
}
MangaSummary(
expandedDescription = desc,
shrunkDescription = trimmedDescription,
expanded = expanded,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickableNoIndication { onExpanded(!expanded) },
)
}
}
@OptIn(ExperimentalAnimationGraphicsApi::class)
@Composable
private fun MangaSummary(
expandedDescription: String,
shrunkDescription: String,
expanded: Boolean,
modifier: Modifier = Modifier,
) {
val animProgress by animateFloatAsState(
targetValue = if (expanded) 1f else 0f,
label = "summary",
)
Layout(
modifier = modifier.clipToBounds(),
contents = listOf(
{
Text(
text = "\n\n", // Shows at least 3 lines
style = MaterialTheme.typography.bodyMedium,
)
},
{
Text(
text = expandedDescription,
style = MaterialTheme.typography.bodyMedium,
)
},
{
SelectionContainer {
Text(
text = if (expanded) expandedDescription else shrunkDescription,
maxLines = Int.MAX_VALUE,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.secondaryItemAlpha(),
)
}
},
{
val colors = listOf(Color.Transparent, MaterialTheme.colorScheme.background)
Box(
modifier = Modifier.background(Brush.verticalGradient(colors = colors)),
contentAlignment = Alignment.Center,
) {
val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down)
Icon(
painter = rememberAnimatedVectorPainter(image, !expanded),
contentDescription = stringResource(
if (expanded) R.string.manga_info_collapse else R.string.manga_info_expand,
),
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.background(Brush.radialGradient(colors = colors.asReversed())),
)
}
},
),
) { (shrunk, expanded, actual, scrim), constraints ->
val shrunkHeight = shrunk.single()
.measure(constraints)
.height
val expandedHeight = expanded.single()
.measure(constraints)
.height
val heightDelta = expandedHeight - shrunkHeight
val scrimHeight = 24.dp.roundToPx()
val actualPlaceable = actual.single()
.measure(constraints)
val scrimPlaceable = scrim.single()
.measure(Constraints.fixed(width = constraints.maxWidth, height = scrimHeight))
val currentHeight = shrunkHeight + ((heightDelta + scrimHeight) * animProgress).roundToInt()
layout(constraints.maxWidth, currentHeight) {
actualPlaceable.place(0, 0)
val scrimY = currentHeight - scrimHeight
scrimPlaceable.place(0, scrimY)
}
}
}
private val DefaultTagChipModifier = Modifier.padding(vertical = 4.dp)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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) },
)
}
}

@ -0,0 +1,14 @@
package org.xtimms.tokusho.sections.details
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.base.state.UiState
data class DetailsUiState(
val manga: Manga? = null,
override val isLoading: Boolean = false,
override val message: String? = null,
) : UiState() {
override fun setLoading(value: Boolean) = copy(isLoading = value)
override fun setMessage(value: String?) = copy(message = value)
}

@ -9,22 +9,37 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaState
import org.xtimms.tokusho.core.components.DetailsToolbar
const val DETAILS_DESTINATION = "details"
const val MANGA_ID_ARGUMENT = "{mangaId}"
const val DETAILS_DESTINATION = "details/$MANGA_ID_ARGUMENT"
@Composable
fun DetailsView(
coil: ImageLoader,
mangaId: Long,
navigateBack: () -> Unit,
) {
val viewModel: DetailsViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val chapterListState = rememberLazyListState()
LaunchedEffect(mangaId) {
viewModel.getDetails(mangaId)
}
Scaffold(
topBar = {
val isFirstItemVisible by remember {
@ -68,10 +83,29 @@ fun DetailsView(
contentType = DetailsViewItem.INFO_BOX
) {
DetailsInfoBox(
coil = coil,
imageUrl = uiState.manga?.largeCoverUrl ?: "",
title = uiState.manga?.title ?: "",
author = uiState.manga?.author ?: "",
artist = "",
state = uiState.manga?.state ?: MangaState.FINISHED,
isTabletUi = false,
appBarPadding = topPadding,
)
}
item(
key = DetailsViewItem.DESCRIPTION_WITH_TAG,
contentType = DetailsViewItem.DESCRIPTION_WITH_TAG,
) {
ExpandableMangaDescription(
defaultExpandState = true,
description = uiState.manga?.description ?: "",
tagsProvider = { uiState.manga?.tags?.toList() },
onTagSearch = { },
onCopyTagToClipboard = { },
)
}
}
}

@ -0,0 +1,55 @@
package org.xtimms.tokusho.sections.details
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
import org.xtimms.tokusho.core.parser.MangaIntent
import org.xtimms.tokusho.sections.details.data.MangaDetails
import org.xtimms.tokusho.sections.details.domain.DetailsLoadUseCase
import org.xtimms.tokusho.utils.lang.onEachWhile
import javax.inject.Inject
@HiltViewModel
class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val detailsLoadUseCase: DetailsLoadUseCase,
) : BaseViewModel<DetailsUiState>(), DetailsEvent {
private val intent = MangaIntent(savedStateHandle)
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, false) })
val manga = details.map { x -> x?.toManga() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
override val mutableUiState = MutableStateFlow(DetailsUiState())
fun getDetails(mangaId: Long) {
launchLoadingJob(Dispatchers.Default) {
detailsLoadUseCase.invoke(intent)
.onEachWhile {
if (it.allChapters.isEmpty()) {
return@onEachWhile false
}
true
}.collect {
mutableUiState.update {
val manga = details.firstOrNull { it != null } ?: return@collect
it.copy(
manga = manga.toManga()
)
}
}
}
}
}

@ -0,0 +1,134 @@
package org.xtimms.tokusho.sections.details
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.components.BackIconButton
import org.xtimms.tokusho.core.components.ViewInBrowserButton
import org.xtimms.tokusho.ui.theme.TokushoTheme
const val PICTURES_ARGUMENT = "{pictures}"
const val FULL_POSTER_DESTINATION = "full_poster/$PICTURES_ARGUMENT"
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun FullImageView(
pictures: Array<String>,
navigateBack: () -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { pictures.size }
val uriHandler = LocalUriHandler.current
fun openUrl(url: String) {
uriHandler.openUri(url)
}
Scaffold(
topBar = {
TopAppBar(
title = { },
navigationIcon = {
BackIconButton(onClick = navigateBack)
},
actions = {
ViewInBrowserButton(
onClick = {
pictures.getOrNull(pagerState.currentPage)?.let { url ->
openUrl(url)
}
}
)
}
)
}
) {
Column(
modifier = Modifier
.padding(it)
.fillMaxSize()
) {
HorizontalPager(
modifier = Modifier.weight(1f),
state = pagerState,
pageSpacing = 16.dp,
verticalAlignment = Alignment.CenterVertically
) { page ->
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = pictures[page],
contentDescription = "image$page",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
}
}
Row(
modifier = Modifier
.height(50.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
pictures.forEachIndexed { index, _ ->
val color =
if (pagerState.currentPage == index)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.primaryContainer
Box(
modifier = Modifier
.padding(4.dp)
.clip(CircleShape)
.background(color)
.size(8.dp)
.clickable {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
}
)
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun FullPosterPreview() {
TokushoTheme {
FullImageView(
pictures = arrayOf("", ""),
navigateBack = {}
)
}
}

@ -0,0 +1,52 @@
package org.xtimms.tokusho.sections.details.data
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
data class MangaDetails(
private val manga: Manga,
val description: CharSequence?,
val isLoaded: Boolean,
) {
val id: Long
get() = manga.id
val chapters: Map<String?, List<MangaChapter>> = manga.chapters?.groupBy { it.branch }.orEmpty()
val branches: Set<String?>
get() = chapters.keys
val allChapters: List<MangaChapter> by lazy { listOf() }
fun toManga() = manga
fun filterChapters(branch: String?) = MangaDetails(
manga = manga.filterChapters(branch),
description = description,
isLoaded = isLoaded,
)
}
fun Manga.filterChapters(branch: String?): Manga {
if (chapters.isNullOrEmpty()) return this
return withChapters(chapters = chapters?.filter { it.branch == branch })
}
private fun Manga.withChapters(chapters: List<MangaChapter>?) = Manga(
id = id,
title = title,
altTitle = altTitle,
url = url,
publicUrl = publicUrl,
rating = rating,
isNsfw = isNsfw,
coverUrl = coverUrl,
tags = tags,
state = state,
author = author,
largeCoverUrl = largeCoverUrl,
description = description,
chapters = chapters,
source = source,
)

@ -0,0 +1,71 @@
package org.xtimms.tokusho.sections.details.domain
import android.text.Html
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.tokusho.core.parser.MangaDataRepository
import org.xtimms.tokusho.core.parser.MangaIntent
import org.xtimms.tokusho.core.parser.MangaRepository
import org.xtimms.tokusho.sections.details.data.MangaDetails
import org.xtimms.tokusho.utils.lang.sanitize
import java.io.IOException
import javax.inject.Inject
class DetailsLoadUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository,
private val imageGetter: Html.ImageGetter,
) {
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent)) {
"Cannot resolve intent $intent"
}
send(MangaDetails(manga, null, false))
try {
val details = getDetails(manga)
send(MangaDetails(details, details.description?.parseAsHtml(withImages = false), false))
send(MangaDetails(details, details.description?.parseAsHtml(withImages = true), true))
} catch (e: IOException) {
throw e
}
}
private suspend fun getDetails(seed: Manga) = runCatchingCancellable {
val repository = mangaRepositoryFactory.create(seed.source)
repository.getDetails(seed)
}.getOrThrow()
private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? {
return if (withImages) {
runInterruptible(Dispatchers.IO) {
parseAsHtml(imageGetter = imageGetter)
}.filterSpans()
} else {
runInterruptible(Dispatchers.Default) {
parseAsHtml()
}.filterSpans().sanitize()
}.takeUnless { it.isBlank() }
}
private fun Spanned.filterSpans(): Spanned {
val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>()
for (span in spans) {
spannable.removeSpan(span)
}
return spannable
}
}

@ -2,6 +2,4 @@ package org.xtimms.tokusho.sections.explore
import org.xtimms.tokusho.core.base.event.UiEvent
interface ExploreEvent : UiEvent {
}
interface ExploreEvent : UiEvent

@ -36,6 +36,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.collapsable
import org.xtimms.tokusho.core.components.ExploreButton
@ -50,7 +51,7 @@ const val EXPLORE_DESTINATION = "explore"
@Composable
fun ExploreView(
coil: ImageLoader,
navController: NavController,
navigateToSource: (MangaSource) -> Unit,
topBarHeightPx: Float,
topBarOffsetY: Animatable<Float, AnimationVector1D>,
padding: PaddingValues,
@ -60,7 +61,7 @@ fun ExploreView(
ExploreViewContent(
coil = coil,
navController = navController,
navigateToSource = navigateToSource,
uiState = uiState,
event = viewModel,
topBarHeightPx = topBarHeightPx,
@ -72,7 +73,7 @@ fun ExploreView(
@Composable
fun ExploreViewContent(
coil: ImageLoader,
navController: NavController,
navigateToSource: (MangaSource) -> Unit,
uiState: ExploreUiState,
event: ExploreEvent?,
nestedScrollConnection: NestedScrollConnection? = null,
@ -166,7 +167,7 @@ fun ExploreViewContent(
}
items(
items = uiState.sources,
key = { it.ordinal },
key = { it.name },
contentType = { it }
) { item ->
Box(
@ -176,11 +177,10 @@ fun ExploreViewContent(
SourceItem(
coil = coil,
faviconUrl = item.faviconUri(),
title = item.title,
onClick = {
navController.navigate(LIST_DESTINATION)
}
)
title = item.title
) {
navigateToSource(item)
}
}
}
}

@ -22,7 +22,7 @@ class ExploreViewModel @Inject constructor(
)
init {
viewModelScope.launch(Dispatchers.IO) {
launchJob(Dispatchers.Default) {
val result = mangaSourcesRepository.allMangaSources
mutableUiState.update {
it.copy(

@ -0,0 +1,5 @@
package org.xtimms.tokusho.sections.list
import org.xtimms.tokusho.core.base.event.PagedUiEvent
interface MangaListEvent : PagedUiEvent

@ -0,0 +1,16 @@
package org.xtimms.tokusho.sections.list
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.base.state.PagedUiState
data class MangaListUiState(
val manga: List<Manga> = listOf(),
override val nextPage: String? = null,
override val loadMore: Boolean = true,
override val isLoading: Boolean = false,
override val message: String? = null,
) : PagedUiState() {
override fun setLoading(value: Boolean) = copy(isLoading = value)
override fun setMessage(value: String?) = copy(message = value)
}

@ -1,41 +1,133 @@
package org.xtimms.tokusho.sections.list
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.core.components.MangaCompactGridItem
import org.xtimms.tokusho.core.components.ScaffoldWithSmallTopAppBar
import org.xtimms.tokusho.utils.composable.onBottomReached
import org.xtimms.tokusho.utils.system.toast
const val LIST_DESTINATION = "list"
const val PROVIDER_ARGUMENT = "{source}"
const val LIST_DESTINATION = "provider/${PROVIDER_ARGUMENT}"
@Composable
fun MangaListView(
sourceName: String,
coil: ImageLoader,
source: MangaSource,
navigateBack: () -> Unit,
navigateToDetails: () -> Unit,
navigateToDetails: (Long) -> Unit,
) {
val viewModel: MangaListViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
MangaListView(
coil = coil,
source = source,
uiState = uiState,
event = viewModel,
navigateBack = navigateBack,
navigateToDetails = navigateToDetails
)
}
@Composable
private fun MangaListView(
coil: ImageLoader,
source: MangaSource,
uiState: MangaListUiState,
event: MangaListEvent?,
navigateBack: () -> Unit,
navigateToDetails: (Long) -> Unit,
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
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()
}
}
}
}

@ -0,0 +1,125 @@
package org.xtimms.tokusho.sections.list
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.update
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
import org.xtimms.tokusho.core.parser.MangaRepository
import org.xtimms.tokusho.utils.lang.call
import org.xtimms.tokusho.utils.lang.removeFirstAndLast
import org.xtimms.tokusho.utils.lang.require
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@HiltViewModel
class MangaListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
) : BaseViewModel<MangaListUiState>(), MangaListEvent {
private var loadingJob: Job? = null
val source = MangaSource.valueOf(savedStateHandle.get<String>(PROVIDER_ARGUMENT.removeFirstAndLast())!!)
private val repository = mangaRepositoryFactory.create(source)
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val listError = MutableStateFlow<Throwable?>(null)
private val hasNextPage = MutableStateFlow(false)
override val mutableUiState = MutableStateFlow(MangaListUiState())
init {
setLoading(true)
launchLoadingJob(Dispatchers.Default) {
mutableUiState
.distinctUntilChangedBy { it.loadMore }
.filter { it.loadMore }
.collectLatest { uiState ->
val list = repository.getList(
offset = mangaList.value?.size ?: 0,
filter = null,
)
val oldList = mangaList.getAndUpdate { oldList ->
if (oldList.isNullOrEmpty()) {
list
} else {
oldList + list
}
}.orEmpty()
hasNextPage.value = list.size > oldList.size || hasNextPage.value
mutableUiState.update {
it.copy(
manga = list,
nextPage = "2",
loadMore = hasNextPage.value,
isLoading = false
)
}
}
}
}
protected fun loadList(append: Boolean): Job {
loadingJob?.let {
if (it.isActive) return it
}
return launchLoadingJob(Dispatchers.Default) {
try {
listError.value = null
val list = repository.getList(
offset = if (append) mangaList.value?.size ?: 0 else 0,
filter = null,
)
val oldList = mangaList.getAndUpdate { oldList ->
if (!append || oldList.isNullOrEmpty()) {
list
} else {
oldList + list
}
}.orEmpty()
hasNextPage.value = if (append) {
list.isNotEmpty()
} else {
list.size > oldList.size || hasNextPage.value
}
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
listError.value = e
if (!mangaList.value.isNullOrEmpty()) {
errorEvent.call(e)
}
hasNextPage.value = false
}
}.also { loadingJob = it }
}
fun loadNextPage() {
if (hasNextPage.value && listError.value == null) {
loadList(append = true)
}
}
override fun loadMore() {
if (mutableUiState.value.canLoadMore) {
mutableUiState.update { it.copy(loadMore = true) }
}
}
override fun showMessage(message: String?) {
TODO("Not yet implemented")
}
override fun onMessageDisplayed() {
TODO("Not yet implemented")
}
}

@ -25,6 +25,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
@ -45,8 +46,9 @@ fun SearchHostView(
var query by remember { mutableStateOf("") }
val performSearch = remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
LaunchedEffect(focusRequester) {
focusRequester.requestFocus()
}
@ -68,7 +70,9 @@ fun SearchHostView(
if (isCompactScreen) BackIconButton(onClick = navigateBack)
},
keyboardActions = KeyboardActions(
onSearch = { performSearch.value = true }
onSearch = {
keyboardController?.hide()
}
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
singleLine = true,

@ -20,7 +20,7 @@ import org.xtimms.tokusho.core.collapsable
import org.xtimms.tokusho.core.model.ShelfCategory
import org.xtimms.tokusho.ui.theme.TokushoTheme
const val SHELF_DESTINATION = "stub"
const val SHELF_DESTINATION = "shelf"
@Composable
fun ShelfView(
@ -100,8 +100,11 @@ fun ShelfPreview() {
TokushoTheme {
Surface {
ShelfViewContent(
categories = emptyList(),
currentPage = { 2 },
categories = listOf(
ShelfCategory(1, "Test 1", 1L, 1L),
ShelfCategory(2, "Test 2", 2L, 2L)
),
currentPage = { 0 },
showPageTabs = true,
getNumberOfMangaForCategory = { 2 },
getLibraryForPage = { library.values.toTypedArray().getOrNull(0).orEmpty() },

@ -10,7 +10,7 @@ import androidx.compose.ui.graphics.Color
import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes
val LocalTonalPalettes = staticCompositionLocalOf {
Color(0xFF0057C9).toTonalPalettes()
Color(0xFF1978D2).toTonalPalettes()
}
inline val Number.a1: Color

@ -33,4 +33,4 @@ object FixedAccentColors {
@Composable get() = 30.a3
}
const val SEED = 0x0057c9
const val SEED = 0x1978D2

@ -0,0 +1,36 @@
package org.xtimms.tokusho.utils
import kotlinx.coroutines.flow.FlowCollector
class Event<T>(
private val data: T,
) {
private var isConsumed = false
suspend fun consume(collector: FlowCollector<T>) {
if (!isConsumed) {
collector.emit(data)
isConsumed = true
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Event<*>
if (data != other.data) return false
return isConsumed == other.isConsumed
}
override fun hashCode(): Int {
var result = data?.hashCode() ?: 0
result = 31 * result + isConsumed.hashCode()
return result
}
override fun toString(): String {
return "Event(data=$data, isConsumed=$isConsumed)"
}
}

@ -1,7 +0,0 @@
package org.xtimms.tokusho.utils
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import org.xtimms.tokusho.utils.material.SecondaryItemAlpha
fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha)

@ -0,0 +1,71 @@
package org.xtimms.tokusho.utils.composable
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
/**
* Extension function to load more items when the bottom is reached
* @param buffer Tells how many items before it reaches the bottom of the list to call `onLoadMore`. This value should be >= 0
* @param onLoadMore The code to execute when it reaches the bottom of the list
* @author Manav Tamboli
*/
@Composable
fun LazyListState.onBottomReached(
buffer: Int = 0,
onLoadMore: suspend () -> Unit
) {
// Buffer must be positive.
// Or our list will never reach the bottom.
require(buffer >= 0) { "buffer cannot be negative, but was $buffer" }
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
?: return@derivedStateOf true
// subtract buffer from the total items
lastVisibleItem.index >= layoutInfo.totalItemsCount - 1 - buffer
}
}
LaunchedEffect(shouldLoadMore) {
snapshotFlow { shouldLoadMore.value }
.collect { if (it) onLoadMore() }
}
}
/**
* Extension function to load more items when the bottom is reached
* @param buffer Tells how many items before it reaches the bottom of the list to call `onLoadMore`. This value should be >= 0
* @param onLoadMore The code to execute when it reaches the bottom of the list
* @author Manav Tamboli
*/
@Composable
fun LazyGridState.onBottomReached(
buffer: Int = 0,
onLoadMore: suspend () -> Unit
) {
// Buffer must be positive.
// Or our list will never reach the bottom.
require(buffer >= 0) { "buffer cannot be negative, but was $buffer" }
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
?: return@derivedStateOf true
// subtract buffer from the total items
lastVisibleItem.index >= layoutInfo.totalItemsCount - 1 - buffer
}
}
LaunchedEffect(shouldLoadMore) {
snapshotFlow { shouldLoadMore.value }
.collect { if (it) onLoadMore() }
}
}

@ -0,0 +1,25 @@
package org.xtimms.tokusho.utils.composable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.alpha
import org.xtimms.tokusho.utils.material.SecondaryItemAlpha
fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha)
@OptIn(ExperimentalFoundationApi::class)
fun Modifier.clickableNoIndication(
onLongClick: (() -> Unit)? = null,
onClick: () -> Unit,
): Modifier = composed {
Modifier.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onLongClick = onLongClick,
onClick = onClick,
)
}

@ -0,0 +1,52 @@
package org.xtimms.tokusho.utils.lang
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Parcel
import android.os.Parcelable
import androidx.core.content.IntentCompat
import androidx.core.os.BundleCompat
import androidx.core.os.ParcelCompat
import androidx.lifecycle.SavedStateHandle
import java.io.Serializable
inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? {
return BundleCompat.getParcelable(this, key, T::class.java)
}
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? {
return IntentCompat.getParcelableExtra(this, key, T::class.java)
}
inline fun <reified T : Serializable> Intent.getSerializableExtraCompat(key: String): T? {
return getSerializableExtra(key) as T?
}
inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getSerializable(key, T::class.java)
} else {
getSerializable(key) as T?
}
}
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(): T? {
return ParcelCompat.readParcelable(this, T::class.java.classLoader, T::class.java)
}
inline fun <reified T : Serializable> Parcel.readSerializableCompat(): T? {
return ParcelCompat.readSerializable(this, T::class.java.classLoader, T::class.java)
}
inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T {
return checkNotNull(getSerializableCompat(key)) {
"Serializable of type \"${T::class.java.name}\" not found at \"$key\""
}
}
fun <T> SavedStateHandle.require(key: String): T {
return checkNotNull(get(key)) {
"Value $key not found in SavedStateHandle or has a wrong type"
}
}

@ -0,0 +1,18 @@
package org.xtimms.tokusho.utils.lang
import androidx.annotation.AnyThread
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.xtimms.tokusho.utils.Event
@Suppress("FunctionName")
fun <T> MutableEventFlow() = MutableStateFlow<Event<T>?>(null)
typealias EventFlow<T> = StateFlow<Event<T>?>
typealias MutableEventFlow<T> = MutableStateFlow<Event<T>?>
@AnyThread
fun <T> MutableEventFlow<T>.call(data: T) {
value = Event(data)
}

@ -0,0 +1,16 @@
package org.xtimms.tokusho.utils.lang
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
fun <T> Flow<T>.onEachWhile(action: suspend (T) -> Boolean): Flow<T> {
var isCalled = false
return onEach {
if (!isCalled) {
isCalled = action(it)
}
}.onCompletion {
isCalled = false
}
}

@ -1,5 +1,28 @@
package org.xtimms.tokusho.utils.lang
import android.net.Uri
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
inline fun <C : CharSequence?> C?.ifNullOrEmpty(defaultValue: () -> C): C {
return if (this.isNullOrEmpty()) defaultValue() else this
}
fun String.removeFirstAndLast() = substring(1, length - 1)
fun Array<String>.toNavArgument(): String = Uri.encode(Json.encodeToString(this))
fun String.longHashCode(): Long {
var h = 1125899906842597L
val len: Int = this.length
for (i in 0 until len) {
h = 31 * h + this[i].code
}
return h
}
fun CharSequence.sanitize(): CharSequence {
return filterNot { c -> c.isReplacement() }
}
fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF'

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:name="caret_up"
android:width="24.0dip"
android:height="24.0dip"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<group
android:name="caret01"
android:rotation="90.0"
android:translateX="12.0"
android:translateY="15.0">
<group
android:name="caret_l"
android:rotation="45.0">
<group
android:name="caret_l_pivot"
android:translateY="4.0">
<group
android:name="caret_l_rect_position"
android:translateY="-1.0">
<path
android:name="caret_l_rect"
android:fillColor="#000"
android:pathData="M -1.0,-4.0 l 2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l -2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,-8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 Z" />
</group>
</group>
</group>
<group
android:name="caret_r"
android:rotation="-45.0">
<group
android:name="caret_r_pivot"
android:translateY="-4.0">
<group
android:name="caret_r_rect_position"
android:translateY="1.0">
<path
android:name="caret_r_rect"
android:fillColor="#000"
android:pathData="M -1.0,-4.0 l 2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l -2.0,0.0 c 0.0,0.0 0.0,0.0 0.0,0.0 l 0.0,-8.0 c 0.0,0.0 0.0,0.0 0.0,0.0 Z" />
</group>
</group>
</group>
</group>
</vector>
</aapt:attr>
<target android:name="caret01">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="300"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:pathData="M 12.0,9.0 c 0.0,0.66667 0.0,5.0 0.0,6.0"
android:propertyXName="translateX"
android:propertyYName="translateY" />
</aapt:attr>
</target>
<target android:name="caret_l">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="300"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:propertyName="rotation"
android:valueFrom="-45.0"
android:valueTo="45.0"
android:valueType="floatType" />
</aapt:attr>
</target>
<target
android:name="caret_r">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="300"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:propertyName="rotation"
android:valueFrom="45.0"
android:valueTo="-45.0"
android:valueType="floatType" />
</aapt:attr>
</target>
</animated-vector>

@ -68,4 +68,9 @@
<string name="paused">Paused</string>
<string name="upcoming">Upcoming</string>
<string name="unknown">Unknown</string>
<string name="open_in_browser">Open in browser</string>
<string name="manga_info_collapse">Less</string>
<string name="manga_info_expand">More</string>
<string name="action_copy_to_clipboard">Copy to clipboard</string>
<string name="description_placeholder">No description</string>
</resources>
Loading…
Cancel
Save