Categories in shelf preview

master
Zakhar Timoshenko 2 years ago
parent acdce5c3f7
commit 1415a940f4
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -17,7 +17,7 @@ import org.acra.data.StringFormat
import org.acra.ktx.initAcra import org.acra.ktx.initAcra
import org.acra.sender.HttpSender import org.acra.sender.HttpSender
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.xtimms.tokusho.core.database.MangaDatabase import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.prefs.AppSettings import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.updates.Updater import org.xtimms.tokusho.core.updates.Updater
import org.xtimms.tokusho.utils.lang.processLifecycleScope import org.xtimms.tokusho.utils.lang.processLifecycleScope
@ -28,7 +28,7 @@ import javax.inject.Provider
class App : Application() { class App : Application() {
@Inject @Inject
lateinit var database: Provider<MangaDatabase> lateinit var database: Provider<TokushoDatabase>
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()

@ -20,7 +20,7 @@ import org.xtimms.tokusho.core.cache.CacheDir
import org.xtimms.tokusho.core.cache.ContentCache import org.xtimms.tokusho.core.cache.ContentCache
import org.xtimms.tokusho.core.cache.MemoryContentCache import org.xtimms.tokusho.core.cache.MemoryContentCache
import org.xtimms.tokusho.core.cache.StubContentCache import org.xtimms.tokusho.core.cache.StubContentCache
import org.xtimms.tokusho.core.database.MangaDatabase import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.network.MangaHttpClient import org.xtimms.tokusho.core.network.MangaHttpClient
import org.xtimms.tokusho.core.os.NetworkState import org.xtimms.tokusho.core.os.NetworkState
import org.xtimms.tokusho.core.parser.MangaLoaderContextImpl import org.xtimms.tokusho.core.parser.MangaLoaderContextImpl
@ -53,8 +53,8 @@ interface TokushoModule {
@Singleton @Singleton
fun provideMangaDatabase( fun provideMangaDatabase(
@ApplicationContext context: Context, @ApplicationContext context: Context,
): MangaDatabase { ): TokushoDatabase {
return MangaDatabase(context) return TokushoDatabase(context)
} }
@Provides @Provides

@ -2,8 +2,6 @@ package org.xtimms.tokusho.core
import android.graphics.Path import android.graphics.Path
import android.view.animation.PathInterpolator import android.view.animation.PathInterpolator
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.Easing import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -19,7 +17,6 @@ import androidx.navigation.navArgument
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.core.logs.FileLogger import org.xtimms.tokusho.core.logs.FileLogger
import org.xtimms.tokusho.core.model.ShelfCategory
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
import org.xtimms.tokusho.sections.details.DETAILS_DESTINATION import org.xtimms.tokusho.sections.details.DETAILS_DESTINATION
@ -46,6 +43,10 @@ import org.xtimms.tokusho.sections.settings.appearance.DARK_THEME_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.DarkThemeView import org.xtimms.tokusho.sections.settings.appearance.DarkThemeView
import org.xtimms.tokusho.sections.settings.appearance.LANGUAGES_DESTINATION import org.xtimms.tokusho.sections.settings.appearance.LANGUAGES_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.LanguagesView import org.xtimms.tokusho.sections.settings.appearance.LanguagesView
import org.xtimms.tokusho.sections.settings.shelf.SHELF_SETTINGS_DESTINATION
import org.xtimms.tokusho.sections.settings.shelf.ShelfSettingsView
import org.xtimms.tokusho.sections.settings.shelf.categories.CATEGORIES_DESTINATION
import org.xtimms.tokusho.sections.settings.shelf.categories.CategoriesView
import org.xtimms.tokusho.sections.settings.storage.STORAGE_DESTINATION import org.xtimms.tokusho.sections.settings.storage.STORAGE_DESTINATION
import org.xtimms.tokusho.sections.settings.storage.StorageView import org.xtimms.tokusho.sections.settings.storage.StorageView
import org.xtimms.tokusho.sections.shelf.ShelfMap import org.xtimms.tokusho.sections.shelf.ShelfMap
@ -103,10 +104,6 @@ fun Navigation(
composable(BottomNavDestination.Shelf.route) { composable(BottomNavDestination.Shelf.route) {
val library: ShelfMap = emptyMap() val library: ShelfMap = emptyMap()
ShelfView( ShelfView(
categories = listOf(
ShelfCategory(1, "Test 1", 1L, 1L),
ShelfCategory(2, "Test 2", 2L, 2L)
),
currentPage = { 0 }, currentPage = { 0 },
showPageTabs = true, showPageTabs = true,
getNumberOfMangaForCategory = { 2 }, getNumberOfMangaForCategory = { 2 },
@ -151,6 +148,7 @@ fun Navigation(
navigateToAppearance = { navController.navigate(APPEARANCE_DESTINATION) }, navigateToAppearance = { navController.navigate(APPEARANCE_DESTINATION) },
navigateToAbout = { navController.navigate(ABOUT_DESTINATION) }, navigateToAbout = { navController.navigate(ABOUT_DESTINATION) },
navigateToAdvanced = { navController.navigate(ADVANCED_DESTINATION) }, navigateToAdvanced = { navController.navigate(ADVANCED_DESTINATION) },
navigateToShelfSettings = { navController.navigate(SHELF_SETTINGS_DESTINATION) },
navigateToStorage = { navController.navigate(STORAGE_DESTINATION) } navigateToStorage = { navController.navigate(STORAGE_DESTINATION) }
) )
} }
@ -176,6 +174,19 @@ fun Navigation(
) )
} }
composable(SHELF_SETTINGS_DESTINATION) {
ShelfSettingsView(
navigateBack = navigateBack,
navigateToCategories = { navController.navigate(CATEGORIES_DESTINATION) }
)
}
composable(CATEGORIES_DESTINATION) {
CategoriesView(
navigateBack = navigateBack,
)
}
composable(STORAGE_DESTINATION) { composable(STORAGE_DESTINATION) {
StorageView( StorageView(
navigateBack = navigateBack, navigateBack = navigateBack,

@ -4,10 +4,12 @@ import android.net.Uri
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -15,11 +17,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import coil.ImageLoader import coil.ImageLoader
import org.xtimms.tokusho.core.AsyncImageImpl import org.xtimms.tokusho.core.AsyncImageImpl
import org.xtimms.tokusho.ui.theme.TokushoTheme
@Composable @Composable
fun SourceItem( fun SourceItem(
@ -38,16 +44,22 @@ fun SourceItem(
.padding(start = 8.dp, end = 8.dp), .padding(start = 8.dp, end = 8.dp),
horizontalAlignment = Alignment.Start horizontalAlignment = Alignment.Start
) { ) {
AsyncImageImpl( Card(
coil = coil,
model = faviconUrl,
contentDescription = "favicon",
contentScale = ContentScale.Crop,
modifier = modifier modifier = modifier
.size(96.dp) .size(96.dp)
.clip(RoundedCornerShape(8.dp)) .clip(MaterialTheme.shapes.large)
.aspectRatio(1f) .aspectRatio(1f)
) ) {
AsyncImageImpl(
coil = coil,
model = faviconUrl,
contentDescription = "favicon",
contentScale = ContentScale.Crop,
modifier = modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.large)
)
}
Text( Text(
text = title, text = title,
modifier = Modifier modifier = Modifier
@ -61,3 +73,16 @@ fun SourceItem(
) )
} }
} }
@Preview
@Composable
fun SourceItemPreview() {
TokushoTheme {
SourceItem(
coil = ImageLoader(LocalContext.current),
faviconUrl = "".toUri(),
title = "Test",
onClick = { }
)
}
}

@ -0,0 +1,25 @@
package org.xtimms.tokusho.core.database
import android.content.res.Resources
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.xtimms.tokusho.R
class DatabasePrePopulateCallback(private val resources: Resources) : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
arrayOf(
System.currentTimeMillis(),
1,
resources.getString(R.string.read_later),
SortOrder.NEWEST.name,
1,
1,
0L,
)
)
}
}

@ -5,3 +5,5 @@ const val TABLE_TAGS = "tags"
const val TABLE_MANGA_TAGS = "manga_tags" const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources" const val TABLE_SOURCES = "sources"
const val TABLE_HISTORY = "history" const val TABLE_HISTORY = "history"
const val TABLE_FAVOURITES = "favourites"
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"

@ -10,9 +10,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.database.dao.FavouriteCategoriesDao
import org.xtimms.tokusho.core.database.dao.FavouritesDao
import org.xtimms.tokusho.core.database.dao.HistoryDao import org.xtimms.tokusho.core.database.dao.HistoryDao
import org.xtimms.tokusho.core.database.dao.MangaDao import org.xtimms.tokusho.core.database.dao.MangaDao
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
import org.xtimms.tokusho.core.database.entity.FavouriteEntity
import org.xtimms.tokusho.core.database.entity.HistoryEntity import org.xtimms.tokusho.core.database.entity.HistoryEntity
import org.xtimms.tokusho.core.database.entity.MangaEntity import org.xtimms.tokusho.core.database.entity.MangaEntity
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
@ -28,11 +32,13 @@ const val DATABASE_VERSION = 1
TagEntity::class, TagEntity::class,
MangaTagsEntity::class, MangaTagsEntity::class,
MangaSourceEntity::class, MangaSourceEntity::class,
HistoryEntity::class HistoryEntity::class,
FavouriteEntity::class,
FavouriteCategoryEntity::class
], ],
version = DATABASE_VERSION version = DATABASE_VERSION
) )
abstract class MangaDatabase : RoomDatabase() { abstract class TokushoDatabase : RoomDatabase() {
abstract fun getHistoryDao(): HistoryDao abstract fun getHistoryDao(): HistoryDao
@ -40,10 +46,15 @@ abstract class MangaDatabase : RoomDatabase() {
abstract fun getSourcesDao(): MangaSourcesDao abstract fun getSourcesDao(): MangaSourcesDao
abstract fun getFavouritesDao(): FavouritesDao
abstract fun getFavouriteCategoriesDao(): FavouriteCategoriesDao
} }
fun MangaDatabase(context: Context): MangaDatabase = Room fun TokushoDatabase(context: Context): TokushoDatabase = Room
.databaseBuilder(context, MangaDatabase::class.java, "tokusho-db") .databaseBuilder(context, TokushoDatabase::class.java, "tokusho-db.db")
.addCallback(DatabasePrePopulateCallback(context.resources))
.build() .build()
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)

