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
|
||||
|
||||
const val TABLE_MANGA = "manga"
|
||||
const val TABLE_TAGS = "tags"
|
||||
const val TABLE_MANGA_TAGS = "manga_tags"
|
||||
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
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue