Initial facade of manga list
parent
1c24b7d0b2
commit
2aa6732e1d
@ -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
|
||||||
|
}
|
||||||
@ -0,0 +1,223 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shadow
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.ImageLoader
|
||||||
|
import org.xtimms.tokusho.core.AsyncImageImpl
|
||||||
|
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||||
|
|
||||||
|
private const val GridSelectedCoverAlpha = 0.76f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout of grid list item with title overlaying the cover.
|
||||||
|
* Accepts null [title] for a cover-only view.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MangaCompactGridItem(
|
||||||
|
coil: ImageLoader,
|
||||||
|
imageUrl: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
isSelected: Boolean = false,
|
||||||
|
title: String? = null,
|
||||||
|
onClickContinueReading: (() -> Unit)? = null,
|
||||||
|
coverAlpha: Float = 1f,
|
||||||
|
) {
|
||||||
|
GridItemSelectable(
|
||||||
|
isSelected = isSelected,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.Start
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
AsyncImageImpl(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(4.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.aspectRatio(10F / 16F),
|
||||||
|
coil = coil,
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = title!!,
|
||||||
|
modifier = Modifier.padding(4.dp),
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 2,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common cover layout to add contents to be drawn on top of the cover.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MangaGridCover(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
cover: @Composable BoxScope.() -> Unit = {},
|
||||||
|
content: @Composable (BoxScope.() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(MangaCover.Book.ratio),
|
||||||
|
) {
|
||||||
|
cover()
|
||||||
|
content?.invoke(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title overlay for [MangaCompactGridItem]
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.CoverTextOverlay(
|
||||||
|
title: String,
|
||||||
|
onClickContinueReading: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0f to Color.Transparent,
|
||||||
|
1f to Color(0xAA000000),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.fillMaxHeight(0.33f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.align(Alignment.BottomStart),
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
) {
|
||||||
|
GridItemTitle(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(8.dp),
|
||||||
|
title = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(
|
||||||
|
color = Color.White,
|
||||||
|
shadow = Shadow(
|
||||||
|
color = Color.Black,
|
||||||
|
blurRadius = 4f,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
minLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GridItemTitle(
|
||||||
|
title: String,
|
||||||
|
style: TextStyle,
|
||||||
|
minLines: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
maxLines: Int = 2,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = modifier,
|
||||||
|
text = title,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
minLines = minLines,
|
||||||
|
maxLines = maxLines,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = style,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for grid items to handle selection state, click and long click.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun GridItemSelectable(
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
)
|
||||||
|
.selectedOutline(isSelected = isSelected, color = MaterialTheme.colorScheme.secondary)
|
||||||
|
.padding(4.dp),
|
||||||
|
) {
|
||||||
|
val contentColor = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.onSecondary
|
||||||
|
} else {
|
||||||
|
LocalContentColor.current
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(LocalContentColor provides contentColor) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see GridItemSelectable
|
||||||
|
*/
|
||||||
|
private fun Modifier.selectedOutline(
|
||||||
|
isSelected: Boolean,
|
||||||
|
color: Color,
|
||||||
|
) = this then drawBehind { if (isSelected) drawRect(color = color) }
|
||||||
|
|
||||||
|
@PreviewLightDark
|
||||||
|
@Composable
|
||||||
|
fun MangaGridItemPreview() {
|
||||||
|
TokushoTheme {
|
||||||
|
MangaCompactGridItem(
|
||||||
|
coil = ImageLoader(LocalContext.current),
|
||||||
|
imageUrl = "https://cdn.myanimelist.net/images/manga/2/170594l.jpg",
|
||||||
|
title = "Stub",
|
||||||
|
onClick = { },
|
||||||
|
onLongClick = { }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,6 @@
|
|||||||
package org.xtimms.tokusho.core.database
|
package org.xtimms.tokusho.core.database
|
||||||
|
|
||||||
|
const val TABLE_MANGA = "manga"
|
||||||
|
const val TABLE_TAGS = "tags"
|
||||||
|
const val TABLE_MANGA_TAGS = "manga_tags"
|
||||||
const val TABLE_SOURCES = "sources"
|
const val TABLE_SOURCES = "sources"
|
||||||
@ -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,
|
||||||
|
)
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package org.xtimms.tokusho.sections.details
|
||||||
|
|
||||||
|
import org.xtimms.tokusho.core.base.event.UiEvent
|
||||||
|
|
||||||
|
interface DetailsEvent : UiEvent
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package org.xtimms.tokusho.sections.list
|
||||||
|
|
||||||
|
import org.xtimms.tokusho.core.base.event.PagedUiEvent
|
||||||
|
|
||||||
|
interface MangaListEvent : PagedUiEvent
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package org.xtimms.tokusho.sections.list
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.xtimms.tokusho.core.base.state.PagedUiState
|
||||||
|
|
||||||
|
data class MangaListUiState(
|
||||||
|
val manga: List<Manga> = listOf(),
|
||||||
|
override val nextPage: String? = null,
|
||||||
|
override val loadMore: Boolean = true,
|
||||||
|
override val isLoading: Boolean = false,
|
||||||
|
override val message: String? = null,
|
||||||
|
) : PagedUiState() {
|
||||||
|
|
||||||
|
override fun setLoading(value: Boolean) = copy(isLoading = value)
|
||||||
|
override fun setMessage(value: String?) = copy(message = value)
|
||||||
|
}
|
||||||
@ -1,41 +1,133 @@
|
|||||||
package org.xtimms.tokusho.sections.list
|
package org.xtimms.tokusho.sections.list
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil.ImageLoader
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.xtimms.tokusho.core.components.MangaCompactGridItem
|
||||||
import org.xtimms.tokusho.core.components.ScaffoldWithSmallTopAppBar
|
import org.xtimms.tokusho.core.components.ScaffoldWithSmallTopAppBar
|
||||||
|
import org.xtimms.tokusho.utils.composable.onBottomReached
|
||||||
|
import org.xtimms.tokusho.utils.system.toast
|
||||||
|
|
||||||
const val LIST_DESTINATION = "list"
|
const val PROVIDER_ARGUMENT = "{source}"
|
||||||
|
const val LIST_DESTINATION = "provider/${PROVIDER_ARGUMENT}"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MangaListView(
|
fun MangaListView(
|
||||||
sourceName: String,
|
coil: ImageLoader,
|
||||||
|
source: MangaSource,
|
||||||
navigateBack: () -> Unit,
|
navigateBack: () -> Unit,
|
||||||
navigateToDetails: () -> Unit,
|
navigateToDetails: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val viewModel: MangaListViewModel = hiltViewModel()
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
MangaListView(
|
||||||
|
coil = coil,
|
||||||
|
source = source,
|
||||||
|
uiState = uiState,
|
||||||
|
event = viewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
navigateToDetails = navigateToDetails
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MangaListView(
|
||||||
|
coil: ImageLoader,
|
||||||
|
source: MangaSource,
|
||||||
|
uiState: MangaListUiState,
|
||||||
|
event: MangaListEvent?,
|
||||||
|
navigateBack: () -> Unit,
|
||||||
|
navigateToDetails: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
if (uiState.message != null) {
|
||||||
|
LaunchedEffect(uiState.message) {
|
||||||
|
context.toast(uiState.message)
|
||||||
|
event?.onMessageDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ScaffoldWithSmallTopAppBar(
|
ScaffoldWithSmallTopAppBar(
|
||||||
title = sourceName,
|
title = source.title,
|
||||||
navigateBack = navigateBack
|
navigateBack = navigateBack,
|
||||||
|
contentWindowInsets = WindowInsets.systemBars
|
||||||
|
.only(WindowInsetsSides.Horizontal)
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
val listState = rememberLazyGridState()
|
||||||
|
listState.onBottomReached(buffer = 3) {
|
||||||
|
event?.loadMore()
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.padding(padding),
|
.padding(padding),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Button(onClick = { navigateToDetails() }) {
|
if (!uiState.isLoading) LazyVerticalGrid(
|
||||||
Text(text = "Click")
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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
|
package org.xtimms.tokusho.utils.lang
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
inline fun <C : CharSequence?> C?.ifNullOrEmpty(defaultValue: () -> C): C {
|
inline fun <C : CharSequence?> C?.ifNullOrEmpty(defaultValue: () -> C): C {
|
||||||
return if (this.isNullOrEmpty()) defaultValue() else this
|
return if (this.isNullOrEmpty()) defaultValue() else this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.removeFirstAndLast() = substring(1, length - 1)
|
||||||
|
|
||||||
|
fun Array<String>.toNavArgument(): String = Uri.encode(Json.encodeToString(this))
|
||||||
|
|
||||||
|
fun String.longHashCode(): Long {
|
||||||
|
var h = 1125899906842597L
|
||||||
|
val len: Int = this.length
|
||||||
|
for (i in 0 until len) {
|
||||||
|
h = 31 * h + this[i].code
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CharSequence.sanitize(): CharSequence {
|
||||||
|
return filterNot { c -> c.isReplacement() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF'
|
||||||
@ -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>
|
||||||
Loading…
Reference in New Issue