@ -0,0 +1,64 @@
package org.xtimms.tokusho.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
@Dao
abstract class FavouriteCategoriesDao {
@Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0")
abstract suspend fun find(id: Int): FavouriteCategoryEntity
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key")
abstract suspend fun findAll(): List<FavouriteCategoryEntity>
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key")
abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 AND show_in_lib = 1 ORDER BY sort_key")
abstract fun observeAllForLibrary(): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0")
abstract fun observe(id: Long): Flow<FavouriteCategoryEntity?>
@Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long
suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis())
@Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker, `show_in_lib` = :onShelf WHERE category_id = :id")
abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean, onShelf: Boolean)
@Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id")
abstract suspend fun updateOrder(id: Long, order: String)
@Query("UPDATE favourite_categories SET `track` = :isEnabled WHERE category_id = :id")
abstract suspend fun updateTracking(id: Long, isEnabled: Boolean)
@Query("UPDATE favourite_categories SET `show_in_lib` = :isEnabled WHERE category_id = :id")
abstract suspend fun updateLibVisibility(id: Long, isEnabled: Boolean)
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun updateSortKey(id: Long, sortKey: Int)
@Query("DELETE FROM favourite_categories WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime")
abstract suspend fun gc(maxDeletionTime: Long)
@Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0")
protected abstract suspend fun getMaxSortKey(): Int?
suspend fun getNextSortKey(): Int {
return (getMaxSortKey() ?: 0) + 1
}
@Upsert
abstract suspend fun upsert(entity: FavouriteCategoryEntity)
@Query("UPDATE favourite_categories SET deleted_at = :deletedAt WHERE category_id = :id")
protected abstract suspend fun setDeletedAt(id: Long, deletedAt: Long)
}

@ -0,0 +1,169 @@
package org.xtimms.tokusho.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.xtimms.tokusho.core.database.entity.FavouriteEntity
import org.xtimms.tokusho.core.database.entity.MangaEntity
import org.xtimms.tokusho.core.model.ListSortOrder
import org.xtimms.tokusho.sections.shelf.FavouriteManga
@Dao
abstract class FavouritesDao {
/** SELECT **/
@Transaction
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC")
abstract suspend fun findAll(): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
fun observeAll(order: ListSortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
)
return observeAllImpl(query)
}
@Transaction
@Query(
"SELECT * FROM favourites WHERE deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
)
abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAllRaw(offset: Int, limit: Int): List<FavouriteManga>
@Transaction
@Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC",
)
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
arrayOf<Any>(categoryId),
)
return observeAllImpl(query)
}
@Transaction
@Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
)
abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga>
@Query(
"SELECT * FROM manga WHERE manga_id IN " +
"(SELECT manga_id FROM favourites WHERE category_id = :categoryId AND deleted_at = 0)",
)
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
@Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0")
abstract fun observeMangaCount(): Flow<Int>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Transaction
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
abstract suspend fun find(id: Long): FavouriteManga?
@Transaction
@Deprecated("Ignores order")
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
abstract fun observe(id: Long): Flow<FavouriteManga?>
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id AND deleted_at = 0")
abstract fun observeIds(id: Long): Flow<List<Long>>
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0")
abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long>
/** INSERT **/
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(favourite: FavouriteEntity)
/** DELETE **/
suspend fun delete(mangaId: Long) = setDeletedAt(
mangaId = mangaId,
deletedAt = System.currentTimeMillis(),
)
suspend fun delete(mangaId: Long, categoryId: Long) = setDeletedAt(
categoryId = categoryId,
mangaId = mangaId,
deletedAt = System.currentTimeMillis(),
)
suspend fun deleteAll(categoryId: Long) = setDeletedAtAll(
categoryId = categoryId,
deletedAt = System.currentTimeMillis(),
)
suspend fun recover(mangaId: Long) = setDeletedAt(
mangaId = mangaId,
deletedAt = 0L,
)
suspend fun recover(categoryId: Long, mangaId: Long) = setDeletedAt(
categoryId = categoryId,
mangaId = mangaId,
deletedAt = 0L,
)
@Query("DELETE FROM favourites WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime")
abstract suspend fun gc(maxDeletionTime: Long)
/** TOOLS **/
@Upsert
abstract suspend fun upsert(entity: FavouriteEntity)
@Transaction
@RawQuery(observedEntities = [FavouriteEntity::class])
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<FavouriteManga>>
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId")
protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long)
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId AND category_id = :categoryId")
abstract suspend fun setDeletedAt(categoryId: Long, mangaId: Long, deletedAt: Long)
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE category_id = :categoryId AND deleted_at = 0")
protected abstract suspend fun setDeletedAtAll(categoryId: Long, deletedAt: Long)
private fun getOrderBy(sortOrder: ListSortOrder) = when (sortOrder) {
ListSortOrder.NEWEST -> "favourites.created_at DESC"
ListSortOrder.ALPHABETIC -> "manga.title ASC"
ListSortOrder.PROGRESS -> "IFNULL((SELECT percent FROM history WHERE history.manga_id = manga.manga_id), 0) DESC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
}
}

@ -6,8 +6,11 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.xtimms.tokusho.core.model.FavouriteCategory
import org.xtimms.tokusho.core.model.ListSortOrder
import org.xtimms.tokusho.core.model.MangaHistory import org.xtimms.tokusho.core.model.MangaHistory
import org.xtimms.tokusho.core.model.MangaSource import org.xtimms.tokusho.core.model.MangaSource
import org.xtimms.tokusho.sections.shelf.FavouriteManga
import org.xtimms.tokusho.utils.lang.longHashCode import org.xtimms.tokusho.utils.lang.longHashCode
import java.time.Instant import java.time.Instant
@ -41,6 +44,20 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags()) fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id,
title = title,
sortKey = sortKey,
order = ListSortOrder(order, ListSortOrder.NEWEST),
createdAt = Instant.ofEpochMilli(createdAt),
isTrackingEnabled = track,
isVisibleInLibrary = isVisibleInLibrary,
)
fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags())
fun Collection<FavouriteManga>.toMangaList() = map { it.toManga() }
// Model to entity // Model to entity
fun Manga.toEntity() = MangaEntity( fun Manga.toEntity() = MangaEntity(

@ -0,0 +1,46 @@
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_FAVOURITE_CATEGORIES
@Entity(tableName = TABLE_FAVOURITE_CATEGORIES)
data class FavouriteCategoryEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "category_id") val categoryId: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "order") val order: String,
@ColumnInfo(name = "track") val track: Boolean,
@ColumnInfo(name = "show_in_lib") val isVisibleInLibrary: Boolean,
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FavouriteCategoryEntity
if (categoryId != other.categoryId) return false
if (createdAt != other.createdAt) return false
if (sortKey != other.sortKey) return false
if (title != other.title) return false
if (order != other.order) return false
if (track != other.track) return false
return isVisibleInLibrary == other.isVisibleInLibrary
}
override fun hashCode(): Int {
var result = categoryId
result = 31 * result + createdAt.hashCode()
result = 31 * result + sortKey
result = 31 * result + title.hashCode()
result = 31 * result + order.hashCode()
result = 31 * result + track.hashCode()
result = 31 * result + isVisibleInLibrary.hashCode()
return result
}
}

@ -0,0 +1,32 @@
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_FAVOURITES
@Entity(
tableName = TABLE_FAVOURITES,
primaryKeys = ["manga_id", "category_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = FavouriteCategoryEntity::class,
parentColumns = ["category_id"],
childColumns = ["category_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class FavouriteEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "category_id", index = true) val categoryId: Long,
@ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
)

@ -0,0 +1,21 @@
package org.xtimms.tokusho.core.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.time.Instant
@Parcelize
data class FavouriteCategory(
val id: Long,
val title: String,
val sortKey: Int,
val order: ListSortOrder,
val createdAt: Instant,
val isTrackingEnabled: Boolean,
val isVisibleInLibrary: Boolean,
) : Parcelable, ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is FavouriteCategory && id == other.id
}
}

@ -2,14 +2,14 @@ package org.xtimms.tokusho.core.parser
import dagger.Reusable import dagger.Reusable
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.database.MangaDatabase import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.database.entity.toManga import org.xtimms.tokusho.core.database.entity.toManga
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@Reusable @Reusable
class MangaDataRepository @Inject constructor( class MangaDataRepository @Inject constructor(
private val db: MangaDatabase, private val db: TokushoDatabase,
private val resolverProvider: Provider<MangaLinkResolver>, private val resolverProvider: Provider<MangaLinkResolver>,
) { ) {

@ -0,0 +1,134 @@
package org.xtimms.tokusho.data.repository
import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.database.entity.toFavouriteCategory
import org.xtimms.tokusho.core.database.entity.toManga
import org.xtimms.tokusho.core.model.FavouriteCategory
import org.xtimms.tokusho.core.model.ListSortOrder
import org.xtimms.tokusho.utils.ReversibleHandle
import org.xtimms.tokusho.utils.lang.mapItems
import javax.inject.Inject
@Reusable
class FavouritesRepository @Inject constructor(
private val db: TokushoDatabase,
) {
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<Manga>> {
return db.getFavouritesDao().observeAll(categoryId, order)
.mapItems { it.toManga() }
}
fun observeAll(categoryId: Long): Flow<List<Manga>> {
return observeOrder(categoryId)
.flatMapLatest { order -> observeAll(categoryId, order) }
}
fun observeMangaCount(): Flow<Int> {
return db.getFavouritesDao().observeMangaCount()
.distinctUntilChanged()
}
fun observeCategoriesForLibrary(): Flow<List<FavouriteCategory>> {
return db.getFavouriteCategoriesDao().observeAllForLibrary().mapItems {
it.toFavouriteCategory()
}.distinctUntilChanged()
}
fun observeCategoriesIds(mangaId: Long): Flow<Set<Long>> {
return db.getFavouritesDao().observeIds(mangaId).map { it.toSet() }
}
suspend fun getCategoriesIds(mangaIds: Collection<Long>): Set<Long> {
return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet()
}
suspend fun updateCategory(
id: Long,
title: String,
sortOrder: ListSortOrder,
isTrackerEnabled: Boolean,
isVisibleOnShelf: Boolean,
) {
db.getFavouriteCategoriesDao().update(id, title, sortOrder.name, isTrackerEnabled, isVisibleOnShelf)
}
suspend fun updateCategory(id: Long, isVisibleInLibrary: Boolean) {
db.getFavouriteCategoriesDao().updateLibVisibility(id, isVisibleInLibrary)
}
suspend fun updateCategoryTracking(id: Long, isTrackingEnabled: Boolean) {
db.getFavouriteCategoriesDao().updateTracking(id, isTrackingEnabled)
}
suspend fun removeCategories(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.getFavouritesDao().deleteAll(id)
db.getFavouriteCategoriesDao().delete(id)
}
}
}
suspend fun setCategoryOrder(id: Long, order: ListSortOrder) {
db.getFavouriteCategoriesDao().updateOrder(id, order.name)
}
suspend fun reorderCategories(orderedIds: List<Long>) {
val dao = db.getFavouriteCategoriesDao()
db.withTransaction {
for ((i, id) in orderedIds.withIndex()) {
dao.updateSortKey(id, i)
}
}
}
suspend fun removeFromFavourites(ids: Collection<Long>): ReversibleHandle {
db.withTransaction {
for (id in ids) {
db.getFavouritesDao().delete(mangaId = id)
}
}
return ReversibleHandle { recoverToFavourites(ids) }
}
suspend fun removeFromCategory(categoryId: Long, ids: Collection<Long>): ReversibleHandle {
db.withTransaction {
for (id in ids) {
db.getFavouritesDao().delete(categoryId = categoryId, mangaId = id)
}
}
return ReversibleHandle { recoverToCategory(categoryId, ids) }
}
private fun observeOrder(categoryId: Long): Flow<ListSortOrder> {
return db.getFavouriteCategoriesDao().observe(categoryId)
.filterNotNull()
.map { x -> ListSortOrder(x.order, ListSortOrder.NEWEST) }
.distinctUntilChanged()
}
private suspend fun recoverToFavourites(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.getFavouritesDao().recover(mangaId = id)
}
}
}
private suspend fun recoverToCategory(categoryId: Long, ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.getFavouritesDao().recover(mangaId = id, categoryId = categoryId)
}
}
}
}

@ -4,7 +4,7 @@ import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.database.MangaDatabase import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.database.entity.HistoryEntity import org.xtimms.tokusho.core.database.entity.HistoryEntity
import org.xtimms.tokusho.core.database.entity.toMangaHistory import org.xtimms.tokusho.core.database.entity.toMangaHistory
import org.xtimms.tokusho.core.model.MangaHistory import org.xtimms.tokusho.core.model.MangaHistory
@ -15,7 +15,7 @@ const val PROGRESS_NONE = -1f
@Reusable @Reusable
class HistoryRepository @Inject constructor( class HistoryRepository @Inject constructor(
private val db: MangaDatabase, private val db: TokushoDatabase,
) { ) {
suspend fun getOne(manga: Manga): MangaHistory? { suspend fun getOne(manga: Manga): MangaHistory? {

@ -3,7 +3,7 @@ package org.xtimms.tokusho.data.repository
import dagger.Reusable import dagger.Reusable
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.BuildConfig import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.core.database.MangaDatabase import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
@ -11,7 +11,7 @@ import javax.inject.Inject
@Reusable @Reusable
class MangaSourcesRepository @Inject constructor( class MangaSourcesRepository @Inject constructor(
private val db: MangaDatabase, private val db: TokushoDatabase,
) { ) {
private val dao: MangaSourcesDao private val dao: MangaSourcesDao

@ -23,6 +23,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BatterySaver import androidx.compose.material.icons.outlined.BatterySaver
import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.LocalLibrary
import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Storage
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -54,6 +55,7 @@ fun SettingsView(
navigateToAppearance: () -> Unit, navigateToAppearance: () -> Unit,
navigateToAbout: () -> Unit, navigateToAbout: () -> Unit,
navigateToAdvanced: () -> Unit, navigateToAdvanced: () -> Unit,
navigateToShelfSettings: () -> Unit,
navigateToStorage: () -> Unit navigateToStorage: () -> Unit
) { ) {
@ -67,6 +69,7 @@ fun SettingsView(
navigateToAppearance = navigateToAppearance, navigateToAppearance = navigateToAppearance,
navigateToAbout = navigateToAbout, navigateToAbout = navigateToAbout,
navigateToAdvanced = navigateToAdvanced, navigateToAdvanced = navigateToAdvanced,
navigateToShelfSettings = navigateToShelfSettings,
navigateToStorage = navigateToStorage navigateToStorage = navigateToStorage
) )
} }
@ -80,6 +83,7 @@ private fun SettingsViewContent(
navigateToAppearance: () -> Unit, navigateToAppearance: () -> Unit,
navigateToAbout: () -> Unit, navigateToAbout: () -> Unit,
navigateToAdvanced: () -> Unit, navigateToAdvanced: () -> Unit,
navigateToShelfSettings: () -> Unit,
navigateToStorage: () -> Unit navigateToStorage: () -> Unit
) { ) {
@ -151,6 +155,14 @@ private fun SettingsViewContent(
onClick = navigateToAppearance onClick = navigateToAppearance
) )
} }
item {
SettingItem(
title = stringResource(id = R.string.nav_shelf),
description = stringResource(id = R.string.shelf_page),
icon = Icons.Outlined.LocalLibrary,
onClick = navigateToShelfSettings
)
}
item { item {
val allCaches = uiState.httpCacheSize + val allCaches = uiState.httpCacheSize +
uiState.pagesCache + uiState.pagesCache +

@ -0,0 +1,72 @@
package org.xtimms.tokusho.sections.settings.shelf
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Category
import androidx.compose.material.icons.outlined.Update
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceItem
import org.xtimms.tokusho.core.components.PreferenceSubtitle
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.sections.shelf.ShelfViewModel
const val SHELF_SETTINGS_DESTINATION = "shelf_settings"
@Composable
fun ShelfSettingsView(
shelfViewModel: ShelfViewModel = hiltViewModel(),
navigateBack: () -> Unit,
navigateToCategories: () -> Unit
) {
ScaffoldWithTopAppBar(
title = stringResource(R.string.nav_shelf),
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) {
item {
PreferenceSubtitle(text = stringResource(id = R.string.categories))
}
item {
PreferenceItem(
title = stringResource(id = R.string.edit_categories),
description = pluralStringResource(
id = R.plurals.categories_count,
count = shelfViewModel.uiState.value.categories.size,
shelfViewModel.uiState.value.categories.size
),
icon = Icons.Outlined.Category,
onClick = {
navigateToCategories()
}
)
}
item {
PreferenceSubtitle(text = stringResource(id = R.string.updates))
}
item {
PreferenceItem(
title = stringResource(id = R.string.auto_update),
description = "Off",
icon = Icons.Outlined.Update
)
}
}
}
}

@ -0,0 +1,56 @@
package org.xtimms.tokusho.sections.settings.shelf.categories
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.NewLabel
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar
const val CATEGORIES_DESTINATION = "categories"
@Composable
fun CategoriesView(
navigateBack: () -> Unit,
) {
ScaffoldWithClassicTopAppBar(
title = stringResource(R.string.edit_categories),
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { }
) {
Icon(
imageVector = Icons.Outlined.NewLabel,
contentDescription = "New category"
)
Text(
text = stringResource(R.string.add),
modifier = Modifier.padding(start = 16.dp, end = 8.dp)
)
}
},
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) {
}
}
}

@ -0,0 +1,30 @@
package org.xtimms.tokusho.sections.shelf
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
import org.xtimms.tokusho.core.database.entity.FavouriteEntity
import org.xtimms.tokusho.core.database.entity.MangaEntity
import org.xtimms.tokusho.core.database.entity.MangaTagsEntity
import org.xtimms.tokusho.core.database.entity.TagEntity
class FavouriteManga(
@Embedded val favourite: FavouriteEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "manga_id"
)
val manga: MangaEntity,
@Relation(
parentColumn = "category_id",
entityColumn = "category_id"
)
val categories: List<FavouriteCategoryEntity>,
@Relation(
parentColumn = "manga_id",
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>
)

@ -13,15 +13,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import org.xtimms.tokusho.core.components.TabText import org.xtimms.tokusho.core.components.TabText
import org.xtimms.tokusho.core.model.ShelfCategory import org.xtimms.tokusho.core.model.FavouriteCategory
import org.xtimms.tokusho.sections.shelf.ext.visualName
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun ShelfTabs( internal fun ShelfTabs(
categories: List<ShelfCategory>, categories: List<FavouriteCategory>,
pagerState: PagerState, pagerState: PagerState,
getNumberOfMangaForCategory: (ShelfCategory) -> Int?, getNumberOfMangaForCategory: (FavouriteCategory) -> Int?,
onTabItemClick: (Int) -> Unit, onTabItemClick: (Int) -> Unit,
) { ) {
Column( Column(
@ -40,7 +39,7 @@ internal fun ShelfTabs(
onClick = { onTabItemClick(index) }, onClick = { onTabItemClick(index) },
text = { text = {
TabText( TabText(
text = category.visualName, text = category.title,
badgeCount = getNumberOfMangaForCategory(category), badgeCount = getNumberOfMangaForCategory(category),
) )
}, },

@ -0,0 +1,14 @@
package org.xtimms.tokusho.sections.shelf
import org.xtimms.tokusho.core.base.state.UiState
import org.xtimms.tokusho.core.model.FavouriteCategory
data class ShelfUiState(
val categories: List<FavouriteCategory> = emptyList(),
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)
}

@ -8,32 +8,35 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.collapsable import org.xtimms.tokusho.core.collapsable
import org.xtimms.tokusho.core.model.FavouriteCategory
import org.xtimms.tokusho.core.model.ShelfCategory import org.xtimms.tokusho.core.model.ShelfCategory
import org.xtimms.tokusho.ui.theme.TokushoTheme
const val SHELF_DESTINATION = "shelf" const val SHELF_DESTINATION = "shelf"
@Composable @Composable
fun ShelfView( fun ShelfView(
categories: List<ShelfCategory>,
currentPage: () -> Int, currentPage: () -> Int,
showPageTabs: Boolean, showPageTabs: Boolean,
getNumberOfMangaForCategory: (ShelfCategory) -> Int?, getNumberOfMangaForCategory: (FavouriteCategory) -> Int?,
getLibraryForPage: (Int) -> List<ShelfItem>, getLibraryForPage: (Int) -> List<ShelfItem>,
topBarHeightPx: Float, topBarHeightPx: Float,
padding: PaddingValues, padding: PaddingValues,
) { ) {
val viewModel: ShelfViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ShelfViewContent( ShelfViewContent(
categories = categories, uiState = uiState,
currentPage = currentPage, currentPage = currentPage,
showPageTabs = showPageTabs, showPageTabs = showPageTabs,
getNumberOfMangaForCategory = getNumberOfMangaForCategory, getNumberOfMangaForCategory = getNumberOfMangaForCategory,
@ -46,10 +49,10 @@ fun ShelfView(
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ShelfViewContent( fun ShelfViewContent(
categories: List<ShelfCategory>, uiState: ShelfUiState,
currentPage: () -> Int, currentPage: () -> Int,
showPageTabs: Boolean, showPageTabs: Boolean,
getNumberOfMangaForCategory: (ShelfCategory) -> Int?, getNumberOfMangaForCategory: (FavouriteCategory) -> Int?,
getLibraryForPage: (Int) -> List<ShelfItem>, getLibraryForPage: (Int) -> List<ShelfItem>,
topBarHeightPx: Float, topBarHeightPx: Float,
topBarOffsetY: Animatable<Float, AnimationVector1D> = Animatable(0f), topBarOffsetY: Animatable<Float, AnimationVector1D> = Animatable(0f),
@ -66,17 +69,17 @@ fun ShelfViewContent(
) )
.padding(padding) .padding(padding)
) { ) {
val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) } val coercedCurrentPage = remember { currentPage().coerceAtMost(uiState.categories.lastIndex) }
val pagerState = rememberPagerState(coercedCurrentPage) { categories.size } val pagerState = rememberPagerState(coercedCurrentPage) { uiState.categories.size }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
if (showPageTabs && categories.size > 1) { if (showPageTabs && uiState.categories.size > 1) {
LaunchedEffect(categories) { LaunchedEffect(uiState.categories) {
if (categories.size <= pagerState.currentPage) { if (uiState.categories.size <= pagerState.currentPage) {
pagerState.scrollToPage(categories.size - 1) pagerState.scrollToPage(uiState.categories.size - 1)
} }
} }
ShelfTabs( ShelfTabs(
categories = categories, categories = uiState.categories,
pagerState = pagerState, pagerState = pagerState,
getNumberOfMangaForCategory = getNumberOfMangaForCategory, getNumberOfMangaForCategory = getNumberOfMangaForCategory,
) { scope.launch { pagerState.animateScrollToPage(it) } } ) { scope.launch { pagerState.animateScrollToPage(it) } }
@ -93,26 +96,4 @@ fun ShelfViewContent(
} }
} }
@Preview
@Composable
fun ShelfPreview() {
val library: ShelfMap = emptyMap()
TokushoTheme {
Surface {
ShelfViewContent(
categories = listOf(
ShelfCategory(1, "Test 1", 1L, 1L),
ShelfCategory(2, "Test 2", 2L, 2L)
),
currentPage = { 0 },
showPageTabs = true,
getNumberOfMangaForCategory = { 2 },
getLibraryForPage = { library.values.toTypedArray().getOrNull(0).orEmpty() },
padding = PaddingValues(),
topBarHeightPx = 0f,
)
}
}
}
typealias ShelfMap = Map<ShelfCategory, List<ShelfItem>> typealias ShelfMap = Map<ShelfCategory, List<ShelfItem>>

@ -0,0 +1,40 @@
package org.xtimms.tokusho.sections.shelf
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.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
import org.xtimms.tokusho.data.repository.FavouritesRepository
import javax.inject.Inject
@HiltViewModel
class ShelfViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel<ShelfUiState>() {
private val categoriesStateFlow = favouritesRepository.observeCategoriesForLibrary()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val isEmpty = categoriesStateFlow.map {
it?.isEmpty() == true
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
init {
launchJob(Dispatchers.Default) {
mutableUiState.update {
it.copy(
categories = categoriesStateFlow.value ?: emptyList()
)
}
}
}
override val mutableUiState = MutableStateFlow(ShelfUiState())
}

@ -0,0 +1,31 @@
package org.xtimms.tokusho.utils
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.tokusho.utils.lang.processLifecycleScope
fun interface ReversibleHandle {
suspend fun reverse()
}
@OptIn(ExperimentalCoroutinesApi::class)
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
runCatchingCancellable {
withContext(NonCancellable) {
reverse()
}
}.onFailure {
}
}
operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle {
this.reverse()
other.reverse()
}

@ -1,6 +1,7 @@
package org.xtimms.tokusho.utils.lang package org.xtimms.tokusho.utils.lang
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -14,3 +15,7 @@ fun <T> Flow<T>.onEachWhile(action: suspend (T) -> Boolean): Flow<T> {
isCalled = false isCalled = false
} }
} }
inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<List<R>> {
return map { list -> list.map(transform) }
}

@ -8,4 +8,8 @@
<item quantity="one">%1$d minute</item> <item quantity="one">%1$d minute</item>
<item quantity="other">%1$d minutes</item> <item quantity="other">%1$d minutes</item>
</plurals> </plurals>
<plurals name="categories_count">
<item quantity="one">%1$d category</item>
<item quantity="other">%1$d categories</item>
</plurals>
</resources> </resources>

@ -118,4 +118,10 @@
<string name="total_chapters_read">Total chapters read</string> <string name="total_chapters_read">Total chapters read</string>
<string name="min_chapters_read">Minimum chapters read</string> <string name="min_chapters_read">Minimum chapters read</string>
<string name="max_chapters_read">Maximum chapters read</string> <string name="max_chapters_read">Maximum chapters read</string>
<string name="read_later">Read later</string>
<string name="shelf_page">Categories, new chapters updates</string>
<string name="categories">Categories</string>
<string name="edit_categories">Edit categories</string>
<string name="updates">Updates</string>
<string name="add">Add</string>
</resources> </resources>
Loading…
Cancel
Save